雲端 Mac 上的 iOS JSON Merge Patch 驗證

雲端 Mac 上的 iOS JSON Merge Patch 驗證

團隊將使用者設定改為局部更新後,常見的問題不是請求失敗,而是請求成功卻留下舊欄位:用戶端將 null 視為略過,伺服器卻將它解讀為刪除。在雲端 Mac 的建置工作中加入一組 Swift 契約測試,就能在編譯通過之外,進一步確認雙方對更新內容的理解一致。以下只處理 JSON Merge Patch 的文件轉換,不連線至正式環境的 API,也不依賴模擬器。

明確訂定更新協定的四項規則

JSON Merge Patch 的規則由 RFC 7396 定義。物件補丁會逐一處理欄位:未出現的欄位保留,值為 null 的欄位刪除,其他值則遞迴套用。補丁不是物件時,會直接取代整個目標值,因此陣列不會依索引合併。

首先要明確註明 API 使用 application/merge-patch+json,不要只憑 HTTP 請求方法判斷協定。一般 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;只驗證合併核心,無法證明請求編碼正確。

加入資源邊界與重複套用檢查

重複套用同一份補丁,結果應維持一致。這項檢查適合找出誤將陣列視為附加、或將數值視為累加的實作,但無法證明整個 API 沒有重複傳送通知等副作用。

再加入以下檢查,確認巢狀深度過大的物件會明確報錯,而不是無聲地截斷:

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

這份指令稿不使用可能因最佳化而停用的斷言;任何比較失敗都會擲出錯誤,並回傳非零結束狀態。將它安排在耗時的應用程式建置之前執行,只需保留案例名稱與結束狀態,不必將真實使用者的設定寫入日誌。

實際導入時分三層驗收:合併核心執行固定測試向量,請求建構層檢查實際編碼,API 整合層核對伺服器套用更新後的文件。行動端快取也應使用同一組案例,避免頁面先依錯誤規則進行樂觀更新,稍後又被伺服器結果覆寫。最終審查應關注每個欄位的更新含義,而不是某次請求是否回傳成功。

常見問題

JSON Merge Patch 能將物件欄位設為 null 嗎?

補丁物件中的 null 代表刪除欄位,無法同時表達保留欄位並指定 null。業務需要區分這兩種操作時,應採用其他更新協定或明確的操作封裝。根補丁為 null 時,則會將整份文件取代為 null。

陣列元素會按照位置合併嗎?

不會。補丁中的陣列會整體取代原陣列,空陣列也相同。需要按照元素識別碼更新時,應另外定義業務操作,不要將逐項合併加入 JSON Merge Patch。

讓整台 Mac 專心執行團隊的建置工作

選擇 Apple Silicon M4 獨享實體 Mac mini,保留團隊原有的工具鏈與 runner 設定。提供日租、週租、月租與季租,實際可用狀態請以控制台即時資訊為準。

租用建置節點

依建置需求選擇規格

先確認記憶體與儲存空間,再選擇計費週期與節點。