团队把用户设置改成局部更新后,常见故障不是请求失败,而是请求成功却留下旧字段:客户端把 null 当成忽略值,服务端却把它解释为删除。在云端 Mac 的构建任务中加入一组 Swift 契约测试,可以在编译通过之外,验证双方对更新内容的理解一致。下面只处理 JSON Merge Patch 的文档变换,不连接线上接口,也不依赖模拟器。
固定更新协议的四条规则
JSON Merge Patch 的规则由 RFC 7396 定义。对象补丁逐字段处理:未出现的字段保留,值为 null 的字段删除,其他值递归应用。补丁不是对象时,直接替换整个目标值,因此数组不会按索引拼接。
先写清接口使用 application/merge-patch+json,不要仅凭请求方法判断协议。普通 JSON 请求即便同样使用 PATCH,也可能采用完全不同的业务规则。
对象成员中的
null表示删除,根补丁中的null表示整份文档变成空值。若业务必须保存显式空值,就不能让同一个位置的null同时承担赋值与删除。
还要区分空对象与空数组:{} 应用于已有对象时保持内容,[] 则把目标整体替换为空数组。目标原本不是对象时,空对象补丁会得到空对象。
编写独立的 Swift 合并内核
在仓库中新建 ci/merge-patch-check.swift,放入下面代码。使用 Foundation 解析 JSON,并开启片段支持,让数字、字符串和根空值也能进入测试,而不是默认只接受对象与数组。
import Foundation
enum PatchError: Error {
case depthExceeded
case mismatch(String)
}
func parse(_ text: String) throws -> Any {
try JSONSerialization.jsonObject(
with: Data(text.utf8),
options: [.fragmentsAllowed]
)
}
func merge(
_ target: Any,
_ patch: Any,
depth: Int = 0
) throws -> Any {
guard depth <= 64 else {
throw PatchError.depthExceeded
}
guard let fields = patch as? [String: Any] else {
return patch
}
var result = target as? [String: Any] ?? [:]
for (key, value) in fields {
if value is NSNull {
result.removeValue(forKey: key)
} else {
result[key] = try merge(
result[key] ?? NSNull(),
value,
depth: depth + 1
)
}
}
return result
}
func canonical(_ value: Any) throws -> Data {
try JSONSerialization.data(
withJSONObject: value,
options: [.sortedKeys, .fragmentsAllowed]
)
}
func check(
_ name: String,
_ target: String,
_ patch: String,
_ expected: String
) throws {
let actual = try merge(try parse(target), try parse(patch))
let expectedValue = try parse(expected)
guard try canonical(actual) == canonical(expectedValue) else {
throw PatchError.mismatch(name)
}
}
深度限制是本实现的资源策略,不是协议自带规则。这里根调用深度为零,递归调用超过 64 即失败。正式请求入口还应限制报文字节数及整体嵌套深度:数组会直接替换,不会被这个对象递归计数完整检查。
这段内核只接受 JSON 解析后的值,不接受任意业务对象。避免把日期、二进制数据直接塞入字典,再期待序列化阶段自动纠正类型。
用固定向量验收删除与替换
把以下检查追加到同一个文件。每条样本只验证一个主要语义,失败名称可以直接对应代码评审中的协议条款。
try check(
"delete-and-preserve",
#"{"theme":"dark","badge":3}"#,
#"{"badge":null}"#,
#"{"theme":"dark"}"#
)
try check(
"nested-object",
#"{"ui":{"theme":"dark","scale":2}}"#,
#"{"ui":{"scale":3}}"#,
#"{"ui":{"theme":"dark","scale":3}}"#
)
try check(
"replace-array",
#"{"roles":["reader","writer"]}"#,
#"{"roles":["reader"]}"#,
#"{"roles":["reader"]}"#
)
try check("root-null", #"{"enabled":true}"#, "null", "null")
try check("empty-object", "[1,2]", "{}", "{}")
try check("empty-array", #"{"items":[1]}"#, #"{"items":[]}"#, #"{"items":[]}"#)
try check("missing-delete", "{}", #"{"absent":null}"#, "{}")
try check("scalar-root", #"{"count":1}"#, "7", "7")
比较前只排序对象键,不改变数组顺序。这里的序列化比较适用于这些固定样本,不是通用的数字规范化方案,也不应用来计算跨语言签名。
如果客户端将字段建模为普通可选值,编码时很容易把“删除字段”和“不更新字段”都变成省略。请求构造层应明确区分不变、删除、赋值三种状态,并单独检查生成的 JSON;仅验证合并内核不能证明请求编码正确。
增加资源边界与重复应用检查
对同一补丁重复应用,结果应保持一致。这适合发现把数组误做追加、把数值误做累加的实现,但不能证明整个接口没有重复发送通知等副作用。
再加入以下检查,确认超深对象不是静默截断,而是明确报错:
let target = try parse(#"{"ui":{"scale":1}}"#)
let patch = try parse(#"{"ui":{"scale":2},"old":null}"#)
let once = try merge(target, patch)
let twice = try merge(once, patch)
guard try canonical(once) == canonical(twice) else {
throw PatchError.mismatch("idempotence")
}
var deep: Any = 1
for _ in 0..<66 {
deep = ["child": deep]
}
do {
_ = try merge(NSNull(), deep)
throw PatchError.mismatch("depth-limit")
} catch PatchError.depthExceeded {
}
另一类易漏场景是非法 JSON。解析失败必须终止请求,不能捕获错误后改用空字典继续合并,否则损坏输入可能被当成合法更新。重复键的接收策略也应在入口单独约定,不依赖不同解析器碰巧保留相同的值。
接入云端 Mac 的提交检查
先确认当前工具链可用,再在仓库根目录运行:
set -eu
xcrun --find swift
xcrun swift ci/merge-patch-check.swift
脚本不使用可能被优化关闭的断言;任何比较失败都会抛出错误并返回非零退出状态。将它放在耗时的应用构建之前,保留样本名称和退出状态即可,无需把真实用户配置写进日志。
落地时分三层验收:内核跑固定向量,请求构造层检查实际编码,接口集成层核对服务端应用后的文档。移动端缓存也应使用同一组样本,防止页面先按错误规则乐观更新,稍后又被服务端结果覆盖。最终评审关注的是每个字段的更新含义,而不是一次请求是否返回成功。
常见问题
JSON Merge Patch 能把对象字段设置为 null 吗?
对象补丁中的 null 表示删除该字段,不能同时表达保留字段并赋值为 null。业务需要区分这两种操作时,应选择其他更新协议或明确的操作封装。根补丁为 null 则会把整个文档替换为 null。
数组元素会按位置合并吗?
不会。补丁中的数组会整体替换原数组,空数组也一样。需要按元素标识更新时,应先定义独立的业务操作,而不是把逐项合并加入 JSON Merge Patch。
让整台 Mac 只跑团队的构建
选择 Apple Silicon M4 独享物理 Mac mini,保留团队自己的工具链与 runner 配置。按天、周、月、季计费,可用性以控制台实时返回为准。
租用构建节点