API が同じレコードの UUID を大文字から小文字に変えただけなのに、クライアントが別のレコードを挿入してしまう。この問題の原因は、たいてい乱数生成器ではありません。文字列をそのまま主キーとして扱い、比較、キャッシュ、永続化でそれぞれ異なるルールを使っていることにあります。クラウド Mac では、まずこの境界の動作を検証する独立した Swift の受け入れテスト用スクリプトを作り、その後 iOS プロジェクトのテストに組み込めます。シミュレータを起動する必要はありません。
まず入力の契約を明確にする
この記事で受け付けるのは、標準的な 36 バイトのテキスト形式です。16 進文字を長さ 8、4、4、4、12 の五つのグループに分け、ASCII のハイフンで区切ります。入力では大文字と小文字の混在を認め、出力は小文字に統一します。前後の空白、波括弧付きの形式、ハイフンのない形式は受け付けません。
これは、ほかの表現がすべて無効だという意味ではありません。API の入力形式を一つに定めるための方針です。既存の API が別の形式を許容している場合は、明示的な互換レイヤーで対応し、コアのパーサーが暗黙に補正する設計にはしないでください。
また、構文として正しいことと、業務上許可されることは別です。全ゼロの UUID は構造上は有効ですが、注文やドキュメントの識別子としては禁止できます。UUID のバージョンを制限するかどうかも API の契約で決めるべきです。3 番目のグループの先頭文字が特定の数字ではないというだけで、直ちにエラーにしてはいけません。
UUID は識別子であり、アクセス資格情報ではありません。形式が正しくても、サーバー側では現在のユーザーに対象レコードを読み取る権限があるかを確認する必要があります。
実行可能な厳密パーサーを書く
ノード上で xcrun --find swift を実行し、現在のツールチェーンで Swift が使えることを確認します。以下の内容を uuid-contract.swift として保存してください。最初にバイト構造を検証してから Foundation で解析することで、不正規な入力をどこまで許容するかという OS バージョンごとの挙動に依存しないようにします。
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 を実行します。成功時は何も出力されず、失敗時はプロセスが非ゼロの終了コードで終了します。コード中の空の catch ブロックは、想定したエラーが発生したことを示しています。それ以外のエラーは握りつぶされず、スクリプトを失敗させます。XCTest に移す際も、具体的なエラーを検証するアサーションを使い、この区別を維持してください。
データの境界で正規化する
解析は、ネットワークレスポンス、ディープリンクのパラメーター、インポートファイルのデータがドメインモデルに入る位置で行います。ドメイン層では UUID を保持し、リクエストの送信やテキストフィールドへの書き込みが必要になった時点で canonical を呼び出します。UI 層やキャッシュ層が、それぞれ独自に文字列置換を行う設計は避けてください。
ストレージの移行には、特に注意が必要です。まず読み取り専用のコピーで既存のキーを正規形に対応付け、「一つの正規形に複数の既存キーが対応する」ケースを集計します。衝突が見つかったら、業務上のレコード統合ルールに従って処理し、その後で外部キーと一意性制約を更新します。いきなり一括で小文字に変換すると、過去の二つのレコードを一つにまとめてしまうおそれがあります。
UUID の文字列順を時系列順として扱わない
ランダム UUID の辞書順は、作成順を表しません。一覧に安定した順序が必要な場合は、明示的な作成日時フィールドを使い、日時が同じ場合の第 2 ソートキーとして正規化済みの識別子を追加します。時刻の意味を持つ UUID 方式を採用する場合も、バージョンと並べ替えルールは別途定める必要があります。
クラウド Mac の受け入れ検証フローに組み込む
ArmVNC ノードでスクリプトを実行する前に、コミット識別子、sw_vers、xcrun swift --version を記録します。これらはツールチェーンによる違いを説明するための情報であり、業務データは含みません。失敗ケースには固定のテスト値を使い、ユーザーのレコードをそのまま保存しないでください。
継続的インテグレーションに組み込んだら、少なくとも次の四つを確認します。
- 標準形式のテキストを解析でき、大文字と小文字が異なっても同じ値になる。
- 空白、不正な区切り文字、16 進文字以外の文字を含む入力は必ず失敗する。
- 全ゼロ値は明示的な方針に従って処理され、通常の構文エラーとして扱われない。
- 既存キーの移行では先に衝突を確認し、移行前後で関連レコード数が変わらない。
最後に、統合テストを一つ追加します。同じエンティティを大文字と小文字の UUID でそれぞれ入力しても、ストレージ内のレコードは一つのままで、更新が元のレコードに反映されることを確認します。パーサーが保証するのは入力境界の一貫性です。キャッシュ、データベース、業務上の更新処理が本当にこの契約を守っていることは、統合テストで初めて確かめられます。
よくある質問
UUID の大文字と小文字は別の識別子ですか?
いいえ。UUID 値へ変換して比較し、文字列として保存する場合は小文字の標準形式に統一します。既存の文字列キーは、正規化による衝突を移行前に確認してください。
全ゼロの UUID は必ず拒否すべきですか?
構造上は有効です。エンティティの識別子として禁止するかどうかは業務ルールなので、明示的な設定にし、許可と拒否の両方をテストします。
ビルドは専有クラウド Mac におまかせ
Apple Silicon 搭載の物理ノードを選び、マシンを丸ごと専有できます。macOS のグラフィカル環境もコマンドラインもそのまま利用可能です。モデル、利用期間、ノードは注文内容をご確認ください。