JSON 파싱과 객체 역직렬화
data class Author(val name: String)
data class Book(val title: String, val author: Author)
fun main(args: Array<String>) {
val json = """{"title": "Catch-22", "author":{"name":"J.Heller"}}"""
val book = deserialize<Book>(json)
println(book)
}
- 역직렬화할 객체의 타입을 실체화한 타입 파라미터로 deserialize 함수에 넘겨서 새로운 객체 인스턴스를 얻는다.
제이키드의 JSON 역직렬화기는 3단계로 구현돼 있다.
- 어휘 분석기로 렉서라고 부른다
- 어휘 분석기는 여러 문자로 이뤄진 입력 문자열을 토큰의 리스트로 변환한다.
- 토큰에는 문자토큰(문자를 표현하며 JSON 문법에서 중요한 의미), 값 토큰(문자열, 수, boolean, null 등 상수)이 있다.
- 문법 분석기로 파서라고 부른다.
- 토큰의 리스트를 구조화된 표현으로 변환한다.
- JSON의 상위구조를 이해하고 토큰을 JSON에서 지원하는 의미 단위로 변환하는 일을 한다
- JsonObject 인터페이스는 현재 역직렬화하는 중인 객체나 배열을 추적하는데, 파서는 현재 객체의 새로운 프로퍼티를 발견할 때마다 그 프로퍼티의 유형에 해당하는 JSONObject의 함수를 호출한다
- 파싱한 결과로 객체를 생성하는 역직렬화 컴포넌트다

- 각 메서드의 propertyName 파라미터는 JSON 키를 받아 createObject 메서드를 호출한다.
- 간단한 프로퍼티 값은 setSimpleProperty를 호출하면서 실제 값을 value에 넘기는 방식으로 등록한다
- JsonObject를 구현하는 클래스는 새로운 객체를 생성하고, 새로 생성한 객체를 외부 객체에 등록하는 과정을 책임져야 한다.

- 렉서는 문자열을 토큰 리스트로 바꾸고, 파서는 렉서가 만든 토큰 리스트를 분석하면서 의미 단위를 만날 때마다 JsonObject의 메서드를 적절히 호출한다.
- 그 후, JsonObject에 상응하는 코틀린 타입의 인스턴스를 점차 만들어내는 JsonObject구현을 제공한다.
- 클래스 프로퍼티와 JSON키 사이의 대응 관계를 찾아내고, 중첩된 객체 값을 만들어 낸다.
- 모든 중첩 객체 값을 만들고 난 뒤 필요한 클래스의 인스턴스를 새로 만든다.
제이키드는 JSON에서 가져온 이름/값 쌍을 역직렬화하는 클래스의 생성자에 넘긴다. 제이키드는 객체를 생성한 다음 프로퍼티를 설정하는 것을 지원하지 않으므로 역직렬화기는 JSON에서 데이터를 읽는 과정에서 중간에 만든 프로퍼티 객체들을 어딘가에 저장해 뒀다가 나중에 생성자를 호출할 때 써야 한다.
JSON에서는 객체, 컬렉션, 맵과 같은 복합 구조를 만들 필요가 있으므로 ObjectSeed, ObjectListSeed, ValueListSeed는 각각 객체, 복합 객체로 이뤄진 리스트, 간단한 값을 만드는 일을 한다.

- 기본 Seed 인터페이스는 JsonObject를 확장하면서 객체 생성 과정이 끝난 후 결과 인스턴스를 얻기 위한 spawn 메서드를 추가 제공한다.
- 또한 Seed 안에는 중첩된 객체나 중첩된 리스트를 만들 때 사용할 createCompositeProperty 메서드 선언이 들어있다.
- spawn은 ObjectSeed의 경우 생성된 객체를 반환하고, ObjectListSeed, ValueListSeed의 경우 생성된 리스트를 반환한다.

- 파싱을 시작하려면, 직렬화할 객체의 프로퍼티를 담을 ObjectSeed를 하나 생성한다.
- 파서를 호출하면서, 입력 스트림 리더인 json과 시드를 인자로 전달해야 한다.
- 입력 데이터 끝에 도달하면, spawn 함수를 호출해 결과 객체를 생성한다.
class ObjectSeed<out T: Any>(
targetClass: KClass<T>,
override val classInfoCache: ClassInfoCache
) : Seed {
private val classInfo: ClassInfo<T> = classInfoCache[targetClass] //targetClass의 인스턴스를 만들 때 필요한 정보를 캐시
private val valueArguments = mutableMapOf<KParameter, Any?>()
private val seedArguments = mutableMapOf<KParameter, Seed>()
private val arguments: Map<KParameter, Any?>//생성자 파라미터와 그 값을 연결하는 맵을 만든다
get() = valueArguments + seedArguments.mapValues { it.value.spawn() }
override fun setSimpleProperty(propertyName: String, value: Any?) {
val param = classInfo.getConstructorParameter(propertyName)
valueArguments[param] = classInfo.deserializeConstructorArgument(param, value)//널 생성자 파라미터 값이 간단한 값일 경우 그 값을 기록
}
override fun createCompositeProperty(propertyName: String, isList: Boolean): Seed {
val param = classInfo.getConstructorParameter(propertyName)
val deserializeAs = classInfo.getDeserializeClass(propertyName)//프로퍼티에 대한 DeserializeInterface 애노테이션이 있다면 그 값을 가져온다
val seed = createSeedForType( //파라미터 타입에 따라 ObjectSeed나 CollectionSeed를 만든다
deserializeAs ?: param.type.javaType, isList)
return seed.apply { seedArguments[param] = this }// 만든 Seed객체를 seedArgument 맵에 기록
}
override fun spawn(): T = classInfo.createInstance(arguments) //인자 맵을 넘겨서 targetClass 타입의 인스턴스를 만든다
}
- 결과 클래스에 대한 참조와 결과 클래스 안의 프로퍼티에 대한 정보를 저장하는 캐시인 classInfoCache 객체를 인자로 받는다.
- objectSeed는 생성자 파라미터와 값을 연결해주는 맵을 만든다(두 가지 변경 가능한 맵)
- valueArguments는 간단한 값 프로퍼티 저장(setSimpleProperty 호출해서 값 저장)
- seedArguments는 복합 프로퍼티 저장(createCompositeProperty를 호출해 seedArguments 맵에 새 인자 추가)
- 비어 있는 상태에서 새로운 복합 시드를 추가한 후, 입력 스트림에서 들어오는 데이터로 복합 시드에 데이터를 채운다.
- spawn 메서드는 내부에 중첩된 모든 시드의 spawn을 재귀적으로 호출해, 내부 객체 계층 구조를 만든다.
- arguments 프로퍼티의 커스텀 게터 안에서 mapValues 메서드를 사용해 seedArguments 각 원소에 대해 spawn메서드를 호출
- createSeedForType 함수에서 파라미터 타입을 적절히 분석해 ObjectSeed, ObjectListSeed, ValueListSeed 중 하나를 생성
callBy(), 리플렉션을 사용해 객체 만들기(최종 역직렬화 단계)
KCallable.call() 은 인자 리스트를 받아 함수, 생성자를 호출해 주는데, 디폴트 파라미터 값을 지원해주지 않는 한계가 있다. 따라서 디폴트 파라미터 값을 지원하는 KCallable.callBy를 사용해야 한다.

- 파라미터와 파라미터에 해당하는 값을 연결해주는 맵을 인자로 받는다.
- 인자로 받은 맵에서 파라미터를 찾을 수 없는데, 파라미터 디폴트 값이 정의돼 있다면, 그 디폴트 값을 사용한다.
- 파라미터의 순서를 지킬 필요가 없다.
- 객체 생성자에 원래 정의된 파라미터 순서에 신경 쓰지 않고, JSON에서 이름/값 쌍을 읽어 일치하는 파라미터를 찾아 맵에 파라미터 정보와 값을 넣을 수 있다.
- args 맵에 들어있는 각 값의 타입이 생성자의 파라미터 타입과 일치해야 한다(일치 안 할 경우 IllegalArgumentException)
- 파라미터가, 어떤 타입인지 확인해서 JSON에 있는 타입을 적절한 타입으로 변환해야 한다(KPrameter.type 프로퍼티를 활용)


- 타입별 ValueSerializer 구현이 필요한 타입 검사나 변환 수행
- callBy 메서드에 생성자 파라미터와 그 값을 연결해주는 맵을 넘기면 객체의 주 생성자를 호출할 수 있다.
- ValueSerializer 메커니즘을 사용해 생성자를 호출할 때, 맵에 들어가는 값이 생성자 파라미터 정의의 타입과 일치하게 한다.
ClassInfoCashe는 리플랙션 연산의 비용을 줄이기 위한 클래스이다(직렬화와 역직렬화에 사용하는 애노테이션들이 파라미터가 아니라 프로퍼티에 적용된다). JSON에서 모든 키/값 쌍을 읽을 때마다 이런 검색을 수행하면 코드가 느려질 수 있기에 클래스 별로 한 번만 검색을 수행하고 그 결과를 캐시에 넣어둔다.

