API изменил регистр 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 сохраните это различие с помощью проверок конкретных ошибок.
Выполняйте нормализацию на границе данных
Разбирать идентификатор следует в тот момент, когда ответ API, параметр глубокой ссылки или содержимое импортируемого файла попадает в доменную модель. Доменный слой должен хранить UUID и вызывать canonical только при отправке запроса или записи в текстовое поле. Не позволяйте слоям интерфейса и кеширования независимо выполнять строковые замены.
Особая осторожность нужна при миграции хранилища. Сначала на копии, доступной только для чтения, сопоставьте старые ключи с их каноническим представлением и подсчитайте случаи, когда одному каноническому представлению соответствуют несколько старых ключей. Обнаруженные коллизии нужно разрешить по бизнес-правилам объединения записей, а затем обновить внешние ключи и ограничения уникальности. Простое массовое приведение к нижнему регистру может превратить две существующие записи в одну.
Не считайте сортировку UUID как текста хронологической
Лексикографический порядок случайных UUID не отражает порядок создания записей. Если списку нужен стабильный порядок, используйте отдельное поле времени создания, а при совпадении времени — нормализованный идентификатор как вторичный ключ сортировки. Даже если выбран вариант UUID с временной семантикой, версию и правила сортировки необходимо оговорить отдельно.
Включите проверки в приёмочный процесс на облачном Mac
Перед запуском скрипта на узле ArmVNC зафиксируйте идентификатор коммита, вывод sw_vers и xcrun swift --version. Эти сведения помогают объяснить различия между наборами инструментов и не содержат бизнес-данных. Для примеров, которые должны завершаться ошибкой, используйте фиксированные тестовые значения, а не архивируйте реальные пользовательские записи.
После подключения к непрерывной интеграции проверяйте как минимум четыре пункта:
- Стандартное текстовое представление разбирается, а разный регистр даёт одно и то же значение.
- Пробельные символы, неверные разделители и символы вне шестнадцатеричного набора обязательно приводят к ошибке.
- Нулевое значение обрабатывается по явно заданному правилу, а не маскируется под обычную синтаксическую ошибку.
- Перед миграцией старых ключей проверяются коллизии, а количество связанных записей остаётся неизменным после миграции.
В завершение добавьте интеграционный тест: передайте одну и ту же сущность дважды, сначала с UUID в верхнем, затем в нижнем регистре. В хранилище должна остаться одна запись, а обновление должно примениться к исходной. Парсер обеспечивает единые правила на входе, но только интеграционный тест подтверждает, что кеш, база данных и обновления по бизнес-логике действительно соблюдают этот контракт.
Часто задаваемые вопросы
Разный регистр букв означает разные UUID?
Нет. Сравнивайте разобранные значения UUID, а текст храните в стандартной форме с буквами нижнего регистра. Перед миграцией проверьте существующие строковые ключи на коллизии.
Нужно ли всегда отклонять UUID из одних нулей?
Нет. Его структура корректна, но бизнес-контракт может запрещать такое значение в качестве идентификатора сущности. Задайте правило явно и проверьте оба варианта.
Передайте задачи сборки на выделенный облачный Mac
Выберите выделенный узел Apple Silicon и получите в своё распоряжение всю машину — с нативным графическим интерфейсом macOS и командной строкой. Модель, срок аренды и узел указаны в подтверждении заказа.