Проверка обновлений iOS: строгий разбор и сравнение SemVer
Все автоматизированные тесты на облачном Mac прошли, но клиент считает 1.10.0 более старой версией, чем 1.9.0. Проблема не в сборке: при проверке обновлений версии сортируются как строки. Другая распространённая ошибка — трактовать сбой разбора как «обновление не требуется». Стоит серверу прислать данные в неверном формате, и возможность обновиться незаметно исчезает.
Ниже мы выделим проверку версий в отдельный контур тестирования: строгий разбор, сравнение приоритета и только затем применение правил обновления. Все примеры выполняются локально, без сети и без материалов для подписи приложения.
Определяем, какие версии допустимо сравнивать
В статье рассматриваются версии приложения по SemVer: три числовых компонента с необязательными идентификаторами предварительного выпуска и метаданными сборки. Например, 2.4.0-rc.1+build.7. Это не универсальный валидатор полей версии для платформ распространения приложений, и он не заменяет правила публикации для CFBundleShortVersionString.
В основных числовых компонентах ведущие нули запрещены; то же относится к полностью числовым идентификаторам предварительного выпуска. Идентификаторы предварительного выпуска и метаданные могут содержать только ASCII-буквы, цифры и дефисы; ни один компонент между точками не может быть пустым. В метаданных ведущие нули разрешены, но сами метаданные не участвуют в сравнении приоритета.
«Версия новее» не означает «обновление обязательно». Минимальная поддерживаемая версия, охват поэтапного развёртывания, тестовые каналы и отзыв релиза относятся к уровню политики — им не место в компараторе.
Сразу договоримся: некорректный ввод приводит к ошибке разбора, а не к подстановке 0.0.0. В рабочем коде также следует ограничить длину входной строки, например 256 байтами, чтобы аномальные данные не расходовали лишние ресурсы.
Реализуем компаратор без преобразования в целые числа
Поместите следующий код в Swift-файл, доступный тестовому таргету. Числовые компоненты сравниваются сначала по длине, затем по порядку ASCII, поэтому даже очень длинные числа не нужно преобразовывать в Int. Метаданные сборки отбрасываются после проверки, и автоматически синтезированное сравнение на равенство соответствует смыслу «одинаковый приоритет».
struct SemVersion: Comparable {
let core: [String]
let prerelease: [String]
private static func numeric(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) }
}
private static func validNumber(_ value: String) -> Bool {
numeric(value) && (value == "0" || !value.hasPrefix("0"))
}
private static func validIdentifier(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy {
(48...57).contains($0) || (65...90).contains($0)
|| (97...122).contains($0) || $0 == 45
}
}
private static func fields(_ value: String) -> [String] {
value.split(separator: ".", omittingEmptySubsequences: false)
.map(String.init)
}
init?(_ raw: String) {
guard raw.utf8.count <= 256 else { return nil }
let parts = raw.split(
separator: "+", omittingEmptySubsequences: false
).map(String.init)
guard parts.count <= 2 else { return nil }
if parts.count == 2 {
guard Self.fields(parts[1]).allSatisfy(Self.validIdentifier)
else { return nil }
}
let version = parts[0].split(
separator: "-", maxSplits: 1,
omittingEmptySubsequences: false
).map(String.init)
let core = Self.fields(version[0])
guard core.count == 3, core.allSatisfy(Self.validNumber)
else { return nil }
let pre = version.count == 2 ? Self.fields(version[1]) : []
guard pre.allSatisfy({
Self.validIdentifier($0)
&& (!Self.numeric($0) || Self.validNumber($0))
}) else { return nil }
self.core = core
self.prerelease = pre
}
private static func numberLess(_ lhs: String, _ rhs: String) -> Bool {
lhs.count == rhs.count ? lhs < rhs : lhs.count < rhs.count
}
static func < (lhs: Self, rhs: Self) -> Bool {
for (a, b) in zip(lhs.core, rhs.core) where a != b {
return numberLess(a, b)
}
if lhs.prerelease.isEmpty { return false }
if rhs.prerelease.isEmpty { return true }
for (a, b) in zip(lhs.prerelease, rhs.prerelease) where a != b {
let an = numeric(a)
let bn = numeric(b)
if an && bn { return numberLess(a, b) }
if an != bn { return an }
return a < b
}
return lhs.prerelease.count < rhs.prerelease.count
}
}
Здесь нет ни автоматического удаления пробелов, ни поддержки префикса v. Строгий обработчик входных данных не должен незаметно исправлять нарушения протокола. Если существующий интерфейс допускает такие формы записи, преобразуйте их в отдельном слое совместимости и документируйте правила, чтобы клиент и сервер не вырабатывали каждый свои догадки.
Фиксируем порядок версий тестовыми примерами
Нужно покрыть как минимум четыре случая: возрастание основных числовых компонентов, приоритет стабильного выпуска над предварительным, приоритет нечислового идентификатора над числовым и более высокий приоритет длинного списка идентификаторов предварительного выпуска при совпадающем префиксе. Следующие тесты можно сразу добавить в файл модульных тестов. Если реализация находится в модуле приложения, добавьте тестовый импорт этого модуля.
import XCTest
final class SemVersionTests: XCTestCase {
func testPrecedence() throws {
let ordered = [
"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta",
"1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11",
"1.0.0-rc.1", "1.0.0", "1.9.0", "1.10.0",
"999999999999999999999999.0.0"
]
for (a, b) in zip(ordered, ordered.dropFirst()) {
let left = try XCTUnwrap(SemVersion(a))
let right = try XCTUnwrap(SemVersion(b))
XCTAssertLessThan(left, right)
XCTAssertFalse(right < left)
}
}
func testMetadataAndInvalidInput() throws {
let first = try XCTUnwrap(SemVersion("1.2.3+001"))
let second = try XCTUnwrap(SemVersion("1.2.3+build.9"))
XCTAssertEqual(first, second)
for raw in [
"", "1.2", "01.2.3", "1.2.3-01", "1.2.3-",
"1.2.3+", "1.2.3+a..b", "1.2.3+a+b",
" 1.2.3", "1.2.3\n", "1.2.3"
] {
XCTAssertNil(SemVersion(raw), raw)
}
}
}
Не ограничивайтесь соседними версиями. Добавьте проверку транзитивности для произвольных троек: если A меньше B, а B меньше C, то A должно быть меньше C. Также проверьте, что значение не меньше самого себя и что для двух входов с одинаковым приоритетом ни один не считается меньше другого. Такие свойства помогают находить дефекты сортировки лучше, чем отдельный сценарий обновления.
Передаём результат сравнения на уровень политики обновлений
Ошибка разбора не означает, что обновление не требуется
Прикладному интерфейсу лучше явно возвращать один из трёх результатов: версии можно сравнить, текущая версия некорректна или удалённая версия некорректна. При ошибке в удалённых данных приложение должно сохранять работоспособность и регистрировать событие ошибки, которое можно найти в журналах. Не превращайте такую ошибку в сообщение «Установлена последняя версия». Если в политике обязательного обновления указана некорректная минимальная версия, нужно перейти к заранее определённому сценарию обработки сбоя, а не принимать решение наугад.
Тестовые каналы тоже требуют отдельной проверки. В стабильном канале нельзя предлагать установку предварительного выпуска лишь потому, что 2.0.0-beta.1 выше 1.9.0. Сначала отберите допустимые каналы, затем сравните версии и только после этого применяйте правила поэтапного развёртывания и минимальной поддерживаемой версии.
При повторной упаковке той же версии метаданные пригодны для отслеживания сборки, но не для изменения порядка SemVer. Если такие артефакты тоже нужно обновлять, отдельно храните порядковый номер релиза, проверяйте его монотонность и явно определите, допустимо ли сравнение между каналами.
Сохраняем проверяемые результаты приёмки на облачном узле
Добавьте эти тесты в набор модульных тестов существующей общей Scheme и используйте закреплённое в проекте целевое устройство симулятора. Фиксируйте коммит исходного кода, версию инструментальной цепочки, результаты тестов и примеры, на которых произошёл сбой. Подключаться к рабочему интерфейсу обновлений не нужно. Тогда для одного и того же коммита локально и в облаке будет проверяться один и тот же контракт.
Перед слиянием проверьте: все корректные примеры разбираются; ни один некорректный ввод не принимается после скрытого исправления; метаданные не влияют на приоритет; сверхдлинные числа не вызывают переполнения; ошибка разбора не отображается как «обновление не требуется»; стабильный канал не выбирает предварительный выпуск по ошибке.
В завершение проверьте сами тесты, намеренно испортив реализацию несколькими способами: замените числовое сравнение лексикографическим, включите метаданные в сортировку, разрешите ведущие нули. Каждое изменение должно приводить к падению соответствующего теста. Только если проверка перед слиянием ловит эти ошибки, она действительно защищает механизм обновления, а не просто добавляет ещё одну зелёную строку в отчёт.
Часто задаваемые вопросы
Можно ли определять обновление сортировкой строк?
Нет. Строковая сортировка неверно обрабатывает версии вроде 1.10.0 и 1.9.0, а также предрелизы. Сначала нужен строгий разбор, затем сравнение компонентов по правилам SemVer.
Меняют ли метаданные сборки приоритет версии?
Нет. Часть после плюса не влияет на приоритет SemVer. Для различения повторных сборок одной версии нужен отдельный номер выпуска и собственное правило обновления.
Выделенный облачный Mac для сборок
Вся физическая машина — только в вашем распоряжении, без виртуализации. Выберите модель и срок аренды под объём сборок, а архивы, подписи и кэш храните на своём узле.
Выбрать облачный Mac