banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

超级组合!NestJS + tRPC 与CSR绝佳搭档React Query,开启全新开发时代!

最近在重写一个 NestJS 的项目,想着这么集成下 tRPC 供管理面板前端使用。原本在管理面板都是裸写的 API 接口,也没有任何响应类型。如果使用 tRPC 的话,不仅不用手写 API 还能做到 End-to-End Type-safe.

tRPC 是什么#

RPC 是指远程过程调用 (Remote procedure call),tRPC 的 t 强调了 TypeScript 的实现,利用了 TypeScript 的类型系统实现了在客户端与服务端之间的通信过程并提供高效的类型安全。

tRPC 一般在全栈框架中用的比较多,比如 Next.js。况且在 Next.js 推广 App Router 之后,类似 RPC 的方式也会变得比较流行,可以省去很多麻烦。

NestJS 接入 tRPC#

NestJS 接入 tRPC 的方式非常简单。和普通服务端接入类似。下面都以 fastify 作为 NestJS 应用的适配器实现。

NestJS 本身是一个抽象层,我们可以获取到源服务器的实例,这里是 fastify

按照 tRPC 的文档,我们对 fastify 实例注入插件的形式来完成 tRPC 的接入。

首先安装 tRPC 必要的库:

pnpm add @trpc/server zod

新建一个 NestJS module,命名 trpc

nest g mo src/processors/trpc --no-spec
nest g s src/processors/trpc --no-spec

对 Module 标记为全局模块。后续在其他业务模块中定义 tRPC router 会用到。

@Module({
  exports: [tRPCService],
  providers: [tRPCService],
})
@Global()
export class tRPCModule {}

创建一个 trpc.instance.ts 用于实例化 tRPC,这里需要在外部创建实例,好像不是很符合 Angular 哲学,但是这个实例类型类型需要外置,没得办法。

import {
  inferRouterInputs,
  inferRouterOutputs,
  initTRPC,
  inferAsyncReturnType,
} from '@trpc/server'

import { Context } from './trpc.context'

import * as trpcNext from '@trpc/server/adapters/next'

export async function createContext({
  req,
  res,
}: trpcNext.CreateNextContextOptions) {
  return {
    authorization: req.headers.authorization as string | null,
  }
}
export type Context = inferAsyncReturnType<typeof createContext>

export const tRpc = initTRPC.context<Context>().create()

// ====== 以下的类型导出是为 Client 端侧使用的
export type tRpcRouterType = (typeof tRpc)['router']
export type tRpcProcedure = (typeof tRpc)['procedure']
export type tRpc$Config = typeof tRpc._config

export type RouterInputs = inferRouterInputs<AppRouter>
export type RouterOutputs = inferRouterOutputs<AppRouter>

export type AppRouter = tRPCService['appRouter']

在上面我们创建了 tRPC 实例,并对其注入了身份验证所需要的 authorization header。

来到 trpc.service.ts

import { tRpc } from './trpc.instance'
import { fastifyRequestHandler } from '@trpc/server/adapters/fastify'

@Injectable()
export class tRPCService {
  constructor(private readonly authService: AuthService) {
    this._procedureAuth = tRpc.procedure.use(
      // 这里我们也定义身份验证的 procedure
      tRpc.middleware(async (opts) => {
        const authorization = opts.ctx.authorization
        if (!authorization) {
          throw new BizException(ErrorCodeEnum.AuthFail)
        }

        const result = await authService.validate(authorization)
        if (result !== true) {
          throw new BizException(result)
        }
        return opts.next()
      }),
    )
    this.createAppRouter()
  }

  public get t() {
    return tRpc
  }

  private _procedureAuth: typeof tRpc.procedure

  public get procedureAuth() {
    return this._procedureAuth
  }

  appRouter: ReturnType<typeof this.createAppRouter>

