Как устроены аннотации в Kotlin. Часть 2

от автора

Привет! Сегодня с вами Максим Кругликов из Surf Android Team, и мы продолжаем статью об аннотациях в Kotlin, в которой рассмотрим кодовую базу Moshi в качестве примера того, как реальная библиотека использует процессинг аннотаций, рефлексию и lint. В первой мы рассказывали об этих трёх механизмах — рекомендуем посмотреть сначала её.

Введение в Moshi

Moshi — популярная библиотека для парсинга JSON в/из Java или Kotlin-классов. Мы выбрали её для этого примера, потому что это относительно небольшая библиотека, API которой включает в себя несколько аннотаций и использует как процессинг аннотаций, так и рефлексию.

Подключить её можно так:

implementation(“com.squareup.moshi:moshi-kotlin:1.15.0”)

Простейший пример парсинга JSON в экземпляр BookModel:

data class BookModel(  val title: String,  @Json(name = "page_count") val pageCount: Int,  val genre: Genre, ) {  enum class Genre {    FICTION,    NONFICTION,  } }  private val moshi = Moshi.Builder().build() private val adapter = moshi.adapter<BookModel>()   private val bookString = """ {   "title": "Our Share of Night",   "page_count": 588,   "genre": "FICTION" } """   val book = adapter.fromJson(bookString)

Moshi предоставляет несколько аннотаций для настройки того, как классы преобразуются в/из JSON. В примере выше аннотация @Json с параметром name подсказывает адаптеру использовать page_count в качестве ключа в строке JSON, несмотря на то, что поле называется pageCount.

Moshi работает с концепцией классов-адаптеров. Адаптер — это типобезопасный механизм для сериализации определенного класса в строку JSON и десериализации строки JSON обратно в нужный тип. По умолчанию у Moshi есть встроенная поддержка основных типов данных Java, примитивов, коллекций и строк, а также возможность адаптировать другие классы, записывая их поле за полем.

Moshi может генерировать адаптеры либо во время компиляции с помощью процессинга аннотаций, либо во время выполнения программы или приложения с помощью рефлексии, в зависимости от того, какие зависимости мы подключаем. Рассмотрим оба случая.

Moshi с процессингом аннотаций

Чтобы Moshi генерировал классы-адаптеры во время компиляции с помощью процессинга аннотаций, нужно добавить или kapt(“com.squareup.moshi:moshi-kotlin-codegen:1.15.0”) для kapt, или ksp(“com.squareup.moshi:moshi-kotlin-codegen:1.15.0”) для ksp.

Moshi сгенерирует адаптер для каждого класса с пометкой @JsonClass (generateAdapter = true). Например, такого:

@JsonClass(generateAdapter = true) data class BookModel(  val title: String,  @Json(name = "page_count") val pageCount: Int,  val genre: Genre, ) { ... }

Когда приложение собрано, Moshi сгенерирует файл BookModelJsonAdapter в каталоге /build/generated/source/kapt/. Все сгенерированные адаптеры наследуются от JsonAdapter и переопределяют его функции toString(), fromJSON() и toJSON() для работы с конкретным типом.

И теперь при вызове:

private val adapter = moshi.adapter<BookModel>()

Moshi.adapter() вернёт нам сгенерированный BookModelJsonAdapter.

Большая часть логики кодогенерации Moshi находится в AdapterGenerator. AdapterGenerator использует KotlinPoet для создания экземпляра FileSpec с новым классом-адаптером.

Kapt

Для создания процессора аннотаций в kapt необходимо наследоваться от AbstractProcessor. Как Moshi расширяет его в JsonClassCodegenProcessor для обработки аннотации @JsonClass?

Приведенный ниже код, связанный с обработкой класса @Json, скопирован непосредственно из кодовой базы Moshi.

