跳至主内容

在 tRPC 中使用服务器动作

· 1 分钟阅读
Julius Marminge
tRPC Core Team Member
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

tRPC v10 引入的过程创建器模式(builder-pattern)获得了社区的高度认可,许多库都采用了类似模式。甚至出现了tRPC like XYZ这样的术语,证明该模式日益流行。事实上,最近我看到有人询问是否能用类似 tRPC 的 API 编写 CLI 应用。顺带一提,你甚至可以直接使用 tRPC 实现这个需求。但今天我们要讨论的重点不是这个,而是如何在 Next.js 的服务器动作中使用 tRPC。

什么是服务器动作?

如果你最近没有关注 ReactNext.js 的最新特性,服务器动作允许你编写在服务端执行的常规函数,然后在客户端导入并像普通函数一样调用。你可能觉得这听起来和 tRPC 很相似——确实如此。根据 Dan Abramov 的说法,服务器动作是"打包器特性版的 tRPC":

这个描述非常准确,服务器动作确实类似于 tRPC,毕竟它们本质上都是 RPC。两者都允许你在后端编写函数,并在前端通过抽象化的网络层以完全类型安全的方式调用。

那么 tRPC 的定位是什么?为什么需要同时使用 tRPC 和服务器动作?服务器动作是基础原语,而所有原语都相对简陋,在构建 API 时缺乏关键能力。任何通过网络暴露的 API 端点都需要验证和授权机制来防止恶意使用。如前所述,tRPC 的 API 设计广受好评,那么能否用 tRPC 定义服务器动作,并利用其内置的强大功能呢?包括输入验证、通过中间件实现的认证鉴权、输出验证、数据转换器等。我认为完全可以,下面让我们深入探讨。

使用 tRPC 定义服务器动作

备注

先决条件: 使用服务器动作需要 Next.js App Router。此外,我们将使用的 tRPC 功能仅适用于 v11 版本,请确保使用 tRPC 的 beta 发布渠道:

npm install @trpc/server

让我们从初始化 tRPC 和定义基础服务器动作过程开始。我们将使用过程构建器上的 experimental_caller 方法——这个新方法允许自定义过程被函数调用时的执行方式。同时使用适配器 experimental_nextAppDirCaller 使其兼容 Next.js。该适配器会处理客户端使用 useActionState 包裹服务器动作的情况,这种情况会改变服务器动作的调用签名

由于没有常规路由路径(例如 user.byId),我们将使用 span 属性作为元数据。你可以利用这个属性区分不同过程,例如在日志记录或可观测性场景中。

server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
);
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
);

接下来我们将添加上下文。由于不会通过常规 HTTP 适配器托管路由,我们无法通过适配器的 createContext 方法注入上下文。取而代之的是使用中间件注入上下文。本例中,我们将从会话中获取当前用户并注入上下文。

server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
});
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
});

最后,我们将创建一个 protectedAction 过程,用于保护所有操作免受未认证用户的访问。如果你已有现成的中间件实现此功能,可以直接复用;这里我将以内联方式定义一个示例。

server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
export const protectedAction = serverActionProcedure.use((opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
 
return opts.next({
ctx: {
...opts.ctx,
user: opts.ctx.user, // <-- ensures type is non-nullable
(property) user: User
},
});
});
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
export const protectedAction = serverActionProcedure.use((opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
 
return opts.next({
ctx: {
...opts.ctx,
user: opts.ctx.user, // <-- ensures type is non-nullable
(property) user: User
},
});
});

现在,让我们编写一个实际的服务器操作。创建一个 _actions.ts 文件,添加 "use server" 指令,然后定义你的操作。

app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = 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;
const createPost: (input: { title: string; }) => Promise<void>
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = 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;
const createPost: (input: { title: string; }) => Promise<void>

看,定义服务器操作竟如此简单:既能防止未认证用户访问,又通过输入验证抵御 SQL 注入等攻击。现在让我们在客户端导入并调用这个函数。

app/post-form.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await createPost({ title });
}}
>
<input type="text" name="title" />
<button type="submit">Create Post</button>
</form>
);
}
app/post-form.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await 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 context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export 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 context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export 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.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
server/trpc.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = 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.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = 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-actionzsa 等库,它们同样提供类型安全且经过输入验证的服务器操作方案。

想查看实际应用案例?请访问 Trellix tRPC,这是我近期使用这些新原语构建的示例应用。

期待你的反馈

你对此有何看法?欢迎在 Github 讨论区分享意见,帮助我们迭代优化这些基础功能。

目前仍有改进空间,尤其在错误处理方面。Next.js 提倡返回错误对象,我们致力于实现完全类型安全的方案。可参考 Alex 的 WIP PR 了解早期探索。

下次再见,编码愉快!