云端 Mac 上的 iOS UUID 标识符验收:严格解析与规范化

云端 Mac 上的 iOS UUID 标识符验收:严格解析与规范化

接口把同一条记录的 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 图形界面与命令行。机型、周期和节点以订单确认内容为准。