@AutoService(Processor::class) // 1 public class JsonClassCodegenProcessor : AbstractProcessor() {  ...  private val annotation = JsonClass::class.java  ...  // 2  override fun getSupportedAnnotationTypes(): Set<String> = setOf(annotation.canonicalName)  ...  override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {    ...    // 3    for (type in roundEnv.getElementsAnnotatedWith(annotation)) {      ...      val jsonClass = type.getAnnotation(annotation) // 3a       // 3b      if (jsonClass.generateAdapter && jsonClass.generator.isEmpty()) {        // 3c        val generator = adapterGenerator(type, cachedClassInspector) ?: continue        val preparedAdapter = generator          .prepare(generateProguardRules) { … }          .addOriginatingElement(type)          .build()        preparedAdapter.spec.writeTo(filer) // 3d        preparedAdapter.proguardConfig?.writeTo(filer, type) // 3e    }    return false // 4  } }
  1. Необходимо использовать @Autoservice для регистрации JsonClassCodeGenProcessor в компиляторе.

  2. Нужно переопределить функцию getSupportedAnnotationTypes(), чтобы объявить о поддержке нашим процессором аннотаций @JsonClass.

  3. В process() необходимо пройтись по всем элементам TypeElements, помеченным @JsonClass, и для каждого из них:

    1. Получить JsonClass для текущего типа;

    2. Использовать поля generateAdapter и generator из JsonClass, чтобы понять, следует ли генерировать адаптер;

    3. Создать AdapterGenerator для текущего типа;

    4. Записать FileSpec, сгенерированный AdapterGenerator в файл с помощью Filer;

    5. Записать конфигурацию Proguard, сгенерированную AdapterGenerator в файл с помощью Filer.

Вернуть false в конце process(), чтобы указать, что этот процессор не использовал набор TypeElements, переданный в него. Это позволяет другим процессорам также использовать аннотации Moshi.

KSP

Процессоры аннотаций в KSP наследуются от SymbolProcessor. Для KSP также требуется класс, который реализует SymbolProcessorProvider в качестве точки входа для создания экземпляра SymbolProcessor. Давайте посмотрим, как JsonClassSymbolProcessorProvider от Moshi обрабатывает @JsonClass .

@AutoService(SymbolProcessorProvider::class) // 1 public class JsonClassSymbolProcessorProvider : SymbolProcessorProvider {  override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {    return JsonClassSymbolProcessor(environment) // 2  } }  private class JsonClassSymbolProcessor(  environment: SymbolProcessorEnvironment, ) : SymbolProcessor {   private companion object {    val JSON_CLASS_NAME = JsonClass::class.qualifiedName!!  }  ...  override fun process(resolver: Resolver): List<KSAnnotated> {    // 3    for (type in resolver.getSymbolsWithAnnotation(JSON_CLASS_NAME)) {      ...      // 3a      val jsonClassAnnotation = type.findAnnotationWithType<JsonClass>() ?: continue      val generator = jsonClassAnnotation.generator       // 3b      if (generator.isNotEmpty()) continue      if (!jsonClassAnnotation.generateAdapter) continue       try {        val originatingFile = type.containingFile!!        val adapterGenerator = adapterGenerator(logger, resolver, type) ?: return emptyList()// create an AdapterGenerator for the current type        // 3c        val preparedAdapter = adapterGenerator          .prepare(generateProguardRules) { spec ->            spec.toBuilder()              .addOriginatingKSFile(originatingFile)              .build()          }        // 3d        preparedAdapter.spec.writeTo(codeGenerator, aggregating = false)        // 3e        preparedAdapter.proguardConfig?.writeTo(codeGenerator, originatingFile)      } catch (e: Exception) {        logger.error(...)      }    }    return emptyList() // 4  } }
  1. Необходимо спользовать @Autoservice для регистрации JsonClassSymbolProcessorProvider  в компиляторе.

  2. Следует переопределить JsonClassSymbolProcessorProvider.create(), чтобы вернуть экземпляр JsonClassSymbolProcessor.

  3. В process() нужно пройтись по всем KsAnnotated символам, помеченным с помощью @JsonClass, и для каждого из них:

    1. Получить JsonClass для текущего символа.

    2. Использовать поля generateAdapter и generator из JsonClass, чтобы понять, следует ли генерировать адаптер;

    3. Создать AdapterGenerator для текущего типа.

    4. Записать FileSpec, сгенерированный AdapterGenerator в файл с помощью CodeGenerator.

    5. Записывать сгенерированную AdapterGenerator конфигурацию Proguard для текущего типа в файл с помощью CodeGenerator.

  4. Вернуть пустой список в конце process(), чтобы указать, что процессор не оставляет какие-либо символы на более поздние раунды.

Moshi также регистрирует процессор генерации кода класса Json в файле incremental.annotation.processors, чтобы он работал с инкрементальной обработкой.

JsonClassCodegenProcessor и JsonClassCodegenProcessor оказались очень короткими и удобочитаемыми: можно создать очень полезный пользовательский процессор аннотаций без большого количества кода. А поскольку основная часть логики кодогенерации находится в независимом от основного API AdapterGenerator, добавление поддержки KSP в Moshi не потребовало особых дополнительных усилий. Шаги добавления обоих процессоров аннотаций были практически идентичны.

Moshi с рефлексией

Можно добиться такого же поведения при парсинге JSON с помощью рефлексии. Для этого необходимо добавить следующую зависимость:

implementation(“com.squareup.moshi:moshi-kotlin:1.15.0”)

Больше не нужно помечать BookModel с помощью @JsonClass, потому что эта аннотация нужна только для кодогенерации. Вместо этого нужно добавить KotlinJsonAdapterFactory при создании Moshi.

KotlinJsonAdapterFactory — это фабрика адаптеров общего назначения, которая с помощью рефлексии может в рантайме создавать JsonAdapter для любого класса Kotlin.

private val moshi = Moshi.Builder()   .add(KotlinJsonAdapterFactory())   .build()

Теперь когда вызывается Moshi.adapter(), он возвращает адаптер для BookModel, созданный при помощи KotlinJsonAdapterFactory:

private val adapter = moshi.adapter<BookModel>()

При вызове Moshi.adapter<T>() перебирает все доступные адаптеры и фабрики адаптеров, пока не найдет тот, который поддерживает T. Moshi поставляется с несколькими встроенными фабриками, в том числе для примитивов (int, float и других) и enum, но мы можем добавить свои, используя MoshiBuilder().add(). В этом примере KotlinJsonAdapterFactory — единственная добавленная кастомная фабрика.

Вот, как KotlinJsonAdapterFactory обрабатывает аннотацию @Json и ее поле jsonName.

public class KotlinJsonAdapterFactory : JsonAdapter.Factory {  override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {   val rawType = type.rawType   val rawTypeKotlin = rawType.kotlin   val parametersByName = constructor.parameters.associateBy { it.name }   try {     val generatedAdapter = moshi.generatedAdapter(type, rawType) // 1     if (generatedAdapter != null) {       return generatedAdapter     }   } catch (e: RuntimeException) {     if (e.cause !is ClassNotFoundException) {       throw e     }   }   // 2   val bindingsByName = LinkedHashMap<String, KotlinJsonAdapter.Binding<Any, Any?>>()     for (property in rawTypeKotlin.memberProperties) { // 3       val parameter = parametersByName[property.name]        var jsonAnnotation = property.findAnnotation<Json>() // 3a       ...        // 3b       val jsonName = jsonAnnotation?.name?.takeUnless { it == Json.UNSET_NAME } ?: property.name       ...       val adapter = moshi.adapter<Any?>(...)        bindingsByName[property.name] = KotlinJsonAdapter.Binding(         jsonName, // 3c         adapter,         property as KProperty1<Any, Any?>,         parameter,         parameter?.index ?: -1,      )    }     val bindings = ArrayList<KotlinJsonAdapter.Binding<Any, Any?>?>()     ...    for (bindingByName in bindingsByName) {      bindings += bindingByName.value.copy(propertyIndex = index++)    }     return KotlinJsonAdapter(bindings, …).nullSafe() // 4  } }
  1. Необходимо проверить наличие адаптера, сгенерированного с помощью обработчика аннотаций, с помощью Moshi.generatedAdapter(). Если сгенерированный адаптер не найден, нужно перейти к созданию нового при помощи рефлексии.

  2. Нужно создать bindingsByName — сопоставить названия параметров с их Binding’ами. Binding включает в себя информацию об имени параметра в формате JSON, соответствующем адаптере.

  3. Следует изучить все свойства данного типа и для каждого из них:

    1. Найти аннотацию @Json для текущего свойства;

    2. Если оно найдено, задать jsonName в поле name аннотации (например, page_count) в качестве поля jsonName. Если его нет, то использовать имя свойства (например, pageCount) в качестве jsonName.

    3. Использовать jsonName при создании Binding’а для текущего свойства.

