Кодогенерация DTO: зачем она нужна и как её настроить

Data Transfer Object — модель данных, которые мы передаём из одного слоя приложения в другой. В Яндекс Go мы активно используем DTO. Предположим, нужно отобразить в UI приложения для вызова такси экспериментальную кнопку с двумя свойствами — надписью на кнопке и ориентировочным временем ожидания такси. Тогда в сетевом слое надо написать примерно такую DTO-модель:

struct OrderButtonExperimentDTO: Decodable { let buttonTitle: String let estimationMinute: Int
}

Правильно написанная модель позволяет разрабатывать разные слои приложения независимо — но нужно следить за актуальностью самой модели на каждом слое. Например, если в коде выше ожидалось не estimationMinute, а estimationMinutes, то клиент не сможет декодировать полученные из сети данные и пользователь не увидит время ожидания. Такую ошибку легко совершить, в n-й раз перепечатывая названия переменных под каждый слой — а разработчики и правда должны рутинно это делать при любом изменении (или расширении) исходного формата данных. Ещё сложнее заметить ошибку на код-ревью.

Поэтому мы решили добавить механизм, который сам бы составлял и переписывал код моделей DTO в зависимости от исходного формата.

Введение

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

Если мы будем использовать описанную выше модель в другом слое, например в UI, то при изменении формата ответа на бэкенде нам придется менять и UI-слой тоже. Этого можно избежать, если использовать разные DTO модели для разных слоев приложения.

Бэкенд по-прежнему присылает указание, что нужно отрисовать кнопку с тайтлом и временем ожидания:

{ "orderButtonExperiment": { "buttonTitle": "Order taxi", "estimationMinutes": 5 }
}

В нашем примере одна DTO-модель, OrderButtonExperimentDTO, предназначена для сетевого слоя, а вторая, OrderButtonExperiment, — для UI:

struct OrderButtonExperimentDTO: Decodable { let buttonTitle: String let estimationMinutes: Int
} struct OrderButtonExperiment: Decodable { let title: String
} protocol Convertor { func getExperiment(from dto: OrderButtonExperimentDTO) -> OrderButtonExperiment
}

Если посмотреть на написание модели для сетевого слоя, то она действительно довольно рутинная: надо просто повторить в коде то, что возвращает нам бекенд. Если ваш бекенд меняется часто или постоянно добавляются новые endpoint’ы, то вам надо постоянно менять DTO-модели, рискуя ошибиться. Кодогенерация сводит вероятность ошибок к нулю.

Статья будет интересна тем iOS-разработчикам, кто часто пишет или изменяет DTO-модели для работы с сетью в своих проектах. Обычно это быстрорастущие приложения, стартапы, а также большие сервисы, в которых постоянно появляется новая функциональность. Мы поговорим о том, как важно договариваться с бэкендом и как ускорить написание boilerplate-кода.

Начинаем

Приложение Яндекс Go изначально было написано на Objective-C и сейчас в процессе переезда на Swift. Оно постоянно растёт и меняется, поэтому разработчики регулярно решают задачи получения данных из новых endpoint’ов, а также модификации уже существующих ответов бэкенда.

Исторически сетевой слой приложения использовал библиотеку RestKit для работы с запросами, а также для маппинга данных из JSON в DTO.

Маппинг на RestKit имел ряд особенностей. Не было возможности указать обязательность полей, все они были nullable. Ещё одной особенностью было то, что RestKit «закрывал глаза» на типы данных, и в случае, если не мог привести к указанному в DTO типу данных, оставлял property равным nil. Также приходилось писать отдельно от модели обработку snake case/camel case. При взгляде на модель не было возможности понять, какой же ответ мы ожидаем получить.

Пример описания DTO-модели в RestKit:

@interface YXTPassenger : NSObject
@property (nonatomic, copy) NSNumber *userID;
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@end RKObjectMapping *mapping = [RKObjectMapping mappingForClass:[YXTPassenger class]];
[mapping addAttributeMappingsFromDictionary:@{ @"user_id": @"userID", @"first_name": @"firstName", @"last_name": @"lastName"
}];

Мы решили отказаться от RestKit и перевести сетевой слой на работу с URLSession, так как от RestKit мы использовали только маппинг. Для маппинга данных было решено использовать Decodable-модели.

struct Passenger: Decodable { let userId: Int let avatarURL: URL? let name: String
}

