Generate Kotlin Code with KotlinPoet uses Annotation Processor

WindSekirun (wind.seo)·2022년 4월 26일
0

이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.

작성 시점: 2018-03-16

Annotation Processor (주석 처리기) 는 컴파일 시점에 특정 어노테이션을 기반으로 하여 클래스를 생성하고, 메서드를 생성하고, 필드를 생성하는 기술로 JDK 1.5 부터 도입되었다.

그 외 설명은 아래 글을 참고하는게 더 빠를지도 모른다.

(번역) Annotaiton Processing 101

Annotation Processing : Don’t Repeat Yourself, Generate Your Code.

이미 Glide, Dagger, ButterKnife, Data-binding 등이 이 Annotation Processor 기능을 사용해서 코드를 생성하고 하여금 개발자가 모든 코드를 직접 작성하지 않아도 자동으로 생성하는 기능을 가지고 있다.

현재는 주로 자바로 자바의 코드를 생성하는 방법이 주로 쓰이고 있지만, 오늘은 Kotlin을 이용해 Kotlin 코드를 작성하는 법을 작성해보려 한다.

본 프로젝트의 예제로 사용된 프로젝트는 WindSekirun/AttributeParser 이다.

프로젝트 모듈 설정

총 3개의 모듈이 필요하다.

Annotation을 담을 annotation 모듈, APK 파일 내에 포함되지 않고 컴파일 시간에 실행되는 complier 모듈, 그리고 이 두 개를 의존해서 사용하는 demo 모듈이다.

annotation 모듈, complier 모듈은 android-library 플러그인이 아닌 kotlin 플러그인을 사용한다.

annotation 모듈의 build.gradle

apply plugin: 'java'
apply plugin: 'kotlin'

targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_1_8

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}

complier 모듈의 build.gradle

apply plugin: 'kotlin'

targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_1_8

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    compile project(':attribute-parser')
    compile 'com.squareup:kotlinpoet:0.7.0'
    compile 'com.google.auto.service:auto-service:1.0-rc3'
}

어노테이션 제작

간단하게 Class에 부착해 해당 클래스의 이름을 가져와 그 이름을 기반으로 클래스를 만들어보자.

package pyxis.uzuki.live.attribute.parser.annotation

@Target(AnnotationTarget.CLASS, AnnotationTarget.FILE)
@Retention(AnnotationRetention.SOURCE)
annotation class CustomView

Target 는 Class 및 File, 어노테이션 실행 시점은 SOURCE로 고정한다.

이 어노테이션에는 별도의 필드는 필요 없기에, 두지 않는다.

프로세서 제작

complier 모듈에 kt 파일을 하나 생성해보고 AbstractProcessor 를 상속하면 된다.

package pyxis.uzuki.live.attribute.parser.compiler

import com.google.auto.service.AutoService
import pyxis.uzuki.live.attribute.parser.compiler.utils.supportedAnnotationSet
import javax.annotation.processing.AbstractProcessor
import javax.annotation.processing.Processor
import javax.annotation.processing.RoundEnvironment
import javax.annotation.processing.SupportedSourceVersion
import javax.lang.model.SourceVersion
import javax.lang.model.element.TypeElement

@SupportedSourceVersion(SourceVersion.RELEASE_8)
@AutoService(Processor::class)
class Processor : AbstractProcessor() {
    override fun getSupportedAnnotationTypes() = supportedAnnotationSet

    override fun process(p0: MutableSet<out TypeElement>?, p1: RoundEnvironment?): Boolean {
        // TODO: implement something

        return true
    }

}

하나씩 살펴보면..

  • @SupportedSourceVersion: JDK 컴파일 타겟 버전. 여기서는 RELEASE_8
  • @AutoService(Processor::class): META-INF/services/javax.annotation.processing.Processor 를 생성해주는 구글에서 개발한 어노테이션... 이나 현재 시간 기준으로 먹히지 않아 별도로 생성해줘야 함.
  • getSupportedAnnotationTypes() = supportAnnotationSet: supportAnnotationSet 는 별도 파일에 정의된 Property로, Set 를 반환한다.
val supportedAnnotationSet = classNameSetOf(CustomView::class)

fun <T : KClass<*>> classNameSetOf(vararg elements: T) = elements.map { it.java.name }.toHashSet()

이런 식으로 정의하면 된다.

  • process: 실제로 processor 의 내용이 구현될 곳

RoundEnvironment 는 getSupportedAnnotationTypes 에서 선언한 각종 대상 어노테이션을 사용중인 Element (구조 요소) 를 반환하는 역할을 한다.

주로 사용되는 메서드는 RoundedEnvironment.getElementsAnnotatedWith(Class<*>) 인데, 이름대로 해당 어노테이션 클래스를 사용하는 element 의 리스트를 반환한다.

for (element in env?.getElementsAnnotatedWith(CustomView::class.java)!!) {
            val typeElement = element as TypeElement
            val className = typeElement.asClassName()
            className.canonicalName
            className.packageName()
            className.simpleName()
        }

