接口把同一条记录的 UUID 从大写改成小写,客户端却插入了第二条记录。问题通常不在随机数生成器,而在字符串被直接当作主键:比较、缓存和持久化各用一套规则。在云端 Mac 上,可以先把这条边界做成独立 Swift 验收脚本,再接入 iOS 工程测试,不必启动模拟器。
先固定输入契约
本文接受标准的 36 字节文本:十六进制字符分成五组,长度依次为 8、4、4、4、12,中间使用 ASCII 连字符。输入允许大小写混合,输出统一小写;前后空白、花括号、无连字符形式均拒绝。
这不是声称其他表示法都无效,而是给接口选定唯一入口。若旧接口允许另一种形式,应放进显式兼容层,不要让核心解析器偷偷修复。
还要分开两件事:语法合法,不等于业务允许。全零 UUID 结构合法,但订单或文档标识符可以禁止它;是否限定 UUID 版本,同样应由接口契约决定,不能看到第三组首字符不是某个数字就直接报错。
UUID 标识符不是访问凭据。即使格式正确,服务端仍须检查当前用户是否有权读取对应记录。
编写可执行的严格解析器
在节点上运行 xcrun --find swift,确认当前工具链提供 Swift。将下面内容保存为 uuid-contract.swift。代码先检查字节结构,再交给 Foundation 解析,避免依赖某个系统版本对宽松输入的容忍程度。
import Foundation
enum IdentifierError: Error {
case invalidSyntax
case zeroNotAllowed
}
func parseIdentifier(
_ text: String,
allowZero: Bool = false
) throws -> UUID {
let bytes = Array(text.utf8)
let separators: Set<Int> = [8, 13, 18, 23]
guard bytes.count == 36 else {
throw IdentifierError.invalidSyntax
}
for (index, byte) in bytes.enumerated() {
if separators.contains(index) {
guard byte == 45 else {
throw IdentifierError.invalidSyntax
}
} else {
let isHex = (48...57).contains(byte)
|| (65...70).contains(byte)
|| (97...102).contains(byte)
guard isHex else {
throw IdentifierError.invalidSyntax
}
}
}
guard let value = UUID(uuidString: text) else {
throw IdentifierError.invalidSyntax
}
let zero = UUID(
uuid: (0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0)
)
guard allowZero || value != zero else {
throw IdentifierError.zeroNotAllowed
}
return value
}
func canonical(_ value: UUID) -> String {
value.uuidString.lowercased()
}
使用 UTF-8 字节而不是肉眼可见的字符数,是为了明确排除全角字符和形似连字符的 Unicode 符号。这里的 lowercased() 只处理已经验证过的 UUID 输出,不会把任意用户输入先转换后放行。
用固定样本检查边界
继续在同一文件末尾加入测试。成功样本验证值相等、规范化与幂等性;失败样本则覆盖空白、字符替换和长度变化。不要用随机 UUID 代替固定反例,否则失败现场不容易复现。
let upper = "550E8400-E29B-41D4-A716-446655440000"
let lower = upper.lowercased()
let value = try parseIdentifier(upper)
precondition(value == (try parseIdentifier(lower)))
precondition(canonical(value) == lower)
let reparsed = try parseIdentifier(canonical(value))
precondition(canonical(reparsed) == lower)
let invalid = [
"",
" " + lower,
lower + "\n",
"{" + lower + "}",
lower.replacingOccurrences(of: "-", with: ""),
lower.replacingOccurrences(of: "-", with: "-"),
lower.replacingOccurrences(of: "5", with: "g"),
lower + "0"
]
for text in invalid {
do {
_ = try parseIdentifier(text)
fatalError("Invalid UUID accepted")
} catch IdentifierError.invalidSyntax {
}
}
let zero = "00000000-0000-0000-0000-000000000000"
do {
_ = try parseIdentifier(zero)
fatalError("Zero UUID accepted")
} catch IdentifierError.zeroNotAllowed {
}
_ = try parseIdentifier(zero, allowZero: true)
执行 xcrun swift uuid-contract.swift,成功时无输出,失败时进程非零退出。代码中的空捕获块表示命中了预期错误;其他错误没有被吞掉,会继续使脚本失败。移入 XCTest 后,应使用具体错误断言保留同样的区分。
把规范化放在数据边界
解析应发生在网络响应、深链参数或导入文件进入领域模型的位置。领域层持有 UUID,需要发请求或写文本字段时才调用 canonical。不要让界面层、缓存层分别调用字符串替换。
存储迁移尤其要谨慎。先在只读副本中把旧键映射为规范形式,统计“一种规范形式对应多个旧键”的情况。发现冲突后,应按业务记录合并规则处理,再更新外键和唯一约束;直接批量转小写可能把两条历史记录压成一条。
不把 UUID 文本排序当时间排序
随机 UUID 的字典序不能代表创建先后。列表需要稳定顺序时,使用明确的创建时间字段,并在时间相同时追加规范化标识符作为次级排序键。若采用带时间语义的 UUID 方案,也要单独约定版本与排序规则。
接入云端 Mac 的验收流程
在 ArmVNC 节点执行脚本前,记录提交标识、sw_vers 和 xcrun swift --version。这些信息用于解释工具链差异,不包含业务数据;失败样本使用固定测试值,不要直接归档用户记录。
接入持续集成后,至少检查四项:
- 标准文本可以解析,大小写不同得到同一个值。
- 空白、错误分隔符和非十六进制字符必须失败。
- 全零值按显式策略处理,不能被普通语法错误掩盖。
- 旧键迁移先检查冲突,关联记录数量在迁移前后保持一致。
最后补一条集成测试:同一个实体分别以大小写 UUID 输入两次,存储中仍只有一条记录,更新落在原记录上。解析器保证入口一致,集成测试才证明缓存、数据库与业务更新真正遵守了这份契约。
常见问题
UUID 文本大小写不同,应当视为不同标识符吗?
不应当。先解析为 UUID 值再比较,写入文本存储时统一输出小写标准形式。若旧数据库使用区分大小写的字符串键,需要先检查规范化后是否发生键冲突。
全零 UUID 必须拒绝吗?
不一定。它在结构上是有效 UUID,但业务可以禁止将其用作实体标识符。把这项规则作为显式参数,并分别测试允许与拒绝两种情况。
把构建任务交给独享云端 Mac
选择 Apple Silicon 物理节点,独占整台机器,使用原生 macOS 图形界面与命令行。机型、周期和节点以订单确认内容为准。