Переписав DTO-модели на Decodable, мы столкнулись с целым рядом проблем:

  1. Обязательность полей. Из кода не понятно, какие поля в ответах бэкенда должны быть обязательными, а какие нет. Это приводило к тому, что все поля приходилось делать Optional. А когда к нам приходили коллеги и спрашивали, какой минимальный набор полей в ответе бэкенда нам необходим, мы погружались в долгое изучение кода.
  2. Некорректность типов. Swift — строго типизированный язык, и если в модели заявлено, что поле должно быть Int, а в ответе приходит строка с числом, например «5», то Decoding всей модели фейлится, независимо от того, является поле обязательным или нет.
  3. Ошибка при декодировании вложенных объектов. Если при декодинге вложенной модели происходила ошибка (например, не пришло non-optional-поле), то это приводило к фейлу декодинга не только текущей, но и всех родительских моделей.

Например, у нас есть модель заказа:

struct Order: Decodable { let orderId: Int let carNumber: String let carPark: Park? let carType: CarType?
} struct Park: Decodable { let parkId: Int let name: String let address: String?
} enum CarType: String, Decodable { case .minivan case .sedan
}

И мы получаем вот такой JSON:

{ "order": { "order_id"": 1, "car_number": "AB123C77RU", "car_park": { "park_id": 1 }, "car_type": "sedan" }
}

В процессе декодинга модели Park произойдёт ошибка, так как не пришло обязательное поле name, и мы зафейлим и родительскую модель. Причём независимо от того, было ли поле в родительской модели Optional или нет. Если говорить простыми словами, то мы не показали пользователю заказ только потому, что не смогли распарсить данные таксомоторного парка — звучит не очень.

Хорошо бы не фейлить decoding всех моделей данных из-за необязательных полей. Для решения этой проблемы мы использовали Property Wrapper. Подробнее о Property Wrapper’ах можно почитать в официальной документации.

Property Wrapper позволял перехватывать ошибки Decoding’а, устанавливать Optional значения в nil, и логировать ошибки. Его использование позволило не реализовывать метод init(from: Decoder) для каждой DTO-модели, а просто указывать его для всех не Optional-полей.

Пример Property Wrapper:

public protocol DecodingDefaultValueProviding where Self: Decodable { static var decodingDefaultValue: Self { get }
} @propertyWrapper
public struct SafeOptionalDecodable<T: DecodingDefaultValueProviding?>: Decodable { public let wrappedValue: T public init( wrappedValue: T ) { self.wrappedValue = wrappedValue } public init( from decoder: Decoder ) throws { let container = try decoder.singleValueContainer() do { wrappedValue = try container.decode(T.self) } catch { // log error wrappedValue = T.decodingDefaultValue } }
} extension Optional: DecodingDefaultValueProviding where Wrapped: Decodable { public static var decodingDefaultValue: Self { return nil }
}

При написании SafeOptionalDecodable мы черпали вдохновение здесь и здесь.

Вот так бы выглядела наша DTO из прошлого примера:

struct Order: Decodable { let orderId: Int let carNumber: String @SafeOptionalDecodable var carPark: Park? @SafeOptionalDecodable var carType: CarType?
} struct Park: Decodable { let parkId: Int let name: String @SafeOptionalDecodable var address: String?
} enum CarType: String, Decodable { case .minivan case .sedan
}

Из минусов такого решения стоит отметить, что let сменился на var, но это не так страшно.

Дальше мы задумались, а что если в обещанном Enum бэкенд пришлёт не то значение, что мы ожидаем. Решили, что такие ситуации мы будем обрабатывать на уровне конвертации DTO-моделей в доменные, и уже там принимать решение, что с такой моделью делать.

Для этого написали обёртку, которую назвали CodableEnum.

public enum CodableEnum<T: RawRepresentable>: Codable where T.RawValue: Codable { case decoded(T) case undefined(T.RawValue) private enum CodingKeys: String, CodingKey { case decoded case undefined } public init( from decoder: Decoder ) throws { let rawValue = try decoder.singleValueContainer().decode(T.RawValue.self) guard let value = T(rawValue: rawValue) else { self = .undefined(rawValue) return } self = .decoded(value) } public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case let .decoded(value): try container.encode(value.rawValue) case let .undefined(value): try container.encode(value) } }
} public enum CodableEnumError: Error { case undefinedValue(Any)
}

Добавим поддержку CodableEnum в модель из примера, описанного выше:

struct Order: Decodable { let orderId: Int let carNumber: String @SafeOptionalDecodable var carPark: Park? @SafeOptionalDecodable var carType: CodableEnum<CarType>?
} enum CarType: String, Codable { case .minivan case .sedan
}

Предположим, мы получим в ответе бэкенда неподдерживаемое значение для CarType, например:

{ "order": { "order_id"": 1, "car_number": "AB123C77RU", "car_park": { "park_id": 1, "name": "Таксопарк #1" }, "car_type": "bus" }
}

Тогда мы всё равно распарсим DTO-модель, а в конвертере можно будет написать вот так:

func makeDomain(from result: CodableEnum<CarType>) -> DomainModel? { switch result { case let .decoded(carTypeValue): // make domain model from CarType case let .undefined(invalidValue): // make desicion about invalid value return nil }
}

Такой подход позволяет нам добавить логирование ошибок при неправильном парсинге и настроить на них мониторинги. Для логирования мы используем AppMetrica, а для мониторингов настроили DataLens. Также у нас появилась возможность принимать на каждом из этапов решение о том, что и как мы хотим делать с DTO-моделями.

Сумев побороть проблемы при переезде на Swift- и Codable-модели, добавив логирование и мониторинги, мы столкнулись с вопросом обязательности полей.

При взгляде на модели было непонятно, где и какие поля обязательны, какие данные мы можем ожидать и как правильно интерпретировать ответы бэкенда. Мы могли бы сами ответить на эти вопросы, но это было бы не совсем корректно, так как бэкенд был единственным источником правды. Стало ясно, что нам нужны общие правила игры, и мы пошли к командам разработки бэкенда, чтобы выработать общие договоренности по валидации ответов.

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

Например, у нас есть какая-то функциональность в заказе:

struct Order: Decodable { let orderId: Int @SafeOptionalDecodable var someFeature: SomeFeature?
} struct SomeFeature: Decodable { // some data let someValue: String
}

Таким образом, мы сохранили честную обязательность полей внутри SomeFeature для того, чтобы понимать без чего она работать не может, но, в то же время, сделали её необязательной для парсинга всего заказа.

Следующая договорённость была о том, что ребята из бэкендовых команд будут предоставлять схемы данных для ответов endpoint’ов в формате YML-схем.

Согласно Википедии, YML — это язык для хранения информации в понятном человеку формате.

Пример YML-схемы для нашего ответа выглядел бы так:

swagger: '2.0'
definitions: order: additionalProperties: false description: Заказ required: - order_id - car_number properties: order_id: type: integer car_number: type: string car_park: $ref: '#/definitions/park' car_type: type: string enum: - minivan - sedan park: additionalProperties: false description: Парк required: - park_id - name properties: park_id: type: integer name: type: string address: type: string

Имея на руках схемы для ответов бэкенда, мы с лёгкостью можем расставить все необходимые нам признаки обязательности. Мы начали сохранять такие YML-схемы в отдельный git-репозиторий. И договорились что все изменения схем будут проходить через пул-реквесты. А в ревью этих пул-реквестов будут участвовать как бэкенд, так и клиентские разработчики. Обсуждать схемы для новых endpoint в формате пул-реквестов оказалось довольно удобно! И как позитивный побочный эффект, мы начали формировать точное описание протоколов взаимодействия между бэкендом и клиентами. Из минусов я бы выделил, что если один из участников перестаёт следовать этому подходу, то схемы теряют актуальность, а это приводит к расхождению в ответе и парсинге данных.

Спустя какое-то время мы поняли, что нет никакой нужды писать boilerplate-код для таких моделей, когда у нас уже есть её формализованное описание, которое надо только транслировать в Swift. Посмотрев на уже существующие тулзы для кодогенерации под Swift, мы обнаружили, что их не так уж и много, всего две: офицальная SwaggerCodegen и неофициальная SwagGen. Взглянув на SwagGen мы поняли, что он обновляется довольно вяло, хотя со своей задачей справляется. Поэтому решили остановится на официальном SwaggerCodegen, который довольно оперативно добавляет поддержку новых версий Swift.

Для установки воспользуемся brew и выполним в консоли brew install swagger-codegen. Из коробки SwaggerCodegen умеет генерировать много чего, но мы будем использовать его для генерации исключительно DTO-моделей. Принцип работы генератора довольно прост. У него есть шаблоны, описанные в разметке mustache, генератор берёт YML и подставляет поля в шаблоны. А значит, нам надо их поправить. Для этого скачиваем себе шаблоны. Мы ведь iOS-разработчики, поэтому берём шаблоны для Swift здесь и скачиваем их себе. Нас интересуют следующие шаблоны: model.mustache, modelEnum.mustache, modelOВыbject.mustache, modelArray.mustache, modelInlineEnumDeclaration.mustache.

Основной файл, который будет описывать большинство ваших моделей — modelObject.mustache, поэтому правим его. Поскольку оформление кода в каждом проекте своё, не буду описывать само редактирование шаблонов. Наши шаблоны выглядят вот так. Вы можете настроить их под свой проект.

У нас есть YML и шаблоны кода, давайте попробуем сгенерировать код! Для этого выполним команду:

swagger-codegen generate -l swift5 --model-name-suffix Result -Dmodels -i ~/endpoint_schema.yml -t ~/templates/ -o ~/DTO/

Давайте рассмотрим параметры.

-l swift5 — всё просто, указываем язык, нас интересует Swift5, хотя поддерживаются и другие версии.

--model-name-suffix Result — у нас в проекте принято, что все DTO-модели имеют в названии суффикс Result.

-Dmodels — задаём, что нам надо сгенерировать только модели данных, а не весь сетевой слой.

-i ~/endpoint_schema.yml — путь для YML-схемы.

-t ~/templates/ — путь к нашим шаблонам, которые мы скачали и поправили. Если не указывать, то будут взяты шаблоны по умолчанию.

-o ~/DTO/ — путь, куда сложить сгенерированный код.

Запускаем и видим, что код сгенерировался. Отлично! Пусть это не самая короткая команда для запуска, но всегда можно сделать к ней alias. Сгенерированный код будет лежать по указанному пути в папке SwaggerClient/Classes/Swaggers/Models.

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

К сожалению, работа со схемами в Swift не поддержана. Поиск по интернету привёл нас к утилите PyYaml, которая может прочитать схему и превратить её в словарь. Утилита написана на Python. Не беда, написали на Питоне простой скрипт, который модифицировал YML-схемы. Далее просто вызываем скрипт из нашей обёртки и, вуаля, невалидные YML-схемы стали валидными.

Прогнав несколько десятков YML-схем и посмотрев на сгенерированный код, мы поняли, что не всё можно кастомизировать при помощи параметров в SwaggerCodegen. Например, одно из правил, что зарезервированные слова в Swift помечаются префиксом «_», то есть поле id в сгенерированном коде будет выглядеть как «_id».

Посмотрев на это, мы поняли, что не хотим заниматься разбором кода самого SwaggerCodegen, написанного на Java. Вместо этого мы добавили скрипт постобработки сгенерированного кода, где заменяем нейминг, который нас не устраивает. Решили это при помощи простого bash-скрипта и команды sed. После всех преобразований и генерации кода мы дополнительно прогоняем линтер и форматер, чтобы выровнять наш код с code style проекта.

Следующим шагом стала поддержка гетерогенных коллекций в кодогенерации. Мы столкнулись с тем, что в нашем коде много гетерогенных коллекций, которые мы используем для построения UI или описания логики. Возьмем для примера простой YML:

CollectionList: additionalProperties: false required: - animals properties: animals: type: array items: oneOf: - $ref: '#/components/schemas/Cat' - $ref: '#/components/schemas/Dog'

Для него мы хотели бы на выходе получать вот такой сгенерированный код:

struct CollectionList: Decodable { let animals: [OneOf<Cat, Dog>]
} enum OneOf<T1: Decodable, T2: Decodable>: Decodable { case value1(T1) case value2(T2) case undefind(Any)
}

Для этого пришлось написать ещё генерацию OneOf-типов, которые смотрели на задаваемый разделитель в ответе и декодировали тип по нему.

Поскольку у нашего скрипта появилось достаточно большое количество зависимостей (Python, SwaggerCodegen и так далее), мы решили делать его установку через brew.

Так какие плюсы нам дал такой подход к работе с DTO?

  1. Научились договариваться с бэкендом о формате данных «на берегу» и обзавелись формализованным описанием ответов.
  2. Получили формализованные в терминах Swift ответы.
  3. Приобрели логирование ошибок парсинга данных (что очень нам помогло).
  4. Сократили написание рутинного кода и сконцентрировались на более важных аспектах задач.

Но были и минусы:

  1. Без правок кода swagger-codegen не все пожелания к коду настраиваются легко.
  2. Написание YML-схем заранее — довольно трудоёмкий процесс.

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

Сейчас мы начали переводить все DTO-модели наших экспериментов на кодогенерацию. Продолжим её развивать, в планах — добавить поддержку кросс-ссылок на типы из разных файлов, сделать единое хранилище схем со скачиванием из скрипта. А также настроить мониторинги и алерты на невалидные ответы.

На GitHub есть код из статьи, шаблоны и несколько примеров для запуска.

Читайте так же: