第十一章 TRPC中间件的作用与在Next.js中的应用

576 阅读4分钟

一、定义中间件

在前面TRPC使用章节里,我们按照官网示例完成了trpc路由的初始化,并且导出了部分在程序中常用到的变量,如暴露给客户端的函数t.procedure,用于查询和发送数据

// src/trpc/trpc.ts
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
export const router = t.router
export const publicProcedure = t.procedure

顾名思义他是一个公开的过程,对于公开的 API,你可以使用 publicProcedure 来定义,多数情况下我们要在用户登录后才可以执行某个过程,如查询用户的信息,所以我们需要将公开的 API 和需要身份验证的 API 分开,而对于需要身份验证的 API,我们可以定义一个 privateProcedure

详细代码如下:

// src/trpc/trpc.ts
import { getServerSession } from 'next-auth'
import { initTRPC } from '@trpc/server'
import { ManualTRPCError } from '@/lib/utils'
import { getMessages } from '@/lib/tips'
import { authOptions } from '@/app/api/auth/[...nextauth]/route'

const t = initTRPC.create()
const middleware = t.middleware

const isAuth = middleware(async (opts) => {
  const session = await getServerSession(authOptions)

  if (!session?.user?.id) {
    throw new ManualTRPCError('UNAUTHORIZED', getMessages('10035'))
  }

  return opts.next({
    ctx: {
      userId: session.user.id,
      user: session.user,
    },
  })
})

export const router = t.router
export const publicProcedure = t.procedure
export const privateProcedure = t.procedure.use(isAuth)

这段代码中,initTRPC.create() 还是创建了一个 tRPC 实例,但是新增了一个名为 isAuth 的中间件,这个中间件用于处理用户的身份验证。

isAuth 中间件中,它首先通过 getServerSession(authOptions) 获取用户的会话信息。如果会话信息中不存在用户的 id,那么就说明用户没有进行身份验证,或者验证错误,因此抛出一个自定义的 ManualTRPCError 错误,错误信息从 getMessages('10035') 获取。如果用户已经进行了身份验证,那么就将用户的会话信息添加到上下文(ctx)中,并继续执行下一个中间件或者处理程序。

privateProcedure 是一个使用了 isAuth 中间件的 t.procedure 实例。这意味着所有使用 privateProcedure 定义的 API 端点都会先执行 isAuth 中间件,进行用户的身份验证。

这样可以确保只有经过身份验证的用户才能访问需要身份验证的 API,未经身份验证的用户在尝试访问这些 API 时会收到一个错误信息。

这是一个很好的实践,将身份验证逻辑与 API 的业务逻辑分离,使得代码更加清晰和易于维护。同时,由于身份验证逻辑是在中间件中处理的,因此可以在多个需要身份验证的 API 端点之间复用,避免了代码的重复。

二、使用中间件

以完成个人身份信息查询API为例:

使用 privateProcedure.query() 方法定义了一个名为 getUserInfo 的 API 端点。这意味着在调用 getUserInfo 端点之前,会先执行 isAuth 中间件进行用户的身份验证。

// src/trpc/index.ts
import { publicProcedure, router, privateProcedure } from './trpc'
export const appRouter = router({
  //...
  getUserInfo: privateProcedure.query(async ({ ctx }) => {
    try {
      const { userId } = ctx
      const userInfo = await db.user.findFirst({
        where: {
          id: userId,
        },
        select: {
          id: true,
          name: true,
          phone: true,
          email: true,
          image: true,
          bio: true,
          expiredAt: true,
          active: true,
          createdAt: true,
        },
      })
      return userInfo
    } catch (error) {
      handleErrorforInitiative(error)
    }
  }),
})

这样做的目的是确保只有经过身份验证的用户才能访问他们自己的用户信息。未经身份验证的用户在尝试访问 getUserInfo 端点时,会先执行 isAuth 中间件,由于他们没有进行身份验证,因此会收到一个错误信息。这样可以保护用户的信息不被未经授权的用户访问。

三、访问API测试

接下来进行查询测试,在About页面我们是不需要登录就可以访问的,那么我们在该页面调用受保护的

// test=>src/app/(page)/(allow)/about/page.tsx
'use client'
import { trpc } from '@/app/_trpc/client'
import MaxWidthWrapper from '@/components/MaxWidthWrapper'

