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