云端 Mac 上的 iOS JSON Merge Patch 验收

云端 Mac 上的 iOS JSON Merge Patch 验收

团队把用户设置改成局部更新后,常见故障不是请求失败,而是请求成功却留下旧字段:客户端把 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 配置。按天、周、月、季计费,可用性以控制台实时返回为准。

租用构建节点

按构建需求选配置

先确认内存与存储,再选择计费周期和节点。