Create API Contracts (GraphQL, tRPC, oRPC) and Workarounds

打造前后端 API 数据合约(GraphQL、tRPC、oRPC)与相关变通方案

前言

使用 TypeScript 确保程序类型安全以维护大型前端项目的质量与可维护性已经是成熟常见的做法,但有一项破口会让程序变得脆弱且混乱:「程式间 API 沟通」。

TypeScript 本身虽然能静态检查程序内部的类型正确性,但 API 来自外部,任何意料之外的数据都可能被抛过来,只能在程序执行阶段验证数据格式才能真正确保类型安全。

现阶段问题

通常前端开发者需要依赖外部文件像是 Swagger🔗OpenAPI🔗 等来了解 API 格式,并自己在前端描述数据格式,最简单就是断言数据类型。

// 1. 定义类型
type User = {
id: number
name: string
email: string
}
// 2. fetch 调用 API
async function fetchUser(userId: number): Promise<User> {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// 3. 将 response.json() 的结果断言成 User
const data = (await response.json()) as User
return data
}

很大的问题在于前后端对于 API 格式都依赖「信任」,但只要回应不如预期例如都会让程序不知不觉的于执行时产生错误或影响开发体验:

  • 后端改动导致回应改变
  • 前端偷懒或赶时间跳过定义类型
  • 拼写错误

另外一个问题是就算以上问题都没有发生,前后端仍存在 数据格式定义重复的问题,相同的数据需要前后端反复进行描述以确保类型安全。

为了解决以上问题,最好程序之间沟通可以有某种强制约束性且统一的合约,定义一份数据格式供双方通用。

GraphQL

GraphQL Schema🔗 通过自动化代码生成与类型检查,让前后端共享一份强制约束的合约。

以下范例:定义数据 → 索取数据 → 拿到数据

type Project {
name: String
tagline: String
contributors: [User]
}
{
project(name: "GraphQL") {
tagline
}
}
{
"project": {
"tagline": "A query language for APIs"
}
}
  • 优点:
    • 统一通用 Schema 定义,与特定编程语言无关,因此跨语言友好。
    • 丰富的工具链(类型生成、自动文档化、Playground)。
    • 解决 overfetching 和 underfetching 问题。
  • 缺点:
    • 像是缓存、复杂度、概念上与传统 REST 完全不同,可能对旧有系统和习惯有极大的负担。
    • 查询复杂度管理困难(容易过度查询、N+1 问题)。

RPC

RPC(Remote Procedure Call,远程过程调用)是一种通讯方式,让前端像调用本地函数一样去调用后端方法。 例如,前端只需要调用 getUserById(123),背后会自动传送请求到后端的 getUserById 方法,并把回应结果返回,不需要手动处理 HTTP request/response 的细节。

tRPC

让前后端通过 TypeScript 紧密合作的 RPC 框架

以下范例:client 调用 → 发 HTTP 请求 → server 找到 procedure → 回传 JSON

const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({ url: 'http://localhost:3000/trpc' }),
],
});
await trpc.post.create.mutate({ title: "Hello" }); // 背后实际发送请求 POST http://localhost:3000/trpc/post.create
const appRouter = t.router({
post: t.router({
create: t.procedure.input(z.object({ title: z.string() }))
.mutation(({ input }) => db.post.create(input)),
}),
});
  • 优点:
    • 与 TypeScript 类型系统深度整合。
    • 前后端共用一份 API 定义,不需要额外维护文档或类型。
  • 缺点
    • 像是缓存、复杂度、概念上与传统 REST 完全不同,可能对旧有系统和习惯有极大的负担。
    • client 与 server 绑定在一起耦合度高不易拆分。
    • 强烈绑定 TypeScript 类型系统,共享相同的 TypeScript 项目结构。

oRPC

让前后端通过 OpenAPI 紧密合作的 RPC 框架
openapi.yaml
paths:
/users/{id}:
get:
operationId: getUser
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
// 通过 codegen 自动生成
import { getUser } from "./api-client";
const user = await getUser({ id: 123 });
// user 类型会自动对应 OpenAPI 的 User
  • 优点:

    • 使用 OpenAPI 标准,跨语言支持良好。
    • 可通过工具链自动产生类型与文档。
    • API 合约独立于实现。
  • 缺点:

    • 相较 tRPC 缺乏 TypeScript 即时推导的便利性。
    • codegen 过程可能需要额外维护(例如 CI/CD 流程整合)。
    • 社区生态仍在发展中。

变通方法

Runtime 验证

如果没办法通过以上方式统一前后端类型,我们仍然可以在前端主动进行 Runtime 检测,通过 zod🔗 这类验证器来确保类型安全。

const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
const data = await response.json();
const parsed = UserSchema.parse(data);

共享类型

如果前后端共享相同语言(TypeScript)可以将共享类型抽出成独立套件或组成 Monorepo、Monolith 架构,直接共享类型定义。

延伸阅读