通过 Moshi 的案例学习 Kotlin 中的注解

572 阅读9分钟

这是我关于 Kotlin 注解的第二篇文章, 我将以 Moshi的源码为案例, 探讨现实世界中的库是如何通过使用注解处理器, 反射和 lint 来使用注解的. Kotlin 中注解的主要实现方式 对这三种机制进行了高层次的介绍, 我建议你先阅读这一部分.

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 在编译时通过注解处理生成适配器类, 我们需要添加以下任一种方法:
为 kapt 添加 kapt(“com.squareup.moshi:moshi-kotlin-codegen:1.15.0”)

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

例如, Moshi 会为每个注解为 @JsonClass(generateAdapter = true) 的类生成适配器:

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

当我们构建应用程序时, Moshi 会在 /build/generated/source/kapt/目录下生成一个 BookModelJsonAdapter 文件. 任何生成的适配器都将扩展JsonAdapter, 并覆盖其toString(), fromJson()toJson()函数, 以便与特定类型协同工作.

当我们调用

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

的时候, Moshi.adapter() 将返回生成的 BookModelJsonAdapter.

Moshi 的大部分代码生成逻辑都在 AdapterGenerator中, 它在 Moshi 的 kapt 和 KSP 实现中共享. AdapterGenerator使用 KotlinPoet 创建带有新适配器类的 FileSpec.

Kapt

我们在 kapt 中通过扩展 AbstractProcessor 来创建注解处理器. 让我们看看 Moshi 是如何用 JsonClassCodegenProcessor 扩展它来处理 @JsonClass 注解的.

我直接从 Moshi 代码库中复制了下面的代码, 只保留了与处理@JsonClass相关的部分. 如果你感兴趣, 可以自行阅读整个文件!

@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() 以声明 JsonClassCodegenProcessor@JsonClass 注解的支持
  3. process() 中, 遍历所有注解为 @JsonClassTypeElements 并对每一个进行处理:
    a) 获取当前类型的 JsonClass b) 使用 JsonClassgenerateAdaptergenerator 字段来确定是否要生成适配器
    c) 为当前类型创建一个 AdapterGenerator
    d) 使用 FilerAdapterGenerator 为当前类型生成的 FileSpec 写入文件
    e) 使用 FilerAdapterGenerator 为当前类型生成的 Proguard 配置写入文件
  4. process()结束时返回false, 以指定此处理器不使用传入 process 的注解TypeElements集. 这将允许其他处理器也使用 Moshi 注解.

KSP

KSP 中的注解处理器扩展了 SymbolProcessor. KSP 还需要一个实现 SymbolProcessorProvider 的类作为实例化自定义 SymbolProcessor 的入口. 让我们看看 Moshi 的 JsonClassSymbolProcessorProvider 是如何处理 @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() 中, 遍历所有用 @JsonClass(见 Resolver)注解的 KsAnnotated符号, 并对每个符号:
    a) 获取当前符号的 JsonClass
    b) 使用 JsonClassgenerateAdaptergenerator 字段确定是否要生成适配器
    c) 为当前类型创建一个 AdapterGenerator
    d) 使用 CodeGeneratorAdapterGenerator 为当前类型生成的 FileSpec 写入文件
    e) 使用 CodeGeneratorAdapterGenerator 为当前类型生成的 Proguard 配置写入文件
  4. process()结束时返回一个空列表, 以说明该处理器没有将任何符号推迟到后面的轮次中.

Moshi 还在其 增量注解处理器 中注册了 JsonClassCodegenProcessor 以让它与 增量处理 配合使用.

在阅读 Moshi 的代码库时, 我对 JsonClassCodegenProcessorJsonClassSymbolProcessorProvider 的简短和可读性感到惊喜. 我们不需要太多代码就能构建一个非常有用的自定义注解处理器! 由于大部分代码生成逻辑都存在于与 API 无关的 AdapterGenerator 中, 因此为 Moshi 添加 KSP 支持并不需要太多额外工作. 两种注解处理器的高级步骤几乎完全相同.

使用反射的 Moshi

我们可以通过反射实现相同的 JSON 解析行为.

我们需要添加以下依赖关系:

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

我们不再需要用 @JsonClassBookModel 进行注解, 因为只有 codegen 才需要该注解. 相反, 我们需要在构建 Moshi 时添加一个 KotlinJsonAdapterFactory. KotlinJsonAdapterFactory 是一个通用的适配器工厂, 可以在运行时通过反射为任何 Kotlin 类创建一个 JsonAdapter .

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

现在我们调用 Moshi.adapter():

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

它将返回由 KotlinJsonAdapterFactory 在运行时创建的 BookModel 适配器.

调用时, Moshi.adapter<T>() 会遍历所有可用的适配器和适配器工厂, 直到找到一个支持 T 的适配器. Moshi 提供了多个内置工厂, 包括用于基本数据类型(int, float 等)和枚举的工厂, 我们还可以使用 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. 遍历给定Type的所有属性, 并对每个属性进行以下操作: a) 搜索当前属性上的 @Json 注解.
    b) 如果找到了, 则将 jsonName 设置为注解的 name 字段(例如 page_count)作为 jsonName 字段. 如果没有, 则使用属性名称(如 pageCount)作为 jsonName.
    c) 为当前属性创建 Binding 时使用 jsonName
  4. 使用填充的绑定返回新的 KotlinJsonAdapter

当我们调用 toJson()fromJson() 时, 将使用绑定中的 jsonName 作为 JSON 字段名.

Moshi Lint 检查

Moshi 默认不包含任何 Lint 检查, 但幸运的是, Slack 为本案例研究开源了一些与 Moshi 相关的自定义 Lint 检查, 例如首选List而非ArrayMoshi 中类的构造器不能为 private.

所有这些与 Moshi 相关的检查代码都在 MoshiUsageDetector中. 我将以使用 lint API 的 UAST 树为例, 介绍首选List而非Array问题的实现. 在 MoshiUsageDetector 的同伴对象中, 该问题被声明为 ISSUE_ARRAY, 并捕捉到 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. 如果未找到注解, 则提前返回.
  5. 遍历节点的主构造函数参数, 并为每个参数
    a) 如果参数通过了一些检查, 则调用 checkMoshiType() .
  6. checkMoshiType() 中, 如果给定类型是数组, 则报告 ISSUE_ARRAY.
  7. 为了简洁起见, checkMoshiType() 进行了一些递归调用, 我就不一一列举了.

根据第 4 步, 所有检查只对注解为 @JsonClass 的类执行. 这意味着 MoshiUsageDetector 只能在使用注解处理版本的 Moshi 的源代码中工作.

总结一下

这篇文章最终包含了一些代码...但对于这样一个有用且广泛使用的库来说, 代码量比我预期的要少! 编写自定义注解处理器, 反射代码或 Lint 检查并不像我们想象的那样令人生畏, 我希望这些现实世界中的示例能增强你的能力, 去构建你自己的注解处理器, 反射代码或 Lint 检查.

好吧, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy Coding! Stay GOLDEN!