前端接口架构:BFF、REST 与 GraphQL
从分层契约、资源语义和数据聚合出发,理解 REST、BFF、Serverless 与 GraphQL 的边界、选型和工程落地。
前端接口架构:BFF、REST 与 GraphQL
本文整理自
25年下半年面试真题预测.pdf中的 REST、GraphQL、BFF 和前后端联调内容,补充实际项目中容易被追问的契约、失败处理和安全边界。API 风格不是目的,稳定的契约和可验证的业务结果才是目的。
1. 先把接口问题分成三层
一个前端接口通常同时面对三类问题:
- 传输层:HTTP 方法、状态码、缓存、超时和重试。详见 HTTP 请求与响应 和 HTTP 状态码。
- 契约层:请求和响应的字段、类型、错误码、分页和版本兼容。
- 业务编排层:一个页面是否需要调用多个服务,数据如何聚合、裁剪和降级。
REST 主要约束传输和资源契约,GraphQL 主要提供一种可查询的契约,BFF 则是面向具体前端形态的编排层。三者可以组合使用,例如 BFF 对外提供 REST 或 GraphQL,同时在内部调用多个 RPC/REST 服务。
Web / Mobile / 小程序
|
v
BFF(按端适配、聚合、鉴权边界)
/ | \
用户服务 订单服务 内容服务
不要把“接口数量少”当成架构质量的唯一指标。一个大而全的接口可能隐藏耦合和权限问题;很多小接口也可能造成瀑布请求。应结合页面交互、数据所有权和故障边界来设计。
2. 前后端分层与对象边界
PDF 中用 BO、PO、DAO、DTO 描述前后端联调分层。它们不是必须照搬的类名,但可以帮助回答“为什么不直接把数据库对象返回给前端”:
| 对象 | 主要职责 | 是否适合直接返回前端 |
|---|---|---|
| DTO(Data Transfer Object) | 接口输入/输出契约,负责字段形状和校验 | 可以,需脱敏并稳定版本 |
| BO(Business Object) | 承载业务规则处理后的对象 | 通常经过 DTO 映射后返回 |
| PO(Persistent Object) | 与数据库表或持久化模型对应 | 不应直接暴露 |
| DAO(Data Access Object) | 封装数据库查询和写入 | 不应出现在浏览器端 |
一个较清晰的调用链是:
Controller/API -> DTO 校验 -> Service/业务规则 -> BO
-> DAO/Repository -> PO/数据库
-> Response DTO -> 客户端
这样做的价值不只是“分层好看”:
- 数据库字段改名时,不必同步暴露内部结构;
- 权限和脱敏逻辑有明确位置;
- 输入校验、业务校验和持久化错误可以分别处理;
- 接口可以通过版本化 DTO 保持向后兼容。
接口输入应在边界处做运行时校验。TypeScript 类型只在编译期生效,不能代替对 JSON、查询参数和第三方响应的校验。
3. REST:用资源语义组织 HTTP
3.1 核心约束
RESTful API 通常遵循以下原则:
- 资源驱动:使用 URI 标识资源,如
/users/42/orders; - 统一接口:用 HTTP 方法表达对资源的操作;
- 无状态:每个请求携带处理所需的认证和上下文,服务端不依赖某台机器的本地会话;
- 可缓存:在语义允许时利用
Cache-Control、ETag等机制; - 分层:客户端不必知道请求经过网关、BFF、缓存还是源服务。
“无状态”不等于系统不能保存数据。它表示一次请求的处理不应依赖上一次请求留在某台应用实例内存中的临时状态;会话、任务和业务数据仍可以保存在数据库或共享存储中。
3.2 方法语义和幂等性
| 方法 | 常见用途 | 幂等性(目标语义) | 关键注意 |
|---|---|---|---|
GET |
查询资源 | 是 | 不应产生业务副作用 |
POST |
创建资源或触发动作 | 通常不是 | 重试要使用幂等键或业务去重 |
PUT |
用完整表示替换资源 | 通常是 | 缺失字段的含义要明确 |
PATCH |
部分更新资源 | 取决于设计 | 定义合并和冲突语义 |
DELETE |
删除资源 | 通常是 | 重复删除可返回同一结果或 404,需统一契约 |
幂等性描述“重复执行的最终业务效果”,不是说每次响应都完全相同。网络超时后,客户端无法判断服务端是否已经成功处理,写请求应通过 Idempotency-Key、唯一业务号或数据库唯一约束防止重复创建。
3.3 URI、查询参数和响应契约
GET /api/v1/orders?status=paid&cursor=eyJpZCI6MTAw&limit=20 HTTP/1.1
Accept: application/json
Authorization: Bearer <token>
建议把资源筛选、排序和分页放在查询参数中,把资源本身放在请求体中。页码分页容易理解,但数据频繁插入时可能重复或漏项;基于稳定排序键的游标分页更适合大数据集和滚动加载。
响应应包含可机器处理的错误结构,而不是只返回一段给人看的字符串:
{
"error": {
"code": "ORDER_NOT_PAYABLE",
"message": "订单当前状态不允许支付",
"requestId": "req_123",
"details": { "state": "cancelled" }
}
}
message 可以面向用户或日志,但客户端应依赖稳定的 code。不要把数据库异常、堆栈或内部主机名原样返回。
3.4 状态码、缓存与并发更新
2xx表示请求已成功处理;创建资源常用201,异步任务可用202;400表示请求格式或参数不合法,401表示缺少或无效认证,403表示已认证但无权访问,404表示资源不存在;409适合版本冲突、重复创建等业务冲突,429表示触发限流,5xx表示服务端或依赖故障;- GET 响应是否可缓存必须由资源敏感性、用户身份和缓存键共同决定。带用户数据的响应不能被共享缓存跨用户复用。
编辑类接口可以使用乐观并发控制:服务端返回 ETag,更新时要求客户端携带 If-Match。版本不一致返回 412 Precondition Failed,让客户端重新读取并解决冲突,而不是静默覆盖他人的修改。(HTTP 缓存与条件请求)
3.5 版本和兼容
接口演进优先采用向后兼容的方式:新增可选字段、保留旧字段一段时间、明确弃用时间和迁移文档。删除字段、改变字段类型或改变错误语义属于破坏性变更,应通过 URL、媒体类型或网关策略做版本隔离。版本号本身不能替代契约测试。
4. BFF:为前端形态服务的编排层
4.1 BFF 解决什么问题
BFF(Backend For Frontend)是位于前端与多个后端服务之间的一层。它的典型职责是:
- 聚合多个服务请求,减少页面瀑布式调用;
- 按 Web、移动端或小程序裁剪字段和数据结构;
- 处理前端需要但领域服务不应承担的展示适配,例如图片尺寸、权限可见性和分页格式;
- 统一鉴权上下文、超时、追踪和错误映射;
- 在明确边界内做短期缓存和降级。
BFF 不应复制订单、支付等领域规则,也不应绕过领域服务直接读所有数据库表。业务规则仍应由领域服务拥有,否则多个 BFF 会逐渐产生不一致。
4.2 聚合请求的失败边界
聚合不是简单的 Promise.all。需要先区分页面的关键数据和可选数据,并为每个依赖设置超时、取消和降级策略:
type Dashboard = {
profile: Profile
recommendations: Recommendation[]
notices: Notice[]
}
async function loadDashboard(request: Request): Promise<Dashboard> {
const signal = request.signal
const [profile, recommendations, notices] = await Promise.all([
fetchProfile({ signal }),
fetchRecommendations({ signal }).catch(() => []),
fetchNotices({ signal }).catch(() => []),
])
return {
profile: await profile,
recommendations: await recommendations,
notices: await notices,
}
}
实际实现还应做到:
- 使用请求级 deadline,避免一个慢依赖拖住整个页面;
- 对关键依赖失败返回明确错误,对可选模块返回降级标识;
- 记录每个下游的耗时、状态和
traceId; - 只对幂等读请求做有限重试,并使用指数退避和抖动;
- 防止重复聚合、缓存击穿和用户级数据串缓存;
- 在客户端需要局部更新时,考虑并行请求或流式返回,而不是把所有数据强行绑定到一个大响应。
4.3 BFF 的鉴权边界
客户端传来的用户 ID、角色和租户不能直接作为可信权限依据。BFF 应从经过验证的会话或令牌中取得身份,再把最小化的身份上下文传递给下游;下游服务仍要重新检查资源归属。JWT 的结构和校验边界见 Node.js JWT 鉴权。
BFF 还要明确 Cookie、Bearer Token、CSRF 和 CORS 的职责,不能因为“请求经过 BFF”就默认跨站写操作安全。(XSS、CSRF 与安全边界)
5. Serverless:把运维边界交给平台
Serverless 通常由两类能力组合而成:
- FaaS:以函数为部署单元,由平台负责实例创建、扩缩容、网络和运行时;函数由 HTTP、队列、定时器或对象存储事件触发。
- BaaS:把数据库、对象存储、消息队列、身份和日志等能力作为托管服务使用。
它减少了自建服务器和容量调度工作,但没有消除架构约束。函数应尽量无状态,不能把登录会话、锁或任务进度只放在进程内存里;需要持久化时使用数据库、缓存或队列,并明确幂等键和重试语义。
面试或选型时至少说明这些边界:
- 启动和延迟:冷启动、运行时初始化和网络连接会影响尾延迟;高频接口应复用连接、减少初始化,并用 P95/P99 实测,而不是承诺“天然更快”。
- 执行限制:函数有超时、内存、临时磁盘和并发上限;长任务应拆成队列消费者或可查询的任务资源,不能依赖一次 HTTP 请求一直等待。
- 一致性与重试:平台可能重复投递事件或在超时后重试,写操作必须幂等,跨资源流程用 Outbox、状态机或补偿处理。
- 观测与供应链:把 requestId/traceId 贯穿网关、函数和 BaaS;固定运行时/依赖版本,限制函数权限和出站网络,避免把平台托管误当成安全边界。
适合 Serverless 的通常是事件驱动、流量波动明显、边界清晰的短任务;持续高负载、需要长连接或精细控制网络/运行时的服务,可能更适合容器或专用服务。最终按流量曲线、延迟预算、数据合规、调试能力和团队运维成本比较,而不是按“是否无服务器”做结论。(来源:25年下半年面试真题预测.pdf 第 2.8 节。)
6. GraphQL:让客户端声明数据形状
6.1 三个核心部分
GraphQL 通常由 Schema、查询和 resolver 组成:
type User {
id: ID!
name: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
status: String!
}
type Query {
user(id: ID!): User
}
客户端可以只请求当前页面需要的字段:
query UserWithOrders($id: ID!) {
user(id: $id) {
id
name
orders {
id
total
status
}
}
}
resolver 负责把字段映射到数据源。Schema 是契约,不代表 resolver 自动具备权限、缓存或事务语义。
6.2 GraphQL 的工程风险
- N+1 查询:逐个解析
orders可能对每个用户发一次数据库请求。使用 DataLoader 等批处理/缓存机制,把同一请求周期的 ID 合并查询;不要用一个无上限的全局缓存。 - 查询复杂度:客户端可以构造很深或很宽的查询。生产网关应限制深度、字段数量、估算成本、分页大小和超时,必要时使用持久化查询白名单。
- 权限:权限要在 resolver 或领域服务边界校验,不能因为某字段出现在 Schema 中就对所有用户开放。列表字段尤其要检查每条资源的归属。
- 错误模型:GraphQL 可能同时返回
data和errors。客户端要处理部分成功,日志应带路径、请求 ID 和下游错误,而不是只判断 HTTP 状态码。 - 缓存:单一 POST 入口不等于不可缓存,也不等于天然好缓存。可以使用 persisted query、请求级缓存或按资源的服务端缓存,但必须把身份、变量和权限纳入缓存键。
- 写操作:Mutation 仍需要幂等键、冲突检测和审计;GraphQL 语法不会自动提供事务。
订阅或实时更新可以使用 WebSocket/SSE 等传输,但协议选择仍取决于单向/双向通信、代理支持和断线恢复需求。(WebSocket 握手与连接管理)
7. 怎么选 REST、BFF、Serverless 和 GraphQL
| 场景 | 更适合的起点 | 主要理由 | 需要警惕 |
|---|---|---|---|
| 公共资源、缓存友好、团队边界清晰 | REST | HTTP 语义直观,工具链成熟 | 版本兼容、瀑布请求、错误契约 |
| 多端页面需要不同聚合视图 | BFF + REST/GraphQL | 由端侧编排和裁剪数据 | BFF 膨胀、规则重复、下游级联故障 |
| 事件驱动、短任务、流量波动明显 | Serverless/FaaS + BaaS | 平台托管扩缩容和基础设施 | 冷启动、执行上限、重复投递、平台耦合 |
| 字段需求变化快、关联查询多 | GraphQL | 客户端声明字段,减少过取/欠取 | N+1、查询复杂度、权限和缓存 |
| 文件上传、下载、CDN 资源 | REST/专用资源接口 | 与 HTTP 缓存和流式传输配合自然 | 大请求限制、签名 URL、越权 |
| 不稳定或高延迟的异步任务 | REST 202 + 任务资源 |
状态可查询、可重试 | 幂等、过期清理、通知通道 |
成熟系统可以混用:例如 BFF 对页面提供聚合 REST,内部的内容服务使用 GraphQL;关键是记录每个接口的所有者、SLA、权限和故障降级策略。
8. 联调与发布清单
- 先确定请求/响应 Schema、错误码、鉴权方式和示例,再开始并行开发;
- 对 DTO 做运行时校验,生成或维护 OpenAPI/GraphQL Schema;
- 约定超时、取消、重试、幂等键和分页边界;
- 用契约测试验证 BFF 与下游字段,避免只依赖手工联调;
- 对敏感字段做脱敏,日志记录
requestId/traceId,不记录 Cookie、令牌和完整请求体; - 为新增字段、废弃字段和版本迁移设置兼容窗口;
- 用故障注入验证关键依赖超时、部分失败、重复请求和权限拒绝;
- 通过指标观察 P95/P99 延迟、下游错误率、缓存命中率、GraphQL 查询拒绝率和 BFF 降级次数。
9. 面试回答模板
先说明接口面对的客户端、资源和成功标准
-> 选择 REST、BFF、Serverless 或 GraphQL,并解释为什么
-> 讲清 DTO/业务层/持久化层的边界
-> 补充鉴权、缓存、超时、重试、幂等和部分失败
-> 最后说明监控、契约测试和版本迁移
高频追问
Q: BFF 和普通后端有什么区别?
A: BFF 的边界按前端形态或交互需求划分,主要负责聚合、裁剪、适配和统一接入治理;领域规则和数据所有权仍在后端领域服务。若 BFF 开始复制大量业务规则,应重新划分服务边界。
Q: GraphQL 是否一定比 REST 好?
A: 不是。GraphQL 适合字段组合变化快、关联读取多的场景,但要承担查询复杂度、N+1、权限和缓存治理。资源语义清晰、缓存和公共接口优先的场景,REST 往往更简单可靠。
Q: Serverless 是否意味着不需要后端工程?
A: 不是。平台代管了机器和部分扩缩容,但函数仍要处理无状态、冷启动、超时、重复投递、权限、观测和数据一致性。先按延迟预算、任务时长、流量曲线和调试能力选型,不能把 FaaS 当成所有服务的默认部署方式。
Q: BFF 聚合时一个下游失败怎么办?
A: 先区分关键和可选数据,为依赖设置 deadline;关键数据失败返回可识别错误,可选模块返回降级状态。所有失败都要有 trace、指标和有限重试,不能用无限等待或静默空数据掩盖故障。
Q: DTO、BO、PO、DAO 为什么要分开?
A: DTO 是外部契约,BO 承载业务语义,PO 对应持久化结构,DAO 封装数据访问。分开可以隔离数据库变化、权限脱敏和业务规则,并让接口版本演进更可控。