同一条订单记录,接口返回大写 UUID,本地缓存保存小写,去重逻辑却直接比较字符串;另一条链路把 UUID 写成二进制,回读后前几组字节顺序又变了。把这类问题放到云端 Mac 的构建前检查,不需要启动模拟器。先用固定 Swift 向量锁住输入、身份比较与序列化契约,再让 iOS 工程复用同一套规则。
先拆开格式与业务策略
UUID 是 128 位标识符,常见文本形式为五组十六进制字符,长度依次是 8、4、4、4、12。连字符固定出现四次,但这个外形并不说明它一定是随机生成的版本 4。
先写清三条规则:是否接受大写,是否允许首尾空白,是否限制版本。本文接受大小写,拒绝空白、花括号、无连字符形式与非 ASCII 字符;只有明确要求随机标识符的字段才检查版本 4 和对应变体。
“能够解析”是库的能力,“允许进入系统”是协议的决定。不要把解析器的宽松行为直接当成接口契约。
全零 UUID 也要单独决策。它可以符合通用文本格式,却不应自动成为“未设置”的替代值。可选字段优先使用明确的空值语义。
在云端 Mac 运行固定向量
准备已选定开发工具链的云端 Mac,将下面内容保存为 uuid-contract.swift。脚本只使用 Foundation,不依赖工程、网络或随机数。precondition 用于这份测试脚本的失败阻断,不用于应用处理外部输入。
import Foundation
func parseUUID(_ text: String, requireV4: Bool = false) -> UUID? {
let bytes = Array(text.utf8)
guard bytes.count == 36 else { return nil }
let separators: Set<Int> = [8, 13, 18, 23]
for (index, byte) in bytes.enumerated() {
if separators.contains(index) {
guard byte == 45 else { return nil }
} else {
let isHex = (48...57).contains(byte)
|| (65...70).contains(byte)
|| (97...102).contains(byte)
guard isHex else { return nil }
}
}
guard let value = UUID(uuidString: text) else { return nil }
if requireV4 {
let raw = value.uuid
guard raw.6 >> 4 == 4, raw.8 & 0xC0 == 0x80 else {
return nil
}
}
return value
}
let valid = "00112233-4455-4677-8899-aabbccddeeff"
let parsed = parseUUID(valid, requireV4: true)!
precondition(parsed.uuidString.lowercased() == valid)
precondition(parseUUID(valid.uppercased()) == parsed)
let invalid = [
" " + valid,
valid + "\n",
"{" + valid + "}",
valid.replacingOccurrences(of: "-", with: ""),
valid.replacingOccurrences(of: "a", with: "a")
]
for input in invalid {
precondition(parseUUID(input) == nil)
}
let otherVersion = "00112233-4455-5677-8899-aabbccddeeff"
precondition(parseUUID(otherVersion) != nil)
precondition(parseUUID(otherVersion, requireV4: true) == nil)
let wrongVariant = "00112233-4455-4677-0899-aabbccddeeff"
precondition(parseUUID(wrongVariant) != nil)
precondition(parseUUID(wrongVariant, requireV4: true) == nil)
let raw = parsed.uuid
let wire: [UInt8] = [
raw.0, raw.1, raw.2, raw.3,
raw.4, raw.5, raw.6, raw.7,
raw.8, raw.9, raw.10, raw.11,
raw.12, raw.13, raw.14, raw.15
]
precondition(wire == [
0x00, 0x11, 0x22, 0x33,
0x44, 0x55, 0x46, 0x77,
0x88, 0x99, 0xaa, 0xbb,
0xcc, 0xdd, 0xee, 0xff
])
执行 xcrun swift uuid-contract.swift,成功时没有输出,失败时返回非零状态。流水线必须保留该状态;同时记录 xcrun swift --version,便于工具链更新后重跑比较。
身份比较只保留一个入口
应用内部尽量比较 UUID 值,而不是比较接口原文。需要写入文本数据库键时,在严格解析成功后调用 uuidString.lowercased(),得到唯一的小写连字符表示。
不要先删连字符、修剪空白再尝试解析。这样的“修复”会掩盖上游协议错误,也可能让两个原本应被拒绝的输入落到同一个键。展示层允许用户粘贴时,可以显式提供清理步骤,但不能让接口层静默沿用。
旧数据迁移要先统计大小写归一化后的碰撞。例如两条记录仅 UUID 字母大小写不同,不能直接覆盖其中一条。先核对业务关联,再合并或隔离异常记录,并保留迁移结果。
二进制交接检查完整字节序
上面的向量按 UUID 文本中十六进制字符的顺序列出 16 个字节。这个顺序必须成为线协议的一部分,而不是由当前机器的整数存储方式决定。
不要将前八个字节读成原生整数后直接写出。也不要凭某个接口把标识符称为 GUID,就假设它与本协议的内存布局相同。跨语言交接时,要求双方对同一个固定文本输出完全一致的十六进制字节串。
回读也需要独立样本
仅做“自己编码,再自己解码”不够:两边犯同一个错误,往返仍会成功。接收端应直接使用固定的 16 字节样本,断言恢复的文本等于原始向量;再测试 15 字节和 17 字节输入必须被拒绝,不能补零或截断。
把检查接到真实解析路径
脚本验证的是规则示例。落地时,把解析函数移入共享模块,由接口解码、缓存读写和导入工具统一调用;对应向量放进测试目标,避免检查脚本通过而应用仍使用另一条宽松路径。
补齐四组用例:合法大小写应归为同一身份;非法字符与空白应拒绝;版本限制只作用于指定字段;二进制样本应逐字节一致。每组都检查返回结果,不能只断言“没有崩溃”。
失败日志只记录字段名、失败类别与样本编号,不输出真实业务标识符。工具链更新、服务端序列化库替换或历史数据导入时,先重跑这组契约。把失败定位到文本、策略或字节序中的一层,比在缓存和接口之间反复猜测更直接。
常见问题
所有 UUID 都应该强制检查为版本 4 吗?
不应该。只有接口协议明确要求随机版本 4 标识符时才启用该限制;通用 UUID 字段应把文本解析与允许的版本策略分开。
UUID 大小写不同,是否应该视为两个标识符?
不应该。对合法 UUID 先解析,再生成统一的小写连字符文本作为键;保留原始输入只能用于诊断,不能让大小写参与身份判定。