RESTful API 设计中,超过 70% 的开发者在更新资源时仍在使用 PUT 全量替换,而非 PATCH 增量更新。这意味着更新用户邮箱地址时,客户端需要发送整个用户对象——包括姓名、地址、头像等所有字段。JSON Patch(RFC 6902)和 JSON Merge Patch(RFC 7396)正是为解决这一问题而生的两个标准协议,它们让你能精确描述 JSON 文档的变更内容,将网络传输量降低 60%-90%,同时避免并发更新时的「丢失更新」问题。
本文将从协议原理到生产实践,深入对比两种方案的适用场景、实现细节和常见陷阱。
🔐 一、JSON Patch (RFC 6902):原子级精确操作
1.1 核心概念与操作指令
JSON Patch 是一个 IETF 标准(RFC 6902),它定义了一组操作指令(Operations),用于描述从一个 JSON 文档到另一个 JSON 文档的精确转换。每个操作都是一个 JSON 对象,包含 op、path 和可选的 value 字段。
JSON Patch 支持 6 种操作指令:
| 操作指令 | 用途 | 是否需要 value | 复杂度 |
|---|---|---|---|
add |
添加新值 | ✅ 是 | O(n) |
remove |
删除值 | ❌ 否 | O(n) |
replace |
替换值 | ✅ 是 | O(n) |
move |
移动值到新位置 | ❌ 否(需 from) | O(n) |
copy |
复制值到新位置 | ❌ 否(需 from) | O(n) |
test |
断言某个位置的值 | ✅ 是 | O(n) |
📌 记住:
test操作是并发安全的关键。它允许你在执行变更前验证当前状态,确保在你读取数据到写入数据之间,没有其他人修改了目标字段。
来看一个完整的示例。假设我们有这样一个用户对象:
// 原始文档
{
"name": "张三",
"email": "zhangsan@example.com",
"address": {
"city": "北京",
"district": "海淀区"
},
"tags": ["developer", "admin"]
}
我们想要:修改邮箱、添加电话、移除 admin 标签。对应的 JSON Patch 如下:
// JSON Patch 指令集
[
{ "op": "replace", "path": "/email", "value": "zhangsan@new.com" },
{ "op": "add", "path": "/phone", "value": "13800138000" },
{ "op": "remove", "path": "/tags/1" }
]
1.2 JavaScript 完整实现
在前端和 Node.js 环境中,fast-json-patch 是最成熟的 JSON Patch 库:
npm install fast-json-patch
// json-patch-demo.js — JSON Patch 实战演示
import * as jsonpatch from 'fast-json-patch';
const user = {
name: '张三',
email: 'zhangsan@example.com',
age: 28,
address: { city: '北京', district: '海淀区' },
tags: ['developer', 'admin', 'reviewer']
};
// 定义 Patch 指令
const patch = [
{ op: 'replace', path: '/email', value: 'new@example.com' },
{ op: 'add', path: '/phone', value: '13800138000' },
{ op: 'remove', path: '/tags/1' }, // 移除 "admin"
{ op: 'move', from: '/address/district', path: '/address/area' } // 重命名字段
];
// 应用 Patch
const result = jsonpatch.applyPatch(user, patch, true /* validate */);
console.log(JSON.stringify(result.newDocument, null, 2));
// 输出:
// {
// "name": "张三",
// "email": "new@example.com",
// "age": 28,
// "phone": "13800138000",
// "address": { "city": "北京", "area": "海淀区" },
// "tags": ["developer", "reviewer"]
// }
// 反向生成 Patch:自动计算两个对象的差异
const original = { a: 1, b: [1, 2, 3] };
const modified = { a: 2, b: [1, 3, 4] };
const diff = jsonpatch.compare(original, modified);
console.log(diff);
// [
// { "op": "replace", "path": "/a", "value": 2 },
// { "op": "remove", "path": "/b/1" },
// { "op": "add", "path": "/b/2", "value": 4 }
// ]
⚠️ 警告:
compare()生成的 Patch 可能不是最优的。例如把[1,2,3]改成[1,3],它可能生成remove /b/1,也可能生成replace /b为[1,3]。在性能敏感的场景中,建议对生成的 Patch 进行优化后再发送。
1.3 JSON Pointer (RFC 6901) 路径规范
JSON Patch 的 path 字段遵循 JSON Pointer 规范(RFC 6901)。理解路径规则是正确使用 JSON Patch 的前提:
| 路径 | 含义 | 特殊字符处理 |
|---|---|---|
/name |
根对象的 name 字段 | — |
/address/city |
嵌套对象路径 | — |
/tags/0 |
数组第一个元素(0-indexed) | — |
/tags/- |
数组末尾(用于 add 操作) | — |
/data/foo~1bar |
字段名为 foo/bar |
~1 代表 / |
/data/foo~0bar |
字段名为 foo~bar |
~0 代表 ~ |
💡 **提示:**路径中的
~必须编码为~0,/必须编码为~1,且~0必须在~1之前解码。这是最常见的实现 Bug 之一,顺序搞反会导致路径解析错误。
🚀 二、JSON Merge Patch (RFC 7396):简洁的替代方案
2.1 设计哲学与规则
JSON Merge Patch 的设计哲学是「最小惊讶」——它的工作方式就像你对现有对象执行了一次深度合并(deep merge),同时用 null 表示删除。相比 JSON Patch 的 6 种操作指令,JSON Merge Patch 只有 3 条规则:
- ✅ 如果字段值是简单类型(string/number/boolean),直接替换
- ✅ 如果字段值是对象,递归合并
- ❌ 如果字段值是
null,删除该字段
// merge-patch-demo.js — JSON Merge Patch 实战演示
// 原始文档
const original = {
title: 'Goodbye!',
author: { name: '张三', email: 'zhangsan@example.com' },
tags: ['article', 'draft'],
content: '这是一段很长的内容...'
};
// Merge Patch(注意:对象类型会被递归合并,数组类型会被整体替换)
const patch = {
title: 'Hello World!', // 简单类型 → 直接替换
author: { email: 'new@example.com' }, // 对象 → 递归合并(name 保留)
tags: ['article', 'published'], // 数组 → 整体替换!
content: null // null → 删除该字段
};
// 应用 Merge Patch
function applyMergePatch(target, patch) {
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
return patch;
}
const result = { ...target };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete result[key];
} else if (typeof value === 'object' && !Array.isArray(value)
&& typeof result[key] === 'object' && !Array.isArray(result[key])) {
result[key] = applyMergePatch(result[key], value);
} else {
result[key] = value;
}
}
return result;
}
const result = applyMergePatch(original, patch);
console.log(JSON.stringify(result, null, 2));
// {
// "title": "Hello World!",
// "author": { "name": "张三", "email": "new@example.com" }, ← name 保留!
// "tags": ["article", "published"], ← 数组整体替换
// "content" 字段已删除
// }
⚠️ 警告:JSON Merge Patch 会整体替换数组,而非像 JSON Patch 那样支持精确的数组元素操作。如果你需要修改数组中的单个元素,必须使用 JSON Patch 或发送完整的数组。
2.2 两种方案的深度对比
| 对比维度 | JSON Patch (RFC 6902) | JSON Merge Patch (RFC 7396) |
|---|---|---|
| 操作精度 | 原子级,精确到单个字段/数组元素 | 对象级递归,数组整体替换 |
| 数组操作 | ✅ 支持单个元素增删改 | ❌ 只能整体替换 |
| 字段重命名 | ✅ move 操作 |
❌ 不支持 |
| 条件断言 | ✅ test 操作 |
❌ 不支持 |
| Content-Type | application/json-patch+json |
application/merge-patch+json |
| 学习曲线 | 较高(6 种操作 + JSON Pointer) | 极低(直觉式合并) |
| 补丁大小 | 可以很小(精确描述变更) | 可能较大(需包含完整子对象) |
| 可读性 | 中等 | 极高 |
| 服务端实现 | 需要专用 Patch 解析器 | 大多数框架原生支持 |
| 并发安全 | ✅ 配合 test 实现乐观锁 | ❌ 需额外机制 |
| 适用场景 | 复杂业务、协作编辑、审计追踪 | 简单 CRUD、配置更新 |
⚡ **关键结论:**如果你的 API 只需要更新简单对象字段,JSON Merge Patch 是更简洁的选择。但如果你涉及数组元素操作、字段重命名、并发控制或操作审计,JSON Patch 是唯一正确的方案。
💡 三、生产环境实战与避坑指南
3.1 Express/Koa 服务端实现
在 Node.js 服务端同时支持两种 Patch 协议的标准实现:
// server-patch-handler.js — 生产级 PATCH 请求处理器
import express from 'express';
import * as jsonpatch from 'fast-json-patch';
const app = express();
app.use(express.json());
// PATCH /api/users/:id — 自动识别 Content-Type
app.patch('/api/users/:id', async (req, res) => {
const contentType = req.headers['content-type'];
const userId = req.params.id;
// 从数据库加载当前文档
const currentDoc = await db.users.findById(userId);
if (!currentDoc) return res.status(404).json({ error: '用户不存在' });
let patchedDoc;
try {
if (contentType === 'application/json-patch+json') {
// JSON Patch: 验证格式并应用
if (!Array.isArray(req.body)) {
return res.status(400).json({ error: 'JSON Patch 必须是数组' });
}
// 可选:先执行 test 操作验证乐观锁
const testOps = req.body.filter(op => op.op === 'test');
if (testOps.length > 0) {
const testResult = jsonpatch.applyPatch(
structuredClone(currentDoc), testOps, true
);
}
const result = jsonpatch.applyPatch(
currentDoc, req.body, true /* validateOps */
);
patchedDoc = result.newDocument;
} else if (contentType === 'application/merge-patch+json') {
// JSON Merge Patch
patchedDoc = applyMergePatch(currentDoc, req.body);
} else {
return res.status(415).json({
error: '不支持的 Content-Type,请使用 application/json-patch+json 或 application/merge-patch+json'
});
}
// 持久化并返回
await db.users.update(userId, patchedDoc);
res.json(patchedDoc);
} catch (err) {
if (err.name === 'JsonPatchError') {
return res.status(422).json({ error: `Patch 操作失败: ${err.message}` });
}
res.status(500).json({ error: '服务器内部错误' });
}
});
3.2 三大常见陷阱与解决方案
陷阱一:数组索引偏移
连续执行 remove 操作时,后续操作的索引会因为前面的删除而偏移:
// ❌ 错误写法:连续删除导致索引错乱
const patch = [
{ op: 'remove', path: '/items/1' }, // 删除 index 1
{ op: 'remove', path: '/items/2' } // 原来的 index 3,但删除后变成了 index 2!
];
// ✅ 正确写法:从后往前删除,或使用单个 splice 操作
const patch = [
{ op: 'remove', path: '/items/2' }, // 先删后面的
{ op: 'remove', path: '/items/1' } // 再删前面的
];
陷阱二:Merge Patch 的 null 不等于「设置为 null」
在 JSON Merge Patch 中,null 永远表示「删除字段」,无法将字段值设置为 null:
// ❌ 想要把 middleName 设置为 null(确实存在但值为空)
// 但 Merge Patch 会直接删除该字段
{ "middleName": null }
// ✅ 解决方案 1:改用 JSON Patch
[{ "op": "replace", "path": "/middleName", "value": null }]
// ✅ 解决方案 2:使用包装对象
{ "middleName": { "value": null, "exists": true } }
⚠️ **警告:**这是 JSON Merge Patch 最大的设计缺陷。如果你的业务需要区分「字段不存在」和「字段值为 null」,必须使用 JSON Patch 或设计替代方案。
陷阱三:from 路径的顺序依赖
move 和 copy 操作中,from 路径必须在 path 操作之前有效:
// ❌ 错误:先 add 覆盖了 from 路径
[
{ op: "add", path: "/newName", value: "临时值" },
{ op: "move", from: "/oldName", path: "/newName" } // /newName 已被覆盖
]
// ✅ 正确:先 move 再做其他操作
[
{ op: "move", from: "/oldName", path: "/newName" },
{ op: "add", path: "/extra", value: "其他数据" }
]
3.3 Spring Boot 集成方案
Java 后端同样可以优雅地支持两种 Patch 协议:
// JsonPatchController.java — Spring Boot PATCH 控制器
import com.github.fge.jsonpatch.JsonPatch;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserPatchController {
private final ObjectMapper mapper = new ObjectMapper();
@PatchMapping(value = "/{id}", consumes = "application/json-patch+json")
public ResponseEntity<?> applyJsonPatch(
@PathVariable String id,
@RequestBody JsonPatch patch) {
User user = userService.findById(id);
if (user == null) return ResponseEntity.notFound().build();
try {
JsonNode userNode = mapper.valueToTree(user);
JsonNode patchedNode = patch.apply(userNode);
User patchedUser = mapper.treeToValue(patchedNode, User.class);
return ResponseEntity.ok(userService.update(id, patchedUser));
} catch (Exception e) {
return ResponseEntity.unprocessableEntity()
.body(Map.of("error", "Patch 应用失败: " + e.getMessage()));
}
}
@PatchMapping(value = "/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<?> applyMergePatch(
@PathVariable String id,
@RequestBody JsonNode patch) {
User user = userService.findById(id);
if (user == null) return ResponseEntity.notFound().build();
try {
JsonNode userNode = mapper.valueToTree(user);
JsonNode mergedNode = mergePatch(userNode, patch);
User mergedUser = mapper.treeToValue(mergedNode, User.class);
return ResponseEntity.ok(userService.update(id, mergedUser));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
}
private JsonNode mergePatch(JsonNode target, JsonNode patch) {
if (!patch.isObject()) return patch;
var result = target.deepCopy();
patch.fields().forEachRemaining(entry -> {
if (entry.getValue().isNull()) {
((ObjectNode) result).remove(entry.getKey());
} else if (entry.getValue().isObject() && result.has(entry.getKey())
&& result.get(entry.getKey()).isObject()) {
((ObjectNode) result).set(entry.getKey(),
mergePatch(result.get(entry.getKey()), entry.getValue()));
} else {
((ObjectNode) result).set(entry.getKey(), entry.getValue());
}
});
return result;
}
}
3.4 并发控制:用 test 操作实现乐观锁
JSON Patch 的 test 操作是实现乐观并发控制的利器,无需额外的锁机制:
// optimistic-lock.js — 基于 JSON Patch 的乐观锁实现
async function updateUserWithLock(userId, patchOps) {
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
// 1. 读取当前文档(包含版本号)
const current = await db.users.findById(userId);
const version = current._version;
// 2. 在 Patch 前插入 test 操作验证版本
const safeOps = [
{ op: 'test', path: '/_version', value: version },
...patchOps
];
try {
// 3. 原子性地执行 test + 更新
const result = jsonpatch.applyPatch(current, safeOps, true);
result.newDocument._version = version + 1;
await db.users.update(userId, result.newDocument);
return result.newDocument;
} catch (err) {
if (err.name === 'JsonPatchError' && attempt < maxRetries - 1) {
console.log(`版本冲突,第 ${attempt + 1} 次重试...`);
continue; // 版本冲突,重试
}
throw err;
}
}
}
// 使用示例:更新邮箱(带乐观锁保护)
const patch = [
{ op: 'replace', path: '/email', value: 'new@example.com' }
];
await updateUserWithLock('user-123', patch);
💡 **提示:**这种模式比传统的
SELECT ... FOR UPDATE悲观锁更适合高并发读多写少的场景,因为它不会阻塞其他请求,而是在发生冲突时重试。
3.5 前端实战:React/Vue 状态管理集成
在前端应用中,JSON Patch 非常适合实现「乐观更新」和「撤销/重做」功能:
// undo-redo.js — 基于 JSON Patch 的撤销/重做系统
class PatchHistory {
constructor() {
this.undoStack = []; // 正向 Patch
this.redoStack = []; // 反向 Patch
}
// 执行变更并记录历史
apply(doc, patch) {
const reversePatch = jsonpatch.compare(
jsonpatch.applyPatch(doc, patch, true).newDocument,
doc
);
this.undoStack.push({ forward: patch, reverse: reversePatch });
this.redoStack = []; // 清空 redo 栈
return jsonpatch.applyPatch(doc, patch, true).newDocument;
}
// 撤销
undo(doc) {
const entry = this.undoStack.pop();
if (!entry) return doc;
this.redoStack.push(entry);
return jsonpatch.applyPatch(doc, entry.reverse, true).newDocument;
}
// 重做
redo(doc) {
const entry = this.redoStack.pop();
if (!entry) return doc;
this.undoStack.push(entry);
return jsonpatch.applyPatch(doc, entry.forward, true).newDocument;
}
// 批量发送变更到服务端(增量同步)
flushToServer(baseUrl, resourceId) {
const patches = this.undoStack.map(e => e.forward);
if (patches.length === 0) return;
const combinedPatch = patches.flat();
return fetch(`${baseUrl}/${resourceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json-patch+json' },
body: JSON.stringify(combinedPatch)
});
}
}
3.6 JSON Patch vs RESTful 替代方案
| 方案 | 传输量 | 精确度 | 复杂度 | 并发安全 | 审计追踪 |
|---|---|---|---|---|---|
| PUT 全量替换 | 🔴 高 | 🟡 整体 | 🟢 低 | 🔴 易冲突 | 🔴 困难 |
| JSON Merge Patch | 🟡 中 | 🟡 对象级 | 🟢 低 | 🔴 需额外机制 | 🟡 中等 |
| JSON Patch | 🟢 低 | 🟢 原子级 | 🟡 中 | 🟢 test 操作 | 🟢 天然支持 |
| GraphQL Mutation | 🟢 低 | 🟢 字段级 | 🔴 高 | 🟡 需额外机制 | 🟡 中等 |
| gRPC FieldMask | 🟢 低 | 🟢 字段级 | 🟡 中 | 🟡 需额外机制 | 🟡 中等 |
📊 四、总结与最佳实践
选择 JSON Patch 的场景:
- ✅ 需要精确修改数组中的单个元素
- ✅ 需要字段重命名(
move)或复制(copy) - ✅ 需要乐观并发控制(
test操作) - ✅ 需要操作审计日志(每条指令都可记录)
- ✅ 协作编辑系统(如文档协同)
选择 JSON Merge Patch 的场景:
- ✅ 简单的 CRUD 配置更新
- ✅ 嵌套对象的部分字段更新
- ✅ 前后端团队经验有限,追求实现简单
- ✅ 不涉及数组的精确操作
生产环境 Checklist:
- ✅ 设置正确的
Content-Type头(application/json-patch+json或application/merge-patch+json) - ✅ 对 JSON Patch 操作进行格式验证(使用库的 validate 模式)
- ✅ 在敏感字段上使用
test操作防止并发冲突 - ✅ 处理数组索引偏移问题(从后往前删除或使用负索引)
- ✅ 记录 Patch 操作日志用于审计追踪
- ❌ 不要在 Merge Patch 中用
null表示「值为空」 - ❌ 不要假设
compare()生成的 Patch 是最优的 - ❌ 不要忽略 JSON Pointer 中
~0和~1的转义规则
⚡ **关键结论:**JSON Patch 和 JSON Merge Patch 不是互相替代的关系,而是互补的。一个成熟的 API 应该同时支持两者——简单的配置更新用 Merge Patch 降低开发成本,复杂的状态变更用 JSON Patch 保证精确性和安全性。
相关工具与库:
- 🔧 fast-json-patch — JavaScript JSON Patch 实现,支持 compare/apply/validate
- 🔧 json-patch — Go 语言 JSON Patch 实现
- 🔧 jsonpatch — Python JSON Patch 实现
- 🔧 zalando/json-patch — Java JSON Patch 库
- 🔧 jsjson.com/json-format — 在线 JSON 格式化与校验工具