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

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。