function AboutPage() {
  const { data, isLoading } = trpc.getUserInfo.useQuery()
  console.log(data, isLoading, 'data,isLoading')
  return (
    <MaxWidthWrapper className="mb-12 mt-28 sm:mt-40 flex flex-col items-center justify-center text-center">
      AboutPage
    </MaxWidthWrapper>
  )
}

export default AboutPage

如果没有登录,我们在About页面将看到如下信息

登录成功后我们将看到如下信息

四、完成个人信息组件

抽离account组件,把组件变成客户端组件

// src/app/(page)/(protected)/account/page.tsx
import MaxWidthWrapper from '@/components/MaxWidthWrapper'
import UserCard from '@/components/UserCard'

function AccountPage() {
  return (
    <MaxWidthWrapper className="mb-12 mt-28 sm:mt-40 flex flex-col items-center justify-center">
      <UserCard />
    </MaxWidthWrapper>
  )
}

export default AccountPage

个性化的组件大家自行改造,这里给出简要示例,预留一个修改信息的Link

// src/components/UserCard.tsx
'use client'

import { trpc } from '@/app/_trpc/client'
import { cn, reactQueryRetry } from '@/lib/utils'
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Skeleton } from './ui/skeleton'
import Link from 'next/link'
import { buttonVariants } from './ui/button'
import { Mail, PhoneCall, UserCog2 } from 'lucide-react'
import Image from 'next/image'
import { useState } from 'react'
function UserCard() {
  const { data: userInfo, isLoading } = trpc.getUserInfo.useQuery(undefined, {
    retry: reactQueryRetry,
  })
  const [isLoaded, setIsLoaded] = useState(false)
  return (
    <Card className="w-[350px] md:w-[400px]">
      <CardHeader>
        <div className="flex gap-4 relative">
          <div className="w-16 h-16">
            {isLoading ? (
              <Skeleton className="h-full w-full rounded-full" />
            ) : (
              <div className="rounded-full w-full h-full flex items-center justify-center border border-zinc-200 relative bg-zinc-50 overflow-hidden">
                <Image
                  sizes="100%"
                  fill={true}
                  src={userInfo?.image ? userInfo.image : '/images/user.png'}
                  alt="avatar"
                  priority={true}
                  onLoad={() => setIsLoaded(true)}
                />
                {!isLoaded && (
                  <div className="absolute w-16 h-16 animate-pulse rounded-full bg-zinc-300 opacity-25"></div>
                )}
              </div>
            )}
          </div>
          {isLoading ? (
            <div className="flex-1">
              <Skeleton className="h-6 w-full" />
              <Skeleton className="h-12 mt-2 w-full" />
            </div>
          ) : (
            <div className="flex-1">
              <CardTitle>
                {userInfo?.name || userInfo?.email || userInfo?.phone}
              </CardTitle>
              <CardDescription className="mt-2">
                {userInfo?.bio}
              </CardDescription>
            </div>
          )}
          <Link
            className={cn(
              'font-semibold absolute -top-4 -right-4',
              buttonVariants({
                variant: 'outline',
                size: 'icon',
              })
            )}
            href="/account/settings"
          >
            <UserCog2 className="w-5 h-5 text-muted-foreground" />
          </Link>
        </div>
      </CardHeader>
      <CardContent>
        {isLoading ? (
          <>
            <Skeleton className="h-6 w-full" />
            <Skeleton className="h-6 mt-2 w-full" />
          </>
        ) : (
          <>
            {userInfo?.email && (
              <div className="flex items-center gap-2 text-muted-foreground">
                <Mail className="w-4 h-4" />
                <div className="text-sm">{userInfo.email}</div>
              </div>
            )}
            {userInfo?.phone && (
              <div className="mt-2 flex items-center gap-2 text-muted-foreground">
                <PhoneCall className="w-4 h-4" />
                <div className="text-sm">{userInfo.phone}</div>
              </div>
            )}
          </>
        )}
      </CardContent>
    </Card>
  )
}

export default UserCard

增加骨架屏组件

npx shadcn-ui@latest add skeleton

以上代码涉及到的文案提示如下:

'10035': '请先登录',

最终包含骨架屏样式效果如下,数据库手动修改补充一些字段,邮箱为作者本人,手机号不是

接下来使用已有的东西开发更新个人信息,最后上传图片到阿里oss更新头像。