Press "Enter" to skip to content

PUT vs. PATCH:Spring Boot RPC 更新接口设计的深度思考

API Design Banner

前言:那个令人纠结的更新接口

在日常的项目开发中,设计一个“更新”接口是再常见不过的需求了。假设我们有一个“国家”实体,它包含名称、首都、国旗等多个字段。现在需要提供一个RPC接口,根据国家代码(code)来更新其信息。

这时,一个经典的设计抉择摆在了我们面前:
> 客户端在调用更新接口时,是应该传递一个包含所有字段的完整对象,还是只传递需要修改的字段(未修改的字段不传或传null)?

这两种方式分别对应了 RESTful API 设计中的 PUTPATCH 语义。它们没有绝对的好坏之分,但在不同场景下,选择合适的方式会让你的 API 更健壮、更高效、也更易于使用。本文将深入探讨这两种设计方案的优缺点,并给出在 Spring Boot 项目中的最佳实践。


方案一:完整DTO更新 (Full Update / PUT 语义)

这种模式要求客户端每次更新时,都提供一个目标资源的完整表示。即使你只想修改国家的名称,也必须把首都、国旗等所有字段的值一并传来。

  • HTTP 类比: PUT /countries/{code}
// 即使我只想改首都,也必须把name字段带上
// PUT /countries/CHN
{
  "name": "中国",
  "capital": "新首都",
  "flagUrl": "http://example.com/flag.png"
}

优点

  1. 服务端实现简单:服务端的逻辑非常清晰:接收 DTO -> 验证 -> 查找旧实体 -> 用 DTO 的所有字段完全覆盖旧实体的字段 -> 保存。几乎没有复杂的判断逻辑。
  2. 接口意图明确:调用者清楚地知道,这个操作是“替换 (Replace)”操作。调用成功后,服务端资源的状态将与客户端发送的 DTO 完全一致。
  3. 天然幂等性:使用同一个 DTO 多次调用此接口,资源最终的状态总是一样的。这对于需要失败重试的场景非常友好。

缺点

  1. 客户端负担重:为了确保不丢失数据,客户端在更新前通常需要先 GET 一次资源,获取其最新状态,然后在本地修改,最后再 PUT 回去。这无疑增加了客户端的交互次数和逻辑复杂度。
  2. 网络效率低:只改一个字节,却要传输整个庞大的 DTO,在DTO字段很多或网络环境不佳时,这是显著的性能浪费。
  3. 高并发下的“丢失更新”风险:这是一个致命缺陷。
    • 场景:用户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": "新首都"
}

优点

  1. 网络效率高:只传输变更的数据,报文体积小,节省带宽,对移动端尤其友好。
  2. 客户端友好灵活:客户端可以直接构建一个只包含变更字段的DTO,直接调用接口,流程简单。
  3. 并发更新更安全:在上述并发场景中,用户A和用户B的更新操作(只要不修改同一字段)可以和平共存,不会互相覆盖。

缺点

  1. 服务端实现相对复杂:服务端需要逐个字段判断 DTO 中哪些字段有值,然后才去更新数据库对应的列。
  2. “设为null”的歧义:这是 PATCH 模式最核心的难题。当客户端传来 {"capital": null} 时,它的意图是什么?
    • 意图A: 不想更新 capital 字段,所以传了 null
    • 意图B: 就是想把 capital 字段的值更新为 null

如何解决这个歧义,是 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 capitalnull: 客户端未传递此字段,不更新。
  • 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 语义)。 它的网络效率、灵活性和并发安全性带来的好处,远远超过了服务端增加的那一点点实现复杂度。

给你的最终建议:

  1. 首选方案二 (PATCH):启动新项目或设计新接口时,优先采用局部更新的思路。

  2. 根据业务选择null处理策略

    • 如果业务上字段不允许为null:大胆使用最简单的策略一(忽略null值),成本最低,效果最好。
    • 如果业务上字段允许被更新为null
      • 追求标准和简洁,采用策略三 (JSON Merge Patch)
      • 追求极致的明确性和健壮性(如设计开放平台API),采用策略四 (update_mask)
      • 策略二 (Optional) 是一个纯 Java 侧的优雅方案,也可考虑。
  3. 终极形态:如果资源和时间允许,设计最完善的 RESTful API 会同时提供两个端点:

    • PUT /resources/{id}: 用于整体替换
    • PATCH /resources/{id}: 用于局部修改

希望这篇深度分析能帮助你在未来的 API 设计中,做出更明智、更专业的决策!


发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注