  private createAppRouter() {
    const appRouter = tRpc.router({
      user: tRpc.router({
        list: tRpc.procedure.query(() => []),
      }),
    })

    this.appRouter = appRouter
    return appRouter
  }

  applyMiddleware(app: NestFastifyApplication) {
    app.getHttpAdapter().all(`/trpc/:path`, async (req, res) => {
      const path = (req.params as any).path
      await fastifyRequestHandler({
        router: this.appRouter,
        createContext,
        req,
        res,
        path,
      })
    })
  }
}

上面我们定义了 tRPC 的 router。现在我们需要对 fastify app 做一些操作。

首先把 tRPCModule 注入到 AppModule 中去,现在在 bootstrap 中获取 fastify instance 然后应用 tRPCServiceapplyMiddleware 方法。

::: warning
这里我并没有使用文档所说的方法,原因是 https://github.com/trpc/trpc/issues/4820。
:::

来到 bootstrap.ts

import { FastifyAdapter } from '@nestjs/platform-fastify'

const fastifyApp: FastifyAdapter = new FastifyAdapter({
  trustProxy: true,
})
const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  fastifyApp,
)

const trpcService = app.get(tRPCService)
trpcService.applyMiddleware(app)

这样,一个简单 tRPC 架子就结束了,但是我们的进度才刚刚起步。

现在你可以访问 /trpc/user.list 得到一下的结果。

{
  "result": {
    "data": []
  }
}

分业务模块定义 tRPC Router#

预想#

显然,把 router 都写在一个文件中是不符合模块化思想的,所以这节我们把这个拆分,尽可能的符合 NestJS 哲学。

首先,我们要明确我们要干什么,我们的预期是在每个业务模块下存放一个叫 some-biz.trpc.ts 的文件定义 tRPC 路由。类似 some-biz.controller.ts 的命名。

比如,我们这样去定义 tRPC 路由。

// modules/user/user.trpc.ts
import { tRPCService } from '@core/processors/trpc/trpc.service'
import { Injectable } from '@nestjs/common'

@Injectable()
export class UserTrpcRouter implements OnModuleInit {
  private router: ReturnType<typeof this.createRouter>
  constructor(private readonly trpcService: tRPCService) {}

  onModuleInit() {
    this.router = this.createRouter()
  }

  private createRouter() {
    const tRpc = this.trpcService.t
    return tRpc.router({
      user: tRpc.router({
        list: tRpc.procedure.query(() => []),
      }),
    })
  }
}

然后在 Module 中导入。

// modules/user/user.module.ts
import { Global, Module } from '@nestjs/common'

import { UserController } from './user.controller'
import { UserService } from './user.service'
import { UserTrpcRouter } from './user.trpc'

@Module({
  controllers: [UserController],
  providers: [UserService, UserTrpcRouter],
  exports: [UserService, UserTrpcRouter],
})
export class UserModule {}

收集依赖#

以上是我们预想的方式,那么如果这么写 tRPCService 如何自动收集依赖呢。

@nestjs/coreDiscoveryModule 为我们提供了这个方法。

首先对 trpc.module.ts 进行改造:

// helper/trpc.module.ts

import { Global, Module } from '@nestjs/common'
import { DiscoveryModule } from '@nestjs/core'

import { tRPCService } from './trpc.service'

@Module({
  exports: [tRPCService],
  providers: [tRPCService],
  imports: [DiscoveryModule], // 注入 DiscoveryModule
})
@Global()
export class tRPCModule {}

然后在 trpc.service.ts 中对 createAppRouter 进行改造,借助 DiscoveryService 收集到所有的依赖并提取我们需要的 trpcRouter。那么如何提取呢,这里我们用到了 Reflector 获取原信息,然后根据这个过滤。

import { DiscoveryService, Reflector } from '@nestjs/core'

const TRPC_ROUTER = 'trpc_router'

@Injectable()
export class tRPCService {
  constructor(private readonly discovery: DiscoveryService) {
    // ...
  }

