原文
https://trpc.io/blog/trpc-actions
作者: Julius Marminge
我们将开发一个全新的网站, 需要用到 tRPC 作为技术栈之一, 有一些技巧性十足的细节需要被理解,因此需要理解一下 tRPC 的应用
RPC is short for "Remote Procedure Call"
RPC 是“远程过程调用”的缩写
tRPC -> TypeScript Remote Procedure Call
还有个 gRPC 的东东
gRPC is an acronym for "Google Remote Procedure Calls". Google created gRPC as an internal RPC framework, but it became open source in 2015 and the "g" is now considered generic or general-purpose.
下面是原文
引言
在 tRPC v10 中引入的 builder-pattern 受到了社区的广泛赞赏,许多库也采纳了类似的模式。甚至还出现了 tRPC like XYZ
这样的术语,以证明这种模式的日益流行。事实上,前几天我看到 有人在想是否可以用类似 tRPC 的 API 来编写 CLI 应用程序。顺便提一句,您甚至可以 直接使用 tRPC 来实现。但是今天我们不是要讨论这个,而是要讲如何将 tRPC 与 Next.js 的 server action 结合使用
什么是 server action
如果您一直在关注最新的 React 和 Next.js 特性, server action 允许您编写在服务器上执行的常规函数,然后在客户端导入并像调用普通函数一样调用它们。您可能会认为这听起来和 tRPC 类似,这确实是。根据 Dan Abramov 的说法,server action 是 tRPC 的一种 bundler 特性
这完全正确,server action 与 tRPC 相似,归根结底它们都是 RPC。它们都允许您在后端编写函数,并通过抽象的网络层在前端以完整的类型安全性调用这些函数
那么 tRPC 的作用是什么?我为什么需要同时使用 tRPC 和 server action?server action 是一个 primitive,所有 primitive 都相对简单,因此在构建 API 时缺乏一些基本方面。对于任何通过网络暴露的 API 端点,您需要验证和授权请求,以确保 API 不被恶意使用。如前所述,tRPC 的 API 受到社区的赞赏,那么如果我们可以使用 tRPC 来定义 server action,并利用 tRPC 内置的所有出色功能,比如输入验证、通过中间件进行身份验证和授权、输出验证、数据转换等,岂不是很好吗?我认为是的,所以让我们深入探讨
使用 tRPC 定义 server action
注意 前提条件:为了使用 server action,您需要使用 Next.js App Router。此外,我们将使用的所有 tRPC 相关内容仅在 tRPC v11 中可用,所以确保您使用的是 tRPC 的 beta release channel
npm install @trpc/server@next
我们首先初始化 tRPC 并定义基础的 server action 过程。我们将使用程序构建器上的 experimental_caller
方法,这是一个新方法,它允许您自定义在调用时该过程的调用方式。我们还将使用适配器 experimental_nextAppDirCaller
使其与 Next.js 兼容。此适配器将处理 server action 被包装在客户端的 useActionState
中的情况,这会 改变 server action 的调用签名
我们还将使用一个 span
属性作为 元数据,因为没有像使用路由器(例如 user.byId)那样的普通路径。您可以使用 span
属性来区分过程,例如在日志或观察时
// server/trpc.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 适配器来 hosting 路由器,所以不会通过适配器上的 createContext
方法注入任何上下文。相反,我们将使用中间件来注入我们的上下文。在这个例子中,我们将从 session 中检索当前用户,并将其注入上下文。
// server/trpc.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
过程,保护任何未经身份验证的用户的操作。如果您有现成的中间件可以做到这一点,您可以使用它,不过我会在这个例子中定义一个。
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 action。创建一个 _actions.ts
文件,用 "use server"
指令 decorate
它,并定义您的 action
'use server';
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
export const createPost = protectedAction
.input(
z.object({
title: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// 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>
哇哦,定义一个保护未验证用户的 server action 是如此简单,同时还有输入验证来防止 SQL 注入等攻击。现在让我们在客户端导入这个函数并调用它。
'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 构建器及其可组合的可重用过程定义方式,我们可以轻松构建更复杂的 server action。以下是一些示例
可观察性
您可以使用 @baselime/node-opentelemtry
的 trpc 插件以仅仅几行代码添加可观察性(observability)
--- 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 ({ ctx, input }) => {
// Do something with the input
});
有关更多信息,请查看 Baselime 的 tRPC 集成。类似的模式应适用于您使用的任何可观察性平台。
速率限制
您可以使用像 Unkey 这样的服务对您的 server action 进行速率限制。以下是一个使用 Unkey 对每个用户请求次数进行限制的保护 server action 示例
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();
});
'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 ({ ctx, input }) => {
console.log(
`${ctx.user.name} commented on ${input.postId} saying ${input.content}`,
);
});
在 Unkey 的帖子中 阅读有关对 tRPC 程序进行速率限制的更多信息。
可能性是无穷无尽的,我敢打赌您今天已经在 tRPC 应用程序中使用了许多很好的实用中间件。如果没有,您可能会找到一些可以 npm 安装的!
总结
Server action 绝不是万能的。在需要更多动态数据的地方,您可能希望将数据保留在客户端的 React Query 缓存中,并使用 useMutation
进行变更。这是完全合理的。这些新的 primitive 也应该容易逐步采用,因此您可以在合适的地方将现有 tRPC API 中的单个过程迁移到 server action,而无需重写整个 API
通过使用 tRPC 定义您的 server action,您可以共享许多当前使用的相同逻辑,并选择在哪里将变更暴露为 server action 或更传统的变更。作为开发者,您有权选择最适合您应用程序的模式。如果您今天还没有使用 tRPC,还有一些包(例如 next-safe-action 和 zsa)值得您查看,它们可以让您定义类型安全、输入验证的 server action
如果您想查看使用 action 的应用,可以查看我最近利用了这些新 primitive 开发的 Trellix tRPC 应用
您觉得怎么样?我们想听听您的反馈
您觉得怎么样?我们想听听您的反馈
那么,您觉得怎么样?请在 GitHub 上告诉我们,帮助我们迭代以使这些 primitive 达到稳定状态
还有一些工作要做,特别是在错误处理方面。Next.js 主张返回错误,我们希望尽可能做到类型安全。查看 Alex 的这个 WIP PR,了解早期的工作
Until next time, happy coding!
知道这些可以干什么?
当然是我们新网站中要用呀!
比如这种东东
'use client'
import superjson from 'superjson'
import { loggerLink } from '@trpc/client'
import {
experimental_createActionHook,
experimental_createTRPCNextAppDirClient,
experimental_serverActionLink
} from '@trpc/next/app-dir/client'
import { experimental_nextHttpLink } from '@trpc/next/app-dir/links/nextHttp'
import type { AppRouter } from '~/server/routers/_app'
const getBaseUrl = () => {
if (typeof window !== 'undefined') return ''
// if (process.env.KUN_PRODUCTION_ADDRESS) return `https://${process.env.KUN_PRODUCTION_ADDRESS}`
return process.env.KUN_PATCH_ADDRESS
}
export const api = experimental_createTRPCNextAppDirClient<AppRouter>({
config() {
return {
links: [
loggerLink({
enabled: (op) => true
}),
experimental_nextHttpLink({
transformer: superjson,
url: `${getBaseUrl()}/api/trpc`,
headers() {
return {
'x-trpc-source': 'client'
}
}
})
]
}
}
})
export const useAction = experimental_createActionHook<AppRouter>({
links: [
loggerLink(),
experimental_serverActionLink({
transformer: superjson
})
]
})
里面。。。
experimental_createActionHook,
experimental_createTRPCNextAppDirClient,
experimental_serverActionLink
好像有点太阴间了,希望写完能跑.webp