banner
innei

innei

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

スーパーコンビネーション!NestJS + tRPC と CSR の素晴らしいパートナー React Query が、新しい開発時代を切り開きます!

最近 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 アプリに対していくつかの操作を行う必要があります。

まず、tRPCModuleAppModuleに注入します。次に、bootstrap 内で fastify インスタンスを取得し、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 ルーターの定義#

予想#

明らかに、すべてのルーターを 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/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 ルーターのクラスに@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 を使用して、パス@coreapps/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

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。