Привет! Сегодня с вами Максим Кругликов из 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 } }
Необходимо использовать
@Autoserviceдля регистрацииJsonClassCodeGenProcessorв компиляторе.Нужно переопределить функцию
getSupportedAnnotationTypes(), чтобы объявить о поддержке нашим процессором аннотаций@JsonClass.В
process()необходимо пройтись по всем элементамTypeElements, помеченным@JsonClass, и для каждого из них:Получить
JsonClassдля текущего типа;Использовать поля
generateAdapterи generator изJsonClass, чтобы понять, следует ли генерировать адаптер;Создать
AdapterGeneratorдля текущего типа;Записать
FileSpec, сгенерированныйAdapterGeneratorв файл с помощьюFiler;Записать конфигурацию
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 } }
Необходимо спользовать
@Autoserviceдля регистрацииJsonClassSymbolProcessorProviderв компиляторе.Следует переопределить
JsonClassSymbolProcessorProvider.create(), чтобы вернуть экземплярJsonClassSymbolProcessor.В
process()нужно пройтись по всемKsAnnotatedсимволам, помеченным с помощью@JsonClass, и для каждого из них:Получить
JsonClassдля текущего символа.Использовать поля
generateAdapterиgeneratorизJsonClass, чтобы понять, следует ли генерировать адаптер;Создать
AdapterGeneratorдля текущего типа.Записать
FileSpec, сгенерированныйAdapterGeneratorв файл с помощьюCodeGenerator.Записывать сгенерированную
AdapterGeneratorконфигурацию Proguard для текущего типа в файл с помощьюCodeGenerator.
Вернуть пустой список в конце
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 } }
Необходимо проверить наличие адаптера, сгенерированного с помощью обработчика аннотаций, с помощью
Moshi.generatedAdapter(). Если сгенерированный адаптер не найден, нужно перейти к созданию нового при помощи рефлексии.Нужно создать
bindingsByName— сопоставить названия параметров с ихBinding’ами.Bindingвключает в себя информацию об имени параметра в формате JSON, соответствующем адаптере.Следует изучить все свойства данного типа и для каждого из них:
Найти аннотацию
@Jsonдля текущего свойства;Если оно найдено, задать
jsonNameв полеnameаннотации (например,page_count) в качестве поляjsonName. Если его нет, то использовать имя свойства (например,pageCount) в качествеjsonName.Использовать
jsonNameпри созданииBinding’а для текущего свойства.
Вернуть новый
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, ) ... } }
Функция
getApplicableUastTypes()возвращаетUClassдля запуска детектора для всех классов в исходном коде.createUastHandler()возвращаетUElementHandler, который заходит в каждый узел класса. Остальные шаги выполняются вvisitClass().Необходимо найти аннотацию
@JsonClassв текущем классе.Следует выполнить return, если аннотация не найдена.
Нужно пройтись по основным параметрам конструктора узла и для каждого из них:
Вызвать
checkMoshiType()для параметра, если он проходит несколько проверок.
В
checkMoshiType()нужно вызвать метод report, если заданный тип — массив.Функция
checkMoshiType()выполняет несколько рекурсивных вызовов, которых нет в статье — для краткости.
Согласно шагу 4, все проверки выполняются только для классов, аннотированных с помощью @JsonClass. Это означает, что MoshiUsageDetector будет работать только с исходным кодом, в котором используется версия Moshi для процессинга аннотаций.
#Заключение
В этой статье вы найдёте несколько фрагментов кода, которые могут быть вам полезны. Кода оказалось меньше, чем можно было бы ожидать от библиотеки: написание кастомного процессора аннотаций, кода рефлексии или lint-правил оказались не такими сложными, как можно было подумать.
Надеемся, что примеры из статьи мотивируют вас исследовать эту тему дальше и не бояться создавать собственные аннотации.
Больше полезного про Android — в Telegram-канале Surf Android Team.
Кейсы, лучшие практики, новости и вакансии в команду Android Surf в одном месте. Присоединяйтесь!
