이전 포스팅에서는 리플렉션을 사용한 어노테이션 정의를 알아봤습니다.
하지만, 리플렉션을 사용해 어노테이션을 만들 경우 다음과 같은 단점이 있습니다.
새로운 커스텀 어노테이션을 만들 때마다 복잡한 리플렉션 코드를 작성해야 한다.
그렇기 때문에 이번에는 Code Generation을 사용해서 커스텀 어노테이션을 만들어보겠습니다.
아래는 이전 포스팅입니다!
Code Generation을 사용하기 위한 모듈 만들기
다음과 같이 방법으로 모듈을 만든다.
- file → new → new module →Java or Kotlin Libary
- datavalidation-annotation과 datavalidation-processor라는
완료하면 다음과 같이 모듈이 생성된 것을 확인할 수 있습니다.
datavalidation-annotation 모듈
먼저 커스텀 annotation 파일을 만들기 전에 스크립트 파일을 수정해야 합니다.
build.gradle 수정
다음 내용을 추가합니다.
plugins {
id "kotlin-kapt"
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10"
}
sourceCompatibility = "7"
targetCompatibility = "7"
위 내용은 자신의 프로젝트 스크립트가 Groovy인지 KTS인지에 따라
조금 다를 수 있습니다.
위 내용을 추가한 제 프로젝트의 모습은 다음과 같습니다
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id "kotlin-kapt"
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10"
}
java {
sourceCompatibility = JavaVersion.VERSION_1_7
targetCompatibility = JavaVersion.VERSION_1_7
}
sourceCompatibility = "7"
targetCompatibility = "7"
그리고 해당 모듈 안에 클래스 파일을 하나 만들고
다음과 같은 코드를 작성합니다.
/**
* 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
)
해당 어노테이션이 우리가 커스텀할 어노테이션입니다.
간단히 설명하자면
- annotation class DataValidation: CLASS 인 annotation으로, validation 작업을 할 모델 클래스에 달아주는 용도입니다.
- annotation class Nested: 모델의 필드로 또 다른 모델을 갖고 있고 해당 모델 역시 validation 이 필요할 경우에 달아주는 용도입니다.
나머지는 이름만으로 기능을 유추할 수 있다고 생각합니다.
그리고 모델에 validation 작업을 했을 때 리턴할 모델에 대한 클래스 파일을 만들어줍니다.
ValidationResult.kt
/**
* 유효성 검증 결과를 받는 모델
* @param isValid 유효성 검사 결과
* @param invalidFieldNameAndTags 유효하지 않은 필드명과 태그명을 반환
* */
data class ValidationResult(
var isValid: Boolean = true,
val invalidFieldNameAndTags: MutableList<FieldNameAndTag> = mutableListOf()
)
FieldNameAndTag.kt
/**
* 필드 이름과 태그를 갖는 모델
*
* @param fieldName 필드 이름
* @param tag 필드의 태그
* */
data class FieldNameAndTag(
val fieldName: String,
val tag: String
)
datavalidation-processor 모듈
datavalidation-processor 모듈은 빌드 시 annotation 이 달린 클래스를 찾아 code generation을 해주기 위한 모듈입니다.
위에서 생성한 datavalidation-annotation을 참조해야 하기 때문에
다음과 같은 내용을 추가합니다.
plugins {
id 'kotlin'
id "kotlin-kapt"
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10"
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"
위 코드를 추가한 제 스크립트 코드는 다음과 같습니다.
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'kotlin'
id "kotlin-kapt"
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10"
// 추가한 datavalidation-annotation 모듈을 삽입
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"
}
java {
sourceCompatibility = JavaVersion.VERSION_1_7
targetCompatibility = JavaVersion.VERSION_1_7
}
sourceCompatibility = "7"
targetCompatibility = "7"
implementation project(':datavalidation-annotation')
추가한 datavalidation-annotation 모듈을 삽입하기 위해 다음과 위와 코드를 추가했습니다.
필요한 kt 파일을 간단하게 만들어주는 라이브러리를 추가합니다.
implementation 'com.squareup:kotlinpoet:1.5.0'
위 라이브러리에 대한 참고 사이트는 다음과 같습니다.
해당 라이브러리는 어노테이션 프로세서를 컴파일러에 등록하는 작업을 자동으로 해주기 위한 라이브러리입니다.
annotationProcessor "com.google.auto.service:auto-service:1.0-rc6"
다음과 같이 ValidateProcessor를 작성합니다.
package com.example.datavalidation_processor
import com.example.datavalidation_annotation.*
import com.example.datavalidation_annotation.model.FieldNameAndTag
import com.example.datavalidation_annotation.model.ValidationResult
import com.google.auto.service.AutoService
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.asTypeName
import java.io.File
import javax.annotation.processing.AbstractProcessor
import javax.annotation.processing.Processor
import javax.annotation.processing.RoundEnvironment
import javax.lang.model.SourceVersion
import javax.lang.model.element.Element
import javax.lang.model.element.ElementKind
import javax.lang.model.element.Name
import javax.lang.model.element.TypeElement
import javax.tools.Diagnostic
// auto-service 라이브러리의 기능
// 해당 어노테이션으로 자동으로 컴파일러에 프로세서를 등록해줍니다.
@AutoService(Processor::class)
class ValidateProcessor: AbstractProcessor() {
companion object {
// pp\build\generated\source\kaptKotlin 과 대치되는 키로
// auto-service를 통해 파일을 만들면 저장할 위치를 나타냅니다.
const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
// 코드를 생성할 때 파일을 작성하기 위한 빌더 역할
// 즉, DataValidationExtension이라는 파일을 com.datavalidation.generated에 저장한다.
val fileBuilder = FileSpec.builder("com.datavalidation.generated", "DataValidationExtension")
}
// 현재 Processor 가 처리할 annotation 들을 알려준다.
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
)
}
// 각각의 메소드에서 지원할 소스코드 버전을 알려줍니다.
// 예를 들어 SourceVersion.RELEASE_11를 반환하면 이 클래스는 JDK 11에서 컴파일할 때만 사용할 수 있습니다
override fun getSupportedSourceVersion(): SourceVersion {
return SourceVersion.latestSupported()
}
// 코드를 만드는 로직들이 들어갈 곳입니다.
// called twice or more
override fun process(
annotations: MutableSet<out TypeElement>?,
roundEnv: RoundEnvironment
): Boolean {
// 파일 생성기(fileBuilder)에서 생성할 클래스의 내부 요소들을 나타냅니다.
// 일반적으로 이 내부 요소들은 클래스의 속성, 메서드, 내부 클래스 등입니다.
// 이 요소들은 클래스의 구조를 결정하며, 파일 생성기는 이러한 요소들을 기반으로 클래스 파일을 생성합니다.
val classElements = roundEnv.getElementsAnnotatedWith(DataValidation::class.java)
// 엘리먼트 타입이 CLASS 타입인지 확인한다.
if (!checkElementType(ElementKind.CLASS, classElements)) return false
// 각각의 클래스에 유효성 검사를 하는 함수들을 추가한다.
classElements.forEach { fileBuilder.addFunction(makeValidateFunction(it)) }
// FieldNameAndTag를 import 한다.
fileBuilder.addImport(FieldNameAndTag::class.java, "")
// kapt에 의해 생성된 Kotlin 파일들이 저장되는 경로를 가져온다.
val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME]
// 위에서 얻은 위치에 파일을 작성한다.
fileBuilder.build().writeTo(File(kaptKotlinGeneratedDir))
return true
}
// 파라미터로 받은 클래스 엘리먼트를 보고 알맞은 함수를 작성한다.
private fun makeValidateFunction(classElement: Element): FunSpec {
// validate라는 이름으로 함수를 만들고
val validateFunSpec = FunSpec.builder("validate")
// 해당 함수는 classElement와 동일한 자료형을 갖고 있는 파라미터를 받는다.
.receiver(classElement.asType().asTypeName())
// 결과 값으로 ValidationResult를 반환한다.
.returns(ValidationResult::class)
// result라는 변수를 추가하고 ValidationResult를 넣어준다.
.addStatement("val result = %T()", ValidationResult::class.java)
// classElement의 필드, 메서드, 내부 클래스, 열거형 등 모든 것을 가져온다.
val fieldElement = classElement.enclosedElements
// 클래스에 등록되어있는 모든 요소(필드, 메서드, 내부클래스 등)을 돌면서 확인한다.
// 단, 여기에 있는 어노테이션은 Target이 Field로 되어있기 때문에 it에는 Field 값만 가져온다
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")
// if문을 시작 (받은 값이 null일 경우) * 여기서 it은 필드를 의미한다.
validateFunSpec.beginControlFlow("if(${it.simpleName} == null)")
// 코드 라인 추가
validateFunSpec.addStatement("result.isValid = false")
validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
// if문을 닫는다.
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\")"
}
}
각각의 라인에 어떠한 기능을 하고 어떤 역할을 하는지 적었으니 참조하시길 바랍니다.
ValidateProcessor의 전체적인 흐름은 다음과 같습니다.
- 확인해야 할 어노테이션을 설정 = getSupportedAnnotationTypes()
- fileBuilder을 사용하여 생성한 코드를 저장할 파일 저장 위치와 파일 이름을 정의
- override fun process()에서 클래스 Element를 확인하고 각각의 어노테이션에 맞게 코드를 작성해 준다.
자동으로 생성된 파일들의 예시
예를 들어
NotNull 어노테이션으로 생성된 파일은 다음과 같은 모습이 됩니다.
먼저 파일을 생성하는 부분의 코드는 다음과 같습니다.
nonNull?.let { anno ->
// 코멘트를 추가
validateFunSpec.addComment("NonNull Check")
// if문을 시작 (받은 값이 null일 경우) * 여기서 it은 필드를 의미한다.
validateFunSpec.beginControlFlow("if(${it.simpleName} == null)")
// 코드 라인 추가
validateFunSpec.addStatement("result.isValid = false")
validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
// if문을 닫는다.
validateFunSpec.endControlFlow()
}
그리고 생성된 코드는 다음과 같습니다.
fun ClassName.validate(val text: String?): ValidationResult {
val result = ValidationResult()
// NonNull Check
if (text == null) {
result.isValid = false
result.invalidFieldNameAndTags.add("myField : NonNull")
}
return result
}
Code Generation으로 만든 어노테이션 사용하기
App 모듈에 의존성 부여하기
App 모듈에서 해당 어노테이션을 참조하고 사용하기 위해 다음과 같은 의존성을 부여해야 합니다.
- 어노테이션을 사용해야 하기 때문에 datavalidation-annotation을 참조
- datavalidation-processor는 kapt로 프로세싱
implementation project(':datavalidation-annotation')
kapt project(':datavalidation-processor')
커스텀한 어노테이션을 사용할 data class 생성하기
커스텀한 어노테이션을 data class에 적용한다.
예를 들어 다음과 같이 만들 수 있다.
@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 be null")
@Nested
val publisher: Publisher
)
@DataValidation
data class Publisher(
@MaxLength(10, "publisher name is too long")
val publishName: String
)
이후 빌드를 해보면 다음과 같은 파일이 만들어진 것을 확인할 수 있다.
생성된 파일의 코드를 보면 다음과 같다.
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 be 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(publishName == null || publishName.length > 10) {
result.isValid = false
result.invalidFieldNameAndTags.add(FieldNameAndTag("publishName", "publisher name is too long"))
}
return result
}
다음과 같이 유효성 검사를 할 수 있다.
val book = Book(
"Book Title", 0, "Thomas", "email@gmail/com",
Publisher("this is book. maybe it's not a book. I thought it's book.")
)
val validationResult = book.validate()
Timber.d(
StringBuilder()
.appendln("유효성: ${validationResult.isValid}")
.appendln("잘못된 필드: ${validationResult.invalidFieldNameAndTags.joinToString(", ", transform = {it.fieldName})}")
.appendln("메시지: ${validationResult.invalidFieldNameAndTags.joinToString(" & ", transform = { it.tag })}")
.toString()
)
이를 출력하는 로그는 다음과 같다.
정리
어노테이션을 사용해서 데이터의 유효성을 검사하는 방법을 알아봤습니다.
사용하기 어려우면서도 잘 사용하면 쓸데가 많을 거 같네요!
위 포스팅은 아래의 내용을 참조했습니다.
'코틀린' 카테고리의 다른 글
코틀린에서 abstract 클래스는 무엇이고 어떻게 사용할까? (0) | 2023.03.30 |
---|---|
코틀린으로 알고리즘 문제 풀기 - 입력 받기 꿀팁 (0) | 2023.03.23 |
코틀린에서 자주 사용하는 어노테이션(Annotation)@ 정리-2 (0) | 2023.03.11 |
안드로이트 코틀린 Reflection(리플렉션) 기초 정의 (0) | 2023.03.09 |
코틀린에서 자주 사용하는 어노테이션(Annotation)@ 정리-1 (0) | 2023.03.06 |
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."
댓글