上周做系统重构时一个同组的同事突然问我改一个字段客户端为什么要传一整个对象这个问题我太熟悉了。在用 .NET 写 Web API 的几年里几乎每次做资源更新接口都要面对改一个状态值却把整个 DTO 翻来覆去传一遍的尴尬。后来我把部分更新全面切到了 JSON Patch代码量少了一个量级接口设计也清爽得多。这篇我想把这条路完整讲清楚JSON Patch 的原理是什么、RFC 6902 怎么读、.NET 里怎么落地、以及我在实战里踩过的一堆坑。适合正在设计 REST API 的 .NET 后端开发者也适合刚接触 PATCH 方法、想搞明白补丁到底怎么打的人。1. 从拿着 PUT 硬刚聊起JSON Patch 到底解决了什么1.1 全量更新的窘境与半量更新的两难大多数团队最初都用 PUT 做更新。REST 语义上PUT 是用请求里的表示完整替换目标资源所以客户端必须把整个资源对象发过来。问题很快就出现了评论系统里只想把一条评论的status从待审核改到已通过客户端却要把title、content、score、author全部原样发回来。几个接口下来Payload 越来越大性能损失是一方面更麻烦的是谁来保证你发回来的内容一定是服务器上那份——一旦某个字段是旧数据这一 PUT 就把服务器上的新值覆盖掉了。后来不少人改用半量更新定义一堆可空字段的 DTO客户端传哪个字段就更新哪个字段。这种方案能撑一阵子但很快就会撞上几个硬墙可空字段意味着传null到底是不更新还是把字段置空语义彻底模糊。每个资源要写一个专门的 UpdateDTO字段一多DTO 比实体还厚。想更新数组里的某个元素、或者嵌套对象里的某个属性这套方案基本无能为力。我印象最深的一次是给一个资源加了tags数组字段后客户端要往里追加一个元素结果前后端为这个 UpdateDTO 怎么设计来回扯了两天。从那天起我意识到真正的问题是我们在用一个结构化数据格式表达修改意图而这件事应该交给一种专用的补丁描述语言来做。1.2 JSON Patch 的引入时机与判断条件JSON Patch 这个规范本身并不新RFC 6902 早在 2013 年就定下来了。它把一次修改描述成一个操作数组每个操作都有明确的op、path和可选的value配合 REST 的 PATCH 方法刚好补上 PUT 那块空白。一个典型的补丁文档长这样[ { op: replace, path: /status, value: approved }, { op: add, path: /tags/-, value: featured } ]服务器收到后按顺序把操作应用到目标文档上。整个过程是标准的、可预期的客户端也不用再造一套字段置空规则了。那什么场景才适合上 JSON Patch我给一个朴素但实用的判断标准**你的更新接口是不是改了 1 个字段却要传 10 个字段**如果是就该考虑。反过来如果更新本质就是整体替换比如草稿的保存PUT 反而是更诚实的语义。另外如果你的系统对兼容性极其敏感、客户端千奇百怪那么PATCH Builder 模式可能会比强制所有客户端都支持 JSON Patch 更稳妥。不过对绝大多数新项目我的建议很直接把 PATCH 和 JSON Patch 作为更新事实标准PUT 只留给完全重建场景。2. RFC 6902 的六把手术刀add、remove、replace、move、copy、test2.1 六种算子的语义与原子性边界RFC 6902 只定义了六个操作但每一个的操作语义都值得抠细。我常用一张表来给团队做培训操作作用关键约束典型用途add在对象成员或数组指定位置插入值数组下标存在才可插入-表示追加到末尾加标签、插入成员remove删除指定路径的目标目标不存在则报错清空字段、移除数组元素replace替换目标值等价于移除再添加但作为原子操作执行改状态、改名称move将from的值移动到path等价于removeadd但只改变文档结构数组重排copy将from的值复制到path不改动from复制模板项test校验path的值是否等于value不等则整个补丁失败乐观锁、条件更新六个操作里最容易用错的是add和replace。很多人直觉上以为add就是有则改无则增其实规范里add对已存在成员会直接覆盖对数组中不存在的下标会直接报错而replace对不存在的路径同样报错。换句话说它们对路径存在性的预期是相反的写客户端时一定要想清楚。move和copy是复合操作的原子封装。move相当于先remove再从from处拿到值add到path但因为整个 move 是在服务端文档上下文里完成的不存在中间状态被别人读到的问题。这个特性在处理数组重排时特别好用比如把列表第 3 项挪到第 1 位一条move就搞定不用客户端发两次请求。test算子则是我心中的 MVP。它不做修改只做断言因此非常适合当作一次补丁的前置条件。把test放在操作数组最前面就能实现类似 CAS 的乐观并发控制这部分我在第 5 章会详细展开。2.2 JSON Pointer 寻址规则与两个转义陷阱所有path用的都是 JSON PointerRFC 6901语法不复杂空字符串指向整个文档/foo指向对象成员foo/foo/0指向foo数组的第一个元素。有三个点非常容易踩数组末尾追加用-在add操作中/tags/-表示数组末尾这是规范特意留的语法糖。但在remove和replace里-并不是合法下标。对象键名里的/必须写成~1比如想访问成员a/b路径是/a~1b直接写/a/b会被解析成两层嵌套。~本身要写成~0成员名m~n对应/m~0n。解码头时顺序很重要先解~1再解~0反过来会出问题。这两条转义规则看着不起眼却是补丁在真实业务里产生诡异 400 的重灾区。我后面有一章专门讲实际踩坑这里先记住结论客户端发给服务器的 path不是看起来像 JSON 路径的那个字符串而是经过 JSON Pointer 转义的合法指针。3. .NET 里的落地路线官方包还是自己造3.1 官方JsonPatch包的适用边界.NET 生态里做 JSON Patch最省事的是官方Microsoft.AspNetCore.JsonPatch包。它提供JsonPatchDocumentT类型能直接把请求体反序列化成一组补丁操作然后调用ApplyTo(entity)把操作应用到目标对象上。这个包在 ASP.NET Core 的 MVC 管道里接入得也很顺控制器方法参数上标个[FromBody] JsonPatchDocumentReview patchDoc就能拿到客户端传来的补丁。但这个包有一个历史包袱值得说清楚它底层依赖 Newtonsoft.Json 的序列化体系。在 ASP.NET Core 默认使用System.Text.Json的年代要在控制器里正常绑定JsonPatchDocumentT服务注册时通常还得补上 Newtonsoft 的接入builder.Services.AddControllers() .AddNewtonsoftJson(options { options.SerializerSettings.ContractResolver new CamelCasePropertyNamesContractResolver(); options.SerializerSettings.NullValueHandling NullValueHandling.Ignore; });如果你是新项目、又特别不想让System.Text.Json和 Newtonsoft.Json 两套序列化体系并存那么官方包就会让纯净架构的念头有点难受。这个别扭是真的但我的看法是不值得为此放弃经过多年生产验证的补丁实现。序列化器并存带来的最多是包体积和序列化行为不统一的小困扰而补丁解析、路径寻址这些容易出错的地方官方包已经帮你填平了。3.2 一套System.Text.Json最小实现如果你的服务只是 Minimal API或者对依赖有硬性约束也可以只实现一个最小可用的 JSON Patch 应用器覆盖add、remove、replace三种最常见算子就够支撑大多数业务。核心是先把 JSON Pointer 解析出来再在System.Text.Json的文档模型上做映射public static class JsonPointer { public static JsonElement? Resolve(JsonDocument doc, string pointer) { var tokens pointer.Split(/, StringSplitOptions.RemoveEmptyEntries); var element doc.RootElement; foreach (var token in tokens) { var decoded token.Replace(~1, /).Replace(~0, ~); if (element.ValueKind JsonValueKind.Object) { if (!element.TryGetProperty(decoded, out var next)) { return null; } element next; } else if (element.ValueKind JsonValueKind.Array) { if (decoded -) { return null; } var index int.Parse(decoded); if (index 0) { index element.GetArrayLength(); } if (index 0 || index element.GetArrayLength()) { return null; } element element[index]; } else { return null; } } return element.Clone(); } }这里我特意加了对负索引的处理——虽然 RFC 6902 没有规定负索引但不少客户端会默认负数从末尾倒数服务端支持一下能减少很多无谓的争吵。完整实现add到数组、replace到对象成员无非是在JsonDocument或Utf8JsonWriter层面做读改写工作量不大但边界情况不少。我的建议很明确如果你只需要内部服务之间调用自研没问题如果是对外 API老老实实用官方包。对外接口的任何边界 bug都会被成千上万的客户端放大。3.3 选择方案的判断标准把三种路线放在一起对比决策其实不复杂。选型优点代价适合场景官方JsonPatch包功能完整、社区验证充分、接入方便依赖Newtonsoft体系常规Web API、对外接口Minimal API 自研依赖最少、行为可控边界情况自己兜内部调用、受限环境不上JSON Patch最稳妥半量更新语义混乱客户端能力太差我自己的倾向是新项目只要确定要走 REST 风格、又确实有部分更新需求就直接上官方包别再纠结序列化器统一问题。与其花两个月在自研补丁的数组下标 -1 到底该不该支持上吵来吵去不如把这些时间花在真正重要的业务逻辑上。4. Web API实战给一个Review评论系统接上JSON Patch4.1 控制器接入与ApplyTo的ModelState联动用一个评论资源举例子。假设实体叫Review客户端要允许修改status、score、tags控制器可以这样写[HttpPatch({id:long})] public async TaskIActionResult PatchReview( long id, [FromBody] JsonPatchDocumentReview patchDoc) { if (patchDoc null) { return BadRequest(补丁文档不能为空); } var review await _db.Reviews.FirstOrDefaultAsync(r r.Id id); if (review null) { return NotFound(); } patchDoc.ApplyTo(review, ModelState); if (!ModelState.IsValid) { return BadRequest(ModelState); } await _db.SaveChangesAsync(); return Ok(review); }这里ApplyTo(review, ModelState)的ModelState重载很重要。当补丁里某个操作执行失败比如replace一个不存在的路径、或者test断言不成立官方包会抛出JsonPatchException而带ModelState的版本不会让异常直接冒出去而是把错误信息汇入ModelState。这样控制器里统一判ModelState.IsValid就能返回标准的 400客户端拿到的是结构化错误而不是一坨堆栈。4.2 ApplyTo之后的校验补偿很多人第一次写完上面的代码会误以为实体上的[Required]、[Range]这些数据注解会自动生效。实际上ApplyTo只是把补丁操作映射到 CLR 对象的属性上完全绕过 MVC 的模型绑定校验管道。所以正确的顺序是先ApplyTo再手动触发一遍 DataAnnotations 校验。patchDoc.ApplyTo(review, ModelState); var validationContext new ValidationContext(review, null, null); var validationResults new ListValidationResult(); if (!Validator.TryValidateObject(review, validationContext, validationResults, true)) { foreach (var result in validationResults) { foreach (var member in result.MemberNames) { ModelState.AddModelError(member, result.ErrorMessage ?? 数据校验失败); } } return BadRequest(ModelState); }顺序上的讲究是先让补丁在实体上生效再做整体校验。因为补丁里可能出现多个操作的组合效果单独校验每步没有意义得校验打完补丁之后的完整实体状态。4.3 只读字段与嵌套对象的安全约束把JsonPatchDocumentReview直接作用于 EF Core 实体听起来很爽但有个安全隐患客户端可以通过补丁路径访问到实体上你不打算开放的属性甚至导航属性。比如Review实体的AuthorId是创建人理论上绝不该被客户端修改。一旦补丁路径写着/authorId官方包会毫不犹豫地把属性覆盖掉。两个惯用解法。第一用 DTO 而不是实体作为补丁目标DTO 里只放允许修改的字段打完补丁再手动映射回实体。第二实在要用实体就手动保护关键字段var originalAuthor review.AuthorId; patchDoc.ApplyTo(review, ModelState); review.AuthorId originalAuthor;嵌套对象是另一个暗坑。如果 DTO 的某个复杂属性是null客户端补丁想改它的子属性比如/author/name就会触发空引用。最干净的做法是让这个属性始终初始化成一个空对象或者在校验前检查路径前缀是否对应一个非空对象。5. 这些坑我全踩过命名规则、路径逃逸、数组漂移、幂等5.1 camelCase路径翻车现场这是我接 JSON Patch 后遇到的第一个线上事故。前端按 JavaScript 的习惯发来/status一切正常发/reviewerName时却 400。排查后发现DTO 属性是 PascalCase 的ReviewerName而官方包实例在反序列化补丁文档时路径解析用的是序列化设置里的ContractResolver。默认情况下它期望路径和 CLR 属性名匹配也就是/ReviewerName。解决办法是让服务端反序列化补丁时使用CamelCasePropertyNamesContractResolver也就是我在第 3 章写的那段AddNewtonsoftJson配置。改完之后前端一律用 camelCase 发路径准确率瞬间回归。这个坑的教训是**JSON Patch 的 path 不是自由的它必须和服务器端序列化契约一致。**前后端联调时第一步就要约定清楚属性命名策略别等线上爆了再推诿。5.2 特殊字符逃逸与数组下标漂移另一个高频问题出在路径转义。比如业务上有一个标签名字就叫Spring/2024你要补齐路径就得写成/tags/Spring~12024。前端如果不做转义直接传/tags/Spring/2024服务器会把它解析成tags 下的 Spring 下的 2024——一个根本不存在的三层路径于是 404/400 如期而至。为了让前端不再手写转义我在网关层加了一个小工具函数把用户填的路径片段先转成合法 JSON Pointer再做透传。数组下标漂移则更隐蔽。一个补丁里有两条remove比如先删下标 0 再删下标 0删除的是前两个元素但很多初学的人以为第二条删的是原本的下标 1结果数据错乱。这不能怪客户端因为操作数组天然按顺序执行服务端能做的最好防护是在文档里明确数组操作以下一个操作开始前的那一刻为准。真要移除多个指定位置的元素我建议客户端先重排成一次性表述或者改用move把保留项先移动出来。5.3 test算子做乐观锁的正确姿势JSON Patch 的test操作让我避免了不止一次数据覆盖事故。它允许在补丁里加一条断言只有断言成立才继续执行后续操作天然适合做乐观锁[ { op: test, path: /version, value: 7 }, { op: replace, path: /title, value: 修订后的标题 } ]version 7校验通过才会执行替换。如果客户端拿到的资源版本号已经过期test断言失败整个补丁立即报错避免把别人的更新覆盖掉。这里有个细节RFC 6902 没有严格要求一旦某个操作失败就回滚之前的操作实际实现中断言失败时之前的操作可能已经生效。所以正确的姿势是把所有test放在操作数组最前面尽量减少部分应用导致的脏数据。6. 进阶加固请求体上限、日志脱敏与错误收敛6.1 防止move/copy变成放大攻击大部分人对 PATCH 的直觉是请求越小越安全但 JSON Patch 里move和copy可能成为放大攻击的入口。一小段请求就能让服务器构造出极其臃肿的对象比如一个两三项的数组通过copy反复复制几秒内就能撑爆内存。我的防护很朴素给补丁操作数量设上限超过 20 个操作直接拒绝单个操作里的copy/move目标尽量限制在同一个资源范围内。同时给 PATCH 端点加一个合理的请求体长度上限比如 Kestrel 默认的 30MB 对补丁来说太宽了我通常会压到 100KB 以内。[HttpPatch({id:long})] [RequestSizeLimit(100_000)] public async TaskIActionResult PatchReview(...)别看这两条规则简单它们能把绝大多数补丁炸弹挡在业务逻辑之外。6.2 错误信息与日志处理原则JsonPatchException的默认信息里会包含出错的路径和操作这在调试期很友好但直接丢给客户端等于把服务端字段结构暴露了一部分。我一般把 ModelState 里的错误信息统一映射成业务友好的提示比如补丁第 3 条操作不合法再附带一个请求跟踪号具体路径只在服务端日志里完整输出。日志方面有一个血泪教训补丁文档是用户输入直接LogInformation整段打印很容易把password、token这类敏感字段带进日志系统。我在写入日志前会做一层脱敏把已知敏感路径打码。这个习惯后来救了我一次因为某个客户端真的在一次补丁里把accessToken放在replace操作里传了上来。整个过程走下来我个人的体会是JSON Patch 并不是什么高深技术它就是给部分更新这件事定了一套明确的交流协议。真正让它在 .NET 项目里跑得稳的不是某个神奇 API而是对 RFC 6902 语义的尊重以及对命名策略、校验时机、并发控制这些细节的较真。如果你正在被半量更新 DTO 越写越厚折磨不妨按这篇的思路先接一个接口试试改动不大但接口设计清爽程度立竿见影。