element 는 VariableElement, TypeElement 등이 있는데 여기서는 TypeElement 로 cast를 시켜서 ClassName 객체를 구해내게 한다.

여기서 ClassName 는 Kotlin 코드를 좀 더 쉽게 작성할 수 있게 도와주는 KotlinPoet 의 클래스이다. packageName, simpleName 등을 파싱할 수 있게 한다.

META-INF 생성

원래라면 AutoService 가 생성해야 하지만 18년 3월 17일 기준으로는 자동 생성되지 않는다.

src/main/resources/META-INF/services/javax.annotation.processing.Processor 라는 파일을 만들고, 내부에 구동할 Annotation Processor 의 풀네임을 적는다.

pyxis.uzuki.live.attribute.parser.compiler.AttributeParserProcessor

클래스 생성

ClassName 를 가지고 클래스를 생성해보자.

private fun writeAttributes(simpleName: String) {
        val fileName = "${simpleName}Attributes"
        val builder = TypeSpec.objectBuilder(fileName)
        val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME]
                ?.replace("kaptKotlin", "kapt")
                ?.let { File(it, "$fileName.kt") }
                ?: throw IllegalArgumentException("No output directory")

        val typeSpec = builder.build()
        val fileSpec = FileSpec.builder("", fileName).addType(typeSpec).build()
        fileSpec.writeTo(kaptKotlinGeneratedDir)
}

companion object {
        const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
}

맨 첫번째 줄부터 읽어보면,

  1. ${simpleName}Attributes -> 클래스 이름이 StyleView 이면 StyleViewAttributes
  2. builder = TypeSpec.objectBuilder(fileName) -> fileName 란 이름을 가진 object 생성 (object StyleViewAttributes)
  3. kaptKotlinGeneratedDir: Kotlin 에서 annotation processor 를 처리하는 kapt 는 기본적으로 build/generated/source/kaptKotlin/... 에 생성되기 때문에 IDE에서 못 찾는다. 따라서 kapt.kotlin.generated 란 키로 찾은 processingEnv의 출력 경로 옵션에서 kaptKotlin 을 kapt로 교체, File 객체로 만든다. 만일 옵션이 없을 경우에는 IllegalArgumentException 을 발생시킨다.
  4. val typeSpec = builder.build() -> TypeSpec 객체를 생성한다.
  5. val fileSpec = FileSpec.builder("", fileName).addType(typeSpec).build() -> 파일을 생성하는 Spec 객체를 생성한다.
  6. fileSpec.writeTo -> 주어진 경로에 fileSpec 를 작성한다.

이 순서가 된다.

이 코드를 넣고, 데모에서 annotation 모듈과 complier 모듈을 의존한 후 어떤 특정 클래스에 @CustomView 어노테이션을 붙이고 빌드하면 그 클래스의 이름을 가진 Attribute 클래스가 생성될 것이다.

만일 이 클래스에 Property, function 등을 붙이고 싶다면 builder 밑에 작성해주면 된다.

아래는 실제로 AttributeParser 에서 사용중인 코드이다.

private fun writeAttributes(holder: CustomViewHolder) {
        val classTypeName = holder.className
        val classTypeParameterName = holder.simpleName.substring(0, 1).toLowerCase() + holder.simpleName.substring(1)
        val simpleName = holder.simpleName
        val fileName = simpleName + Constants.ATTRIBUTES

        val builder = TypeSpec.objectBuilder(fileName)

        val models = getModelList(simpleName, mAttrBooleanMap, mAttrColorMap, mAttrDimensionMap,
                mAttrIntegerMap, mAttrIntMap, mAttrFractionMap, mAttrFloatMap, mAttrResourceMap, mAttrStringMap)

        for (model in models) {
            builder.addProperty(createAttrsFieldSpec(model))
        }

        builder.addProperty(createRFieldSpec())
        builder.addFunction(createObtainApplyMethodSpec(classTypeName, classTypeParameterName, holder.simpleName))
        builder.addFunction(createApplyMethodSpec(classTypeName, classTypeParameterName, models))
        builder.addFunction(createPrintVariableMethodSpec(simpleName, models))
        builder.addFunction(createBindAttributesMethodSpec(simpleName, models))

        val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME]
                ?.replace("kaptKotlin", "kapt")
                ?.let { File(it, "$fileName.kt") }
                ?: throw IllegalArgumentException("No output directory")

        val typeSpec = builder.build()
        val fileSpec = FileSpec.builder(holder.packageName, fileName).addType(typeSpec).build()
        fileSpec.writeTo(kaptKotlinGeneratedDir)
 }

마무리

원본 라이브러리인 JavaPoet 와 달리 KotlinPoet 는 문서의 양이 너무 적긴 하지만,  충분히 클래스 파일을 생성할 수 있다.

다음 글에서는 KotlinPoet 의 구체적 사용법 등을 몇 가지 예제로 살펴보려고 한다.

profile
Android Developer @kakaobank

0개의 댓글