Разрабатываемые приложения со временем имеют свойство увеличиваться и обрастать новой функциональностью. Как правило, сетевой слой при этом также разрастается, порой до немыслимых размеров. Поддерживать все это руками в какой-то момент становится довольно сложно. Мы отлично прочувствовали на себе все «прелести» такого подхода и в определенный момент решили обратить взор на возможности автоматической генерации. Поиски увенчались успехом, и теперь при разработке мы не пишем ни строчки сетевого кода сами.

Я Сергей Шевцов, Android-разработчик в компании KODE. В статье расскажу, как мы работаем с сетевым слоем в наших приложениях. Возможно, это поможет вам сэкономить многие часы написания сетевого кода.

Давайте сразу перейдем к практике и для примера напишем небольшое приложение для просмотра списка популярных фильмов. Для этого будем использовать TMDB API.

Определимся, что мы хотим получить в итоге:

  1. Готовый сетевой слой на retrofit2, корутинах и kotlin-сериализации.

  2. Единственное действие, необходимое для генерации кода — запуск gradle-таска.

  3. Возможность кастомизировать генерацию кода под нужды проекта.

Для генерации понадобятся две вещи:

  1. Файл спецификации в формате OpenApi.

  2. Подключение и настройка плагина генерации.

Файл спецификации

OpenApi - спецификация представляет собой json/yaml файл, описывающий все, что касается сетевого слоя. Писать и редактировать его руками нравится далеко не всем. К счастью, существуют различные редакторы.

Очень популярный редактор спецификаций, которым в том числе пользуемся и мы — Stoplight. TMDB API, кстати, тоже описано с помощью Stoplight. По какой-то причине кнопки экспорта спецификации на сайте на момент написания статьи не работают, поэтому я опишу запросы самостоятельно. Готовый файл можно взять отсюда.

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

Описываем модели:

Описываем запросы:

Экспортируем спеку:

Файл кладем в проект. Я положу в корень модуля app рядом с build.gradle.

Подключение и настройка плагина

Репозиторий плагина можно найти здесь.

Для начала давайте подключим его. Идем в корневой build.gradle и добавляем эту строку:

// ...
plugins {
  // ...
  id "org.openapi.generator" version "6.3.0" apply false
}

Далее подключаем плагин к нужному модулю:

plugins {
  // ...
  id "org.openapi.generator"
}

Синхронизируем проект и можем приступать к настройке плагина. Удобнее всего настраивать его с помощью extension'а openApiGenerate.

  • generatorName — язык, который будет использоваться для генерации.

  • inputSpec — путь к файлу спецификации.

  • additionalProperties — дополнительные настройки, которые можно задать. По стандарту генератор использует okHttp3 и moshi, нам же это не подходит, поэтому мы укажем плагину, что использовать. Документацию по возможным параметрам можно почитать здесь.

openApiGenerate {
  generatorName = "kotlin"
  inputSpec = "${projectDir.path}/movie-db-api.yaml"
  additionalProperties = [
    "library": "jvm-retrofit2",
    "serializationLibrary": "kotlinx_serialization",
    "useCoroutines": "true"
  ]
}

Таких настроек будет достаточно, чтобы генератор создал для нас готовый модуль. Можем запустить таск openApiGenerate и посмотреть, что получится. Сгенерированные файлы можно найти в build/generate-resources.

Если заглянем в MovieServiceApi, то увидим вполне обычный интерфейс retrofit2:

interface MovieServiceApi {
  // …
   @GET("3/movie/{movie_id}")
   suspend fun getMovieDetails(@Path("movie_id") movieId: kotlin.Long, @Query("language") language: kotlin.String? = "en-US"): Response<MovieDetails>

   // …
   @GET("3/movie/popular")
   suspend fun getPopularMovies(@Query("language") language: kotlin.String? = "en-US", @Query("page") page: kotlin.Int? = 1, @Query("region") region: kotlin.String? = null): Response<PopularMovies>

}

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

import org.openapitools.generator.gradle.plugin.tasks.GenerateTask // 1

android {
  // ...
  sourceSets.main.kotlin.srcDirs += "${buildDir.path}/openapi" // 2
  tasks.preBuild {
    dependsOn(tasks.withType(GenerateTask))
  } // 3
}

