1. 简介

在众多 HTTP 方法中,PATCH 方法有着独特的定位——它专为部分更新资源而生。

本文将深入探讨如何结合 HTTP PATCH 方法与 JSON Patch 格式,在 Spring 构建的 RESTful 接口中实现高效、精准的字段级更新。

2. 业务场景

设想一个 Customer 客户资源,其 JSON 表示如下:

{ 
    "id": "1",
    "telephone": "001-555-1234",
    "favorites": ["Milk", "Eggs"],
    "communicationPreferences": { "post": true, "email": true }
}

现在客户更换了电话号码,并新增了一项“最爱商品”。我们只需更新 telephonefavorites 两个字段。

如何实现?

第一反应可能是 PUT。但 PUT 是全量替换,不仅效率低,客户端还得先 GET 一次原始数据,否则会误删未提交的字段 ❌。

更优雅的方案是使用 HTTP PATCH 方法 ✅。

3. HTTP PATCH 与 JSON Patch 格式

PATCH 方法的核心优势在于:客户端只需发送“差异”,而非完整资源。

一个典型的 PATCH 请求如下:

PATCH /customers/1234 HTTP/1.1
Host: www.example.com
Content-Type: application/json-patch+json
If-Match: "e0023aa4e"
Content-Length: 100

[
  { "op": "replace", "path": "/telephone", "value": "001-555-5678" }
]

关键点:

  • 请求体描述了目标资源的变更方式
  • ✅ 对于 JSON 资源,变更描述格式遵循 JSON Patch 标准。
  • ✅ JSON Patch 文档是一个 JSON 对象数组,每个对象代表一个操作。

4. JSON Patch 操作类型

每个操作由一个 op 字段标识,配合 path(必填)和 from(可选)字段。pathfrom 的值是 JSON Pointer,用于定位目标文档中的具体位置。

4.1. add 操作

用于:

  • 向对象添加新字段
  • 更新已有字段
  • 向数组指定索引插入元素

示例:在 favorites 列表开头添加 “Bread”

{
    "op": "add",
    "path": "/favorites/0",
    "value": "Bread"
}

执行后结果:

{
    "id": "1",
    "telephone": "001-555-1234",
    "favorites": ["Bread", "Milk", "Eggs"],
    "communicationPreferences": { "post": true, "email": true }
}

4.2. remove 操作

删除指定位置的值,或移除数组中指定索引的元素。

示例:移除 communicationPreferences

{
    "op": "remove",
    "path": "/communicationPreferences"
}

执行后结果:

{
    "id": "1",
    "telephone": "001-555-1234",
    "favorites": ["Bread", "Milk", "Eggs"]
    // communicationPreferences 字段被完全移除
}

⚠️ 注意:remove 是彻底删除字段,不是设为 null

4.3. replace 操作

用新值替换指定位置的现有值。

示例:更新电话号码

{
    "op": "replace",
    "path": "/telephone",
    "value": "001-555-5678"
}

执行后结果:

{ 
    "id": "1", 
    "telephone": "001-555-5678", 
    "favorites": ["Bread", "Milk", "Eggs"]
}

4.4. move 操作

from 位置的值移动到 path 位置。

示例:将 favorites 列表的第一个元素移到末尾

{
    "op": "move",
    "from": "/favorites/0",
    "path": "/favorites/-"
}

执行后结果:

{ 
    "id": "1", 
    "telephone": "001-555-5678", 
    "favorites": ["Milk", "Eggs", "Bread"]
}

/favorites/0 指向数组第一个元素,/favorites/- 是一个特殊语法,指向数组末尾。

4.5. copy 操作

from 位置的值复制到 path 位置。

示例:复制 favorites 中的第一个元素到末尾

{
    "op": "copy",
    "from": "/favorites/0",
    "path": "/favorites/-"
}

执行后结果:

{ 
    "id": "1", 
    "telephone": "001-555-5678", 
    "favorites": ["Milk", "Eggs", "Bread", "Milk"]
}

4.6. test 操作

断言 path 处的值是否等于 value。**PATCH 请求是原子操作**,任何一个 test 失败,整个请求都会被拒绝。

常用于校验前置/后置条件。

示例:校验电话号码是否已更新

