在 tRPC 中使用服务器动作
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
tRPC v10 引入的过程创建器模式(builder-pattern)获得了社区的高度认可,许多库都采用了类似模式。甚至出现了tRPC like XYZ这样的术语,证明该模式日益流行。事实上,最近我看到有人询问是否能用类似 tRPC 的 API 编写 CLI 应用。顺带一提,你甚至可以直接使用 tRPC 实现这个需求。但今天我们要讨论的重点不是这个,而是如何在 Next.js 的服务器动作中使用 tRPC。
什么是服务器动作?
如果你最近没有关注 React 和 Next.js 的最新特性,服务器动作允许你编写在服务端执行的常规函数,然后在客户端导入并像普通函数一样调用。你可能觉得这听起来和 tRPC 很相似——确实如此。根据 Dan Abramov 的说法,服务器动作是"打包器特性版的 tRPC":
这个描述非常准确,服务器动作确实类似于 tRPC,毕竟它们本质上都是 RPC。两者都允许你在后端编写函数,并在前端通过抽象化的网络层以完全类型安全的方式调用。
那么 tRPC 的定位是什么?为什么需要同时使用 tRPC 和服务器动作?服务器动作是基础原语,而所有原语都相对简陋,在构建 API 时缺乏关键能力。任何通过网络暴露的 API 端点都需要验证和授权机制来防止恶意使用。如前所述,tRPC 的 API 设计广受好评,那么能否用 tRPC 定义服务器动作,并利用其内置的强大功能呢?包括输入验证、通过中间件实现的认证鉴权、输出验证、数据转换器等。我认为完全可以,下面让我们深入探讨。
使用 tRPC 定义服务器动作
先决条件: 使用服务器动作需要 Next.js App Router。此外,我们将使用的 tRPC 功能仅适用于 v11 版本,请确保使用 tRPC 的 beta 发布渠道:
- npm
- yarn
- pnpm
- bun
- deno
npm install @trpc/server
yarn add @trpc/server
pnpm add @trpc/server
bun add @trpc/server
deno add npm:@trpc/server
让我们从初始化 tRPC 和定义基础服务器动作过程开始。我们将使用过程构建器上的 experimental_caller 方法——这个新方法允许自定义过程被函数调用时的执行方式。同时使用适配器 experimental_nextAppDirCaller 使其兼容 Next.js。该适配器会处理客户端使用 useActionState 包裹服务器动作的情况,这种情况会改变服务器动作的调用签名。
由于没有常规路由路径(例如 user.byId),我们将使用 span 属性作为元数据。你可以利用这个属性区分不同过程,例如在日志记录或可观测性场景中。
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),);
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),);
接下来我们将添加上下文。由于不会通过常规 HTTP 适配器托管路由,我们无法通过适配器的 createContext 方法注入上下文。取而代之的是使用中间件注入上下文。本例中,我们将从会话中获取当前用户并注入上下文。
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});
最后,我们将创建一个 protectedAction 过程,用于保护所有操作免受未认证用户的访问。如果你已有现成的中间件实现此功能,可以直接复用;这里我将以内联方式定义一个示例。
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});export constprotectedAction =serverActionProcedure .use ((opts ) => {if (!opts .ctx .user ) {throw newTRPCError ({code : 'UNAUTHORIZED',});}returnopts .next ({ctx : {...opts .ctx ,user :opts .ctx .user , // <-- ensures type is non-nullable},});});
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});export constprotectedAction =serverActionProcedure .use ((opts ) => {if (!opts .ctx .user ) {throw newTRPCError ({code : 'UNAUTHORIZED',});}returnopts .next ({ctx : {...opts .ctx ,user :opts .ctx .user , // <-- ensures type is non-nullable},});});
现在,让我们编写一个实际的服务器操作。创建一个 _actions.ts 文件,添加 "use server" 指令,然后定义你的操作。
app/_actions.tsts'use server';import {z } from 'zod';import {protectedAction } from '../server/trpc';export constcreatePost =protectedAction .input (z .object ({title :z .string (),}),).mutation (async (opts ) => {// Do something with the input});// Since we're using the `experimental_caller`,// our procedure is now just an ordinary function:createPost ;
app/_actions.tsts'use server';import {z } from 'zod';import {protectedAction } from '../server/trpc';export constcreatePost =protectedAction .input (z .object ({title :z .string (),}),).mutation (async (opts ) => {// Do something with the input});// Since we're using the `experimental_caller`,// our procedure is now just an ordinary function:createPost ;
看,定义服务器操作竟如此简单:既能防止未认证用户访问,又通过输入验证抵御 SQL 注入等攻击。现在让我们在客户端导入并调用这个函数。
app/post-form.tsxtsx'use client';import { createPost } from '~/_actions';export function PostForm() {return (<form// Use `action` to make form progressively enhancedaction={createPost}// `Using `onSubmit` allows building rich interactive// forms once JavaScript has loadedonSubmit={async (e) => {e.preventDefault();const title = new FormData(e.target).get('title');// Maybe show loading toast, etc etc. Endless possibilitiesawait createPost({ title });}}><input type="text" name="title" /><button type="submit">Create Post</button></form>);}
app/post-form.tsxtsx'use client';import { createPost } from '~/_actions';export function PostForm() {return (<form// Use `action` to make form progressively enhancedaction={createPost}// `Using `onSubmit` allows building rich interactive// forms once JavaScript has loadedonSubmit={async (e) => {e.preventDefault();const title = new FormData(e.target).get('title');// Maybe show loading toast, etc etc. Endless possibilitiesawait createPost({ title });}}><input type="text" name="title" /><button type="submit">Create Post</button></form>);}
高级扩展
利用 tRPC 的构建器模式及其可组合的过程定义能力,我们可以轻松构建更复杂的服务器操作。以下是一些示例:
可观测性
只需几行代码即可使用 @baselime/node-opentelemtry 的 tRPC 插件实现可观测性:
diff--- server/trpc.ts+++ server/trpc.ts+ import { tracing } from '@baselime/node-opentelemetry/trpc';export const serverActionProcedure = t.procedure.experimental_caller(experimental_nextAppDirCaller({pathExtractor: (meta: Meta) => meta.span,}),).use(async (opts) => {// Inject user into contextconst user = await currentUser();return opts.next({ ctx: { user } });})+ .use(tracing());--- app/_actions.ts+++ app/_actions.tsexport const createPost = protectedAction+ .meta({ span: 'create-post' }).input(z.object({title: z.string(),}),).mutation(async (opts) => {// Do something with the input});
diff--- server/trpc.ts+++ server/trpc.ts+ import { tracing } from '@baselime/node-opentelemetry/trpc';export const serverActionProcedure = t.procedure.experimental_caller(experimental_nextAppDirCaller({pathExtractor: (meta: Meta) => meta.span,}),).use(async (opts) => {// Inject user into contextconst user = await currentUser();return opts.next({ ctx: { user } });})+ .use(tracing());--- app/_actions.ts+++ app/_actions.tsexport const createPost = protectedAction+ .meta({ span: 'create-post' }).input(z.object({title: z.string(),}),).mutation(async (opts) => {// Do something with the input});
详见 Baselime tRPC 集成文档。类似模式应适用于其他可观测性平台。
速率限制
可使用 Unkey 等服务对服务器操作进行速率限制。这是一个使用 Unkey 按用户限制请求次数的受保护操作示例:
server/trpc.tstsimport {Ratelimit } from '@unkey/ratelimit';export constrateLimitedAction =protectedAction .use (async (opts ) => {constunkey = newRatelimit ({rootKey :process .env .UNKEY_ROOT_KEY !,async : true,duration : '10s',limit : 5,namespace : `trpc_${opts .path }`,});constratelimit = awaitunkey .limit (opts .ctx .user .id );if (!ratelimit .success ) {throw newTRPCError ({code : 'TOO_MANY_REQUESTS',message :JSON .stringify (ratelimit ),});}returnopts .next ();});
server/trpc.tstsimport {Ratelimit } from '@unkey/ratelimit';export constrateLimitedAction =protectedAction .use (async (opts ) => {constunkey = newRatelimit ({rootKey :process .env .UNKEY_ROOT_KEY !,async : true,duration : '10s',limit : 5,namespace : `trpc_${opts .path }`,});constratelimit = awaitunkey .limit (opts .ctx .user .id );if (!ratelimit .success ) {throw newTRPCError ({code : 'TOO_MANY_REQUESTS',message :JSON .stringify (ratelimit ),});}returnopts .next ();});
app/_actions.tsts'use server';import {z } from 'zod';import {rateLimitedAction } from '../server/trpc';export constcommentOnPost =rateLimitedAction .input (z .object ({postId :z .string (),content :z .string (),}),).mutation (async (opts ) => {console .log (`${opts .ctx .user .name } commented on ${opts .input .postId } saying ${opts .input .content }`,);});
app/_actions.tsts'use server';import {z } from 'zod';import {rateLimitedAction } from '../server/trpc';export constcommentOnPost =rateLimitedAction .input (z .object ({postId :z .string (),content :z .string (),}),).mutation (async (opts ) => {console .log (`${opts .ctx .user .name } commented on ${opts .input .postId } saying ${opts .input .content }`,);});
阅读 Unkey 团队关于 tRPC 速率限制的博文获取更多信息。
可能性是无限的,相信你已经积累了许多优秀的实用中间件。如果还没有,不妨探索现成的 npm install 方案!
结语
服务器操作绝非银弹。对于需要动态数据的场景,建议保持客户端 React Query 缓存,通过 useMutation 执行变更。这完全合理。这些新原语支持渐进式采用,可在适当时机将现有 tRPC API 中的单个过程迁移为服务器操作,无需重写整个 API。
通过 tRPC 定义服务器操作,你可以复用当前大量业务逻辑,并自由选择将变更暴露为服务器操作或传统变更方式。作为开发者,你有权选择最适合应用的模式。若尚未使用 tRPC,推荐探索 next-safe-action 和 zsa 等库,它们同样提供类型安全且经过输入验证的服务器操作方案。
想查看实际应用案例?请访问 Trellix tRPC,这是我近期使用这些新原语构建的示例应用。
期待你的反馈
你对此有何看法?欢迎在 Github 讨论区分享意见,帮助我们迭代优化这些基础功能。
目前仍有改进空间,尤其在错误处理方面。Next.js 提倡返回错误对象,我们致力于实现完全类型安全的方案。可参考 Alex 的 WIP PR 了解早期探索。
下次再见,编码愉快!