  4. Вернуть новый KotlinJsonAdapter с заполненными Binding’ами

Теперь при вызове toJson() или fromJson() Moshi будет использовать jsonName из биндингов в качестве имени поля JSON.

Lint-проверки в Moshi

По умолчанию в Moshi нет проверок lint. Но, к счастью, на этой случай Slack опубликовал в открытом доступе некоторые свои проверки, связанные с Moshi. Это «Prefer List over Array» и «Constructors in Moshi classes cannot be private».

Код для этих проверок, связанных с Moshi, содержится в MoshiUsageDetector. В качестве примера работы с деревом UAST из lint API расскажем о реализации правила «Prefer List over Array». Правило объявлено как ISSUE_ARRAY в объекте-компаньоне MoshiUsageDetector и указывает на то, что Moshi не поддерживает массивы.

class MoshiUsageDetector : Detector(), SourceCodeScanner {   override fun getApplicableUastTypes() = listOf(UClass::class.java) // 1   override fun createUastHandler(context: JavaContext): UElementHandler { // 2    return object : UElementHandler() {      override fun visitClass(node: UClass) {        ...        // 3        val jsonClassAnnotation = node.findAnnotation(FQCN_JSON_CLASS)        if (jsonClassAnnotation == null) return // 4        ...        val primaryConstructor =          node.constructors            .asSequence()            .mapNotNull { it.getUMethod() }            .firstOrNull { it.sourcePsi is KtPrimaryConstructor }        ...        for (parameter in primaryConstructor.uastParameters) { // 5          val sourcePsi = parameter.sourcePsi          if (sourcePsi is KtParameter && sourcePsi.isPropertyParameter()) {            val shouldCheckPropertyType = ...            if (shouldCheckPropertyType) {              // 5a              checkMoshiType(                context,                parameter.type,                parameter,                parameter.typeReference!!,              ...              )            }          }        }      }    }  }   private fun checkMoshiType(    context: JavaContext,    psiType: PsiType,    parameter: UParameter,    typeNode: UElement,     ...  ) {    if (psiType is PsiPrimitiveType) return    if (psiType is PsiArrayType) { // 6      ...      context.report(        ISSUE_ARRAY,        context.getLocation(typeNode),        ISSUE_ARRAY.getBriefDescription(TextFormat.TEXT),        quickfixData =          fix()            .replace()            .name("Change to $replacement")            ...            .build()      )      return    }    ... // 7   }   companion object {    private const val FQCN_JSON_CLASS = "com.squareup.moshi.JsonClass"    ...    private val ISSUE_ARRAY =      createIssue(        "Array",        "Prefer List over Array.",        """        Array types are not supported by Moshi, please use a List instead…        """        .trimIndent(),        Severity.WARNING,      )    ...  } }
  1. Функция getApplicableUastTypes() возвращает UClass для запуска детектора для всех классов в исходном коде.

  2. createUastHandler() возвращает UElementHandler, который заходит в каждый узел класса. Остальные шаги выполняются в visitClass().

  3. Необходимо найти аннотацию @JsonClass в текущем классе.

  4. Следует выполнить return, если аннотация не найдена.

  5. Нужно пройтись по основным параметрам конструктора узла и для каждого из них:

    1. Вызвать checkMoshiType() для параметра, если он проходит несколько проверок.

  6. В checkMoshiType() нужно вызвать метод report, если заданный тип — массив.

  7. Функция checkMoshiType() выполняет несколько рекурсивных вызовов, которых нет в статье — для краткости.

Согласно шагу 4, все проверки выполняются только для классов, аннотированных с помощью @JsonClass. Это означает, что MoshiUsageDetector будет работать только с исходным кодом, в котором используется версия Moshi для процессинга аннотаций.

#Заключение

В этой статье вы найдёте несколько фрагментов кода, которые могут быть вам полезны. Кода оказалось меньше, чем можно было бы ожидать от библиотеки: написание кастомного процессора аннотаций, кода рефлексии или lint-правил оказались не такими сложными, как можно было подумать.

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

Больше полезного про Android — в Telegram-канале Surf Android Team. 

Кейсы, лучшие практики, новости и вакансии в команду Android Surf в одном месте. Присоединяйтесь!


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


Комментарии

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

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