- 맵에 값을 저장할 때는 타입 정보가 사라지지만, 돌려받을 값의 타입인 ClassInfo<T>의 타입 인자가 항상 올바른 값이 되게 get 메서드 구현이 보장된다.
- getOrPut에서는 cls에 대한 항목이 cacheMap에 있다면 그 항목을 반환한다.
- 그런 항목이 없다면 전달받은 람다를 호출해 키에 대한 값을 계산하고 계산한 결과 값을 맵에 저장한 다음 반환
ClassInfo 클래스는 대상 클래스의 새 인스턴스를 만들고 필요한 정보를 캐시 해둔다
class ClassInfo<T : Any>(cls: KClass<T>) {
private val className = cls.qualifiedName
private val constructor = cls.primaryConstructor
?: throw JKidException("Class ${cls.qualifiedName} doesn't have a primary constructor")
private val jsonNameToParamMap = hashMapOf<String, KParameter>()
private val paramToSerializerMap = hashMapOf<KParameter, ValueSerializer<out Any?>>()
private val jsonNameToDeserializeClassMap = hashMapOf<String, Class<out Any>?>()
init {
constructor.parameters.forEach { cacheDataForParameter(cls, it) }
}
private fun cacheDataForParameter(cls: KClass<*>, param: KParameter) {
val paramName = param.name
?: throw JKidException("Class $className has constructor parameter without name")
val property = cls.declaredMemberProperties.find { it.name == paramName } ?: return
val name = property.findAnnotation<JsonName>()?.name ?: paramName
jsonNameToParamMap[name] = param
val deserializeClass = property.findAnnotation<DeserializeInterface>()?.targetClass?.java
jsonNameToDeserializeClassMap[name] = deserializeClass
val valueSerializer = property.getSerializer()
?: serializerForType(param.type.javaType)
?: return
paramToSerializerMap[param] = valueSerializer
}
fun getConstructorParameter(propertyName: String): KParameter = jsonNameToParamMap[propertyName]
?: throw JKidException("Constructor parameter $propertyName is not found for class $className")
fun getDeserializeClass(propertyName: String) = jsonNameToDeserializeClassMap[propertyName]
fun deserializeConstructorArgument(param: KParameter, value: Any?): Any? {
val serializer = paramToSerializerMap[param]
if (serializer != null) return serializer.fromJsonValue(value)
validateArgumentType(param, value)
return value
}
private fun validateArgumentType(param: KParameter, value: Any?) {
if (value == null && !param.type.isMarkedNullable) {
throw JKidException("Received null value for non-null parameter ${param.name}")
}
if (value != null && value.javaClass != param.type.javaType) {
throw JKidException("Type mismatch for parameter ${param.name}: " +
"expected ${param.type.javaType}, found ${value.javaClass}")
}
}
fun createInstance(arguments: Map<KParameter, Any?>): T {
ensureAllParametersPresent(arguments)
return constructor.callBy(arguments)
}
private fun ensureAllParametersPresent(arguments: Map<KParameter, Any?>) {
for (param in constructor.parameters) {
if (arguments[param] == null && !param.isOptional && !param.type.isMarkedNullable) {
throw JKidException("Missing value for parameter ${param.name}")
}
}
}
}
- 여기서는!! 를 썼지만 실제 프로덕션 코드는 어떤 문제가 발생했는지를 알려주는 메시지가 들어있는 예외를 던진다.
- 초기화 시 각 생성자 파라미터에 해당하는 프로퍼티를 찾아 애노테이션을 가져온다.
- 데이터를 3가지 맵에 저장한다
- jsonNameToParam : JSON 파일의 각 키에 해당하는 파라미터 저장
- paramToSerializer : 각 파라미터에 대한 직렬화기를 저장
- jsonNameToDeserializeClass : @DeserializeInterface 애노테이션 인자로 지정한 클래스를 저장
- classInfo는 프로퍼티 이름으로 생성자 파라미터를 제공할 수 있으며, 생성자를 호출하는 코드는 그 파라미터를 파라미터와 생성자 인자를 연결하는 맵의 키로 사용한다.
- casheDataForParameter, validateArgumentType, ensureAllParametersPresent 함수는 비공개 함수이다.

- 생성자에 필요한 모든 필수 파라미터가 맵에 들어 있는지 검사.
- 두 경우 모두 아니면 예외
- 파라미터에 디폴트 값이 있으면, param.isOptional이 true로, 파라미터에 대한 인자가 인자 맵에 없어도 아무 문제가 없다.
- 파라미터가 널이 될 수 있는 값이면, 디폴트 파라미터 값으로 null을 사용한다.
- 리플렉션 캐시를 사용하면 역직렬화 하는 과정을 제어하는 애노테이션을 찾는 과정을 JSON 데이터에서 발견한 모든 프로퍼티에 대해 반복할 필요 없이 프로퍼티 이름별로 한 번만 수행할 수 있다.
'KotlinInAction > 애노테이션과 리플렉션' 카테고리의 다른 글
| 리플렉션: 실행 시점에 코틀린 객체 내부 관찰 (0) | 2022.08.16 |
|---|---|
| 애노테이션 선언과 적용 (0) | 2022.08.15 |