一个接口能够返回 JSON,只说明通信链路通了。真正的 API 设计还需要回答:谁可以调用、哪些字段可信、重复提交会发生什么、同时修改会不会覆盖数据,以及客户端如何从失败中恢复。
本文围绕一个文章管理系统展开:访客可以阅读已发布文章;作者可以创建和修改自己的文章;管理员可以管理全部文章。文章包含标题、正文、分类、作者和版本号。REST 与 GraphQL 会使用同一套业务规则,方便比较它们的表达方式。
文中的 TypeScript 示例用于解释边界。Zod 校验片段可以在安装 Zod 的 TypeScript 环境中独立运行;涉及 articleService、数据库和认证中间件的片段是架构示意,不是本博客已经实现的后端。
1. 先看一次请求如何经过后端
假设作者提交了一篇新文章:
POST /api/articles HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json
{
"title": "Understanding API design",
"body": "A practical introduction to API boundaries.",
"categoryId": "backend"
}一次典型处理过程可以拆成以下步骤:
请求到达
→ 检查请求大小、Content-Type,并解析 JSON
→ 验证凭证,得到当前调用者
→ 校验字段格式,得到可信的输入对象
→ 加载相关资源,检查权限与业务约束
→ 在事务中执行写入
→ 转换为对外响应,记录结果与耗时这不是固定不变的中间件顺序。例如网关可以在解析正文前拒绝没有凭证的请求;修改文章时,需要先识别文章,才能检查它属于谁。关键是每层有明确职责,不能让数据库写入发生在必要的检查之前。
还要区分三种数据:原始请求 req.body、校验通过后的输入,以及数据库实体。它们不应该直接互相替代。客户端没有提交 authorId,服务端应从认证结果确定作者;数据库里即使有内部审核备注,也不代表响应应该把它公开。
2. 认证与授权:确认身份之后还要检查权限
2.1 Authentication 与 Authorization
认证回答“调用者是谁”,授权回答“这个调用者能否对这个资源执行这个操作”。登录成功的 Alice 不应该因此获得修改 Bob 文章的权限。
type Actor = {
id: string;
role: "author" | "admin";
};
type ArticleOwner = {
authorId: string;
};
function canEditArticle(actor: Actor, article: ArticleOwner): boolean {
return actor.role === "admin" || article.authorId === actor.id;
}这里的 actor 必须来自服务端验证过的身份,不能从请求正文读取 role。如果系统支持多个组织,还需要检查文章与调用者所属的租户,不能仅凭全局角色名称放行。
隐藏前端的“编辑”按钮只能改善体验。权限检查必须覆盖直接发送的 HTTP 请求、批量接口和 GraphQL Resolver 等所有访问入口。对象 ID 即使难以猜测,也不能替代资源级授权。
2.2 Cookie、Session、Bearer 与 JWT 不在同一个层次
这几个词经常被当成互斥方案,其实它们描述的是不同问题:
| 概念 | 回答的问题 | 例子 |
|---|---|---|
| Cookie | 浏览器怎样保存和自动携带一段数据 | 携带不透明的会话 ID |
| Session | 服务端怎样维护一次登录的状态 | 会话 ID 对应用户、有效期和撤销状态 |
| Bearer | 如何使用持有的令牌访问服务 | Authorization: Bearer ... |
| JWT | 令牌怎样组织声明和保护完整性 | 带签名的用户及权限声明 |
Session ID 可以放在 Cookie 中;JWT 也可以放在 Cookie 中。Bearer Token 可以是 JWT,也可以是服务端查询后才能解释的不透明字符串。选择时需要分别决定凭证格式、携带方式和失效策略。
2.3 Session 登录如何工作
浏览器应用的一种设计是:后端验证登录凭证,生成随机会话 ID,把会话状态保存在数据库或 Redis,再通过 Cookie 交给浏览器。
Set-Cookie: __Host-session=<opaque_session_id>; Path=/; Secure; HttpOnly; SameSite=Lax后续请求携带 Cookie,服务端查找会话并确定用户。退出登录时,删除或撤销服务端会话,同时清除浏览器 Cookie;仅删除前端状态并没有使被复制的会话凭证失效。
Secure 限制通过安全连接发送,HttpOnly 阻止脚本直接读取 Cookie,SameSite 约束部分跨站携带行为。会话还需要有效期、登录后轮换和服务端失效管理。OWASP 会话管理指南
由于浏览器会自动携带 Cookie,状态变更接口还需要合适的 CSRF 防护,例如校验来源与 CSRF Token。HttpOnly 不会阻止已注入页面的脚本代替用户发请求;CORS 也不能替代认证或完整的 CSRF 防护。OWASP CSRF 防护指南
这种方案适合统一管理登录状态的浏览器应用。代价是每个服务都需要能够访问或验证会话状态,不能只考虑“登录接口怎么写”。
2.4 JWT:能解码不代表可信
常见签名 JWT 由三部分组成:
base64url(header).base64url(payload).signaturePayload 通常可被读取,签名不等于加密。不要把密码等秘密放进去,也不要把前端解码得到的 role 当成服务端授权依据。
服务端应使用成熟库验证签名和允许的算法,并检查约定的签发者 iss、接收方 aud、过期时间 exp,以及存在时的 nbf 等时间约束。仅验证“签名正确”仍可能接受发给另一个系统的令牌。OWASP REST 安全指南
短期 JWT 可以减少每次查询会话的需要,但会带来状态新鲜度问题:用户刚被取消管理员权限,旧 Token 中的角色可能仍未过期。系统需要在短有效期、撤销机制、权限版本或关键操作实时查询之间做取舍。这里的“无状态验证”不表示整个认证系统完全没有状态。
2.5 Access Token、Refresh Token 与重试
Access Token 用来访问 API;Refresh Token 用来向授权服务换取新的访问凭证,不应当作普通业务请求的 Bearer Token。
客户端遇到凭证过期,可以按策略刷新一次,再恢复原操作;不要把所有 401 都解释为可刷新,也不要在刷新失败后无限循环。多个请求同时失败时,应协调成一次刷新,避免旧 Refresh Token 被并发使用。
刷新令牌轮换会在成功刷新后替换旧令牌,并可通过重复使用检测识别异常。客户端类型不同,保护要求也不同;OAuth 安全最佳实践对公共客户端提出了轮换或发送者约束等要求。RFC 9700
恢复写操作之前,还要知道原操作是否已经执行。收到服务器明确的认证失败,与发送请求后连接中断,是两种不同情况;后者需要业务幂等机制,而不是无条件再发一次创建请求。
2.6 OAuth 2.0、OIDC、Basic 和 API Key
OAuth 2.0 用于授权访问资源;OpenID Connect 在其上增加身份认证层。因此“使用 OAuth 登录”需要进一步说明身份是通过 OIDC,还是某个提供商的专用用户信息接口确认的。ID Token 面向登录客户端,不应直接拿来替代业务 API 的 Access Token。OpenID 官方说明
理解授权码流程时,可以把它拆成:跳转到身份提供方、用户完成认证与授权、客户端收到授权码、客户端换取令牌,再建立自己的登录状态。实现应使用维护良好的库,处理 PKCE、重定向地址校验,以及适用的 state、nonce 检查,不要自行拼凑一个只在成功路径可用的流程。
Basic Authentication 将用户名和密码编码后放入请求头,Base64 不提供保密性,必须配合 HTTPS。它没有自动解决凭证轮换或细粒度权限问题。API Key 则常用于识别调用应用或项目,需要配合权限范围、配额、轮换和撤销;它不天然代表当前终端用户。
对于文章管理系统,可以选择浏览器会话管理后台登录,为独立集成提供受限 API 凭证。没有必要仅因为“JWT 很常见”就把所有调用方式统一成一种令牌。
3. 输入校验:从未知数据到明确的业务输入
3.1 TypeScript 类型不等于运行时检查
下面的类型断言不会检查网络传入的数据:
type CreateArticleInput = {
title: string;
};
const input = JSON.parse('{"title": 42}') as CreateArticleInput;
// TypeScript accepts this, but input.title is still a number at runtime.编译后的 JavaScript 不会因为类型声明自动拒绝数字标题。请求正文、查询参数和外部服务响应都应该在信任边界做运行时检查。
3.2 用 Schema 明确字段规则
下面使用 Zod 定义创建、部分更新和分页输入。默认未知字段处理方式不一定符合业务需要,这里显式使用严格对象,只允许列出的字段。Zod 基础用法与 Schema API
import { z } from "zod";
const TitleSchema = z.string().trim().min(1).max(120);
const CreateArticleSchema = z.object({
title: TitleSchema,
body: z.string().min(1).max(50_000),
categoryId: z.string().min(1).max(64),
}).strict();
const UpdateArticleSchema = CreateArticleSchema.partial().refine(
(input) => Object.values(input).some((value) => value !== undefined),
{ message: "At least one field is required" },
);
const LimitSchema = z.string()
.regex(/^[1-9]\d{0,2}$/)
.default("20")
.transform(Number)
.pipe(z.number().int().max(100));
const result = CreateArticleSchema.safeParse({
title: " ",
body: "An article body",
categoryId: "backend",
});
if (!result.success) {
console.log(result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})));
}空白标题经过 trim() 后会失败;authorId、role 等额外字段也会被拒绝。成功后应使用 result.data,不要又把原始 req.body 展开写进数据库。
LimitSchema 有意先检查字符串格式,再转换为数字。这样不会把空字符串隐式变成 0,也不会接受 20abc。这个 Schema 接收缺失值 undefined;若参数来自 URLSearchParams.get(),需要把表示缺失的 null 转为 undefined。示例只修剪标题,正文中的空格可能属于内容,不应随意清理。
3.3 缺失、null 与空字符串不是一回事
对于部分更新接口,需要在文档中写清:
| 输入 | 本例约定 |
|---|---|
不提交 title |
保留原来的标题 |
"title": null |
拒绝;标题不允许为空值 |
"title": "" 或仅空白 |
拒绝;标题必须有内容 |
"title": "New title" |
更新标题 |
{} |
拒绝没有任何修改的请求 |
.partial() 只是让字段可以省略,不会自动定义清空字段的语义。对于允许清空的摘要,可以另行规定 null 表示清除;不要让每个客户端自行猜测。
3.4 Schema、业务规则与数据库各管什么
Schema 能检查 categoryId 是字符串,却不知道分类是否真的存在;也不知道当前作者是否有权在这个分类下发布。业务服务负责这些规则,数据库负责外键、唯一性等最终约束。
例如两个请求都先检查“slug 尚未被占用”,然后同时创建,应用层检查可能都通过。因此仍需要数据库唯一约束,并把相应冲突转换成稳定的业务错误。先查后写不能替代并发控制。
校验也不等于所有安全处理:SQL 应使用参数化查询,用户 HTML 需要按展示方式处理,响应应选择允许公开的字段。不要通过一个“过滤危险字符”的函数试图同时解决所有问题。
3.5 让客户端知道哪个字段需要修改
本文把无法解析的 JSON 视为 400,把可解析但不符合字段规则的请求视为 422。团队也可以选择一致的 400 约定,关键是错误结构稳定,并且区分可纠正的输入问题和服务端故障。
下面采用 Problem Details 的基础结构;errors 和 requestId 是本 API 自定义的扩展字段,客户端依赖稳定的 code,界面文案可以本地化。RFC 9457
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation",
"title": "Invalid article input",
"status": 422,
"errors": [
{ "field": "title", "code": "REQUIRED", "message": "Title is required" }
],
"requestId": "req_01"
}服务端可以在日志中保存详细异常,但响应不应暴露数据库 SQL、密钥或内部堆栈。requestId 让客户端报告的问题能够对应到服务端记录。
4. REST:设计资源及其 HTTP 契约
4.1 从业务资源出发,而不是照抄数据库表
REST 还涉及无状态交互、缓存、统一接口和分层等约束,不只是“URL 使用名词”。下面讨论常见的资源导向 HTTP API;无状态交互要求请求携带所需上下文,并不禁止服务端保存文章等资源数据。
GET /api/articles 查询可见文章
POST /api/articles 创建文章
GET /api/articles/123 读取一篇文章
PATCH /api/articles/123 部分修改文章
DELETE /api/articles/123 删除文章
POST /api/articles/123/publications 发起发布把“发布”建模为资源适合需要记录发布时间、审核人或发布任务的系统。若业务只需要一个简单状态变更,也可以使用明确约定的更新接口。路径风格不应掩盖业务规则:不能允许通过普通 PATCH 绕过审核直接发布。
列表查询必须先限定调用者可见的数据,再分页和统计。先取 20 条再删除无权查看的文章,会导致页面数量不稳定,也可能泄露隐藏资源的总数。
4.2 方法语义、安全性与幂等性
安全方法的请求语义不要求改变业务状态;幂等表示重复相同请求的预期效果与执行一次一致,不要求每次响应状态码和正文一样。MDN HTTP 方法说明
| 方法 | 本例用途 | 重复调用的考虑 |
|---|---|---|
| GET | 读取文章 | 不应借读取触发发布或删除 |
| POST | 创建新文章 | 没有额外机制时可能创建多篇 |
| PUT | 替换目标资源的可写表示 | 应保持幂等语义 |
| PATCH | 按约定应用部分修改 | 是否幂等取决于修改操作 |
| DELETE | 删除指定文章 | 第二次可以返回 404,仍可保持幂等 |
PUT 不是“只修改传过来的字段”的通用别名。若提供它,需要定义完整可写表示及省略字段的处理;服务端生成的 ID 等字段则由契约决定。
PATCH 的正文格式需要明确。把标题设置为固定值可以设计为幂等操作,“阅读量加一”则不是。本文使用自定义 JSON 更新对象,不声称它是 JSON Patch 或 JSON Merge Patch;如果采用这些标准格式,要使用对应的媒体类型与语义。RFC 5789
4.3 状态码应该帮助调用方采取下一步
下面是文章系统的响应约定,不是所有 API 必须照搬的唯一方案:
| 状态码 | 本例情况 | 客户端动作 |
|---|---|---|
| 200 | 读取或修改成功,并返回表示 | 更新界面 |
| 201 | 创建文章成功 | 使用返回的 ID 和 Location |
| 204 | 删除成功且无响应正文 | 不要再解析 JSON |
| 400 | JSON 无法解析或查询格式错误 | 修正请求 |
| 401 | 缺少有效身份凭证 | 登录或按策略刷新 |
| 403 | 已识别用户但不允许编辑 | 提示无权限,不重复刷新 |
| 404 | 资源不存在,或按策略隐藏其存在 | 显示不可用 |
| 409 | slug 重复、业务状态冲突 | 修改输入或重新确认操作 |
| 412 | If-Match 条件不成立 | 重新读取并处理版本冲突 |
| 422 | 字段校验失败 | 定位到表单字段 |
| 429 | 超过调用额度 | 按 Retry-After 等策略退避 |
| 500 / 503 | 意外错误 / 暂时无法服务 | 结合操作是否可安全重试处理 |
HTTP 方法、条件请求与核心状态码的语义由 RFC 9110 定义。401 响应需要适当的 WWW-Authenticate 挑战;Bearer API 不能只返回一段“未登录”的 JSON。
HTTP/1.1 201 Created
Location: /api/articles/123
Content-Type: application/json
{
"id": "123",
"title": "Understanding API design",
"authorId": "user_7",
"version": 1
}错误响应还应提供稳定的机器可读代码。同样是 409,SLUG_TAKEN 与 ARTICLE_ALREADY_PUBLISHED 的恢复方式不同,客户端不应该解析英文错误句子来决定行为。
4.4 分页、筛选与排序是同一份契约
GET /api/articles?category=backend&sort=-createdAt&limit=20&cursor=<opaque_cursor>Offset 分页容易实现,但数据插入或删除后,后续页可能重复或遗漏。Cursor 分页可以围绕排序键继续读取;本例以 createdAt DESC, id DESC 排序,使用唯一 ID 打破时间相同的平局。
游标应对应相同的筛选和排序条件,并且不能替代每次请求的权限检查。服务端必须限制 limit、白名单校验排序字段,不能直接把客户端输入拼成 SQL。涉及可变排序字段时,还需要说明列表是否提供快照一致性;Cursor 不会自动解决所有变化。
{
"items": [
{ "id": "123", "title": "Understanding API design" }
],
"pageInfo": {
"endCursor": "opaque_cursor",
"hasNextPage": true
}
}4.5 超时重试:避免创建两篇一样的文章
客户端超时只说明没有及时收到响应,不说明数据库没有写入。用户点击“重试”时,服务器可能已经创建好了文章。
可以为创建接口约定 Idempotency-Key。这是一项需要实现和记录的应用契约,不是加一个请求头就会自动生效:
POST /api/articles HTTP/1.1
Idempotency-Key: create-article-7f31
Content-Type: application/json服务端将键绑定到调用者、操作和请求内容摘要。同一个键与相同输入应复用已有结果;同键不同输入应拒绝;正在执行的重复请求如何等待或返回冲突也要说明。
幂等记录必须与业务写入协调,使用唯一约束和事务防止两个并发请求都先通过“键不存在”的检查。对于通知等外部副作用,可以在事务中记录待发送事件,再由任务发送,并让消费者去重。键的保留时间有限,所以不能宣称系统永远“恰好执行一次”。
4.6 并发编辑:防止覆盖别人的修改
Alice 与 Bob 都读到版本 7。Alice 保存后变成版本 8;Bob 如果直接保存,会覆盖自己从未看过的修改。
一种方案是在读取响应中返回强 ETag,修改时要求 If-Match:
PATCH /api/articles/123 HTTP/1.1
If-Match: "article-123-v7"
Content-Type: application/json
{ "title": "A revised title" }ETag 必须随该表示变化,不能随意使用一个与内容不一致的常量。条件失败返回 412,客户端保留本地编辑并提示重新加载或合并,而不是把最新 ETag 换上后静默覆盖。MDN If-Match
数据库应原子比较版本并写入,而不是先比较、稍后无条件更新。以下 SQL 是核心机制示意,权限与租户范围也必须由服务保证:
UPDATE articles
SET title = :title, version = version + 1
WHERE id = :id AND version = :expected_version;影响行数为 0 说明条件没有匹配,需要结合资源状态和对外策略处理。对于权限或归属可并发变化的系统,还要在事务或条件更新中保证检查与写入一致。
4.7 缓存与接口演进
公开文章可以设计缓存策略,并通过条件读取减少重复传输;草稿、个人信息和按用户裁剪的响应则需要明确的私有缓存或禁止存储策略。缓存键不能把不同用户的内容混在一起。MDN HTTP 缓存
接口演进时,新增可选字段通常比重命名或删除字段更容易兼容,但严格客户端也可能拒绝新字段。要结合消费者契约测试验证,而不是仅凭“这是新增字段”判断没有影响。
发布破坏性变更前,应提供迁移窗口、弃用说明并观察旧接口调用量。无论采用 /v1、媒体类型版本,还是其他约定,关键都是让客户端知道什么时候必须调整。
5. GraphQL:客户端选字段,服务端控制执行边界
5.1 Schema 描述可查询的图
对于文章详情页,客户端希望一次取得标题、作者和部分评论。下面的 Schema 同时描述读取与更新,省略了真实项目中的其他字段:
type User {
id: ID!
name: String!
}
type Comment {
id: ID!
content: String!
}
type Article {
id: ID!
title: String!
body: String!
categoryId: ID!
author: User!
comments(first: Int! = 10): [Comment!]
version: Int!
}
input UpdateArticleInput {
title: String
body: String
categoryId: ID
}
type Query {
article(id: ID!): Article
}
type Mutation {
updateArticle(id: ID!, input: UpdateArticleInput!, expectedVersion: Int!): Article!
}ID! 表示非空 ID 输入或输出,但不会检查数据库里是否存在这个 ID。[Comment!] 表示列表本身可以为空值,列表中不允许有空值元素;返回 [] 则表示成功查询到零条评论。Query.article 可为空,给“没有可见文章”留出一种表达方式。GraphQL Schema 与类型
输入类型同样不能表达所有业务约束。GraphQL 接受字符串标题,并不代表标题长度合法;本例沿用前面的 Schema,拒绝显式 null、空标题和空更新对象。
5.2 Query、变量与 Resolver 如何配合
query ArticlePage($id: ID!, $first: Int!) {
article(id: $id) {
id
title
author {
name
}
comments(first: $first) {
content
}
}
}{
"id": "123",
"first": 5
}查询文档定义数据形状,变量承载本次输入。服务端先解析和验证操作,再执行字段解析。Resolver 可以从父对象、数据库、缓存或其他服务取得值;多个字段不一定对应同一张表,也不一定各执行一次 SQL。GraphQL 执行机制
修改文章使用 Mutation。这里的 expectedVersion 与 REST 的条件更新承担相同的业务职责,返回字段由客户端选择:
mutation EditArticle($id: ID!, $input: UpdateArticleInput!, $version: Int!) {
updateArticle(id: $id, input: $input, expectedVersion: $version) {
id
title
version
}
}{
"id": "123",
"input": { "title": "A revised title" },
"version": 7
}下面的 Resolver 示意调用方式。Context 由请求级认证层建立,articleService 负责可见性、编辑权限、业务校验与并发版本检查:
const resolvers = {
Query: {
article: (_parent: unknown, args: { id: string }, ctx: Context) =>
ctx.articleService.findVisibleById(ctx.actor, args.id),
},
Article: {
author: (article: ArticleRecord, _args: unknown, ctx: Context) =>
ctx.loaders.userById.load(article.authorId),
comments: (
article: ArticleRecord,
args: { first: number },
ctx: Context,
) => {
if (!Number.isInteger(args.first) || args.first < 1 || args.first > 50) {
throw new GraphQLError("first must be between 1 and 50", {
extensions: { code: "BAD_USER_INPUT" },
});
}
return ctx.commentService.listVisible(ctx.actor, article.id, args.first);
},
},
Mutation: {
updateArticle: async (
_parent: unknown,
args: { id: string; input: unknown; expectedVersion: number },
ctx: Context,
) => {
const actor = requireActor(ctx);
const input = UpdateArticleSchema.safeParse(args.input);
if (!input.success) {
throw new GraphQLError("Invalid article input", {
extensions: { code: "BAD_USER_INPUT" },
});
}
return ctx.articleService.update({
actor,
id: args.id,
changes: input.data,
expectedVersion: args.expectedVersion,
});
},
},
};这里假设从 graphql 导入 GraphQLError,并提供项目定义的类型和服务。服务抛出的权限、冲突等错误仍需映射成约定的客户端错误,同时屏蔽内部异常。
不要只在 /graphql 路由检查“已经登录”,就开放所有字段。资源访问规则应集中在业务层;涉及邮箱等敏感字段时,还需要字段级策略。这样 REST、GraphQL 和后台任务才不会各有一套互相矛盾的权限规则。GraphQL 授权指南
5.3 部分数据与错误可以同时返回
假设标题与作者读取成功,但评论服务暂时失败。因为上面的评论列表允许为空值,可以返回:
{
"data": {
"article": {
"id": "123",
"title": "Understanding API design",
"author": { "name": "Alice" },
"comments": null
}
},
"errors": [
{
"message": "Comments are temporarily unavailable",
"path": ["article", "comments"],
"extensions": { "code": "UPSTREAM_UNAVAILABLE" }
}
]
}客户端可以展示文章主体,再单独处理评论失败。如果失败字段被声明为非空,空值会向上传播到允许为空的父位置,所以 ! 不应只是“看起来更严格”的装饰。请求在执行前就解析或验证失败时,响应则不会包含成功执行的数据。GraphQL 响应说明
不要用“GraphQL 永远返回 200”作为规则。HTTP 状态还受传输层、媒体类型和失败阶段影响;客户端既要检查网络和 HTTP 结果,也要处理 GraphQL 的 data 与 errors。上面的 extensions.code 是应用约定,不是所有服务统一提供的标准错误码。
5.4 N+1 查询:字段少不一定数据库工作少
如果列表接口取得 20 篇文章,随后每篇文章的 author Resolver 都单独查询一次,就可能形成 1 次文章查询加 20 次作者查询。若很多文章属于同一个作者,还会重复读取同一用户。
DataLoader 可以在一次请求内合并加载并缓存重复键。批量函数必须按输入键顺序返回结果,并处理不存在的数据;加载器通常按请求创建,不能把某个用户权限下取得的结果放进跨用户共享实例。DataLoader 官方说明
它不是万能加速器。嵌套评论、不同筛选条件和跨服务请求仍需要单独分析。应观察实际 SQL 数量和耗时,再决定使用批量加载、JOIN、预取或缓存。客户端只选择 title,也不保证某个 Resolver 没有在内部加载整条记录。
5.5 复杂度控制、缓存与订阅
GraphQL 的灵活性需要配套预算:限制单次列表数量、查询深度与宽度、估算字段成本、设置超时,并按用户或应用限制调用量。只限制深度无法阻止同一层的大量别名查询;只限制 HTTP 请求次数也不能衡量每次查询的数据库成本。GraphQL 安全指南
对于受控客户端,可以使用可信操作列表或持久化操作,但仍需校验变量和权限。缓存也需要考虑变量、用户身份及字段可见性;GraphQL 并非无法缓存,只是不能简单按一个公共 URL 缓存所有结果。
Subscription 用于持续接收事件,不代表默认就采用某一种传输协议。实际系统还要处理断线恢复、连接期间的身份过期和事件可见性。文章编辑器只有在确实需要实时协作时,才值得承担这部分复杂度。
6. 同一个需求,如何选择 REST 或 GraphQL
假设文章详情页需要文章、作者和评论。REST 可以设计一个组合响应,也可以提供多个资源接口;GraphQL 可以让客户端声明组合字段。不要把比较预设成“REST 必须请求三次,GraphQL 必须一次完成”。
| 评估维度 | REST 设计重点 | GraphQL 设计重点 |
|---|---|---|
| 客户端需求 | 稳定资源契约,必要时增加聚合接口 | 不同客户端选择不同字段 |
| 性能 | 请求数、表示大小、服务端查询与缓存 | 字段执行计划、批量加载、复杂度预算 |
| 错误处理 | 状态码与稳定问题结构 | 传输错误、字段错误与部分数据 |
| 权限 | 路由、资源与响应字段 | 操作、资源、字段与关联路径 |
| 演进 | 字段兼容、版本与弃用期 | Schema 弃用、消费者使用情况与迁移 |
| 运维 | 按路由统计耗时与错误 | 按操作及字段成本分析,而不只看 /graphql |
对于这个文章系统,如果只有一个后台和一套稳定页面,我会先评估资源导向 API 能否满足需求。如果移动端、网页和合作方长期需要明显不同的数据组合,再评估 GraphQL 的收益是否足以覆盖执行控制和维护成本。
两者可以共用业务服务。更换接口风格不能解决权限缺失、数据库慢查询或业务规则混乱。
7. 把修改文章的成功与失败路径走完整
以 Alice 修改自己的文章为例,REST 请求携带凭证、更新字段和 If-Match;GraphQL Mutation 则携带变量与 expectedVersion。它们最后都调用同一业务服务。
确认身份
→ 解析并校验输入
→ 在一致的业务操作中加载文章、验证权限及版本
→ 只更新允许修改的字段
→ 原子提交并递增版本
→ 返回允许公开的新表示| 场景 | 服务端行为 | 客户端如何恢复 |
|---|---|---|
| 作者正常保存 | 返回新文章与版本 | 更新本地状态 |
| 凭证失效 | 拒绝未认证操作 | 按约定刷新一次或重新登录 |
| 修改他人的文章 | 拒绝并避免泄露敏感内容 | 提示无权限 |
| 标题只有空格 | 返回字段错误 | 保留草稿并定位标题 |
| 版本已经变化 | 拒绝过期版本写入 | 比较新旧版本或人工合并 |
| 数据库事务失败 | 回滚并记录内部错误 | 保留输入,按错误类型处理 |
| 保存成功但响应丢失 | 不把网络超时当成业务回滚 | 重新读取状态;创建操作使用幂等键 |
网络失败后,前端不应该丢掉用户输入。后端也不应该把所有异常映射成 500,否则客户端无法区分“改一下标题”与“稍后再试”。
日志至少能关联请求 ID、操作、结果和耗时;审计记录可以说明谁修改了哪篇文章。不要记录完整 Token、密码或无必要的敏感正文。REST 关注具体路由,GraphQL 还应关注操作名称与成本,单看 /graphql 的成功率不够。
8. 实践检查清单与自测
可以把以下场景写成接口级测试,断言响应之外,还检查数据库是否发生了正确的变化:
- 无凭证请求被拒绝,数据库没有新文章。
- Alice 无法通过替换 ID 修改 Bob 的文章。
authorId等不允许写入的字段被拒绝,服务端使用认证身份确定作者。- 缺失字段、显式
null、空白标题、超长正文和非法分页参数得到明确处理。 - 同一幂等键并发创建只产生一个业务结果,同键不同输入被拒绝。
- 两个相同旧版本的修改竞争时,只允许符合并发策略的写入成功。
- GraphQL 评论失败时,按照 Schema 的空值约定保留或传播数据。
- 不同用户之间不复用带权限上下文的 DataLoader 缓存。
- 复杂查询被预算限制,错误响应不泄露堆栈或凭证。
复习时尝试回答以下问题,再用文中的例子核对:
- Session 和 JWT 为什么不是 Cookie 的对立面? 前两者涉及状态或令牌设计,Cookie 是携带机制,必须分层比较。
- JWT 签名正确为什么仍可能不能访问 API? 还要检查接收方、有效期、撤销策略以及当前资源权限。
- GraphQL 已经有类型,为什么还需要业务校验? 字符串类型不表达标题长度、资源归属或允许的状态转换。
- 第二次 DELETE 返回 404,会破坏幂等性吗? 不一定;需要比较预期业务效果,而不是只比较响应。
- 为什么数据库唯一约束不能仅靠“先查一下”代替? 并发请求可能同时通过检查,最终约束需要原子保证。
- 读取 ETag 后为什么还需要条件更新? 读取与写入之间数据可能变化,版本判断必须与写入协调。
- GraphQL 请求减少了,为什么后端反而更慢? 一个请求可能触发大量字段解析和数据库访问,需要测量实际执行成本。
复习的目标是能够从一个具体需求写出请求、成功响应和失败响应,并说明权限在哪里检查、写入如何防止重复或覆盖,以及调用方下一步应该做什么。