Как я избавился от тысяч строк валидации в Swift

от автора

Каждый раз, когда нужно добавить новую модель в проект, приходится писать буквально одинаковый код: с одинаковыми проверками, с одинаковыми корректировками, с одинаковыми Codable, с одинаковыми тестами.

Полагаю, вы тоже постоянно с этим сталкиваетесь, особенно при работе с текстом, например, вот типичный код:

struct User {    let name: String      // Должно быть непустым и без пробелов    let awards: [Award]   // Должны быть отсортированы по убыванию    let progress: Double  // Не может быть отрицательным    init?(name: String, awards: [Award], progress: Double) {        let name = name.trimmingCharacters(in: .whitespacesAndNewlines)        guard !name.isEmpty, progress >= 0 else { return nil }        self.name = name        self.awards = awards.sorted(by: >)        self.progress = progress    }}

Всего три поля — и уже куча логики в инициализаторе. Причем пример-то еще упрощенный.

И не забываем добавить Codable, ведь данные приходят с сервера:

extension User: Codable {    init(from decoder: any Decoder) throws {        let container = try decoder.container(keyedBy: CodingKeys.self)        guard let user = User(            name: try container.decode(String.self, forKey: .name),            awards: try container.decode([Award].self, forKey: .awards),            progress: try container.decode(Double.self, forKey: .progress)        ) else {            throw DecodingError.dataCorrupted(.init(                codingPath: decoder.codingPath,                debugDescription: "Invalid Data"            ))        }        self = user    }}

Если этого не сделать, декодер пропустит ваш init? и запишет сырые данные прямо в поля. Валидации не будет.

Этот бойлерплейт приходится писать для каждой модели. И тестировать каждый инициализатор тоже нужно: пустая строка, отрицательное число, неотсортированный массив. И вот мы уже тестируем не бизнес-логику, а то, что guard не пропустит кривые данные.

Решение

Однажды я наткнулся на композицию объектов у Егора Бугаенко cactoos и, вдохновившись, попытался перенести все в Swift. Но в итоге пошел даже дальше: перенес валидацию из рантайма в типы.

Получилась библиотека Primity. Тот же User теперь выглядит так:

struct User: Codable {    let name: Name    let awards: Awards    let progress: Progress}extension User {    typealias Name = NonEmpty<Trimmed<String>>    typealias Awards = Descended<Array<Award>>    typealias Progress = NonNegative<Double>}

Никаких guard, никакого ручного Codable — нет ничего лишнего и все понятно без комментариев.

Врапперы сериализуют свое содержимое напрямую. При декодировании сами вызывают свой init(_:) или init?(_:), и если данные не проходят валидацию — бросают ошибку. Вам не нужно писать init(from decoder:) руками.

Почему через typealias?

Я специально прячу все внутрь модели, чтобы не привязывать клиентский код к конкретной цепочке оберток. Клиенту не важно, что под капотом у User.Name. Если завтра я решу, что User.Name должен быть NonEmpty<Collapsed<Trimmed<String>>> вместо NonEmpty<Trimmed<String>>, все вызовы User.Name(expressing:) останутся прежними. Клиентский код не сломается, да и читается намного легче и понятнее.

// Хорошо: не важно, какие врапперы внутриif let name = User.Name(expressing: input) { ... }// Плохо: привязка к конкретной цепочке, сломается при измененияхif let name = NonEmpty(Trimmed(input)) { ... }

Как это работает

Враппер — это структура с одним полем. Она дублирует поведение вложенного значения.

Врапперы бывают двух видов:

Валидаторы проверяют входное значение и отбрасывают плохое. Возвращают опционал:

NonEmpty — непустая строка или коллекция

NonNegative / Positive — числовые ограничения  

Within — значение в заданных границах

Корректоры всегда принимают значение и приводят его к нужному виду:

Trimmed, Collapsed, Ragged, Stripped— чистят пробелы, декоративные символы

Capitalized, Lowercased, Uppercased — меняют регистр

Sorted — сортирует

Truncated — обрезает по длине

Clamped — ограничивает в заданном диапазоне

Обратите внимание на названия. Корректоры — это причастия с окончанием -ed: они описывают, что уже сделано со значением (обрезано, отсортировано, ограничено). Валидаторы — прилагательные: они описывают состояние значения (непустое, положительное, внутри диапазона). Такое разделение по частям речи помогает с первого взгляда отличить «всегда пропускает, но меняет» от «проверяет и либо принимает, либо отбрасывает».

Композиция вместо конфигурации

Сложные правила собираются из простых блоков:

typealias Tag = NonEmpty<Lowercased<Truncated<`32`,Collapsed<Trimmed<Stripped<String>>>>>>let tag: Tag? = " Swift   💙 DEVelopment   💻 "// "swift development"

Каждый слой делает одно дело. Результат — строка, которая гарантированно чистая, непустая и в нижнем регистре.

Вот еще пример, опции с вариантами ответов можно организовать так:

typealias TwoThroughNine<Value: Withinable> = Within<`2`, `9`, Value> where Value.Bound == Inttypealias AnswerOption = NonEmpty<Truncated<`32`,Collapsed<Trimmed<String>>>>typealias AnswerOptions = TwoThroughNine<OrderedSet<AnswerOption>>

Или вот еще примеры:

typealias Rating = Clamped<`1`, `5`, Int>typealias Percentage = Clamped<`0`, `100`, Int>typealias Progress = Clamped<`0.0`, `1.0`, Double>

Работа с текстом

Вот строковые комбинации, которыми я постоянно пользуюсь, меняя только названия:

typealias Title = NonEmpty<Truncated<`256`,Collapsed<Trimmed<String>>>>typealias Paragraph = NonEmpty<Truncated<`1024`,Collapsed<Trimmed<String>>>typealias Name = NonEmpty<Truncated<`64`,Collapsed<Trimmed<Stripped<String>>>>>typealias Tag = NonEmpty<Lowercased<Truncated<`32`,Collapsed<Trimmed<Stripped<String>>>>>>
let title: Title? = "   Swift   Development   "   // "Swift Development"let name: Name? = "👋 Mia 🌍"                     // "Mia"let tag: Tag? = "  SWIFT  DeV  "                   // "swift dev"

Порядок применения

Врапперы применяются справа налево:

typealias Tag = Lowercased<Truncated<`32`,Collapsed<Trimmed<Stripped<String>>>>>// Эквивалентная цепочкаlet tag = string    .stripped()          // чистим от декоративных символов    .trimmed()           // убираем пробелы по краям    .collapsed()         // схлопываем множественные пробелы в один     .truncated(to: 32)   // обрезаем по длине    .lowercased()        // приводим к нижнему регистру

Порядок иногда важен. Если сначала обрезать по длине, а потом схлопнуть пробелы, результат получится короче, чем задумано.

Удобства

Врапперы понимают литералы

let string: Trimmed<Stripped<String>> = "hello world"let array: NonEmpty<Ascended<Array<Int>>>? = [5, 1, 3, 2, 4]let double: NonNegative<Double>? = 95.97

Все это построено на базовом протоколе Expressible с init(expressing:), который вызывает вложенные инициализаторы автоматически:

typealias Paragraph = NonEmpty<Collapsed<Trimmed<RichText>>>let paragraph = Paragraph(expressing: richText)// Без этого пришлось бы писать что-то такоеlet paragraph = Paragraph(Collapsed(Trimmed(richText)))

Достать вложенное значение просто

Оператор * раскрывает любой враппер и возвращает его сырое значение. Без цепочки .value через слои:

let string = *Trimmed(Stripped("swift"))  // Stringlet array = *NonEmpty(Ascended([5, 1, 3, 2, 4]))  // [Int]?let double = *NonNegative(95.97)  // Double?

Если предпочитаете явные методы — они по-прежнему доступны:

let string = Trimmed(Stripped("swift")).asString()let array = NonEmpty(Ascended([5, 1, 3, 2, 4]))?.asArray()let double = NonNegative(95.97)?.asDouble()// Базовый метод для всех типовlet text = Truncated(Collapsed(richText)).expressed()

Изменения без мутаций

Врапперы иммутабельны, но для базовых типов есть методы, которые возвращают новый враппер с измененными данными:

Ascended([5, 2, 1, 4, 3])    .appending(6)NonEmpty(["en": "Hello", "fr": "Bonjour"])?    .setting("Привет", for: "ru")Trimmed("Jobs")    .prepending("Steve ")NonNegative(16.7)?    .multiplying(by: 9.4)

Методы есть для массивов, строк, словарей, сетов и чисел. Но вы также можете их добавить и для своих типов.

Проверка один раз

Главная сила — не только в том, что модель выглядит чище. Но и в том, что значение, однажды созданное враппером, можно спокойно передавать внутри системы.

Раньше метод получал значение и сразу требовал повторной проверки: пустая ли строка? Есть ли пробелы по краям? Отсортирован ли массив? 

Сейчас метод принимает User.Name — и тип уже гарантирует корректность. Никаких guard внутри метода, никаких корректировок. Проверка произошла один раз, при создании, и больше не нужна нигде.

Такие гарантии сильно снижают кодовую базу. Валидация исчезает не только из моделей, но и из соответствующих методов, сервисов, контроллеров и всех тестов.

Рутинные тесты больше не нужны

Раньше каждый инициализатор требовал отдельных тестов: пустая строка, отрицательное число, неотсортированный массив. Мы тестировали не бизнес-логику, а то, что guard отработал правильно.

С врапперами эти проверки встроены в тип. NonEmpty не может содержать пустую строку, потому что компилятор не позволит создать такой экземпляр. NonNegative не может быть меньше нуля по определению типа.

Это как c Hashable — мы не пишем тесты, что словарь правильно хеширует ключи, потому что это гарантия языка. Валидация перешла из рантайма в типовую систему. Тестировать ее в своих моделях бессмысленно.

Тесты остаются только для бизнес-логики. А проверка «не пусто», «не отрицательно», «отсортировано» — это больше не ваша забота.

Codable из коробки

Враппер кодирует внутреннее значение напрямую — без оберток, без поля valueJSON остается плоским и обратно совместимым.

// Значение кодируется и декодируется напрямую"swift dev"// Без этого было бы вот так{"value":{"value":{"value":{"value":{"value":"swift dev"}}}}}

То есть врапперы продолжают работать с теми же стерилизованными данными, что были до этого. Не нужно нигде ничего менять или подстраивать.

При декодировании враппер сам вызывает свой init(_:) или init?(_:). Если данные не проходят валидацию — враппер бросает понятную ошибку: «Value must not be empty», «Value ‘6’ must be within bounds 1…5» и тому подобное.

Минусы тоже есть

Миграция существующего проекта требует времени. Нужно заменить типы в моделях, подружить код из разных частей системы, удалить ставшие лишними проверки и тесты. Да, новый функционал можно вводить поэтапно, но лучше всего сделать все разом — по модулю или по всему проекту.

На границах с другими библиотеками придется гонять типы туда-обратно: доставать значение из враппера для передачи во внешний API, оборачивать обратно на выходе. Это местами не так удобно, но терпимо — цена за гарантии внутри своего кода.

Но после перехода скорость добавления новых моделей и методов растет существенно. Меньше бойлерплейта, меньше тестов на валидацию, меньше мысленной нагрузки при чтении кода.

А что с производительностью?

Враппер — структура с одним полем. В Swift это value-type, она лежит на стеке или встроена в родителя без дополнительных аллокаций. NonEmpty<Trimmed<String>> занимает ровно столько же памяти, сколько String.

И еще, typealias — это псевдоним, а не новый тип. Если User.Name и Player.Name указывают на одну и ту же композицию, компилятор считает их одним типом и не дублирует метаданные.

На практике уникальных комбинаций мало: для строковых полей в проекте от силы три-четыре паттерна, и все модели их переиспользуют. Так что и тут оверхед копеечный.

Под капотом

Вся библиотека построена на Protocol-Oriented Programming и четырех базовых протоколах.

Все врапперы подчиняются протоколу AnyWrapping. От него разветвляются два:

Wrapping — корректоры, инициализация всегда успешна

MaybeWrapping — валидаторы, инициализация падает на плохих данных

То же самое с созданием из «сырого» значения: базовый AnyExpressible дает два пути:

Expressible — для корректоров, создание всегда успешно

MaybeExpressible — для валидаторов, создание может вернуть nil

Каждый враппер требует от вложенного значения ровно один протокол: Trimmed просит Trimmable, CapitalizedCapitalizable, SortedSortableВраппер сам ничего не делает — он лишь дублирует поведение вложенного значения.

В цепочке Capitalized<Trimmed<String>> внутренний String должен быть и Capitalizable, и Trimmable. Каждый слой добавляет одно ограничение, а компилятор собирает их вместе. NonEmpty<String> ведет себя как String, Ascended<Array<Int>> — как Array. Никакого сгенерированного кода, только протоколы и пустые расширения.

Если коротко, то каждый враппер создается по следующему шаблону:

protocol Capitalizable {    func capitalized() -> Self}struct Capitalized<Wrapped>: Wrapping where Wrapped: Capitalizable {    let value: Wrapped    init(_ value: Wrapped) {         self.value = value.capitalized()     }}

Больше подробностей в README файле.

Философия дизайна

  • Один инвариант на тип — каждый примитив делает ровно одно дело

  • Ноль знаний о предметной области — никакой бизнес-логики, никаких внешних зависимостей

  • Композиция вместо конфигурации — собирайте типы в стек вместо передачи правил валидации

  • Ломаться сразу, а не потом — невалидные значения отклоняются в момент создания

Где найти

Primity выложен в открытый доступ. Внутри подробные комментарии в README и прямо в исходниках — загляните в любой файл, чтобы понять, как все устроено под капотом.

Если есть вопросы или предложения — пишите в комментариях или в issues, буду рад обсудить.


П.С. Я действительно вырезал тысячи строк валидации и тестов, так что заголовок не был кликбейтом. Из проекта на ~100 000 строк кода было вырезано ~5000: хелперы и их вызовы, проверки в инициализаторах и методах, ручные Codable-расширения, часть тестов для моделей и функций.

Модели теперь собираются просто и без какой-либо головной боли, а новый враппер, если он нужен, пишется в девять строк кода (ладно, с расширениями чуть больше). Код читается как обычный английский текст, поддерживается без усилий, и описывает сам себя.

ссылка на оригинал статьи https://habr.com/ru/articles/1043520/