NestJS + tRPC:打破开发边界,体验前后端同构开发的魔法!!

相关资料

前言

在传统的后端开发中,接口的设计往往是一个繁琐而耗时的过程。每个接口都需要手动定义,参数校验、文档编写等工作不可避免。而当我们引入 tRPC 时,这一切都变得简单明了。tRPC旨在提供一种强类型的远程过程调用(RPC)解决方案,帮助我们实现端到端的强类型安全,从而简化整体开发流程。接下来,我们开始深入了解 NestJS 如何接入 tRPC,以及在这一融合过程中,如何实现无缝的前后端同构开发体验。

Nest接入tRPC

实现tRPC模块

首先在NestJS项目中,引入tRPC相关的依赖:

bash 复制代码
pnpm add @trpc/server zod

在创建模块之前,我们先写好模块相关的定义文件:

typescript 复制代码
// trpc.interface.ts
import { AnyRouter } from '@trpc/server/dist/core/router'

export interface ITrpcModuleOptions<TRouter extends AnyRouter = AnyRouter> {
    prefix: '/trpc' | string
    router: TRouter
}

// 存放tRPC路由
export const TRPC_ROUTER_TOKEN = Symbol('TRPC_ROUTER_TOKEN')

// 存放tRPC路由前缀
export const TRPC_PREFIX_TOKEN = Symbol('TRPC_PREFIX_TOKEN')

上面代码首先定义了tRPC模块接受的参数 ITrpcModuleOptions 它接受一个泛型参数 TRouterTRouter extends AnyRouter = AnyRouter 表示传入的TRouter类型必须符合 AnyRouter 的定义,且使用AnyRouter作为默认的泛型参数。

prefix: '/trpc' | string 表示模块定义了prefix属性来分组传入的router需要路由匹配哪个路径,比如传入 "/trpc",就表示匹配以 http://localhost:5200/trpc 为头的请求地址。

router: TRouter 表示传入模块的tRPC路由,tRPC框架暴露了一个方法 trpc.router() 用于定义tRPC路由,具体在下文详解。

我们还需要额外定义两个常量 TRPC_ROUTER_TOKEN \ TRPC_PREFIX_TOKEN 方便注入前面说到的 prefix 跟 router 两个参数到模块的全局上下文中,以便于后面直接通过依赖注入获取到这两个参数。

定义好模块需要的相关类型后,我们需要创建一个自定义模块,用于将tRPC接入NestJS自身的模块系统。

完整的模块代码如下:

typescript 复制代码
// trpc.module.ts
import { DynamicModule, Inject, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'

import { ITrpcModuleOptions, TRPC_PREFIX_TOKEN, TRPC_ROUTER_TOKEN } from './trpc.interface'
import { tRPCMiddleware } from './trpc.middleware'

/**
 * tRPC模块
 */
@Module({})
export class tRPCModule implements NestModule {
    @Inject(TRPC_PREFIX_TOKEN)
    private readonly prefix!: ITrpcModuleOptions['prefix']

    static forRoot(options: ITrpcModuleOptions): DynamicModule {
        if (!options.prefix || !options.router) {
            throw new Error('trpc路由和前缀必须指定')
        }

        return {
            module: tRPCModule,
            providers: [
                // 依赖注入配置项
                { provide: TRPC_ROUTER_TOKEN, useValue: options.router },
                { provide: TRPC_PREFIX_TOKEN, useValue: options.prefix }
            ]
        }
    }

    configure(consumer: MiddlewareConsumer) {
        // 绑定tRPC中间件并指定路由前缀
        consumer.apply(tRPCMiddleware).forRoutes(this.prefix)
    }
}

首先我们定义了一个模块类 tRPCModule 实现NestJS的模块定义 NestModuleNestModule 定义了一个configure方法,用于向上下文注入路由组件。这里我们通过 consumer.apply(tRPCMiddleware).forRoutes(this.prefix) 这行代码把前面实现的tRPCMiddleware注入到全局上下文,并绑定对应的 prefix 路由前缀。

我们定义了一个静态方法 forRoot(options: ITrpcModuleOptions) 它接受一个我们在上文定义的模块参数 ITrpcModuleOptions 然后返回一个 DynamicModule,我们使用前面定义的两个Token: TRPC_ROUTER_TOKEN \ TRPC_PREFIX_TOKEN 将模块参数注入当前模块上下文的provider,方便在tRPCMiddleware中获取,也因此我们可以通过下面代码注入prefix,并在configure中使用:

typescript 复制代码
export class tRPCModule implements NestModule {

    // 依赖注入prefix
    @Inject(TRPC_PREFIX_TOKEN)
    private readonly prefix!: ITrpcModuleOptions['prefix']
    
    ...
    
    configure(consumer: MiddlewareConsumer) {
        // 绑定tRPC中间件并指定路由前缀
        consumer.apply(tRPCMiddleware).forRoutes(
            this.prefix // 使用了上面依赖注入的prefix
        )
    }
}

接下来我们来实现tRPCMiddleware,接管Nest请求:

typescript 复制代码
// trpc.middleware.ts
import { Request, Response, NextFunction } from 'express'
import * as tRpcExpress from '@trpc/server/adapters/express'
import { Inject, Injectable, NestMiddleware, Type } from '@nestjs/common'
import { ModuleRef } from '@nestjs/core'

import { ITrpcModuleOptions, TRPC_ROUTER_TOKEN } from './trpc.interface'
import { buildCreateContext } from './buildCreateContext'

@Injectable()
export class tRPCMiddleware implements NestMiddleware {
    @Inject(TRPC_ROUTER_TOKEN)
    private readonly router!: ITrpcModuleOptions['router']

    constructor(private readonly moduleRef: ModuleRef) {}

    /**
     * 对接trpc中间件
     */
    use(req: Request, res: Response, next: NextFunction) {
        const createContext = buildCreateContext(req, res, this.moduleRef)

        const handler = tRpcExpress.createExpressMiddleware({
            router: this.router,
            createContext
        })

        handler(req, res, next)
    }
}

这里我们创建了tRPCMiddleware类并实现了 NestMiddleware 接口,接口中的use方法会传递Nest的Request和Response,以及用来调用下一个中间件的next方法,我们都知道Nest默认使用基于express的底层实现,此处的Request和Response实际是被Nest包裹了一层的express对象,这也是我们能够接入tRPC的切入点。

相关tRPC、express文档在这:

Express Adapter | tRPC

我们查看tRPC文档中关于接入express的章节(在第三部分,"Use the Express adapter"中)可以发现:

typescript 复制代码
const app = express(); 
app.use('/trpc', trpcExpress.createExpressMiddleware({ 
        router: appRouter,
        createContext,
    })
);

trpcExpress.createExpressMiddleware 实际上返回的是一个express的RouteHandler,我们去express的文档看看RouteHandler的定义就可以知道,express的handler也可以接收三个参数,分别对应 RequestResponseNextFunction

基于此,我们创建了一个handler:

typescript 复制代码
/**
 * 对接trpc中间件
 */
use(req: Request, res: Response, next: NextFunction) {
    ...
    ...
    const handler = tRpcExpress.createExpressMiddleware({
        router: this.router,
        createContext
    })

    handler(req, res, next)
}

然后我们直接在use方法中调用handler,传入 req, res, next,这样就把Nest的请求转交给tRPC来执行了。

接下来我们需要让tRPC可以注入Nest的依赖。

tRPC中有一个功能叫 Context ,它提供了一个统一的地方来存储和传递请求的相关信息,例如用户身份验证、请求参数、数据库连接等。而Nest提供了一个类 ModuleRef 通过它我们可以用当前请求上下文的标识来注入provider,我们创建一个帮助函数 buildCreateContext 使用它来封装一层我们自己的实现:

typescript 复制代码
import { Request, Response } from 'express'
import { Type } from '@nestjs/common'
import { ContextIdFactory, ModuleRef } from '@nestjs/core'

type Context = {
    res: Response
    req: Request
    inject: <TInput = any, TResult = TInput>(typeOrToken: Type<TInput> | Function | string | symbol) => Promise<TResult>
}

type BuildCreateContextFn<TContext, TContextFn = () => TContext> = (
    req: Request,
    res: Response,
    moduleRef: ModuleRef
) => TContextFn

const buildCreateContext: BuildCreateContextFn<Context> = (req: Request, res: Response, moduleRef: ModuleRef) => {
    // trpc上下文内使用inject注入nest依赖项
    const inject = <TInput = any, TResult = TInput>(typeOrToken: Type<TInput> | Function | string | symbol) => {
        // 获取当前请求上下文id
        const contextId = ContextIdFactory.getByRequest(req)

        // 获取请求上下文对应moduleRef实例并resolve依赖
        return moduleRef.resolve<TResult>(typeOrToken, contextId, {
            strict: false
        })
    }

    return () => ({
        req,
        res,
        inject
    })
}

export { buildCreateContext, Context }

上面代码中,首先我们定义了暴露给tRPC的 Context 定义,表示我们希望它来帮我们储存哪些数据,其中 inject 就是我们需要包装moduleRef并实现的注入函数。res和req对应的就是上面tRPCMiddleware的use方法中传递过来的请求和响应对象。

接下来我们定义 buildCreateContext 的函数方法签名 BuildCreateContextFn,这样我们在写函数实现的时候就可以省略参数的类型签名了,这里buildCreateContext函数返回的是trpcExpress.createExpressMiddleware所需的createContext函数,我们在buildCreateContext函数中实现了上面提到的inject方法:

typescript 复制代码
// trpc上下文内使用inject注入nest依赖项
const inject = <TInput = any, TResult = TInput>(typeOrToken: Type<TInput> | Function | string | symbol) => {
    // 获取当前请求上下文id
    const contextId = ContextIdFactory.getByRequest(req)

    // 获取请求上下文对应moduleRef实例并resolve依赖
    return moduleRef.resolve<TResult>(typeOrToken, contextId, {
        strict: false
    })
}

然后我们会在tRPCMiddleware的use方法中使用buildCreateContext:

typescript 复制代码
/**
 * 对接trpc中间件
 */
use(req: Request, res: Response, next: NextFunction) {
    // 调用方法创建createContext
    const createContext = buildCreateContext(req, res, this.moduleRef)

    const handler = tRpcExpress.createExpressMiddleware({
        router: this.router,
        createContext
    })

    // 处理tRPC路由
    handler(req, res, next)
}

这样我们就完整实现了tRPC模块。

导出tRPC实例

在tRPC模块对应的文件夹中,新建 trpc.context.ts 内容如下:

typescript 复制代码
// trpc.context.ts
import { initTRPC } from '@trpc/server'
import { Context } from './buildCreateContext'

export const tRPC = initTRPC.context<Context>().create()
export const router = tRPC.router
export const middleware = tRPC.middleware
export const procedure = tRPC.procedure
export const mergeRouters = tRPC.mergeRouters

上面代码中,我们导入了上文buildCreateContext中定义好的tRPC的Context类型,然后我们调用 initTRPC.context<Context>().create() 创建了tRPC实例,后续tRPC路由定义的时候直接导入这个实例即可。

这里我们可以对tRPC实例中的对象进行具名导出,方便后面对路由的定义以及procedure的调用。

定义tRPC路由

接下来我们创建几个tRPC路由测试一下接入效果。

首先在Nest项目中创建router文件夹,里面创建几个文件:

typescript 复制代码
// post.router.ts
import { procedure, router } from '@libs/trpc'

export const PostRouter = router({
    post: router({
        list: procedure.query(async () => [])
    }) ,,
})


// user.router.ts
import { procedure, router } from '@libs/trpc'
import { GreetingService } from '../services/greeting.service'
import { z } from 'zod'

export const UserRouter = router({
    user: router({
        greeting: procedure
            .input(
                z.object({
                    name: z.string()
                })
            )
            .query(async ({ ctx, input }) => {
                const greeting = await ctx.inject(GreetingService)
                return greeting.getHello(input.name)
            })
    })
})

注意看这一行:const greeting = await ctx.inject(GreetingService) 这里我们使用上面在buildCreateContext中实现的inject注入了GreetingService的实例。

然后,在router中创建index.ts:

typescript 复制代码
import { mergeRouters } from '@libs/trpc'
import { UserRouter } from './user.router'
import { PostRouter } from './post.router'

export const appRouter = mergeRouters(UserRouter, PostRouter)

type AppRouter = typeof appRouter

export type { AppRouter }

我们使用mergeRouters将User路由和Post路由组合成AppRouter,然后需要在AppModule里使用它:

typescript 复制代码
import { Module } from '@nestjs/common'

import { tRPCModule } from '@libs/trpc'
import { GreetingService } from '../services/greeting.service'
import { appRouter } from './router'

@Module({
    imports: [
        // 导入tRPC模块
        tRPCModule.forRoot({
            // 路由前缀
            prefix: '/trpc',
            
            // tRPC路由定义
            router: appRouter
        })
    ],
    providers: [GreetingService],
    controllers: []
})
export class AppModule {}

测试

打开终端并进入项目目录,执行下面代码:

bash 复制代码
pnpm start:dev

浏览器访问 http://localhost:5200/trpc/post.list

对了,这里我把默认3000端口改成5200了,^_^

如果你想调用user下的greeting接口,你可以这么访问:/trpc/user.greeting?input={"name": "小卢同学"}

这样,NestJS就成功接入了tRPC。

React接入tRPC

现在这是我们的项目结构:

apps下有两个项目,service对应的是我们的Nest后端服务,client是我已经提前创建好的前端项目。

需要注意的是,这里前端项目使用rsbuild构建,当然如果你使用的是vite或webpack,也是可以的,看个人喜好。

引用后端项目

为了让tRPC端到端的类型支持能够在前端正常使用,我们需要让前端项目对后端项目进行引用,即配置一下前端项目的reference。

修改client/tsconfig.json,添加reference:

json 复制代码
{
  ...
  ...
  "references": [
    {
      "path": "../service/tsconfig.app.json"
    }
  ]
}

然后我们可以配置一下paths字段,让它有一个alias指向service项目,方便代码里引用后端项目暴露的类型:

json 复制代码
"compilerOptions": {
  ...
  ...
  "baseUrl": "./",
  "paths": {
    ...
    ...
    "@service": ["../service/src"]
  }
},
"include": [
  "src"
],
"references": [
  {
    "path": "../service/tsconfig.app.json"
  }
]

这里我们添加了这一行:"@service": ["../service/src"]

PS. 这里的配置是为了让typescript能够跨项目支持到Nest后端中 type AppRouter = typeof appRouter,此处由于是两个项目,AppRouter的类型不能被前端项目正确识别,因此需要建立项目间的引用关系。

引入tRPC依赖

接下来我们需要引入tRPC相关的前端依赖:

bash 复制代码
pnpm add @trpc/client @trpc/react-query @tanstack/react-query^4.0.0

这里使用官网推荐的方式,引入react-query进行接入。

封装全局Context组件

在src/widgets下新建一个tRPCContext文件夹。

首先我们需要初始化tRPC的react-query实例,在tRPCContext中新建 context.ts

typescript 复制代码
// src/widgets/tRPCContext/context.ts
import { createTRPCReact } from '@trpc/react-query'

import { AppRouter } from '@service'

export const trpc = createTRPCReact<AppRouter>()

这里我们从后端导入了 AppRouter 类型,并使用 createTRPCReact<AppRouter>() 这个方法创建了一个tRPC客户端。

然后我们创建一个 TRPCContext 组件:

typescript 复制代码
// src/widgets/tRPCContext/index.tsx
import React, { PropsWithChildren, useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'

// 导出trpc实例
import { trpc } from './context'

interface ItRPCContextProps {
    urls: string[]
}

export const TRPCContext: React.FC<PropsWithChildren<ItRPCContextProps>> = ({ children, urls }) => {
    const [queryClient] = useState(() => new QueryClient())
    const [trpcClient] = useState(() =>
        trpc.createClient({
            links: urls.map(url => httpBatchLink({ url }))
        })
    )

    return (
        <trpc.Provider client={trpcClient} queryClient={queryClient}>
            <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
        </trpc.Provider>
    )
}

以上大部分都是react-query的相关写法,这里就不再赘述了,这里我们重点看:

typescript 复制代码
const [trpcClient] = useState(() =>
    trpc.createClient({
        links: urls.map(url => httpBatchLink({ url }))
    })
)

这里urls是TRPCContext接受的参数,tRPC可以指定多个服务端连接来进行负载均衡或故障转移,具体来说,当传入多个链接时,tRPC 会根据一定的策略(比如轮询、随机选择等)来选择一个链接进行请求。如果选择的链接出现故障或不可用,tRPC 会自动切换到其他可用的链接,以确保请求的可靠性和稳定性。

使用TRPCContext

定义完组件之后,我们需要在前端入口文件处使用该组件:

typescript 复制代码
import React from 'react'
import ReactDOM from 'react-dom/client'

import { TRPCContext } from './widgets/tRPCContext'

import '@unocss/reset/tailwind-compat.css'
import '@assets/style.css'

import { Test } from './modules/Test'

const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(
    <React.StrictMode>
        <!-- 使用TRPCContext包裹子节点 -->
        <TRPCContext urls={['http://localhost:5200/trpc']}>
            <Test />
        </TRPCContext>
    </React.StrictMode>
)

这里我们对urls指定了后端项目运行监听的地址,然后我们就可以在子组件里使用 trpc.xxx.useQuery() 来访问后端的tRPC路由了:

typescript 复制代码
import { useState } from 'react'
import { trpc } from '@widgets/tRPCContext'

export const Test = () => {
    const [input, setInput] = useState('')
    const { data } = trpc.user.greeting.useQuery({ name: input })

    return (
        <div>
            <p>{data}</p>
            <input className="border-2 outline-none" type="text" onChange={e => setInput(e.target.value)} />
        </div>
    )
}

这里我们对input绑定了onChange事件,触发setInput更新input的值,然后使用 trpc.user.greeting.useQuery({ name: input }) 对后端进行请求,效果如下:

可以看到数据都是从后端过来的,不过你需要手动对onChange做一下防抖,防止像这样大量的请求发出。

不过使用react-query,你也可以这样:

typescript 复制代码
const { data, refetch } = trpc.user.greeting.useQuery({ name: input }, { enabled: false })

...
const onBtnGreetingClick = () => refetch()
...

通过 enabled: false 禁止自动提交,然后使用 refetch 方法手动触发提交。

PS. react-query还支持对相同请求参数的响应进行缓存,所以你不用在这块担心性能问题。

测试类型安全

接下来我们修改一下后端看看:

这里我对greeting接口添加了一个age参数,指定它为number类型,这时候我们去看看前端项目:

对应传参数的地方直接就报错了,然后告诉我要传入age参数,我们传一个字符串看看:

对于错误的参数类型也会马上爆出来。

结语

通过 tRPC 和 NestJS 的结合,我们能够打破前后端开发的边界。tRPC 的类型安全特性使得我们能够在开发过程中获得更好的开发体验和更高的代码质量,我非常推荐大家尝试这种开发方式。

最后,希望这篇文章能够帮助你更好地了解 tRPC 的类型安全特性以及它在 NestJS 中的应用,相关代码我放在github上了:

MartinLevine/nestjs-trpc-react-template: A full-stack development template based on NestJS and tRPC. (github.com)

各位小伙伴有收获的话请务必帮我点点star,这对我非常重要。

谢谢你们~

相关推荐
专注VB编程开发20年4 分钟前
jss html5-node.nodeType 属性用于表示节点的类型
前端·js
烛阴1 小时前
Promise无法中断?教你三招优雅实现异步任务取消
前端·javascript
GUIQU.1 小时前
【Vue】单元测试(Jest/Vue Test Utils)
前端·vue.js
暮乘白帝过重山1 小时前
Ollama 在本地分析文件夹中的文件
前端·chrome·ollama
一只小风华~1 小时前
Web前端开发:CSS Float(浮动)与 Positioning(定位)
前端·css·html·html5·web
前端张三1 小时前
vue3中ref在js中为什么需要.value才能获取/修改值?
前端·javascript·vue.js
moyu841 小时前
前端从后端获取数据的流程与指南
前端
涛哥码咖2 小时前
Rule.resourceQuery(通过路径参数指定loader匹配规则)
前端·webpack
夕水2 小时前
这个提升效率宝藏级工具一定要收藏使用
前端·javascript·trae
会飞的鱼先生3 小时前
vue3 内置组件KeepAlive的使用
前端·javascript·vue.js