  private createAppRouter() {
    const p = this.discovery.getProviders()
    const routers = p
      .filter((provider) => {
        try {
          return this.reflector.get(TRPC_ROUTER, provider.metatype)
        } catch {
          return false
        }
      })
      .map(({ instance }) => instance.router)
      .filter((router) => {
        if (!router) {
          this.logger.warn('missing router.')
        }

        return !!router
      })

    const appRouter = tRpc.mergeRouters(...(routers as any))

    this.appRouter = appRouter
    return appRouter
  }
}

既然是原信息,总要有地方去加装饰的。我们的预期是在 trpc Router 的类上加上 @TRPCRouter 的装饰器。

首先定义 TRPCRouter

const TRPC_ROUTER = 'trpc_router'

export const TRPCRouter = (): ClassDecorator => {
  return (target) => {
    Reflect.defineMetadata(TRPC_ROUTER, true, target)
  }
}

这个常量值你可以放在任何地方。

然后对在 TRPCRouter 加上装饰。

import { TRPCRouter } from '@core/common/decorators/trpc.decorator'
import { defineTrpcRouter } from '@core/processors/trpc/trpc.helper'
import { tRPCService } from '@core/processors/trpc/trpc.service'
import { Injectable, OnModuleInit } from '@nestjs/common'

+ @TRPCRouter()
@Injectable()
export class UserTrpcRouter implements OnModuleInit {
  private router: ReturnType<typeof this.createRouter>
  constructor(private readonly trpcService: tRPCService) {}

  onModuleInit() {
    this.router = this.createRouter()
  }

  private createRouter() {
    const t = this.trpcService.procedureAuth
    return defineTrpcRouter('user', {
      user: t.query(() => []),
    })
  }
}

OK,这样就完成了第一步。

最后,别忘了,把 TRPCModule 加入到顶层 Module。

::: error
注意这里,TRPCModule 需要依赖收集各个模块,请确保 TRPCModule 在各个模块之后加载。

@Module({
  imports: [
    // processors
    CacheModule,
    DatabaseModule,
    HelperModule,
    LoggerModule,
    GatewayModule,

    // BIZ
    AggregateModule,
    AuthModule,
    PostModule,
    UserModule,
    CategoryModule,
    ConfigsModule,
    NoteModule,
    PageModule,

    // TRPCModule should be after all biz modules
    tRPCModule,
  ],
})
export class AppModule {}

:::

细心的你发现了我使用了 defineTrpcRouter,这个是什么捏,这也是下面要讲到的。

类型安全#

现在我们改造了分模块定义 tRPC router 外加使用全自动收集依赖,导致类型寄了。

上面我们用了 mergeRouters as 了一个 any,这一步我们需要把这个 any 类型定义出来。

接下来就是广播体操了,没什么必要性。代码贴着这里了。

// trpc.service.ts
interface TA {
  router: any
}

type ExtractRouterType<T extends TA> = T['router']

type MapToRouterType<T extends any[]> = {
  [K in keyof T]: ExtractRouterType<T[K]>
}

type Routers = MapToRouterType<tRpcRouters>

然后新建一个 trpc.routes.ts

import { UserTrpcRouter } from '@core/modules/user/user.trpc'

export type tRpcRouters = [UserTrpcRouter]

这里还是需要把每个 tRPC Router 的类导入进来,不过他只是一个 type。不知道有没有其他更好的方式这步也能省略。

修改 creatEAppRouter 方法:

- const appRouter = tRpc.mergeRouters(...(routers as any))
+ const appRouter = tRpc.mergeRouters(...(routers as any as Routers))

对了,至于上面的 defineTrpcRouter 是为了解决一部分 tRPC.router 嵌套很深的情况,最后也是为类型服务,这里就不展开了。代码如下:

import { tRpc, tRpcRouterType } from './trpc.instance'

