Хабрахабр

Кодогенерация в Uber

Каждый из нас делает рутинную работу. Каждый пишет boilerplate код. Зачем? Не лучше ли автоматизировать данный процесс и работать только над интересными задачами? Читай эту статью, если хочешь, чтобы такую работу за тебя делал комп.

image

Я работаю в команде, которая называется «mobile platform». Данная статья написана на основе расшифровки доклада Зака Свирса (Zack Sweers), разработчика мобильных приложений Uber, с которым он выступил на конференции MBLT DEV в 2017 году.

В Uber около 300 разработчиков мобильных приложений. В основном мы работаем над внутренними фреймворками, библиотеками, архитектурами и так далее. Работа моей команды заключается в том, чтобы максимально упростить и улучшить процесс разработки мобильных приложений. Это может быть завтра, а может быть в следующем месяце или даже году. Из-за большого штата нам приходится делать масштабные проекты, которые понадобятся нашим инженерам в будущем.

Кодогенерация для автоматизации

Мне хотелось бы продемонстрировать ценность процесса кодогенерации, а также рассмотреть несколько практических примеров. Сам процесс выглядит примерно так:

FileSpec.builder("", "Presentation") .addComment("Code generating your way to happiness.") .addAnnotation(AnnotationSpec.builder(Author::class) .addMember("name", "%S", "Zac Sweers") .useSiteTarget(FILE) .build()) .build()

Это пример использования Kotlin Poet. Kotlin Poet — библиотека с хорошим API, которая генерирует Kotlin-код. Итак, что же мы здесь видим?

  1. FileSpec.builder создаёт файл с именем “Presentation”.
  2. .addComment() — добавляет комментарий в сгенерированный код.
  3. .addAnnotation() — добавляет аннотацию с типом Author.
  4. .addMember() — добавляет переменную “name” с параметром, в нашем случае это “Zac Sweers”. %S — тип параметра.
  5. .useSiteTarget() — устанавливает SiteTarget.
  6. .build() — завершает описание кода, который будет генерироваться.

После кодогенерации получается следующее:

Presentation.kt
// Code generating your way to happiness.
@file:Author(name = "Zac Sweers")

Результат кодогенерации — файл с названием, комментарием, аннотацией и именем автора. Сразу возникает вопрос: «Зачем мне нужно генерировать этот код, если я могу сделать это в пару простых действий?» Да, вы правы, но что, если мне нужна тысяча таких файлов с различными вариантами конфигураций? Что произойдёт, если мы начнём изменять значения в этом коде? Что, если у нас есть множество презентаций? Что, если нам предстоит множество конференций?

conferences .flatMap .onEach { (presentationName, comment, author) -> FileSpec.builder("", presentationName) .addComment(comment) .addAnnotation(AnnotationSpec.builder(Author::class) .addMember("name", "%S", author) .useSiteTarget(FILE) .build()) .build() }

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

Кодогенерация без ошибок

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

Есть класс Person: Рассмотрим простой пример.

class Person(val firstName: String, val lastName: String)

Допустим, мы хотим добавить к нему сериализацию в JSON. Делать это будем с помощью библиотеки Moshi, так как она достаточно проста и отлично подходит для демонстрации. Создаём PersonJsonAdapter и наследуемся от JsonAdapter с параметром типа Person:

class Person(val firstName: String, val lastName: String) class PersonJsonAdapter : JsonAdapter<Person>() {
}

Далее реализуем метод fromJson. Он предоставляет reader для считывания информации, которая в конце будет возвращена в Person. Затем заполняем поля с именем и фамилией и получаем новое значение Person:

class Person(val firstName: String, val lastName: String) class PersonJsonAdapter : JsonAdapter<Person>() { override fun fromJson(reader: JsonReader): Person? { lateinit var firstName: String lateinit var lastName: String return Person(firstName, lastName) }
}

Далее смотрим данные в формате JSON, проверяем их и заносим в нужные поля:

class Person(val firstName: String, val lastName: String) class PersonJsonAdapter : JsonAdapter<Person>() { override fun fromJson(reader: JsonReader): Person? { lateinit var firstName: String lateinit var lastName: String while (reader.hasNext()) { when (reader.nextName()) { "firstName" -> firstName = reader.nextString() "lastName" -> lastName = reader.nextString() } } return Person(firstName, lastName) }
}

Это сработает? Да, но есть нюанс: внутри JSON должны содержаться объекты, которые мы считываем. Для того, чтобы фильтровать лишние данные, которые могут прийти от сервера, добавим ещё одну строчку кода:

