
前言:那个令人纠结的更新接口
在日常的项目开发中,设计一个“更新”接口是再常见不过的需求了。假设我们有一个“国家”实体,它包含名称、首都、国旗等多个字段。现在需要提供一个RPC接口,根据国家代码(code)来更新其信息。
这时,一个经典的设计抉择摆在了我们面前:
> 客户端在调用更新接口时,是应该传递一个包含所有字段的完整对象,还是只传递需要修改的字段(未修改的字段不传或传null)?
这两种方式分别对应了 RESTful API 设计中的 PUT 和 PATCH 语义。它们没有绝对的好坏之分,但在不同场景下,选择合适的方式会让你的 API 更健壮、更高效、也更易于使用。本文将深入探讨这两种设计方案的优缺点,并给出在 Spring Boot 项目中的最佳实践。
方案一:完整DTO更新 (Full Update / PUT 语义)
这种模式要求客户端每次更新时,都提供一个目标资源的完整表示。即使你只想修改国家的名称,也必须把首都、国旗等所有字段的值一并传来。
- HTTP 类比:
PUT /countries/{code}
// 即使我只想改首都,也必须把name字段带上
// PUT /countries/CHN
{
"name": "中国",
"capital": "新首都",
"flagUrl": "http://example.com/flag.png"
}
优点
- 服务端实现简单:服务端的逻辑非常清晰:接收 DTO -> 验证 -> 查找旧实体 -> 用 DTO 的所有字段完全覆盖旧实体的字段 -> 保存。几乎没有复杂的判断逻辑。
- 接口意图明确:调用者清楚地知道,这个操作是“替换 (Replace)”操作。调用成功后,服务端资源的状态将与客户端发送的 DTO 完全一致。
- 天然幂等性:使用同一个 DTO 多次调用此接口,资源最终的状态总是一样的。这对于需要失败重试的场景非常友好。
缺点
- 客户端负担重:为了确保不丢失数据,客户端在更新前通常需要先
GET一次资源,获取其最新状态,然后在本地修改,最后再PUT回去。这无疑增加了客户端的交互次数和逻辑复杂度。 - 网络效率低:只改一个字节,却要传输整个庞大的 DTO,在DTO字段很多或网络环境不佳时,这是显著的性能浪费。
- 高并发下的“丢失更新”风险:这是一个致命缺陷。
- 场景:用户A和用户B同时操作。
- 用户A
GET到国家信息{"name": "中国", "capital": "北京"}。 - 用户B也
GET到同样的信息。 - 用户B修改了首都并提交
PUT {"name": "中国", "capital": "新首都"}。数据库更新成功。 - 紧接着,用户A修改了名称并提交
PUT {"name": "中华人民共和国", "capital": "北京"}。 - 最终结果:用户B对首都的更新,被用户A携带的旧数据
"capital": "北京"给覆盖了!虽然可以通过乐观锁(@Version)机制来缓解,但这增加了实现的复杂度。
方案二:部分DTO更新 (Partial Update / PATCH 语义)
这种模式允许客户端只发送他们想要修改的字段。这就像是给资源打上一个“补丁”,而不是替换整个资源。
- HTTP 类比:
PATCH /countries/{code}
// 只想改首都,就只传capital字段
// PATCH /countries/CHN
{
"capital": "新首都"
}
优点
- 网络效率高:只传输变更的数据,报文体积小,节省带宽,对移动端尤其友好。
- 客户端友好灵活:客户端可以直接构建一个只包含变更字段的DTO,直接调用接口,流程简单。
- 并发更新更安全:在上述并发场景中,用户A和用户B的更新操作(只要不修改同一字段)可以和平共存,不会互相覆盖。
缺点
- 服务端实现相对复杂:服务端需要逐个字段判断 DTO 中哪些字段有值,然后才去更新数据库对应的列。
- “设为null”的歧义:这是 PATCH 模式最核心的难题。当客户端传来
{"capital": null}时,它的意图是什么?- 意图A: 不想更新
capital字段,所以传了null。 - 意图B: 就是想把
capital字段的值更新为null。
- 意图A: 不想更新
如何解决这个歧义,是 PATCH 模式成功的关键。
如何优雅地处理 PATCH 中的 'null' 难题?
既然“null”的歧义是 PATCH 模式最大的挑战,那我们有哪些成熟的武器来应对它呢?
策略一:简单约定(忽略null值)
这是最常用、最简单的策略,尤其适合内部系统或业务上字段不允许为null的场景。
- 约定:DTO 中所有值为
null的字段,一律视为“不更新”。 - 优点:实现极其简单。
- 缺点:无法将任何字段的值真正更新为
null。
Spring Boot 实现示例:
我们可以借助一些工具类来轻松实现“忽略null值的属性拷贝”。
// DTO
public class CountryUpdateDTO {
private String name;
private String capital;
// getters and setters
}
// Service Impl
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.bean.copier.CopyOptions;
// ...
public void updateCountry(String code, CountryUpdateDTO dto) {
CountryEntity country = repository.findByCode(code).orElseThrow(ResourceNotFoundException::new);
// 使用Hutool工具类,只拷贝dto中不为null的属性到country实体
CopyOptions options = CopyOptions.create().setIgnoreNullValue(true);
BeanUtil.copyProperties(dto, country, options);
// 如果不想引入三方库,可以手动判断
/*
if (dto.getName() != null) {
country.setName(dto.getName());
}
if (dto.getCapital() != null) {
country.setCapital(dto.getCapital());
}
*/
repository.save(country);
}
策略二:使用 java.util.Optional
在 DTO 中使用 Optional 来精确表达三种状态:不更新、更新为null、更新为新值。
Optional capital为null: 客户端未传递此字段,不更新。Optional.empty(): 客户端传递了{"capital": null},更新为null。Optional.of("新首都"): 客户端传递了{"capital": "新首都"},更新为新值。
public class CountryUpdateDTO {
// 使用Optional包装,可以精确表达意图
private Optional name;
private Optional capital;
// ...
}
// Service层需要判断Optional的状态
if (dto.getCapital() != null) { // 检查Optional对象本身是否存在
country.setCapital(dto.getCapital().orElse(null)); // orElse(null)处理更新为null的情况
}
这种方式在 Java 代码层面非常优雅,但可能需要对 Jackson 等 JSON 库进行额外配置以良好支持 Optional 的序列化和反序列化。
策略三:遵循 JSON Merge Patch (RFC 7392)
这是一个业界标准,规则清晰:
- 如果请求 JSON 中包含某个键且其值为
null(例如{"capital": null}),则意为将资源的该字段更新为null。 - 如果请求 JSON 中不包含某个键,则意为不修改该字段。
这要求客户端在序列化时,能够忽略掉那些未被修改的、值为null的字段。大部分现代 JSON 库都能配置实现这一点。
策略四:使用字段掩码 update_mask (Google API 风格)
这是 Google Cloud API 采用的一种极其健壮和明确的模式。请求中额外增加一个 update_mask 字段,明确告知服务端本次要更新哪些字段。
// PATCH /countries/CHN
{
"country_dto": {
"name": "中华人民共和国",
"capital": null // 即使这里是null,也会被处理
},
"update_mask": ["name", "capital"] // 明确告诉服务器,只处理name和capital字段
}
- 优点:意图毫无歧义,非常强大和灵活。
- 缺点:增加了请求报文的复杂度和客户端、服务端的处理逻辑。
结论与最佳实践
| 特性 | 方案一 (PUT) | 方案二 (PATCH) |
|---|---|---|
| 网络效率 | 低 | 高 |
| 客户端友好性 | 差 | 好 |
| 并发安全 | 差 (易丢失更新) | 好 |
| 服务端实现 | 简单 | 相对复杂 |
| 核心问题 | 丢失更新 | null值歧义 |
综合来看,对于绝大多数现代应用,我强烈推荐选择方案二(Partial Update / PATCH 语义)。 它的网络效率、灵活性和并发安全性带来的好处,远远超过了服务端增加的那一点点实现复杂度。
给你的最终建议:
-
首选方案二 (PATCH):启动新项目或设计新接口时,优先采用局部更新的思路。
-
根据业务选择
null处理策略:- 如果业务上字段不允许为
null:大胆使用最简单的策略一(忽略null值),成本最低,效果最好。 - 如果业务上字段允许被更新为
null:- 追求标准和简洁,采用策略三 (JSON Merge Patch)。
- 追求极致的明确性和健壮性(如设计开放平台API),采用策略四 (
update_mask)。 - 策略二 (
Optional) 是一个纯 Java 侧的优雅方案,也可考虑。
- 如果业务上字段不允许为
-
终极形态:如果资源和时间允许,设计最完善的 RESTful API 会同时提供两个端点:
PUT /resources/{id}: 用于整体替换。PATCH /resources/{id}: 用于局部修改。
希望这篇深度分析能帮助你在未来的 API 设计中,做出更明智、更专业的决策!