type ObjWithKey<T extends string, Value> = { [K in T]: Value }

export const defineTrpcRouter = <
  T extends string,
  P extends Parameters<tRpcRouterType>[0],
>(
  route: T,
  routes: P,
) => {
  const rpcRoute = tRpc.router(routes)
  return tRpc.router({
    [route]: rpcRoute,
  } as any as ObjWithKey<T, typeof rpcRoute>)
}

然后在 user.trpc.ts 中我们有简单的方式定义路由了。

import { TRPCRouter } from '@core/common/decorators/trpc.decorator'
import { defineTrpcRouter } from '@core/processors/trpc/trpc.helper'
import { tRPCService } from '@core/processors/trpc/trpc.service'
import { Injectable, OnModuleInit } from '@nestjs/common'

@TRPCRouter()
@Injectable()
export class UserTrpcRouter implements OnModuleInit {
  private router: ReturnType<typeof this.createRouter>
  constructor(
    private readonly trpcService: tRPCService,
    private readonly service: UserService,
  ) {}

  onModuleInit() {
    this.router = this.createRouter()
  }

  private createRouter() {
    const t = this.trpcService.procedureAuth
    // 干净了很多
    return defineTrpcRouter('user', {
      user: t.query(() => []),
      getCurrentUser: t.query(() => this.service.getCurrentUser()),
    })
  }
}

大功告成。接下来就需要接入到 Client 侧了。

接入 Client 侧#

NestJS 不是一个全栈框架,虽然 tRPC 能够提供类型完备的通讯接口,但是在 Client 侧无法拿到类型完备的类型实例还是不行的。

如果你的 Client 侧代码刚好和 NestJS 在同一个 monorepo,那么我们继续往下走了。如果不是,请先改造成 monorepo 的形式。

现在我们的目录结构是这样的。

├── apps
  ├── console # React
  └── core # NestJS

在 React 项目中,安装必要的依赖。

pnpm add @trpc/client @trpc/server @trpc/react-query @tanstack/react-query

使用 tsPath 映射路径 @coreapps/core 也就是 NestJS 项目中。

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@core/*": ["../core/src/*"]
    }
  },
  "include": ["src"]
}

vite.config.ts 也可以加上 alias。

import { resolve } from 'path'
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  // alias
  resolve: {
    alias: [
      {
        find: /^@core\/(.*)/,
        replacement: resolve(__dirname, '../core/src/$1'),
      },
    ],
  },
})

初始化 tRPC client。

import {
  createTRPCReact,
  httpBatchLink,
  inferReactQueryProcedureOptions,
} from '@trpc/react-query'

import {
  AppRouter,
  RouterInputs,
  RouterOutputs,
} from '@core/processors/trpc/trpc.instance' // 从 NestJS 项目中引用,这里之前都导出了

import { API_URL } from '~/constants/env'

import { getToken } from './cookie'

export const trpc = createTRPCReact<AppRouter>()

export const tRpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: `${API_URL}/trpc`, // 填服务的地址
      async headers() {
        return {
          authorization: getToken()!, // 鉴权头
        }
      },
    }),
  ],
})

export type ReactQueryOptions = inferReactQueryProcedureOptions<AppRouter>

export type { RouterInputs, RouterOutputs }

在顶层挂载组件:

import { QueryClientProvider } from '@tanstack/react-query'
import { Suspense } from 'react'

import { trpc, tRpcClient } from './lib/trpc'
import { queryClient } from './providers/query-core'

export function App() {
  return (
    <trpc.Provider client={tRpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <Suspense>{/** Your App */}</Suspense>
      </QueryClientProvider>
    </trpc.Provider>
  )
}

现在你可以这样使用 tRPC 了。

const { data, isLoading } = trpc.user.user.useQuery({})

已知问题,tRPC 不支持泛型的类型安全。

此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/programming/nestjs-with-trpc-and-dependency-injection


Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.