使用 tRPC 和 Deno 构建类型安全的 API
Deno 是一个一体化、零配置的工具链,用于编写 JavaScript 和 TypeScript,并原生支持 Web 平台 API,使其成为快速构建后端和 API 的理想选择。为了让我们的 API 更易于维护,我们可以使用 tRPC,这是一个 TypeScript RPC(远程过程调用)框架,它使你无需模式声明或代码生成即可构建完全类型安全的 API。
在本教程中,我们将使用 tRPC 和 Deno 构建一个简单的类型安全 API,用于返回恐龙信息
你可以在此 GitHub 仓库中找到本教程的所有代码。
🚨️ Deno 2 已发布。 🚨️
凭借对 Node/npm 的向后兼容性、内置包管理、一体化零配置工具链,以及原生 TypeScript 和 Web API 支持,编写 JavaScript 从未如此简单。
设置 tRPC
要在 Deno 中开始使用 tRPC,我们需要安装所需的依赖项。得益于 Deno 的 npm 兼容性,我们可以使用 npm 版本的 tRPC 包以及 Zod 进行输入验证
deno install npm:@trpc/server@next npm:@trpc/client@next npm:zod jsr:@std/path
这会安装最新的 tRPC 服务器和客户端包,Zod 用于运行时类型验证,以及 Deno 标准库的 `path` 工具。这些包将使我们能够在客户端和服务器代码之间构建一个类型安全的 API 层。
这将在项目根目录中创建一个 `deno.json` 文件,用于管理 npm 和 jsr 依赖项
{
"imports": {
"@std/path": "jsr:@std/path@^1.0.6",
"@trpc/client": "npm:@trpc/client@^11.0.0-rc.593",
"@trpc/server": "npm:@trpc/server@^11.0.0-rc.593",
"zod": "npm:zod@^3.23.8"
}
}
设置 tRPC 服务器
构建 tRPC 应用的第一步是设置服务器。我们将首先初始化 tRPC 并创建基础路由器和过程构建器。这些将是定义 API 端点的基础。
创建 `server/trpc.ts` 文件
// server/trpc.ts
import { initTRPC } from "@trpc/server";
/**
* Initialization of tRPC backend
* Should be done only once per backend!
*/
const t = initTRPC.create();
/**
* Export reusable router and procedure helpers
* that can be used throughout the router
*/
export const router = t.router;
export const publicProcedure = t.procedure;
这会初始化 tRPC 并导出我们将用于定义 API 端点的路由器和过程构建器。`publicProcedure` 允许我们创建不需要身份验证的端点。
接下来,我们将创建一个简单的数据层来管理我们的恐龙数据。使用以下内容创建 `server/db.ts` 文件
// server/db.ts
import { join } from "@std/path";
type Dino = { name: string; description: string };
const dataPath = join("data", "data.json");
async function readData(): Promise<Dino[]> {
const data = await Deno.readTextFile(dataPath);
return JSON.parse(data);
}
async function writeData(dinos: Dino[]): Promise<void> {
await Deno.writeTextFile(dataPath, JSON.stringify(dinos, null, 2));
}
export const db = {
dino: {
findMany: () => readData(),
findByName: async (name: string) => {
const dinos = await readData();
return dinos.find((dino) => dino.name === name);
},
create: async (data: { name: string; description: string }) => {
const dinos = await readData();
const newDino = { ...data };
dinos.push(newDino);
await writeData(dinos);
return newDino;
},
},
};
这会创建一个简单的文件型数据库,用于将恐龙数据读取和写入 JSON 文件。在生产环境中,你通常会使用适当的数据库,但这对于我们的演示来说效果很好。
⚠️️ 在本教程中,我们硬编码数据并使用文件型数据库。但是,你可以连接到各种数据库并使用 ORM,例如 Drizzle 或 Prisma。
最后,我们需要提供实际数据。让我们创建一个包含一些恐龙样本数据的 `./data.json` 文件
// data/data.json
[
{
"name": "Aardonyx",
"description": "An early stage in the evolution of sauropods."
},
{
"name": "Abelisaurus",
"description": "\"Abel's lizard\" has been reconstructed from a single skull."
},
{
"name": "Abrictosaurus",
"description": "An early relative of Heterodontosaurus."
},
{
"name": "Abrosaurus",
"description": "A close Asian relative of Camarasaurus."
},
...
]
现在,我们可以创建定义 tRPC 路由器和过程的主服务器文件。创建 `server/index.ts` 文件
// server/index.ts
import { createHTTPServer } from "@trpc/server/adapters/standalone";
import { z } from "zod";
import { db } from "./db.ts";
import { publicProcedure, router } from "./trpc.ts";
const appRouter = router({
dino: {
list: publicProcedure.query(async () => {
const dinos = await db.dino.findMany();
return dinos;
}),
byName: publicProcedure.input(z.string()).query(async (opts) => {
const { input } = opts;
const dino = await db.dino.findByName(input);
return dino;
}),
create: publicProcedure
.input(z.object({ name: z.string(), description: z.string() }))
.mutation(async (opts) => {
const { input } = opts;
const dino = await db.dino.create(input);
return dino;
}),
},
examples: {
iterable: publicProcedure.query(async function* () {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve, 500));
yield i;
}
}),
},
});
// Export type router type signature, this is used by the client.
export type AppRouter = typeof appRouter;
const server = createHTTPServer({
router: appRouter,
});
server.listen(3000);
这设置了三个主要端点
- `dino.list`:返回所有恐龙
- `dino.byName`:按名称返回特定的恐龙
- `dino.create`:创建一个新的恐龙
- `examples.iterable`:tRPC 对异步迭代器支持的演示
服务器配置为监听端口 3000,并将处理所有 tRPC 请求。
虽然你现在可以运行服务器,但你将无法访问任何路由并使其返回数据。让我们来解决这个问题!
设置 tRPC 客户端
服务器准备就绪后,我们可以创建一个客户端,以完全类型安全的方式调用我们的 API。创建 `client/index.ts` 文件
// client/index.ts
/**
* This is the client-side code that uses the inferred types from the server
*/
import {
createTRPCClient,
splitLink,
unstable_httpBatchStreamLink,
unstable_httpSubscriptionLink,
} from "@trpc/client";
/**
* We only import the `AppRouter` type from the server - this is not available at runtime
*/
import type { AppRouter } from "../server/index.ts";
// Initialize the tRPC client
const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === "subscription",
true: unstable_httpSubscriptionLink({
url: "https://:3000",
}),
false: unstable_httpBatchStreamLink({
url: "https://:3000",
}),
}),
],
});
const dinos = await trpc.dino.list.query();
console.log("Dinos:", dinos);
const createdDino = await trpc.dino.create.mutate({
name: "Denosaur",
description:
"A dinosaur that lives in the deno ecosystem. Eats Nodes for breakfast.",
});
console.log("Created dino:", createdDino);
const dino = await trpc.dino.byName.query("Denosaur");
console.log("Denosaur:", dino);
const iterable = await trpc.examples.iterable.query();
for await (const i of iterable) {
console.log("Iterable:", i);
}
这段客户端代码展示了 tRPC 的几个关键特性
- **来自服务器路由器的类型推断**。客户端通过 `AppRouter` 类型导入自动继承服务器的所有类型定义。这意味着你可以获得对所有 API 调用的完整类型支持和编译时类型检查。如果你在服务器上修改一个过程,TypeScript 将立即标记任何不兼容的客户端用法。
- **进行查询和修改(mutations)**。该示例演示了两种类型的 API 调用:用于获取无副作用数据的查询(`list` 和 `byName`),以及用于修改服务器端状态的操作(`create`)。客户端自动知道每个过程的输入和输出类型,从而在整个请求生命周期中提供类型安全。
- **使用异步迭代器**。`examples.iterable` 演示了 tRPC 对使用异步迭代器进行数据流传输的支持。此功能对于实时更新或分块处理大型数据集特别有用。
现在,让我们启动服务器来看看它的实际效果。在我们的 `deno.json` 配置文件中,让我们创建一个新的 `tasks` 属性,包含以下命令
{
"tasks": {
"start": "deno -A server/index.ts",
"client": "deno -A client/index.ts"
}
// Other properties in deno.json remain the same.
}
我们可以使用 `deno task` 列出可用任务
deno task
Available tasks:
- start
deno -A server/index.ts
- client
deno -A client/index.ts
现在,我们可以使用 `deno task start` 启动服务器。服务器运行后,我们可以使用 `deno task client` 运行客户端。你将看到如下输出
deno task client
Dinos: [
{
name: "Aardonyx",
description: "An early stage in the evolution of sauropods."
},
{
name: "Abelisaurus",
description: "Abel's lizard has been reconstructed from a single skull."
},
{
name: "Abrictosaurus",
description: "An early relative of Heterodontosaurus."
},
...
]
Created dino: {
name: "Denosaur",
description: "A dinosaur that lives in the deno ecosystem. Eats Nodes for breakfast."
}
Denosaur: {
name: "Denosaur",
description: "A dinosaur that lives in the deno ecosystem. Eats Nodes for breakfast."
}
Iterable: 0
Iterable: 1
Iterable: 2
成功!运行 `./client/index.ts` 展示了如何创建 tRPC 客户端并使用其 JavaScript API 与数据库交互。但是我们如何检查 tRPC 客户端是否从数据库推断出了正确的类型呢?让我们修改 `./client/index.ts` 中的以下代码片段,将 `description` 传入 `number` 而不是 `string`
// ...
const createdDino = await trpc.dino.create.mutate({
name: "Denosaur",
description:
- "A dinosaur that lives in the deno ecosystem. Eats Nodes for breakfast.",
+ 100,
});
console.log("Created dino:", createdDino);
// ...
当我们重新运行客户端时
deno task client
...
error: Uncaught (in promise) TRPCClientError: [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": [
"description"
],
"message": "Expected string, received number"
}
]
at Function.from (file:///Users/andyjiang/Library/Caches/deno/npm/registry.npmjs.org/@trpc/client/11.0.0-rc.608/dist/TRPCClientError.mjs:35:20)
at file:///Users/andyjiang/Library/Caches/deno/npm/registry.npmjs.org/@trpc/client/11.0.0-rc.608/dist/links/httpBatchStreamLink.mjs:118:56
at eventLoopTick (ext:core/01_core.js:175:7)
tRPC 成功抛出了一个 `invalid_type` 错误,因为它期望的是 `string` 而不是 `number`。
下一步是什么?
既然你已经对如何在 Deno 中使用 tRPC 有了基本的了解,你可以
- 使用 Next.js 或 React 构建实际的前端
- 使用 tRPC 中间件为你的 API 添加身份验证
- 使用 tRPC 订阅实现实时功能
- 为更复杂的数据结构添加输入验证
- 集成 PostgreSQL 等合适的数据库,或使用 Drizzle 或 Prisma 等 ORM
- 将你的应用部署到 Deno Deploy 或通过 Docker 部署到任何公共云
🦕 使用 Deno 和 tRPC 愉快地进行类型安全编码!
🚨️ 想了解更多 Deno 吗? 🚨️
查看我们新的 Deno 学习教程系列,你将在其中学习
…以及更多内容,都在简短、易懂的视频中。新教程每周二和周四发布。