{
    "op": "test", 
    "path": "/telephone",
    "value": "001-555-5678"
}

5. 使用 JSON Patch 的 HTTP PATCH 请求

回到 Customer 场景,我们发起一个 PATCH 请求,同时更新电话和最爱商品:

curl -i -X PATCH http://localhost:8080/customers/1 \
  -H "Content-Type: application/json-patch+json" \
  -d '[
    {"op":"replace","path":"/telephone","value":"+1-555-56"},
    {"op":"add","path":"/favorites/0","value":"Bread"}
  ]'

关键点:

  • Content-Type 必须是 application/json-patch+json
  • ✅ 请求体是一个 JSON Patch 操作数组

服务端如何处理?自己解析?太麻烦且容易出错。

推荐使用成熟的库:json-patch

6. 在 Spring Boot 中实现 JSON Patch

6.1. 依赖

引入 json-patch 库:

<dependency>
    <groupId>com.github.java-json-tools</groupId>
    <artifactId>json-patch</artifactId>
    <version>1.12</version>
</dependency>

定义 Customer 实体:

public class Customer {
    private String id;
    private String telephone;
    private List<String> favorites;
    private Map<String, Boolean> communicationPreferences;
    // standard getters and setters
}

6.2. REST 控制器方法

@PatchMapping(path = "/{id}", consumes = "application/json-patch+json")
public ResponseEntity<Customer> updateCustomer(
    @PathVariable String id, 
    @RequestBody JsonPatch patch) {

    try {
        Customer customer = customerService.findCustomer(id)
            .orElseThrow(CustomerNotFoundException::new);
        
        Customer patchedCustomer = applyPatch(patch, customer);
        customerService.updateCustomer(patchedCustomer);
        
        return ResponseEntity.ok(patchedCustomer);
        
    } catch (JsonPatchException | JsonProcessingException e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    } catch (CustomerNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    }
}

private Customer applyPatch(JsonPatch patch, Customer target) 
    throws JsonPatchException, JsonProcessingException {
    
    ObjectMapper objectMapper = new ObjectMapper();
    JsonNode patchedNode = patch.apply(objectMapper.convertValue(target, JsonNode.class));
    return objectMapper.treeToValue(patchedNode, Customer.class);
}

核心逻辑 applyPatch 解析:

  1. objectMapper.convertValue(target, JsonNode.class):将 Customer 对象转为 Jackson 的 JsonNode
  2. patch.apply(...):由 json-patch 库执行所有操作,返回新的 JsonNode
  3. objectMapper.treeToValue(...):将 JsonNode 绑定回 Customer 对象

整个过程简单粗暴,且原子性由库保证。

6.3. 测试

创建客户:

curl -i -X POST http://localhost:8080/customers \
  -H "Content-Type: application/json" \
  -d '{
    "telephone":"+1-555-12",
    "favorites":["Milk","Eggs"],
    "communicationPreferences":{"post":true,"email":true}
  }'

响应:

HTTP/1.1 201 Created
Location: http://localhost:8080/customers/1

发起 PATCH 更新:

curl -i -X PATCH http://localhost:8080/customers/1 \
  -H "Content-Type: application/json-patch+json" \
  -d '[
    {"op":"replace","path":"/telephone","value":"+1-555-56"},
    {"op":"add","path":"/favorites/0","value":"Bread"}
  ]'

响应(200 OK):

{
  "id":"1",
  "telephone":"+1-555-56",
  "favorites":["Bread","Milk","Eggs"],
  "communicationPreferences":{"post":true,"email":true}
}

7. 总结

本文介绍了在 Spring REST 接口中利用 JSON Patch 实现部分更新的完整方案:

  • PATCH 方法是部分更新的语义标准
  • ✅ JSON Patch 提供了结构化的变更描述格式
  • ✅ 借助 json-patch 库,服务端处理变得异常简单

相比手动解析部分更新字段,JSON Patch 更标准、更灵活,尤其适合字段多且更新场景复杂的接口。踩坑建议:务必处理好 JsonPatchException,并考虑结合 If-Match 实现乐观锁。

示例代码已托管至 GitHub:https://github.com/baeldung/spring-rest-http


原始标题:Using JSON Patch in Spring REST APIs | Baeldung