openApiGenerate {
  // ...
  outputDir = "${buildDir.path}/openapi" // 4
  // ...
}
  1. Добавление импорта класса таска генерации, для использования в п.3.

  2. Добавление в качестве sourceSet'а папки build/openapi. Это нужно, чтобы IDE видела этот sourceSet, иначе будет компилиться, но все файлы будут подсвечены красным.

  3. Делаем preBuild таски зависимыми от тасков генерации. Это будет триггерить генерацию при билде. (Быстрый способ, но есть куда улучшать. Имейте это в виду. По-хорошему лучше делать это перед компиляцией)

  4. Настраиваем директорию вывода для плагина.

Теперь картина после запуска таска генерации следующая:

Все служебные сгенерированные файлы (build.gradle, gradlew.bat и пр.) теперь бесполезны, так как использоваться будут те, что лежат непосредственно у нас в проекте. Давайте настроим правила игнора для плагина. Для этого создадим файл openapi-generator-ignore, где укажем, что плагину генерировать не нужно.

/**/docs/
/**/gradle/wrapper/
/**/org/openapitools/client/infrastructure/
/**/.openapi-generator-ignore
/**/build.gradle
/**/gradlew
/**/gradlew.bat
/**/README.md
/**/settings.gradle

Файл положим туда же, где спека:

Ну и, разумеется, задаем путь к этому файлу в конфигурации таска с помощью параметра ignoreFileOverride. Заодно давайте сразу настроим имена пакетов, задав параметры apiPackage и modelPackage.

openApiGenerate {
  // ...
  ignoreFileOverride = "${projectDir.path}/openapi-generator-ignore"
  apiPackage = "com.example.popularmovies.schema.api"
  modelPackage = "com.example.popularmovies.schema.model"
  // ...
}

Картина после генерации теперь такая:

Правда использовать интерфейс MovieServiceApi у нас не выйдет, так как по умолчанию генератор пытается импортировать сюда дополнительные классы из пакета infrastructure, который до этого мы вписали в игнор-лист.

Вот тут есть небольшая загвоздка — используя генерацию «из коробки», мы становимся зависимы от стандартных шаблонов генерации. Специфика и потребности каждого отдельного проекта могут быть совершенно разными.

К счастью, плагин генерации предоставляет нам возможность написать собственные шаблоны. Описываются они с помощью языка шаблонов mustache. Вот тут можно найти все шаблоны, используемые генератором для языка kotlin. Как-то так, например, выглядит кусочек стандартного шаблона data-класса:

…