class Person(val firstName: String, val lastName: String) class PersonJsonAdapter : JsonAdapter<Person>() { override fun fromJson(reader: JsonReader): Person? { lateinit var firstName: String lateinit var lastName: String while (reader.hasNext()) { when (reader.nextName()) { "firstName" -> firstName = reader.nextString() "lastName" -> lastName = reader.nextString() else -> reader.skipValue() } } return Person(firstName, lastName) }
}

На этом моменте мы успешно обходим область рутинного кода. В этом примере только два поля значения. Однако, в этом коде есть куча различных участков, на которых у вас внезапно может возникнуть сбой. Вдруг мы допустили ошибку в коде?

Рассмотрим другой пример:

class Person(val firstName: String, val lastName: String)
class City(val name: String, val country: String)
class Vehicle(val licensePlate: String)
class Restaurant(val type: String, val address: Address)
class Payment(val cardNumber: String, val type: String)
class TipAmount(val value: Double)
class Rating(val numStars: Int)
class Correctness(val confidence: Double)

Если у вас возникает хотя бы одна проблема через каждые 10 моделей или около того, то это значит, что на этом участке у вас обязательно возникнут сложности. И это тот случай, когда кодогенерация действительно может прийти вам на помощь. Если классов много, без автоматизации работать не получится, потому что опечатки допускают все люди. С помощью кодогенерации все задачи будут выполнены автоматически и без ошибок.

Например, она выдаёт информацию о коде или подсказывает, если что-то идёт не так. У кодогенерации есть и другие преимущества. Если вы используете генерируемый код, то сможете увидеть, как действительно будет выглядеть рабочий код. Кодогенерация будет полезна на стадии тестирования. Вы даже можете запускать кодогенерацию во время тестов, чтобы упростить себе работу.

Вывод: стоит рассмотреть кодогенерацию в качестве возможного решения для избавления от ошибок.

Теперь рассмотрим программные инструменты, которые помогают с кодогенерацией.

Инструменты

  1. Библиотеки JavaPoet и KotlinPoet для Java и Kotlin соответственно. Это эталоны кодогенерации.
  2. Шаблонизация. Популярный пример шаблонизации для Java — Apache Velocity, а для iOS — Handlebars.
  3. SPI — Service Processor Interface. Он встроен в Java и позволяет создать и применить интерфейс, а затем объявить его в JAR. Когда программа выполняется, вы можете получить все готовые реализации интерфейса.
  4. Compile Testing — библиотека от Google, которая помогает проводить тестирование компиляции. В рамках кодогенерации это означает: «Вот то, что я ожидал, а вот то, что я в итоге получил». Запустится компиляция в памяти, а затем система сообщит вам, был ли завершён этот процесс или какие ошибки возникли. Если компиляция была завершена, вам будет предложено сравнить результат с вашими ожиданиями. Сравнение проводится на основе скомпилированного кода, поэтому не стоит беспокоиться о таких вещах, как форматирование кода или о чем-то ещё.

Инструменты для сборки кода

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

  1. Annotation Processing (Обработка аннотаций) — вы можете написать аннотации в коде и запросить у программы дополнительную информацию о них. Компилятор выдаст информацию ещё до того, как закончит работать с исходным кодом.
  2. Gradle — система сборки приложений со множеством хуков (хук — перехват вызова функций) в её жизненном цикле сборки кода. Она широко применяется при разработке на Android. Также она позволяет применить кодогенерацию к исходному коду, который не зависит от текущих исходников.

Теперь рассмотрим несколько примеров.

Butter Knife

Butter Knife – это библиотека, которую разработал Jake Wharton. Он является достаточно известной фигурой в сообществе разработчиков. Библиотека очень популярна среди Android-разработчиков, потому что она помогает избежать большого количества рутинной работы, с которой сталкивается практически каждый.

Обычно мы инициализируем view таким образом:

TextView title;
ImageView icon; void onCreate(Bundle savedInstanceState) { title = findViewById(R.id.title); icon = findViewById(R.id.icon);
}

С помощью Butterknife это будет выглядеть так:

@BindView(R.id.title) TextView title;
@BindView(R.id.icon) ImageView icon; void onCreate(Bundle savedInstanceState) { ButterKnife.bind(this);
}

И мы легко сможем добавлять любое количество view, при этом метод onCreate не будет обрастать boilerplate кодом:

@BindView(R.id.title) TextView title;
@BindView(R.id.text) TextView text;
@BindView(R.id.icon) ImageView icon;
@BindView(R.id.button) Button button;
@BindView(R.id.next) Button next;
@BindView(R.id.back) Button back;
@BindView(R.id.open) Button open; void onCreate(Bundle savedInstanceState) { ButterKnife.bind(this);
}

Вместо того, чтобы каждый раз вручную делать эту привязку, вы попросту добавляете в эти поля аннотации @BindView, а также идентификаторы (ID), за которыми они закреплены.

Она также имеет прекрасную масштабируемость для новых данных. В Butter Knife классно то, что она проанализирует код и сгенерирует вам все его аналогичные участки. Эта библиотека также отлично подходит для удаления данных. Поэтому если появляются новые данные, нет необходимости снова применять onCreate или отслеживать что-либо вручную.

Поиск view происходит путём распознавания кода, и этот процесс выполняется на стадии обработки аннотаций. Итак, как же выглядит эта система изнутри?

У нас есть вот такое поле:

@BindView(R.id.title) TextView title;

Судя по этим данным, они применяются в некой FooActivity:

// FooActivity
@BindView(R.id.title) TextView title;

У неё имеется свое значение (R.id.title), которое выступает в роли целевого объекта. Обратите внимание, что во время обработки данных этот объект становится постоянным значением внутри системы:

// FooActivity
@BindView(2131361859) TextView title;

Это нормально. Это то, к чему Butter Knife должна иметь доступ в любом случае. В качестве типа имеется компонент TextView. Само поле называется title. Если мы, например, сделаем из этих данных класс-контейнер, то получится что-то вроде этого:

ViewBinding( target = "FooActivity", id = 2131361859, name = "title", type = "field", viewType = TextView.class
)

Итак, все эти данные можно без труда получить во время их обработки. Это также очень похоже на то, чем занимается Butter Knife внутри системы.

В результате у нас генерируется вот такой класс:

