Привет! Сегодня с вами Максим Кругликов из 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 в одном месте. Присоединяйтесь!

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