/**
 * {{{description}}}
 *
{{#allVars}}
 * @param {{{name}}} {{{description}}}
{{/allVars}}
 */
{{#parcelizeModels}}
@Parcelize
{{/parcelizeModels}}
{{#multiplatform}}{{^discriminator}}@Serializable{{/discriminator}}{{/multiplatform}}{{#kotlinx_serialization}}{{#serializableModel}}@KSerializable{{/serializableModel}}{{^serializableModel}}@Serializable{{/serializableModel}}{{/kotlinx_serialization}}{{#moshi}}{{#moshiCodeGen}}@JsonClass(generateAdapter = true){{/moshiCodeGen}}{{/moshi}}{{#jackson}}{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}{{/jackson}}
{{#isDeprecated}}
@Deprecated(message = "This schema is deprecated.")
{{/isDeprecated}}
{{>additionalModelTypeAnnotations}}
{{#nonPublicApi}}internal {{/nonPublicApi}}{{#discriminator}}interface{{/discriminator}}{{^discriminator}}data class{{/discriminator}} {{classname}}{{^discriminator}} (

{{#allVars}}
{{#required}}{{>data_class_req_var}}{{/required}}{{^required}}{{>data_class_opt_var}}{{/required}}{{^-last}},{{/-last}}

{{/allVars}}

…

Алгоритм примерно такой: берем нужный шаблон, правим под свои нужды, используем. Это непросто, но, к счастью, делать это больше одного раза не придется. Все тонкости описать лаконично я не смогу. Посмотреть мануалы можно здесь. Также там можно немного поиграть с mustache в демке.

Базовый синтаксис примерно такой:

{{#имя переменной}}код для вставки{{/имя переменной}} // 1

{{^имя переменной}}код для вставки{{/имя переменной}} // 2

{{#имя переменной}}{{{имя другой переменной}}}{{/имя переменной}} // 3

За выполнение условия считаем значение true, если переменная логическая, либо наличие переменной, если она другого типа.

  1. Если условие выполняется, то будет сгенерирован указанный код.

  2. Если условие не выполняется, то будет сгенерирован указанный код.

  3. Если переменная есть, то будет вставлено значение из указанной переменной.

Вот здесь можно взять мои файлы шаблонов для генерации кода с использованием retrofit2, корутин и kotlin-сериализации. Создаем папку templates и кладем эти файлы туда.

В конфигурации дописываем:

openApiGenerate {
  // ...
  templateDir = "${projectDir.path}/templates"
  // additionalProperties убираем, т.к. эти переменные теперь не нужны
}

После генерации можем зайти в интерфейс MovieServiceApi и убедиться, что ошибки больше нет:

Зато если зайти в MovieDetails, то можно обнаружить ошибку в release_date.

Связано это с тем, что переменная по умолчанию имеет тип string+date в файле movie-db-api.yaml:

#…
components:
  schemas:
# …
  MovieDetails:
#    …
     release_date:
       type: string
       format: date
#     …

Переменные с таким типом генератор по умолчанию конвертирует в LocalDateTime. У нас есть две пути, как исправить эту ошибку.

Первый — использование String вместо LocalDateTime с помощью typeMappings. Делается это так:

openApiGenerate {
  // ...
  typeMappings = [
    "string+date": "kotlin.String"
  ]
}

Этот путь возможен, если бэк отправляет нам даты как попало, и мы не можем гарантировать, что строка в ответе конвертируется в LocalDateTime без ошибок.

Второй — использование LocalDateTime с сериализатором. Для этого создадим объект LocalDateSerializer и положим его в пакет com.example.popularmovies.data.serializers:

import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

object LocalDateSerializer : KSerializer<LocalDate> {
 override val descriptor: SerialDescriptor =
   PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)

 override fun deserialize(decoder: Decoder): LocalDate {
   return LocalDate.parse(decoder.decodeString(), DateTimeFormatter.ISO_LOCAL_DATE)
 }

 override fun serialize(encoder: Encoder, value: LocalDate) {
   encoder.encodeString(value.format(DateTimeFormatter.ISO_LOCAL_DATE))
 }
}

Теперь нам нужно научить генератор добавлять импорт этого сериализатора в генерируемые дата-классы. Для этого используем additionalProperties, который мы использовали ранее. Добавим туда кастомную переменную serializersPackage.

openApiGenerate {
  // ...
  additionalProperties = [
    "serializersPackage": "com.example.popularmovies.data.serializers"
  ]
}

После этого мы можем спокойно использовать ее в шаблонах:

import kotlinx.serialization.*
{{#serializersPackage}}import {{serializersPackage}}.*{{/serializersPackage}}

// …

Если мы сейчас сгенерируем код, то увидим, что нужный импорт добавлен и ошибки больше нет:

import com.example.popularmovies.schema.model.Genre

import kotlinx.serialization.*
import com.example.popularmovies.data.serializers.*

// ...

@Serializable
public data class MovieDetails (

   // …

   @Serializable(with = LocalDateSerializer::class)
   @SerialName(value = "release_date") val releaseDate: java.time.LocalDate?,

   // …
)

Теперь обратим внимание на переменные budget и revenue — они имеют тип Int, тогда как другие числовые переменные имеют тип Long.

@Serializable
public data class MovieDetails (

   // …

   @SerialName(value = "budget") val budget: kotlin.Int?,

   @SerialName(value = "revenue") val revenue: kotlin.Int?,

   @SerialName(value = "runtime") val runtime: kotlin.Long?,

  // …
)

Бюджет и сборы фильма явно могут быть больше максимального значения Int (2 147 483 647). В чем же тут дело? Если мы заглянем в файл movie-db-api.yaml, то мы увидим, что budget и revenue имеют тип integer+usd, тогда как runtime, например, имеет тип integer+int64.

…
components:
  schemas:
# ...
  MovieDetails:
#    …
     budget:
       type: integer
       format: usd
     revenue:
       type: integer
       format: usd
     runtime:
       type: integer
       format: int64
#   …

Плагин генерации понимает, что для переменной runtime с типом integer+int64 нужно использовать тип Long, а вот формат integer+usd кастомный, и генератор не знает, что с ним делать, поэтому использует стандартный тип для integer - Int. Для начала давайте изменим это стандартное поведение с помощью typeMappings.

openApiGenerate {
  // ...
  typeMappings = [
    "integer": "kotlin.Long"
  ]
}

Теперь по умолчанию для чисел будет использоваться тип Long.

@Serializable
public data class MovieDetails (

   // …

   @SerialName(value = "budget") val budget: kotlin.Long?,

   @SerialName(value = "revenue") val revenue: kotlin.Long?,

   // …
)

А сейчас мы сделаем так, чтобы вместо Long нам приходило значение с типом Usd. Usd — это value класс, который будет использоваться для денег. Положим его в пакет com.example.popularmovies.domain.entity.

import kotlinx.serialization.Serializable

@Serializable
@JvmInline
value class Usd(val amount: Long)

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

openApiGenerate {
  // ...
  typeMappings = [
    "integer": "kotlin.Long",
    "integer+usd": "Usd" // 1
  ]
  importMappings = [
    "Usd": "com.example.popularmovies.domain.entity.Usd" // 2
  ]
}
  1. Указываем, что тип integer+usd будет маппиться в Usd

  2. Указываем путь к классу для типа Usd

Итоговая конфигурация плагина выглядит так:

openApiGenerate {
 generatorName = "kotlin"
 inputSpec = "${projectDir.path}/movie-db-api.yaml"
 outputDir = "${buildDir.path}/openapi"
 ignoreFileOverride = "${projectDir.path}/openapi-generator-ignore"
 apiPackage = "com.example.popularmovies.schema.api"
 modelPackage = "com.example.popularmovies.schema.model"
 templateDir = "${projectDir.path}/templates"
 additionalProperties = [
   "serializersPackage": "com.example.popularmovies.data.serializers"
 ]
 typeMappings = [
   "integer": "kotlin.Long",
   "integer+usd": "Usd"
 ]
 importMappings = [
   "Usd": "com.example.popularmovies.domain.entity.Usd"
 ]
}

После генерации мы видим, переменные budget и revenue теперь имеют тип Usd. Как раз то, что нам было нужно.

@Serializable
public data class MovieDetails (

  // …

   @SerialName(value = "budget") val budget: com.example.popularmovies.domain.entity.Usd?,

   @SerialName(value = "revenue") val revenue: com.example.popularmovies.domain.entity.Usd?,

  // …
)

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

Лайфхак для написания собственных mustache-шаблонов

Чтобы узнать список всех доступных для шаблона параметров, можно заменить весь шаблон на {{{this}}} и запустить генерацию. В сгенерированных файлах будут указаны все параметры из спеки, среди которых можно найти нужные.

Как все работает в процессе разработки у нас

  1. Бизнес-аналитик публикует изменения спецификации в отдельном репозитории (этим также может заниматься бэк).

  2. Изменения проходят ревью разработчика.

  3. Изменения попадают в основную ветку API.

  4. Разработчик обновляет файл спецификации в проекте.

  5. Вносит правки там, где сломались мапперы.

  6. Всё!

Почему это удобно

  • Сетевой слой не нужно писать. Вообще.

  • Аналитик (либо бэк) — единственный источник правды.

  • Нет человеческого фактора. Так как источник правды один, ошибиться можно только при написании спецификации.

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

  • KMM проекты. С помощью плагина можно генерировать мультиплатформенный сетевой слой.

Требования

  • Нужна спека в OpenAPI.

  • Кривой нейминг в спеке — кривой нейминг в коде. У генератора чувства вкуса нет, что вы ему дадите, то он и сгенерирует.

  • Генератор не дружит с анонимными объектами. Все Request-ы и Response-ы должны быть созданы в виде моделей в рамках OpenAPI-спеки. Тут пример того, что они из себя представляют.

  • Ревью спеки гораздо важнее, чем раньше. «Проверю потом, когда буду использовать» не прокатит.

Дополнительно

  • Для всех запросов TMDB нужно в query передавать API key. Инструкция по его получению открывается сразу по ссылке ниже. В проекте API key подставляется с помощью interceptor-а.

  • Генерировать из нескольких файлов спецификаций довольно проблематично, т.к. изначально плагин заточен под генерацию из одного файла. Для решения такой задачи можно создать несколько gradle-тасков под каждый файл спеки или написать плагин, который будет создавать отдельный таск под каждый файл из директории. Важно, чтобы outputDir каждого таска был свой, иначе каждый последующий таск будет ломать кэш предыдущему.

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

Ссылки

Комментарии (1)


  1. quaer
    25.05.2023 09:24
    +1

    OpenApi-спецификация представляет собой json/yaml файл, описывающий все,
    что касается сетевого слоя. Писать и редактировать его руками нравится
    далеко не всем

    Кому не нравится писать на Kotlin/Java, будут писать на птичьем :)