最近 NestJS のプロジェクトを再構築していて、管理パネルのフロントエンド用に tRPC を統合しようと考えています。元々、管理パネルでは API インターフェースを直接記述しており、レスポンスタイプもありませんでした。tRPC を使用すれば、手動で API を書く必要がなく、エンドツーエンドで型安全を実現できます。
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 モジュールを作成し、trpc
と名付けます。
nest g mo src/processors/trpc --no-spec
nest g s src/processors/trpc --no-spec
モジュールをグローバルモジュールとしてマークします。今後、他のビジネスモジュールで tRPC ルーターを定義する際に使用します。
@Module({
exports: [tRPCService],
providers: [tRPCService],
})
@Global()
export class tRPCModule {}
tRPC
をインスタンス化するためのtrpc.instance.ts
を作成します。ここでは外部でインスタンスを作成する必要がありますが、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()
// ====== 以下の型のエクスポートはクライアント側で使用されるものです
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 ヘッダーを注入しました。
次に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(
// ここで認証のプロシージャを定義します
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 のルーターを定義しました。次に fastify アプリに対していくつかの操作を行う必要があります。
まず、tRPCModule
をAppModule
に注入します。次に、bootstrap 内で fastify インスタンスを取得し、tRPCService
のapplyMiddleware
メソッドを適用します。
::: 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 ルーターの定義#
予想#
明らかに、すべてのルーターを 1 つのファイルに書くことはモジュール化の思想に反します。したがって、このセクションではこれを分割し、できるだけ 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(() => []),
}),
})
}
}
次に、モジュール内でインポートします。
// 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/core
のDiscoveryModule
がこの方法を提供してくれます。
まず、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 ルーターのクラスに@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(() => []),
})
}
}
これで第一歩が完了しました。
最後に、TRPCModule
をトップレベルのモジュールに追加することを忘れないでください。
::: error
ここで注意が必要なのは、TRPCModule
が各モジュールの依存関係を収集する必要があるため、TRPCModule
は各モジュールの後にロードされる必要があります。
@Module({
imports: [
// processors
CacheModule,
DatabaseModule,
HelperModule,
LoggerModule,
GatewayModule,
// BIZ
AggregateModule,
AuthModule,
PostModule,
UserModule,
CategoryModule,
ConfigsModule,
NoteModule,
PageModule,
// TRPCModuleはすべてのビジネスモジュールの後に
tRPCModule,
],
})
export class AppModule {}
:::
注意深いあなたは、defineTrpcRouter
を使用していることに気づいたでしょう。これは何かというと、次に説明することです。
型安全#
現在、ビジネスモジュールごとに tRPC ルーターを定義し、自動的に依存関係を収集するように改造したため、型が寄ってしまいました。
上記では、mergeRouters
を 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 ルーターのクラスをインポートする必要がありますが、これは単なる型です。他に良い方法があれば、このステップも省略できるかもしれません。
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()),
})
}
}
大功告成です。次に、クライアント側に接続する必要があります。
クライアント側への接続#
NestJS はフルスタックフレームワークではありませんが、tRPC は完全な型の通信インターフェースを提供できます。しかし、クライアント側で完全な型のインスタンスを取得できないのは問題です。
もしクライアント側のコードが NestJS と同じ monorepo にある場合は、次に進みましょう。そうでない場合は、まず monorepo 形式に変更してください。
現在のディレクトリ構造は次のようになっています。
├── apps
├── console # React
└── core # NestJS
React プロジェクトに必要な依存関係をインストールします。
pnpm add @trpc/client @trpc/server @trpc/react-query @tanstack/react-query
tsPath を使用して、パス@core
をapps/core
、つまり NestJS プロジェクトにマッピングします。
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@core/*": ["../core/src/*"]
}
},
"include": ["src"]
}
vite.config.ts
にもエイリアスを追加できます。
import { resolve } from 'path'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
// エイリアス
resolve: {
alias: [
{
find: /^@core\/(.*)/,
replacement: resolve(__dirname, '../core/src/$1'),
},
],
},
})
tRPC クライアントを初期化します。
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>{/** あなたのアプリ */}</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