02. HTTP 与 API 基础
前端更关注页面和交互,后端更关注请求和响应。你做的不是“接口函数”,而是一套可长期维护的通信契约。
核心认知
- 一个 API 的质量,不只看能不能返回数据,还看语义是否稳定、错误是否清晰、边界是否可控。
- 后端最怕“看起来能用”,但一遇到重试、超时、重复提交就出错。
一个请求最小应该包含什么
- 请求方法:
GET/POST/PATCH/DELETE - 路径:资源的定位方式
- Query:过滤、分页、排序
- Header:鉴权、请求追踪、缓存控制
- Body:创建或更新的负载
响应最少要考虑:
- 状态码
- 响应体结构
- 错误结构
- 是否幂等
- 是否可缓存
必懂 6 件事
1. 方法和状态码
GET用于读,POST常用于创建,PUT/PATCH用于更新,DELETE用于删除。200/201/204表示成功,400/401/403/404/409/500要能区分场景。- 不要把所有失败都返回
200再塞一个错误文案。
最常见的状态码语义:
400 Bad Request:参数错误、校验失败401 Unauthorized:未登录或 token 无效403 Forbidden:已登录,但没权限404 Not Found:资源不存在409 Conflict:重复提交、状态冲突、唯一约束冲突500 Internal Server Error:服务内部异常
2. URL、分页、过滤
- 列表接口要提前设计分页,不要默认全量返回。
- 过滤条件要明确,例如状态、时间范围、排序字段。
- 参数命名要稳定,不要今天
pageSize,明天size。
一个比较稳的列表接口例子:
GET /users?page=1&pageSize=20&status=active&keyword=tom&sortBy=createdAt&sortOrder=desc
响应可以保持稳定结构:
{
"data": [
{ "id": 1, "name": "Tom" }
],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 135
}
}
3. 统一错误结构
- 错误响应要有稳定的机器可读字段,而不只是给人看的提示语。
- 最小结构可以是:
{
"code": "USER_NOT_FOUND",
"message": "user not found",
"requestId": "req_123"
}
一个 Express 版的统一错误处理中间件:
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use((req, res, next) => {
const requestId = crypto.randomUUID();
req.requestId = requestId;
res.setHeader('X-Request-Id', requestId);
next();
});
app.get('/users/:id', async (req, res, next) => {
try {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({
code: 'USER_NOT_FOUND',
message: 'user not found',
requestId: req.requestId,
});
}
res.json({ data: user });
} catch (error) {
next(error);
}
});
app.use((error, req, res, _next) => {
console.error(req.requestId, error);
res.status(500).json({
code: 'INTERNAL_ERROR',
message: 'internal server error',
requestId: req.requestId,
});
});
4. 幂等性、超时、重试
- 幂等性是后端基本功,尤其是支付、下单、回调这类操作。
- 超时和重试不是客户端的问题,服务端也要考虑重复请求和中间状态。
- 典型手段包括唯一索引、幂等键、状态机。
先把定义说清楚:
- 幂等不是“请求只能发一次”
- 幂等是“同一个业务请求重复执行多次,最终业务结果一致”
例如用户连续点两次“提交订单”,理想结果不是创建两笔订单,而是后端识别出这是同一笔业务意图,最终只保留一笔。
为什么这件事必须后端处理:
- 客户端超时,不代表服务端没成功
- 网络重试、用户重复点击、第三方回调重放,都会把同一笔业务再次打到服务端
- 如果后端没有幂等保护,就会出现重复下单、重复扣款、重复发券、重复扣库存
一个常见方案是要求客户端传 Idempotency-Key:
POST /orders
Idempotency-Key: 75b1f5b6-0d8f-4f53-9b39-1e8ad3d247fa
服务端伪代码:
const key = req.header('Idempotency-Key');
if (!key) {
throw new BadRequestError('missing idempotency key');
}
try {
return await db.orders.create({
data: { userId, idempotencyKey: key, ...payload },
});
} catch (error) {
if (isUniqueConflict(error)) {
return db.orders.findUnique({
where: { userId_idempotencyKey: { userId, idempotencyKey: key } },
});
}
throw error;
}
这个方案的关键点是:
- 客户端必须在“同一笔业务重试”时复用同一个 key
- 后端必须把
userId + idempotencyKey落到唯一约束里 - 真正兜底的是数据库唯一约束,而不是“先查一下再插入”
数据库约束示例:
CREATE UNIQUE INDEX uk_orders_user_idempotency
ON orders (user_id, idempotency_key);
除了“唯一约束 + 幂等键”,还有两种常见做法:
- 状态机:例如订单只能从
PENDING -> PAID,如果已经是PAID,重复回调就不再处理 - 去重表:例如 webhook 或消息消费时,先插一条唯一
event_id,插入失败就说明处理过
状态机伪代码:
UPDATE orders
SET status = 'PAID'
WHERE id = 123
AND status = 'PENDING';
如果影响行数是 0,就表示这次更新没有真的推进状态,通常意味着已经处理过,或者状态不合法。
你还要特别注意顺序问题:
- 幂等判断必须发生在副作用之前,或者和副作用放在同一事务里
- 不能先扣库存,再做幂等判断;否则重复请求可能已经把库存扣了两次
- 幂等解决的是“重复执行”,不是所有并发问题;库存、余额这类仍要配合事务、条件更新、锁
一句话记住:
- 幂等要解决的是“同一笔业务因为重试或重复提交被执行多次时,如何保证最终只生效一次”
5. 限流与输入校验
- 后端不能假设客户端一定传对参数。
- 需要在系统边界做类型、格式、范围校验。
- 对高频接口要有限流意识,避免被刷爆。
下面是一个用 zod 做 Query 校验的最小例子:
import { z } from 'zod';
const listUsersQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['active', 'disabled']).optional(),
});
app.get('/users', async (req, res, next) => {
try {
const query = listUsersQuerySchema.parse(req.query);
const result = await userService.list(query);
res.json(result);
} catch (error) {
next(error);
}
});
限流伪代码:
const key = `rate-limit:${userId}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 60);
}
if (count > 100) {
throw new TooManyRequestsError();
}
6. API 分层
一个健康的 API 至少分三层:
- 路由 / Controller:接收请求、参数校验、组织响应
- Service:业务逻辑
- Repository / DAO:数据库访问
别把所有逻辑都写在路由里。后面一接数据库、鉴权、日志、缓存,单文件会迅速失控。
最小实践
- 做一个
users模块,包含列表、详情、创建、更新、删除。 - 给列表接口加分页和过滤。
- 所有错误都返回统一 JSON 结构。
- 给每个请求生成
requestId,方便日志关联。
常见误区
- 把“接口通了”当作 API 设计完成。真正难的是错误、边界和兼容性。
- 把分页留到后面再加。线上一旦数据量起来,再补分页成本很高。
- 只靠前端做参数校验。只要请求能直接打到服务,后端就必须自己校验。
学会的标准
- 你能设计出一组语义清晰的 API,而不是只堆路径。
- 你能解释为什么重复提交会带来数据问题。
- 你能说清楚哪些错误该返回
400、401、403、409。 - 你能写出带
requestId和统一错误结构的最小 API 服务。