public final class FooActivity_ViewBinding implements Unbinder { private FooActivity target; @UiThread public FooActivity_ViewBinding(FooActivity target, View source) { this.target = target; target.title = Utils.findRequiredViewAsType(source, 2131361859, // R.id.title "field 'title'", TextView.class); }
}

Здесь мы видим, что все эти кусочки данных собираются воедино. В итоге, мы имеем целевой класс ViewBinding из java-библиотеки Underscore. Внутри, эта система устроена таким образом, что каждый раз при создании экземпляра класса, она тут же выполняет всю эту привязку к той информации (коду), который вы сгенерировали. И всё это предварительно статически генерируется во время обработки аннотаций, а значит, это технически правильно.

Вернёмся к нашему программному конвейеру:

А затем во время выполнения метода bind мы производим идентичный поиск одного и того же класса простым способом: мы берём его название и дописываем ViewBinding в конце. Во время обработки аннотации система считывает эти аннотации и генерируется класс ViewBinding. Сам по себе участок с ViewBinding в процессе обработки переписывается в указанную область при помощи JavaPoet.

RxBindings

Сама по себе RxBindings не отвечает за кодогенерацию. Она не занимается обработкой аннотаций и не является плагином Gradle. Это обычная библиотека. Она предоставляет статические фабрики, основанные на принципе реактивного программирования для Android API. Это значит, что, например, если у вас есть setOnClickListener, то появится метод для клика, который будет возвращать поток(Observable) событий. Он выступает в роли моста (шаблон проектирования).

Но на самом деле в RxBinding есть кодогенерация:

Это означает, что всё это на самом деле создается путём кодогенерации. В этой директории под названием buildSrc есть задача Gradle, которая называется KotlinGenTask. У неё также есть артефакты Kotlin, которые содержат функции-расширения для всех целевых типов. RxBinding имеет реализации на Java. Например, вы можете сгенерировать все функции-расширения Kotlin, и вам не придётся контролировать их по отдельности. И всё это очень жёстко подчиняется правилам.

Как это выглядит на самом деле?

public static Observable<Object> clicks(View view) { return new ViewClickObservable(view);
}

Перед вами вполне классический метод RxBinding. Здесь возвращаются Observable объектов. Метод называется clicks. Работа с click-событиями происходит «под капотом». Опустим лишние фрагменты кода для сохранения читабельности примера. В Kotlin это выглядит следующим образом:

fun View.clicks(): Observable<Object> = RxView.clicks(this)

Эта extension-функция возвращает Observable объектов. Во внутренней структуре программы она напрямую вызывает привычный для нас Java-интерфейс. В Kotlin придётся поменять это на тип Unit:

fun View.clicks(): Observable<Unit> = RxView.clicks(this)

То есть в Java это выглядит так:

public static Observable<Object> clicks(View view) { return new ViewClickObservable(view);
}

А так — Kotlin-код:

fun View.clicks(): Observable<Unit> = RxView.clicks(this)

У нас есть класс RxView, в котором содержится данный метод. Мы можем подставить соответствующие фрагменты данных в атрибут target, в атрибут name с названием метода и в тип, который мы расширяем, а также в тип возвращаемого значения. Всей этой информации будет достаточно, чтобы приступить к написанию этих методов:

BindingMethod( target = "RxView", name = "clicks", type = View.class, returnType = "Observable<Unit>"
)

Теперь мы сможем напрямую подставить эти фрагменты в сгенерированный код на Kotlin внутри программы. Вот что получится в итоге:

fun View.clicks(): Observable<Unit> = RxView.clicks(this)

Service Gen

Над Service Gen мы работаем в Uber. Если вы работаете в компании и имеете дело с общими характеристиками и общим программным интерфейсом как для бэкенда, так и для и клиентской стороны, то вне зависимости от того, разрабатываете ли вы Android, iOS или веб-приложения, нет смысла вручную создавать модели и сервисы для работы команды.

Она обрабатывает аннотации, анализирует данные и генерирует хеш-код из двух строк, метод equals() и другие реализации. Мы используем библиотеку AutoValue от Google для моделей объектов. Она также отвечает за поддержку расширений.

У нас имеется объект типа Rider:

@AutoValue
abstract class Rider { abstract String uuid(); abstract String firstName(); abstract String lastName(); abstract Address address();
}

У нас есть строки с ID, firstname, lastname и address. Для работы с сетью мы используем библиотеки Retrofit и OkHttp и в качестве формата данных — JSON. Также мы используем RxJava для реактивного программирования. Так выглядит наш сгенерированный API сервис:

interface UberService { @GET("/rider") Rider getRider()
}

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

Что и как Uber делает сегодня

Последняя задача моей команды — создать текстовый редактор с нуля. Мы решили больше не писать вручную код, который впоследствии попадает в сеть, поэтому мы используем Thrift. Это что-то вроде языка программирования и протокола одновременно. Uber использует Thrift как язык для технических характеристик.

struct Rider { 1: required string uuid; 2: required string firstName; 3: required string lastName; 4: optional Address address;
}

В Thrift мы определяем API-контракты между бэкендом и клиентской стороной, а затем просто генерируем соответствующий код. Чтобы парсить данные пользуемся библиотекой Thrifty, а для кодогенерации — JavaPoet. В конце генерируем реализации с помощью AutoValue:

@AutoValue
abstract class Rider { abstract String uuid(); abstract String firstName(); abstract String lastName(); abstract Address address();
}

Всю работу мы выполняем в JSON. Существует расширение под названием AutoValue Moshi, которое можно добавить в AutoValue-классы при помощи статического jsonAdapter метода:

@AutoValue
abstract class Rider { abstract String uuid(); abstract String firstName(); abstract String lastName(); abstract Address address(); static JsonAdapter<Rider> jsonAdapter(Moshi moshi) { return new AutoValue_Rider.JsonAdapter(moshi); }
}

Thrift помогает и в разработке сервисов:

service UberService { Rider getRider()
}

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

service UberService { Rider getRider() (path="/rider")
}

После кодогенерации мы получим наш сервис:

interface UberService { @GET("/rider") Single<Rider> getRider();
}

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

struct Rider
struct City
struct Vehicle
struct Restaurant
struct Payment
struct TipAmount
struct Rating
// And 6000 more

На данный момент у нас есть около 5-6 приложений. И в них множество сервисов. И все проходят через один и тот же программный конвейер. Писать всё это вручную было бы безумием.

Предлагать сотрудникам проводить десериализацию через переписывание кода через DI-граф — тоже сомнительно. В сериализации в JSON “adapter” не нужно регистрировать в Moshi, а если используете JSON — то не нужно регистрировать в JSON.

Мы можем это сгенерировать, так как знаем об этих типах, до того, как произошла компиляция. Но мы работаем с Java, поэтому можем воспользоваться паттерном Factory, который генерируем через библиотеку Fractory. Fractory генерирует адаптер вроде этого:

class ModelsAdapterFactory implements JsonAdapter.Factory { @Override public JsonAdapter<?> create(Type type, Set<? extends Annotation> annotations, Moshi moshi) { Class<?> rawType = Types.getRawType(type); if (rawType.isAssignableFrom(Rider.class)) { return Rider.adapter(moshi); } else if (rawType.isAssignableFrom(City.class)) { return City.adapter(moshi); } else if (rawType.isAssignableFrom(Vehicle.class)) { return Vehicle.adapter(moshi); } // Etc etc return null; }
}

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

Система автоматически определит, какие адаптеры нужно выбрать, и вызовет их. Здесь видны уже упомянутые ранее типы с названиями сервисов. У нас есть 6000 таких адаптеров. Но здесь мы сталкиваемся с ещё одной проблемой. Код растянется. Даже если разделить их между собой в рамках одного шаблона, модель “Eats” или “Driver” попадёт в модель “Rider” или окажется в его приложении. Поэтому нужно каким-то образом разделить адаптеры: После определ`нного момента, он не сможет даже поместиться в .dex файл.

В конечном итоге, мы проанализируем код заранее и создадим для него рабочий подпроект, как в Gradle:

Элементы, использующие приложение Rider, теперь зависят от него. Во внутренней структуре эти зависимости становятся зависимостями Gradle. В итоге наша задача будет решена, и всё это будет регулироваться системой сборки кода внутри программы. С его помощью они будут формировать нужные им модели.

Все они компилируются в различные объекты: Но здесь мы сталкиваемся с ещё одной проблемой: теперь у нас n-количество моделей фабрик.

class RiderModelFactory
class GiftCardModelFactory
class PricingModelFactory
class DriverModelFactory
class EATSModelFactory
class PaymentsModelFactory

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

Он содержится в процессе связывания данных. Решение: у нас есть некоторая поддержка в библиотеке Fractory, которая помогает нам одним хитрым приёмом. Вводим метаданные при помощи параметра classpath в Java-архив для их дальнейшего хранения:

class RiderModelFactory // -> json // -> ridermodelfactory-fractory.bin class MyAppGlobalFactory // Delegates to all discovered fractories

Теперь каждый раз, когда нужно задействовать их в приложении, заходим в фильтр директории classpath с этими файлами, а затем извлекаем их оттуда в формате JSON, чтобы узнать, какие из зависимостей доступны.

Как все сочетается друг с другом

Данные оттуда поступают в Thrifty и проходят парсинг. У нас есть Thrift. Она выдаёт файлы в формате Java. Далее они проходят через программу для кодогенерации, которую мы называем Jenga. А во время процесса компиляции происходит обработка аннотаций. Всё это происходит ещё до предварительной стадии обработки или до компиляции. Он также вызывает AutoValue Moshi для обеспечения JSON-поддержки. Наступает очередь AutoValue генерировать реализацию. Всё происходит во время процесса компиляции. В этом участвует и Fractory. Процессу предшествует компонент создания самого проекта, который, в первую очередь, генерирует подпроекты Gradle.

Так, например, здесь есть Gradle, создание шаблонов, AutoValue, JavaPoet для кодогенерации. Теперь, когда вы видите полную картину, вы начинаете замечать инструменты, которые упоминались ранее. Все инструменты не только полезны сами по себе, но и в сочетании друг с другом.

Минусы кодогенерации

Нужно рассказать и о подводных камнях. Самый очевидный минус — раздувание кода и потеря контроля над ним. Например, Dagger занимает примерно 10% от всего кода в приложении. Модели занимают значительно большую долю — около 25%.

Нам предстоит провести некий статистический анализ кода и понять, какие участки действительно задействованы в работе. В Uber мы пытаемся решить проблему, выбрасывая ненужный код. Когда выясним это, сможем произвести некие преобразования и посмотреть, что из этого выйдет.

Это поможет ускорить установку и работу приложений, а также сэкономить нам деньги. Мы рассчитываем сократить количество генерируемых моделей примерно на 40%.

Как кодогенерация влияет на сроки разработки проекта

Кодогенерация, безусловно, ускоряет разработку, но сроки зависят и от инструментов, которые использует команда. Например, если вы работаете в Gradle, скорее всего, вы это делаете в размеренном темпе. Дело в том, что Gradle генерирует модели один раз в день, а не тогда, когда хочет разработчик.

Узнать больше о разработке в Uber и других топовых компаниях

28 сентября в Москве стартует 5-я Международная конференция мобильных разработчиков MBLT DEV. 800 участников, топовые спикеры, квизы и задачки для тех, кому интересная разработка для Android и iOS.

Стать участником или партнёром MBLT DEV 2018 можно на сайте конференции. Организаторы конференции — e-Legion и РАЭК.

Видеозапись доклада

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»