안녕하세요. 강남언니에서 안드로이드 앱 개발을 담당하는 Thomas 입니다.
Android 개발을 하면서, 수많은 Annotation 을 아무렇지 않게 사용하고있었는데, 도대체 Annotation 뒤에서 무슨 일이 일어나고있는 것인지 궁금증이 생겼습니다. 그래서 리햅기간을 이용해 Annotation 을 한번 파보고, 간단히 데이터모델의 필드 유효성 판단을 하는 Annotation 을 만들어보려 합니다.
이전 글에서 Annotation 에 대한 대략적인 설명과, Reflection 을 이용하는 Annotation 을 만들어보았습니다.
Reflection 을 이용해 Custom Annotation 을 만들경우, 새로운 Annotation 을 추가할 때마다 복잡한 Reflection 관련 코드를 작성해야 하고 validating 로직을 이해하기도 어렵고, 성능면에서도 떨어지기 때문에 이번에는 Code Generation 을 이용해 Annotation Processing 을 해보려 합니다.
모듈 만들기
file → new → new module →Java or Kotlin Libary 로
datavalidation-annotation 과 datavalidation-processor 모듈을 만들어줍니다.
datavalidation-annotation 모듈
datavalidation-annotation 모듈은 annotation 클래스 들을 정의해 놓은 모듈 입니다.
먼저 build.gradle 파일은 아래와 같습니다.
apply plugin: 'kotlin'
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
sourceCompatibility = "7"
targetCompatibility = "7"
아래는 validation 용도의 annotation 들을 정의한 파일입니다.
package com.april21dev.datavalidation_annotation.annotation
/**
* Model Class Annotation
* Any classes that need validation should be annotated with this
*/
@Target(AnnotationTarget.CLASS)
annotation class DataValidation
/**
* Nested field
* Nested model fields should be annotated with this. If not, ignored
*/
@Target(AnnotationTarget.FIELD)
annotation class Nested
/**
* Not null constraint
*/
@Target(AnnotationTarget.FIELD)
annotation class NotNull(
val tag: String
)
/**
* String field minimum length constraint
*/
@Target(AnnotationTarget.FIELD)
annotation class MinLength(
val length: Int,
val tag: String
)
/**
* String field maximum length constraint
*/
@Target(AnnotationTarget.FIELD)
annotation class MaxLength(
val length: Int,
val tag: String
)
/**
* String field regex match constraint
*/
@Target(AnnotationTarget.FIELD)
annotation class Regex(
val regex: String,
val tag: String
)
/**
* Number minimum value
*/
@Target(AnnotationTarget.FIELD)
annotation class MinValue(
val value: Long,
val tag: String
)
/**
* Number maximum value
*/
@Target(AnnotationTarget.FIELD)
annotation class MaxValue(
val value: Long,
val tag: String
)
DataValidation
은 타겟이 CLASS 인 annotation 으로, validation 작업을 할 모델 클래스에 달아주는 용도입니다.
Nested
는 모델의 필드로 또 다른 모델을 갖고 있고 해당 모델 역시 validation 이 필요할 경우에 달아주는 용도입니다.
나머지 annotation 들은 의도가 자명하다고 판단되어 설명하지 않아도 될것 같아, 생략하겠습니다.
아래는 모델에 validation 작업을 했을 때 리턴할 모델 입니다.
data class ValidationResult(
var isValid: Boolean = true,
val invalidFieldNameAndTags: MutableList<FieldNameAndTag> = mutableListOf()
)
data class FieldNameAndTag(
val fieldName: String,
val tag: String
)
isValid 필드는 모든 필드가 유효한지 여부를 체크하는 용도이고, fieldName 과 tag 는 혹시 필요할 까 해서 만든 필드로, 유효하지 않은 필드명과 사용자가 annotation 에 파라미터로 넣은 부가 설명용 필드입니다.
datavalidation-processor 모듈
datavalidation-processor 모듈은 빌드시 annotation 이 달린 클래스를 찾아 code generation 을 해주기 위한 모듈입니다.
build.gradle 파일은 아래와 같습니다.
apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation project(':datavalidation-annotation')
implementation 'com.squareup:kotlinpoet:1.5.0'
implementation "com.google.auto.service:auto-service:1.0-rc6"
kapt "com.google.auto.service:auto-service:1.0-rc6"
}
sourceCompatibility = "7"
targetCompatibility = "7"
custom annotation 이 담긴 datavalidation-annotation 모듈을 참조해주었습니다.
kotlinpoet 은 kotlin 파일을 편하게 만들어주기 위한 라이브러리 입니다.
auto-service 는 어노테이션 프로세서를 컴파일러에 등록하는 작업을 자동으로 해주기 위한 라이브러리입니다. (해당 라이브러리 없이 하려면 META-INF 파일을 만들어 몇가지 설정을 해줘야 한다는데, 굳이 시도는 안해봤습니다)
아래는 실제 Processor 코드입니다.
@AutoService(Processor::class)
class Processor : AbstractProcessor() {
companion object {
const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
val fileBuilder =
FileSpec.builder("com.datavalidation.generated", "DataValidationExtension")
}
override fun getSupportedAnnotationTypes(): MutableSet<String> {
return mutableSetOf(
DataValidation::class.java.name,
Nested::class.java.name,
NotNull::class.java.name,
MinLength::class.java.name,
MaxLength::class.java.name,
MinValue::class.java.name,
MaxValue::class.java.name,
Regex::class.java.name
)
}
override fun getSupportedSourceVersion(): SourceVersion {
return SourceVersion.latestSupported()
}
//called twice or more
override fun process(
annotations: MutableSet<out TypeElement>?,
roundEnv: RoundEnvironment
): Boolean {
val classElements = roundEnv.getElementsAnnotatedWith(DataValidation::class.java)
if (!checkElementType(ElementKind.CLASS, classElements)) return false
classElements.forEach { fileBuilder.addFunction(makeValidateFunction(it)) }
fileBuilder.addImport(FieldNameAndTag::class.java, "")
val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME]
fileBuilder.build().writeTo(File(kaptKotlinGeneratedDir))
return true
}
private fun makeValidateFunction(classElement: Element): FunSpec {
val validateFunSpec = FunSpec.builder("validate")
.receiver(classElement.asType().asTypeName())
.returns(ValidationResult::class)
.addStatement("val result = %T()", ValidationResult::class.java)
val fieldElement = classElement.enclosedElements
fieldElement.forEach {
val nonNull = it.getAnnotation(NotNull::class.java)
val minLength = it.getAnnotation(MinLength::class.java)
val maxLength = it.getAnnotation(MaxLength::class.java)
val minValue = it.getAnnotation(MinValue::class.java)
val maxValue = it.getAnnotation(MaxValue::class.java)
val regex = it.getAnnotation(Regex::class.java)
val nested = it.getAnnotation(Nested::class.java)
nonNull?.let { anno ->
validateFunSpec.addComment("NonNull Check")
validateFunSpec.beginControlFlow("if(${it.simpleName} == null)")
validateFunSpec.addStatement("result.isValid = false")
validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
validateFunSpec.endControlFlow()
}
minLength?.let { anno ->
validateFunSpec.addComment("Minimum Length Check")
validateFunSpec.beginControlFlow("if(${it.simpleName} == null || ${it.simpleName}.length < ${anno.length})")
validateFunSpec.addStatement("result.isValid = false")
validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
validateFunSpec.endControlFlow()
}
maxLength?.let { anno ->
validateFunSpec.addComment("Maximum Length Check")
validateFunSpec.beginControlFlow("if(${it.simpleName} == null || ${it.simpleName}.length > ${anno.length})")
validateFunSpec.addStatement("result.isValid = false")
validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
validateFunSpec.endControlFlow()
}
minValue?.let { anno ->
validateFunSpec.addComment("Minimum Value Check")
validateFunSpec.beginControlFlow("if(${it.simpleName} == null || ${it.simpleName}.toLong() < ${anno.value})")
validateFunSpec.addStatement("result.isValid = false")
validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
validateFunSpec.endControlFlow()
}
maxValue?.let { anno ->
validateFunSpec.addComment("Minimum Value Check")
validateFunSpec.beginControlFlow("if(${it.simpleName} == null || ${it.simpleName}.toLong() > ${anno.value})")
validateFunSpec.addStatement("result.isValid = false")
validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
validateFunSpec.endControlFlow()
}
regex?.let { anno ->
validateFunSpec.addComment("Regex Match Check")
validateFunSpec.beginControlFlow("if(${it.simpleName} == null || !%S.toRegex().matches(${it.simpleName}))", anno.regex)
validateFunSpec.addStatement("result.isValid = false")
validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
validateFunSpec.endControlFlow()
}
nested?.let { _ ->
validateFunSpec.addComment("Nested Check")
validateFunSpec.beginControlFlow("if(${it.simpleName} != null)")
validateFunSpec.addStatement("val nestedValidation = ${it.simpleName}.validate()")
validateFunSpec.beginControlFlow("if(!nestedValidation.isValid)")
validateFunSpec.addStatement("result.isValid = false")
validateFunSpec.addStatement("result.invalidFieldNameAndTags.addAll(nestedValidation.invalidFieldNameAndTags)")
validateFunSpec.endControlFlow()
validateFunSpec.endControlFlow()
}
}
return validateFunSpec.addStatement("return result").build()
}
private fun checkElementType(kind: ElementKind, elements: Set<Element>): Boolean {
if (elements.isEmpty()) return false
elements.forEach {
if (it.kind != kind) {
printMessage(
Diagnostic.Kind.ERROR, "Only ${kind.name} Are Supported", it
)
return false
}
}
return true
}
private fun printMessage(kind: Diagnostic.Kind, message: String, element: Element) {
processingEnv.messager.printMessage(kind, message, element)
}
private fun createFieldNameAndTag(fieldName: Name, tag: String): String {
return "FieldNameAndTag(\"$fieldName\", \"$tag\")"
}
}
먼저 AbstractProcessor 를 상속받고 필수 메소드들을 오버라이딩 해주었습니다.
@AutoService(Processor::class) 는 위에서 말씀드린 auto-service 라이브러리의 기능으로 해당 어노테이션 하나만으로 자동으로 컴파일러에 프로세서를 등록해줍니다.
kapt.kotlin.generated
는 app\build\generated\source\kaptKotlin
과 대치되는 키로 파일을 만들면 저장할 위치를 나타냅니다.
getSupportedAnnotationTypes()
메소드 에서 해당 Processor 가 처리할 annotation 들을 알려줍니다.
getSupportedSourceVersion()
메소드에서는 지원할 소스코드 버전을 알려줍니다.
process()
는 코드를 만드는 로직들이 들어갈 곳입니다.
RoundEnvironment 를 통해 원하는 엘리먼트 들을 추출할 수 있습니다.
전체적인 flow 는
- DataValidation 이 달린 클래스 엘리먼트들을 추출하고
- 각 엘리먼트에 대해
fun class.validate()
함수들을 생성할 파일에 작성해주었습니다.
app 모듈
앱 모듈에서는 어노테이션을 사용해보려 합니다.
build.gradle 파일은 아래와 같습니다.
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
defaultConfig {
applicationId "com.april21dev.datavalidationkotlin"
minSdkVersion 21
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation project(':datavalidation-annotation')
kapt project(':datavalidation-processor')
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}
어노테이션을 사용해야 하기 때문에 datavalidation-annotation 을 참조해주고
datavalidation-processor 는 kapt 로 프로세싱 하도록 설정해주었습니다.
아래는 데이터 모델들입니다.
@DataValidation
data class Book(
@MinLength(10, "title length minimum is 10")
@MaxLength(50, "title length maximum is 50")
val title: String,
@MinValue(1, "book is not free")
@MaxValue(100000, "book is too expensive")
val price: Int,
@MaxLength(10, "author name is too long")
val authorName: String,
@Regex("^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$", "authorEmail is invalid")
val authorEmail: String,
@NotNull("publisher should is null")
@Nested
val publisher: Publisher
)
@DataValidation
data class Publisher(
@MaxLength(10, "publisher name is too long")
val name: String
)
간단히 Book 클래스와 Publisher 클래스를 만들어주고 각 필드에 대해 annotation 들을 달아주었습니다.
그 후 빌드를 하면 아래와 같이 DataValidationExtension.kt 파일이 자동으로 생성됩니다.
DataValidationExtension.kt 파일 안에는 Book 과 Publisher 에 대한 validate() 함수가 쓰여있습니다.
fun Book.validate(): ValidationResult {
val result = ValidationResult()
// Minimum Length Check
if(title == null || title.length < 10) {
result.isValid = false
result.invalidFieldNameAndTags.add(FieldNameAndTag("title", "title length minimum is 10"))
}
// Maximum Length Check
if(title == null || title.length > 50) {
result.isValid = false
result.invalidFieldNameAndTags.add(FieldNameAndTag("title", "title length maximum is 50"))
}
// Minimum Value Check
if(price == null || price.toLong() < 1) {
result.isValid = false
result.invalidFieldNameAndTags.add(FieldNameAndTag("price", "book is not free"))
}
// Minimum Value Check
if(price == null || price.toLong() > 100000) {
result.isValid = false
result.invalidFieldNameAndTags.add(FieldNameAndTag("price", "book is too expensive"))
}
// Maximum Length Check
if(authorName == null || authorName.length > 10) {
result.isValid = false
result.invalidFieldNameAndTags.add(FieldNameAndTag("authorName", "author name is too long"))
}
// Regex Match Check
if(authorEmail == null ||
!"^[\\w!#${'$'}%&'*+/=?`{|}~^-]+(?:\\.[\\w!#${'$'}%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}${'$'}".toRegex().matches(authorEmail)) {
result.isValid = false
result.invalidFieldNameAndTags.add(FieldNameAndTag("authorEmail", "authorEmail is invalid"))
}
// NonNull Check
if(publisher == null) {
result.isValid = false
result.invalidFieldNameAndTags.add(FieldNameAndTag("publisher", "publisher should is null"))
}
// Nested Check
if(publisher != null) {
val nestedValidation = publisher.validate()
if(!nestedValidation.isValid) {
result.isValid = false
result.invalidFieldNameAndTags.addAll(nestedValidation.invalidFieldNameAndTags)
}
}
return result
}
fun Publisher.validate(): ValidationResult {
val result = ValidationResult()
// Maximum Length Check
if(name == null || name.length > 10) {
result.isValid = false
result.invalidFieldNameAndTags.add(FieldNameAndTag("name", "publisher name is too long"))
}
return result
}
아래는 실제 validate() 함수를 사용하는 예시와 그 결과 입니다.
val book = Book(
"힐링페이퍼 인재상", 0, "Thomas", "thomas@healingpaper/com",
Publisher("높은 기준을 추구합니다. 소신있게 반대하고 헌신합니다. 틀릴 수도 있다고 생각합니다.")
)
val validationResult = book.validate()
Log.v("Validation",
StringBuilder()
.appendln("유효성: ${validationResult.isValid}")
.appendln("잘못된 필드: ${validationResult.invalidFieldNameAndTags.joinToString(", ", transform = {it.fieldName})}")
.appendln("메시지: ${validationResult.invalidFieldNameAndTags.joinToString(" & ", transform = { it.tag })}")
.toString()
)
유효성: false
잘못된 필드: price, authorEmail, name
메시지: book is not free & authorEmail is invalid & publisher name is too long
지금까지 간단히 데이터 모델의 유효성을 판단하는 로직을 annotation 을 통해 생성해 보았습니다.
이 외에도 annotation 을 활용하면 내가 원하는 다양한 기능들을 boilerplate 코드 없이 간결하게 구현해 생산성을 많이 높여줄수 있을거라 생각합니다.
끝까지 읽어주셔서 감사합니다.