内容概要
Java Champion 及 Spring 专家 Victor Rentea 以一种风趣幽默的方式,回顾了在设计 REST API 时最常见的错误。他结合了在超过 150 家公司工作的经验,深入剖析了各种设计陷阱,从业余的失误到危险的“无心之过”都囊括其中。演讲内容涵盖了破坏性变更 (Breaking Changes)、领域模型泄露 (Domain Model Leakage)、性能问题、端点耦合、CRUD 与 CQRS 的权衡、PUT 方法的滥用、对 REST 原则的盲目崇拜 (Religious REST Fallacy) 以及不当的错误处理等多个方面,并提出了如何规避这些问题,从而设计出更健壮、可维护和可扩展的 API。
目录
-
引言
-
REST API 设计:破坏性变更
-
版本控制策略
-
客户端迁移策略
-
如何检测破坏性变更
-
性能陷阱
-
减少网络调用:BFF 与 GraphQL
-
不必要的请求代理
-
有状态的端点
-
应对复杂性扩展:领域模型泄露
-
解决方案:DTO 与映射器 (Mappers)
-
使用 MapStruct 进行自动映射
-
自动映射的局限性
-
应用层面的 CQRS
-
后端与数据库之间的 CQRS
-
最终一致性
-
Put 与 Post:关于返回数据
-
“编辑”页面的问题:易变数据与并发更新
-
使用操作按钮代替编辑页面
-
URL 中的动词:对 REST 的“原教旨主义”谬误
-
Patch 与语义化端点
-
不恰当的错误处理
-
总结:千万别这么做
引言
大家好,很高兴能来到这里。今天我们来聊聊 REST 设计中的陷阱。首先,我想对 Spring I/O 说声“嗨”,这是我第一次来到这里。我至少梦想了五年能参加这个会议,今天终于来到了美丽的巴塞罗那。我叫 Victor,有 18 年的 Java 编程经验,是一名 Java Champion,使用 Spring 框架也超过了 10 年。我为许多公司提供技术工作坊和咨询服务,说实话,这种生活方式并不健康,不推荐大家模仿。
不过,这让我有机会在不同的工作坊中反复强调同样的设计理念。每当这时,我就会把这些想法整理成类似今天这样的演讲,分享给大家。我的很多演讲视频都可以在网上找到。我还会为我的社区定期举办网络研讨会,这个社区是全球最大的关于如何编写整洁、可维护代码的社区。当然,在工作之余,我也努力拥有自己的生活。
REST API 设计:破坏性变更
好了,让我们进入正题:REST API 设计。让我们回到 TCP/IP 时代,它的一位创造者 John Postel 曾提出一个健壮性原则:系统在自己发送的东西上应该保守,但在接收他人的东西时应该开放和宽容。简而言之,就是向后兼容。
为了让大家在午饭后振作起来,我们来做个小互动。屏幕上有一个请求体和一个响应体,它们之间没有直接关系。现在,请大家告诉我,在这两个数据结构中做哪些改动,会强制我的客户端必须升级?也就是说,哪些是破坏性变更 (breaking change)?
大家可以大声说出来。比如,增加一个必填字段,原本 phone 字段是可选的,现在变成了必填。还有吗?改变字段类型,比如把 age 从数字改成字符串。或者,重命名字段,比如把 firstName 改成一个听起来更酷的名字,像 fame。
还有日期格式的变更,到底是美式还是欧式标准?或者对字段增加新的校验规则,比如验证它是否为合法的电子邮件地址。这些都是常见的破坏性变更。
我想特别提一下枚举 (enum) 类型。如果你在请求中不再接受某种货币类型,或者在响应中为某个枚举增加了一个新的值,都可能导致客户端崩溃。我的客户经常会惊讶地说:“哦,我们只是改了个枚举而已。”然后系统就炸了。
当然,增加新的可选字段或在响应中添加新字段,通常不是破坏性变更。这是向后兼容性的基本要求。我们不希望强制客户端升级。
但随着时间推移,API 会变得越来越臃肿。比如,phone 一开始是一个完整的字符串。为了避免破坏性变更,后来我们又增加了 prefix 和 localNumber 两个字段来分别表示区号和本地号码。你能想象在响应中同时看到 phone、prefix 和 localNumber 吗?这在请求中会更混乱,客户端既可以传完整的字符串,也可以传两个独立的字段。这种为了兼容而产生的冗余,会让 API 的技术债越积越多,质量不断下降。
版本控制策略
当 API 变得混乱不堪时,你最终会划清界限,推出 V2 版本。但这也意味着你的代码逻辑会开始变得复杂,充斥着类似 if (version == 3) 这样的判断。我看到现场有人举手了,说明大家深有体会。
创建 V2 版本时,我们通常会复制粘贴 V1 的 DTO、Mapper 等代码,然后稍作修改,这会导致代码质量进一步恶化。理想情况下,我希望大家永远不需要同时维护超过两个并行版本。但现场似乎有大约 20 个人举手表示,他们不得不支持三个甚至更多的版本,这真的不是个好兆头。
你需要做的第一件事是找出谁在使用你的 API。除了通过 OAuth 服务账户查看授权信息,更可靠的方法是利用可观测性工具,比如通过追踪 ID (Trace ID) 在 Zipkin 等系统中分析实际的请求来源。这里我想提一下 PACT.io,这个工具非常有趣,它可以帮你确定哪个客户端依赖你响应中的哪个具体字段。
客户端迁移策略
接下来就是一场与客户的“战争”,迫使他们从 V1 升级到 V2。你可以打电话、恳求、甚至威胁停止提供新功能。当然,也可以采取一些友好的方式,比如提供详细的迁移指南、代码示例,甚至可以提供像 OpenRewrite 这样的自动化工具,帮助他们完成 90% 的迁移工作。
最高效的方式是直接联系他们,问:“需要我帮你一起写代码吗?” 如果你们在同一家公司,这招通常很管用。但无论如何,千万不要随意抛出错误,或者谎称 V1 版本有安全漏洞来迫使他们升级。虽然我听说有朋友这么干成功了,但这绝不是正道。否则,你将陷入维护多个并行版本的泥潭。
当你终于有机会创建一个全新的 API 时,一定要尽一切努力避免未来再次出现破坏性变更。于是,你可能会过度设计,比如把 email 设计成一个数组,因为“谁知道未来会不会有多个邮箱呢?”或者把 phone 设计成一个包含 type 和 number 的复杂对象,因为“万一以后需要支持家庭电话呢?”
这种过度设计的顶峰是使用一个 Map<String, Object> 类型的 metadata 或 extensions 字段。这本质上是放弃了任何模式 (schema) 约束,我亲眼见过项目因此而走向失败。
关于版本号的放置位置,最常见的方式是放在 URL 中,比如 /v2/users。但 REST 的提出者 Roy Fielding 认为,在 URL 中加入 v1 就像是对你的客户竖起中指,暗示他们:“我以后肯定会坑你,这只是第一版而已。”
我曾与罗马尼亚一家大型电商的 CTO 交流,他的观点更激进。他说:“如果我们实在无法通过增加可选字段来兼容,那可能意味着业务发生了巨大变化。这时,我宁愿创建一个全新的微服务,使用新的数据库。旧的服务会在三个月内下线。” 这种“为删除而设计 (design for deletion)”的理念,从一开始就让系统易于移除,虽然听起来很残酷,但有时却很有效。
如何检测破坏性变更
与其纠结于何时以及如何进行版本控制,一个更重要的问题是:我们如何能快速地检测到破坏性变更?
我建议你在自己的项目中设置一个“契约测试 (contract test)”。通过 Spring Boot Test 运行你的应用,生成 OpenAPI 契约文件,然后将它与昨天存储在 Git 里的版本进行比对。这就像一个审批测试 (approval test),确保你没有在无意中破坏 API 契约。如果确实有变更,你需要确认并覆盖旧的契约文件。
如果你想确保你消费的 API 契约与提供方一致,情况会更复杂,因为代码通常在不同的 Git 仓库中。Spring Cloud Contract 和 Pact 是两种可行的解决方案。它们的核心思想是,当契约不匹配时,构建过程会失败,从而在早期发现问题。
在某些幸运的情况下,客户端和服务器可能使用同一种语言(比如都是 Java)并且在同一个代码库中。这时,一种常见的模式是让客户端直接依赖服务提供方的 client JAR 包。服务方发布新版本后,像 Dependabot 或 Renovate 这样的工具会自动扫描所有依赖该 JAR 包的项目,并提交一个升级版本的拉取请求 (Pull Request),从而大大简化了客户端的升级过程。
性能陷阱
接下来我们谈谈性能。想象一下,你的数据库里有数百万条记录,而你只提供了一个根据 ID 获取单条记录的 GET /items/{id} 接口。这会出什么问题?
客户端会循环遍历所有 ID,然后一个接一个地向你发送请求。如果他们再用上并行流 (parallel stream),那简直是一场性能灾难。网络、CPU、内存都会不堪重负。
显而易见的解决方案是提供一个批量获取的接口。你可以让客户端在 URL 中传递多个 ID,但如果 URL 长度超过 2000 个字符,可能会被中间的网络设备截断。
一个常见的做法是使用 POST 方法来模拟 GET 请求,将 ID 列表放在请求体中。但如果客户端一次性传来十万个 ID,返回的响应体可能会撑爆内存,除非你使用像 JSON Lines 这样支持流式处理的格式。
还有人提出在 GET 请求的请求体中传递 ID。几年前 HTTP 标准确实修改了,允许 GET 请求携带请求体,但这有风险,因为很多中间代理或网关可能会丢弃它。说实话,POST 就足够好了。
有趣的是,当我提供批量接口后,有些客户为了省事,仍然用这个接口一次只查询一个 ID。对此,我们的对策是进行速率限制 (rate limiting),并在错误信息中提醒他们使用正确的接口。
当然,在没有明确需求时,提供批量接口可能是一种过早优化 (premature optimization)。更好的做法是先设置好监控告警,当发现某个客户端以极高的频率调用单点查询接口时,再动手实现批量接口。
还有一点值得深思:客户为什么需要一次性下载几十万条数据?他们想用这些数据做什么?这些处理逻辑是否本应由我们的服务来完成?这涉及到服务边界和职责划分的问题。
最后,如果你使用了自增 ID,就要小心了。这可能会被恶意用户利用,通过遍历 ID 来爬取你的全部数据。因此,最好使用像 UUID 这样无规律、不可预测的标识符。
减少网络调用:BFF 与 GraphQL
另一个常见的性能问题是,当客户端(尤其是移动端)在一个慢速网络环境下,为了加载一个页面需要调用多个微服务。
解决方案是引入一个聚合层,也就是所谓的“为前端服务的后端” (Backend for Frontend, BFF)。BFF 位于客户端和后端微服务之间,它会快速地从各个微服务获取所需数据,聚合成一个完整的响应,然后一次性返回给客户端。
这里不得不提 GraphQL。Netflix 的技术团队曾分享说,他们已经不再使用 REST,而是在面向设备的边界上全面采用 GraphQL,在后端服务之间则使用 gRPC。GraphQL 的核心优势之一就是能够聚合来自多个服务的数据,从而减少网络往返次数。不过,请记住,你不是 Netflix,不要盲目跟风,但这个思路值得借鉴。
不必要的请求代理
在微服务架构中,有时会看到这样的调用链:A 调用 B,B 又调用 C。但如果你仔细观察,会发现 B 只是把从 C 获取的响应原封不动地返回给了 A,没有增加任何业务价值。
这通常是出于安全或解耦的历史原因,但现在可能已经没有必要了。这种无意义的代理浪费了计算资源,也增加了延迟。我们应该定期审视系统的整体调用链路,消除这种不必要的中间层,让 A 直接调用 C。
有状态的端点
还记得早期的 EJB 或 Struts 时代吗?我们会在后端打开一个搜索会话,然后一页一页地请求数据。这种设计要求服务器为每个客户端维护状态。
这意味着负载均衡器不能再简单地将请求轮询到任意实例,而必须使用粘性会话 (sticky sessions),将来自同一客户端的所有请求都路由到同一个实例上。这严重影响了系统的水平扩展能力和性能。
应对复杂性扩展:领域模型泄露
性能问题通常关系到系统的可伸缩性 (scalability)。但可伸缩性不仅指吞吐量和延迟,还指复杂性的扩展能力,即如何在不断增加功能的同时保持代码的整洁。
现在,想象一下,你把你最核心的领域模型 (domain model)——如果你使用 Hibernate,那很可能就是你的实体 (Entity) 类——直接通过 REST API 暴露为 JSON。这会带来什么后果?
现场有朋友很直接地回答:“狗屎 (Shit)”。是的,这非常糟糕。为什么?因为你的核心领域模型会被 API 契约锁定。一旦有客户端依赖它,你就无法再轻易地修改它,否则会导致破坏性变更。我见过好几个项目因此而失败,数百万行代码付诸东流。
此外,这还会导致敏感信息泄露。当你在实体类中增加一个新字段(比如电话号码)时,它可能会自动暴露在 API 中。为了解决这个问题,你可能会使用 @JsonIgnore 之类的注解,但这又给这个本应纯净的类增加了额外的职责。更糟糕的是,如果你使用了延迟加载 (lazy loading),在序列化时可能会触发额外的数据库查询,造成性能问题。
解决方案:DTO 与映射器 (Mappers)
正确的做法是使用数据传输对象 (Data Transfer Objects, DTOs)。将 API 的契约与内部实现(领域模型)分离开来。这是软件工程中最古老的原则之一:契约保持稳定、清晰、语义化,而内部实现则可以自由演进以满足新的需求。
这样做的代价是需要编写大量的样板代码,用于在 DTO 和实体之间转换数据,例如 dto.setName(entity.getName())。这种代码既枯燥又容易出错。
使用 MapStruct 进行自动映射
为了解决这个问题,我们可以使用自动映射工具,比如 MapStruct、ModelMapper 或 Orika。现场超过一半的人都在使用这类工具。
MapStruct 在其中脱颖而出,因为它在编译时生成 Java 源代码,而不是使用反射。这带来了三大好处:
-
性能极高:生成的代码可以被 JIT 编译器优化。
-
可调试:你可以像调试手写代码一样,在生成的代码中设置断点。
-
易于迁移:如果有一天你不想用它了,可以直接把生成的代码复制到你的项目中。
自动映射的局限性
然而,自动映射也有其局限性。API 的 DTO 需要保持稳定,而内部的领域模型却在不断演进。比如,某天你决定将 firstName 和 lastName 合并成一个 FullName 对象。
当你这样做之后,构建应该会失败,提示你映射关系已损坏。你需要手动调整映射规则,有时这会变得非常复杂,需要写很多注解或默认方法。更糟糕的是,这种复杂性可能会让你放弃重构,导致技术债越积越多。
因此,当映射逻辑变得异常复杂时,就应该果断放弃自动映射,转为手动编写这部分代码。
应用层面的 CQRS
我们再来看一个例子。一个 InventoryItemDTO 同时用于创建和查询物品。这有什么问题?在创建时,id、creationDate、status 等字段都是由服务器生成的,客户端不应该也无法提供。
将创建和查询操作混用同一个 DTO,会让客户端和服务器都感到困惑。正确的做法是将它们分开,创建 CreateItemRequest 和 ItemResponse 两个不同的 DTO。
当你这么做的时候,恭喜你,你已经在应用层面上实践了命令查询职责分离 (Command Query Responsibility Segregation, CQRS)。CQRS 的核心思想是,用于修改数据(命令)和用于读取数据(查询)的操作应该使用不同的数据模型。
通常,在系统初期,我们倾向于像操作一张 Excel 表格一样对待数据。但随着业务发展,查询操作会变得越来越复杂,需要聚合、连接、报表等,我们更关心延迟和可用性。而写入操作则更关心数据的一致性。CQRS 正是为了应对这种分离而生。
后端与数据库之间的 CQRS
CQRS 同样可以应用于后端与数据库之间。如果你在使用 Hibernate,当实现一个搜索功能时,绝对不要直接查询完整的实体对象 (SELECT e FROM Entity e)。这会导致不必要的数据加载。
你应该构造一个投影 (projection) 对象,只从数据库中查询你需要的字段。Spring Data JPA 提供了非常方便的接口投影功能。而当你需要修改数据时,则应该加载完整的实体,并通过其方法来执行业务逻辑。
最终一致性
当对性能和可用性的要求越来越高时,你可能会遇到强一致性 (strong consistency) 的瓶颈。这时,你需要在业务上做出权衡,是否愿意牺牲一部分一致性来换取更高的性能?
一旦你决定跨越这道坎,就进入了最终一致性 (eventual consistency) 的世界。最古老的例子是物化视图 (materialized view),它预先计算好查询结果,但数据可能不是最新的。其他常见的方案包括使用 Redis 做缓存、用 Elasticsearch 做全文搜索,或者将预先计算好的 JSON 字符串直接存储起来,查询时直接返回,省去序列化的开销。
如果你对这个话题感兴趣,我强烈推荐你去看看 Greg Young 关于 CQRS 的经典演讲。
Put 与 Post:关于返回数据
一个相关的问题是:PUT 或 POST 请求(尤其是 PUT)是否应该返回更新后的数据?
现场有人承认自己这么做过。通常,我们不仅返回数据,还会返回“增强后”的数据,比如附加上创建时间、创建人等信息,美其名曰“为了客户端方便”。
但这破坏了 CQRS 原则。更重要的是,它带来了几个问题:
-
耦合:
PUT的响应体和GET的响应体很可能变成了同一个 DTO,修改一个会影响另一个。 -
安全风险:你可能会在不经意间泄露敏感信息。我有一个客户就因此在生产环境中泄露了个人身份信息 (PII)。
-
资源浪费:客户端可能根本不需要你返回的这些数据。
除非你有非常充分的理由,否则 PUT 请求不应该返回数据。客户端如果需要更新后的数据,应该在 PUT 成功后再次发起一个 GET 请求。
“编辑”页面的问题:易变数据与并发更新
我们都见过通用的“编辑”按钮和编辑页面。这看似方便,实则可能是灾难的开始。
想象一个编辑库存物品的页面,上面有名称、描述、成本、库存数量等字段。这里面有什么问题?一个明显的问题是,库存 (stock) 是一个非常易变的数据。当一个用户正在编辑物品描述时,另一个客户可能已经下单一双,导致数据库中的库存数量发生了变化。如果用户此时提交表单,就可能会用旧的库存数量覆盖掉新的值,导致数据丢失。
作为后端开发者,看到这样的界面,第一反应就应该是并发更新问题。解决方案之一是让前端只提交变更量 (delta),比如“增加 5 个库存”,而不是提交绝对值。但这还不够,你还需要考虑乐观锁或悲观锁。
使用操作按钮代替编辑页面
但真正的解决方案是跳出思维定式,去观察用户到底在做什么。你会发现,用户打开这个页面,通常是为了完成一个特定的任务:要么是修改描述,要么是调整成本,要么是增减库存。
因此,一个更好的设计是用明确的操作按钮代替通用的编辑页面。比如,提供“停用”、“调整成本”、“售出”等按钮。每个按钮对应一个特定的用户意图和业务操作。
这样做的好处是:
-
UI 和 API 更具语义化,清晰地反映了用户的意图。
-
并发冲突的风险更低。
-
服务器端的逻辑更简单。
当然,这也意味着你需要开发更多的 API 和 UI 界面,并且需要前后端团队更紧密地协作。
URL 中的动词:对 REST 的“原教旨主义”谬误
当我们使用操作按钮时,API 的 URL 设计也会相应改变。例如,停用一个物品的 API 可能是 POST /items/{id}/deactivate。
这时,一些“REST 原教旨主义者”会跳出来说:“URL 里不能用动词!”他们会把你绑在火刑柱上烧死。
但事实上,如果你死守 CRUD,把所有逻辑都塞进一个宽泛的 PUT 方法里,你的 API 语义会变得非常混乱。在复杂的业务领域,使用动词来表达一个明确的操作,往往比生搬硬套名词更自然、更具可读性。
这里还需要注意 PUT 和 POST 的区别。PUT 是幂等的 (idempotent),多次调用结果相同(例如,设置价格为 100 元)。而 POST 通常不是幂等的,多次调用会产生不同的结果(例如,售出一个商品)。因此,像“售出”这样的操作应该使用 POST。
Patch 与语义化端点
这自然引出了 PATCH 方法。PATCH 用于对资源进行部分更新。但如果你直接使用一个 JSON 对象来表示要修改的字段,解析起来会很麻烦。更好的方式是使用像 JSON Patch 这样有明确结构的格式。
然而,PATCH 最大的问题和通用的“编辑”页面一样:它缺乏业务语义。你收到一堆数据变更,但并不知道用户为什么要这么做。这就像一个包含了 300 行代码修改的 Git 提交,提交信息却只写了“更新”。
相比之下,一个 POST /items/{id}/deactivate 的请求,其意图就非常清晰。当然,对于一些纯粹的数据维护场景,比如用户自定义配置,使用 PATCH 也是合理的。
不恰当的错误处理
最后,我们来谈谈错误处理。当服务器发生内部错误时,永远不要直接返回 500 Internal Server Error 和一堆堆栈信息,这会泄露实现细节。
对于客户端的请求错误,比如字段校验失败,你应该返回 4xx 状态码(如 400 或 422)。更重要的是,不要一次只返回一个校验错误。用户提交表单,你告诉他“邮箱格式错误”;他改完再提交,你又告诉他“电话号码缺失”。这体验非常糟糕。
你应该一次性返回所有的校验错误。有一个 RFC 7807 标准定义了如何结构化地返回错误信息,虽然用的人不多,但值得参考。
总结:千万别这么做
为了让大家加深印象,我模仿一下 David Schmidt 的幽默风格,用反讽的方式总结一下今天的内容。如果你想把 API 设计搞砸,请务必做到以下几点:
-
永远不要考虑向后兼容。你是老大,他们只是客户端而已。
-
直接暴露你的内部领域模型,保持简单,别搞什么 DTO 和 Mapper。
-
鼓励客户端循环调用你的单点查询接口。这样你就可以向老板证明你的 API 流量有多大,好涨工资去付 AWS 账单。
-
尽可能多地代理请求,理由同上。
-
在所有场景中复用同一个 DTO,因为要遵守 DRY (Don't Repeat Yourself) 原则嘛。
-
所有的
PUT请求都必须返回数据库里的所有数据,至于 GDPR,那是法务的事。 -
CRUD 就是你所需要的一切,CRUD one ring to rule them all。
谢谢大家!
