본문으로 바로가기
반응형

기본적으로  Json 객체의 구현은 input값에 엄격하고, kotlin type safety를 준수하며, JSON을 표준으로 표현하기 위해서 serialize 할 수 있는 kotlin 값들을 제한합니다.

다시말해, Kotlin의 value와 모델이 정확하게 pair가 맞아야만 serialize가 가능하기 때문에 예외적인 상황들을 처리하려면 custom 하게 json 객체를 만들어야 합니다.

이번장에서는 JSON format을 정의하고 객체를 만드는 방법에 대해서 설명합니다.


 하기 링크을 번역 및 의역 하였습니다.

github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md

이 글은 여러개의 series로 구성되었습니다.

2020/10/22 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Fast apply #1

2020/11/03 - [개발이야기/Kotlin] - [Kotlinx serialization] 기본 사용법#2

2020/11/17 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Builtin classes #3

2020/11/27 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Serializers #4

2020/12/04 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 -Polymorphism #5


Json format을 설정하기 위해서는 기존 Json 객체를 이용하여 custom한 json 객체를 만들수도 있고, Json() builder를 이용할 수도 있으며, JsonBuilder DSL에 추가적인 값을 설정할수도 있습니다.

이렇게 만들어진 Json format instance는 immutable하고 Thread-safe 하므로 top level property에 간단한게 정의하여 사용할 수 있습니다.

실제로 클래스에 추가된 format의 정보들을 cache하므로 한번 생성된 custom 객체를 재활용하여 사용하는게 성능상 유리합니다.

Json configuration

이제 Json 객체에 추가 가능한 설정값에 대해서 알아봅니다.

Pretty printing

앞선 다른 포스팅에서 많이 사용했던 옵션입니다.

실제로 사람이 보기에 readability가 높아지도록 indent를 주어 표기하는 방법입니다.

(로그를 찍어 json 데이터를 확인할때 좋겠죠?)

val format = Json { prettyPrint = true }

@Serializable 
data class Project(val name: String, val language: String)

fun main() {                                      
    val data = Project("kotlinx.serialization", "Kotlin")
    println(format.encodeToString(data))
}

{
    "name": "kotlinx.serialization",
    "language": "Kotlin"
}

실제로 옵션을 주지 않았다면 아래처럼 출력되었을겁니다.

{ "name": "kotlinx.serialization", "language": "Kotlin" }

물론 보여주는 형식만 다를뿐 decode시에는 어떤 형태로 들어오든 상관이 없습니다.

lenient parsing

기본적으로 json에서 사용되는 key 값은 따옴표("")로 감싸져야 합니다.

value 또한 enum, string type일 경우 따옴표로 감싸져야 하며, intenger는 따옴표 없이 숫자값만 들어가야 합니다.

lenient option을 true로 주면 따옴표 형식들에 대한 체크를 느슨하게 해줍니다.

val format = Json { isLenient = true }

enum class Status { SUPPORTED }                                                     

@Serializable 
data class Project(val name: String, val status: Status, val votes: Int)
    
fun main() {             
    val data = format.decodeFromString<Project>("""
        { 
            name   : kotlinx.serialization,
            status : SUPPORTED,
            votes  : "9000"
        }
    """)
    println(data)
}

위 예제에서 String, Enum, integer와 key 값까지 따옴표("")를 전부 거꾸로 썼지만 정상적으로 모델로 decoding 됩니다.

(옵션이 없다면 exception이 발생합니다.)

Project(name=kotlinx.serialization, status=SUPPORTED, votes=9000)

Ignore unknown keys

json을 encode/decode 고정된 단말에서만 진행한다면 문제가 없지만, 서버와 단말간, 이기종 단말간 또는 3rd party와 json으로 통신할 때 정확한 property를 맞출수 없습니다.

버전이 변경됨에 따라 값이 추가될 수도, 삭제될 수도 있기 때문인데, 기본 json은 객체를 사용한다면 json key와 kotlin model의 property는 완벽하게 pair가 맞아야만 에러 없이 동작합니다.

이를 좀 느슨하게 풀어주기 위해 ignoreUnknownKeys 옵션을 사용합니다.

이 역시 기본 사용법 optional properties 섹션에서 언급했습니다.

2020/11/03 - [개발이야기/Kotlin] - [Kotlinx serialization] 기본 사용법#2

val format = Json { ignoreUnknownKeys = true }

@Serializable 
data class Project(val name: String)
    
fun main() {             
    val data = format.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","language":"Kotlin"}
    """)
    println(data)
}

위 예제어서 Project class는 name property 하나만 가지고 있지만 json에는 language 값도 포함되어 있습니다.

Project(name=kotlinx.serialization)

Coercing input values

Json 형식은 항상 같은 코드가 동작하는 동일한 기기의  동일 버전에서만을 위한것이 아니므로 변동 가능성이 있습니다.

하지만 Kotlin serialization은 강력하게 type check를 하기 때문에 조금 느슨하게 조정하려면 coerceInputValues 속성을 true로 주고 써야 합니다.

이 옵션은 decoding 시에만 동작하며, 아래 두가지 경우에 유효 합니다.

  • non null property에 null 입력이 들어온 경우
  • enum값을 담는 property에 정해지지 않은 enum이 들어오는 경우 

이와 같은 경우 model에서 미리 정의된 default 값이 사용됩니다.

val format = Json { coerceInputValues = true }

@Serializable 
data class Project(val name: String, val language: String = "Kotlin")

fun main() {
    val data = format.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","language":null}
    """)
    println(data)
}

Project(name=kotlinx.serialization, language=Kotlin)

실제 json에는 null이 들어있지만 decoding된 값에는 기본값이 "kotlin"이 들어갑니다.

"default value라서 당연한거 아닌가"라는 생각을 할수도 있지만 json에 값이 주어지면 default 값은 무시되는게 원칙입니다. (이것도 2장에음 언급되어 있으니 갸우뚱 하신다면 2장을 먼저 읽고 오시기 바랍니다.)

따라서 coerceInputValues 을 true로 주지 않는다면 당연히 non null type에 null을 넣으면 exception이 발생합니다.

Encoding defaults

Kotline의 default 값이 있는 property의 경우 실제 값이 세팅되지 않으면, json으로 encoding시 제외됩니다.

이는 decoding시에 기본값으로 다시 채워 주기 때문에 불필요하게 할 json string으로 출력할때 포함시킬 필요가 없기 때문입니다.

하지만 동일 단말, 동일 버전에서 Json을 처리 할때는 문제가 없지만 이기종간 (서버/클라이언트) 또는 다른 버전간 json으로 통신할 때는 문제 발생의 소지가 있습니다.

따라서 값이 설정되지 않더라도 기본값을 json으로 출력하기 위하여 encodeDefaults 값을 true로 설정해야 합니다.

val format = Json { encodeDefaults = true }

@Serializable 
class Project(val name: String, 
    val language: String = "Kotlin",
    val website: String? = null
)           

fun main() {
    val data = Project("kotlinx.serialization")
    println("encodeDefaults: ${format.encodeToString(data)}")
    println("no options: ${Json.encodeToString(data)}")
}

encodeDefaults: {"name":"kotlinx.serialization","language":"Kotlin","website":null}

no options: {"name":"kotlinx.serialization"}

Allowing structured map keys

Json에서 사용되는 key는 기본적으로 string 입니다.

따라서 encoding시에 key로 사용되는 부분은 primitive 값이거나 enum이어야 합니다.

기본적으로 map으로 된 형태는 encode 할수 없으나 allowStructuredMapKeys 속성을 활성화 하면 map의 key와 value를 순서대로 나열하는 json array 형태로 표현할 수 있습니다.

val format = Json { allowStructuredMapKeys = true }

@Serializable 
data class Project(val name: String)
    
fun main() {             
    val map = mapOf(
        Project("kotlinx.serialization") to "Serialization",
        Project("kotlinx.coroutines") to "Coroutines"
    )
    println(format.encodeToString(map))
}

[{"name":"kotlinx.serialization"},"Serialization",{"name":"kotlinx.coroutines"},"Coroutines"]

결과를 보면 map형태를 encoding 할 경우 [key1, value1, key2, value2...] 형태를 갖습니다.

Allowing special floating-point values

Double.NaN 나 infinites 같은 특별한 floating point들은 실제로 JVM에서는 많이 쓰이지만 json 표준에서는 지원하지 않는 표현입니다.

따라서 이를 표현하기 위해서는 allowSpecialFloatingPointValues 속성을 활성화 해줘야 합니다.

val format = Json { allowSpecialFloatingPointValues = true }

@Serializable
class Data(
    val value: Double
)                     

fun main() {
    val data = Data(Double.NaN)
    println(format.encodeToString(data))
}

{"value":NaN}

Class discriminator

5장 polymorphic에서 부모타입으로 입력받아 처리하는 경우 "type" 이 기본으로 추가 됩니다.

"type"이라는 기본 key의 이름을 바꾸려면 classDiscriminator 속성을 활성화 시킵니다.

또한 @SerialName과 함께 쓴다면 type을 표기하기 위해 사용되는 key, value값을 모두 custom 하게 사용할 수 있습니다.

val format = Json { classDiscriminator = "#class" }

@Serializable
sealed class Project {
    abstract val name: String
}
            
@Serializable         
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
    val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
    println(format.encodeToString(data))
}  

{"#class":"owned","name":"kotlinx.coroutines","owner":"kotlin"}

Json elements

지금까지 json string과 object간 서로 변환하는 방법에 대해 설명했습니다. Json은 실제로 매우 유연하게 작성되기 때문에 kotlin의 엄격한 type safe부분과는 맞지 않을수 있어 serialization 작업 전에 데이터를 변경하여 맞출 필요가 있습니다.

Json을 원하는대로 변경하기 위해서 JsonElement 객체를 이용합니다.

어떻게 사용하는지 다양한 형태로 변형하는 방법을 알아보겠습니다.

Parsing to Json element

json string은 parsing을 하는 Json.parseToJsonElement 함수를 이용하여 JsonElement 객체로 만들 수 있습니다.

이 단계는 decoding이나 deserialize등을 하는 단계가 아니며, 오로지 json parser만 사용됩니다.

fun main() {
    val element = Json.parseToJsonElement("""
        {"name":"kotlinx.serialization","language":"Kotlin"}
    """)
    println(element)
}

{"name":"kotlinx.serialization","language":"Kotlin"}

예제처럼 JsonElement를 print 하면(toString()) string 그대로를 출력합니다.

parsing을 하는 작업이므로 string에 문제가 있다면 kotlinx.serialization.json.internal.JsonDecodingException이 발생합니다.

Subtypes of Json elements

JsonElement class는 JSON 문법에 따르는 세개의 subtype을 제공합니다.

  • JsonPrimitive: String, number, boolean, null등 JSON에서 제공하는 기본타입을 나타냅니다.
  • JsonArray: "[...]" 로 표현되는 JSON array를 나타냅니다. return되는 값은 JsonElementList 입니다.
  • JsonObject: "{..}"로 표현되는 JSON object를 나타냅니다. Map 형태이므로 String key와 JsonElement를 value로 갖습니다.

JsonElement는 각 subtype에 상응하는 JsonXxx extensions 가지고 있어 바로 호출해서 이에 상응하는 객체를 뽑을 수 있습니다.

JsonPrimitive의 경우 Kotlin primitive로 쉽게 전환 가능하도록 (int, intOrNull, long, longOrNul 등등) 함수를 제공합니다.

    fun main() {

        val element = Json.parseToJsonElement("""
        {
            "name": "kotlinx.serialization",
            "forks": [{"votes": 42}, {"votes": 9000}, {}]
        }
    """)

        val forkObject = element.jsonObject["forks"]
        LogDebug(TAG) {"#1: ${forkObject.toString()}"}

        val forkArray = forkObject?.jsonArray
        LogDebug(TAG) {"#2: ${forkArray.toString()}"}

        forkArray?.forEach {
            val value = it.jsonObject["votes"]?.jsonPrimitive?.int
            LogDebug(TAG) {"#3: ${value?.toString()}"}
        }
    }

각 단계에서의 출력값은 아래와 같습니다.

#1: [{"votes":42},{"votes":9000},{}]
#2: [{"votes":42},{"votes":9000},{}]
#3: 42
#3: 9000
#3: null

Json element builders

buildJsonArraybuildJsonObject 함수를 이용하여 JsonElement 객체를 직접 생성할 수 도 있습니다.

Kotlin 표준 lib의 collection builder와 유사하지만 좀더 편하게 사용할 수 있는 DSL 형태로 제공합니다.

fun main() {
    val element = buildJsonObject {
        put("name", "kotlinx.serialization")
        putJsonObject("owner") {
            put("name", "kotlin")
        }
        putJsonArray("forks") {
            addJsonObject {
                put("votes", 42)
            }
            addJsonObject {
                put("votes", 9000)
            }
        }
    }
    println(element)
}

{"name":"kotlinx.serialization","owner":{"name":"kotlin"},"forks":[{"votes":42},{"votes":9000}]}

Decoding Json element

JsonElement object는 Json.decodeFromJsonElement() 함수로 decoding 할수 있습니다.

@Serializable 
data class Project(val name: String, val language: String)

fun main() {
    val element = buildJsonObject {
        put("name", "kotlinx.serialization")
        put("language", "Kotlin")
    }
    val data = Json.decodeFromJsonElement<Project>(element)
    println(data)
}

Project(name=kotlinx.serialization, language=Kotlin)

Json transformations

serialization 이후에 JSON의 출력 형태를 원하는대로 변경하거나 이렇게 변경된 input된 값을 deserialization 하기 위해 앞서 custom serializer를 사용하는 방법을 언급했습니다.

(#4번 링크 참조)

2020/11/27 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Serializers #4

 

[Kotlinx serialization] Json 직렬화/역직렬화 - Serializers #4

이 글은 android 기준으로 설명을 진행합니다. 하기 링크를 참고 하였습니다. github.com/Kotlin/kotlinx.serialization/blob/master/docs/serializers.md 또한 로그 출력을 위해서 println이나 이와 동일한 역..

tourspace.tistory.com

 

만약 작은 변경만 필요한 경우 serializer를 custom하게 만들기 위해 Encoder, Decoder 규칙들이나 형식에 따라 구현하기에는 부담이 있기 때문에 이런 부담을 줄이기 위해 Json elements tree를 가공하기 위한 API를 제공합니다.

이때 사용하는 JsonTransformingSerializer class는 추상 클래스로 KSerializer를 구현 하고 있습니다.

EncoderDecoder를 직접 구현하기 보다는 transformSerializer()transformDeserializer() 함수를 사용한 JsonElement로 JSON tree의 변경 부분을 JsonTransformingSerializer에 알려주면 됩니다.

물론 이를 이해하기 위해서는 Custom serializer를 생성하는 방법에 대한 이해가 선행되어야 합니다.

Array wrapping

간단한 예제를 통해 사용 방법에 대한 이해를 해보도록 합니다.

먼저 REST API와 통신한다고 할때 User 정보가 한개 또는 여러개 내려올 수 있다고 가정합니다.

@Serializable 
data class Project(
    val name: String,
    @Serializable(with = UserListSerializer::class)      
    val users: List<User>
)

@Serializable
data class User(val name: String)

Project의 users 속성은 User 객체를 List에 담습니다.

다만 json에서의 user 표현은 아래와 같다고 가정합니다.

  • user가 한명 일때: json object로 표현됨
  • user가 여러명 일때: json array로 묶어서 표현됨

유저가 여러개 일때는 일반 json array 묶여서 전달되어 List<User>로 받는것이 문제가 되지 않으나, 유저가 하나일때는 json object 하나만 내려온다면 이는 문제가 생길 수 있으므로 이를 구분하여 처리하기 위해 with 속성으로 따로 UserListSerializer를 만들어 사용하도록 합니다.

object UserListSerializer : JsonTransformingSerializer<List<User>>(ListSerializer(User.serializer())) {
    // If response is not an array, then it is a single object that should be wrapped into the array
    override fun transformDeserialize(element: JsonElement): JsonElement =
        if (element !is JsonArray) JsonArray(listOf(element)) else element
}

deserialization만 수정할것 이므로 JsonTransformingSerializer를 상속받아 transforDeserialize를 override 하도록 구성했습니다.

JsonTransformingSerializer의 생성자로 Generic으로 설정된 List<User>의 serializer를 넣어주기 위해 builtin serializer인 ListSerializer를 이용합니다.

ListSerializer 역시 Generic값으로 사용되는 User의 serializer를 생성자에 넣어줘야 하므로 User객체의  User.serializer()로 serializer를 얻어서 넣어줍니다.

(User 클래스는 이미 @Serializable 하므로 자동 생성된 serialzier가 존재함)

transformDeserialize를 override 하여 element가 JosnArray아 아니라면 list 형태로 만들어서 JsonElement로 반환하도록 해 줍니다.

따라서 아래와 같이 단수개, 복수개의 user 값이 들어오더라도 UserListSerializer를 통해서 정상적으로 list로 만들어져 동작함을 알 수 있습니다.

fun main() {     
    println(Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","users":{"name":"kotlin"}}
    """))
    println(Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","users":[{"name":"kotlin"},{"name":"jetbrains"}]}
    """))
}

Project(name=kotlinx.serialization, users=[User(name=kotlin)]) Project(name=kotlinx.serialization, users=[User(name=kotlin), User(name=jetbrains)])

Array unwrapping

이번엔 반대의 작업을 해봅니다.

Project class는 user를 list 형태로 가지고 있지만 단일개수 일때는 json object로,  여러개일때는 json array로 만들도록 합니다

@Serializable 
data class Project(
    val name: String,
    @Serializable(with = UserListSerializer::class)      
    val users: List<User>
)

@Serializable
data class User(val name: String)

object UserListSerializer : JsonTransformingSerializer<List<User>>(ListSerializer(User.serializer())) {

    override fun transformSerialize(element: JsonElement): JsonElement {
        require(element is JsonArray) // we are using this serializer with lists only
        return element.singleOrNull() ?: element
    }
}

fun main() {     
    val data = Project("kotlinx.serialization", listOf(User("kotlin")))
    println(Json.encodeToString(data))
}

{"name":"kotlinx.serialization","users":{"name":"kotlin"}}

위와같이 list에 값이 하나라면 json object로 표현됩니다.

Manipulating default values

앞선 포스팅들에서 기본값들은 json으로 변환시 생략된다고 언급했습니다.

default값이 선언된 class의 property는 아니지만 특정값이 들어오는 경우 deafult 값으로 인지하여 json string에서는 생략하는 형태를 구현해 보겠습니다.

 

@Serializable
class Project(val name: String, val language: String)

object ProjectSerializer : JsonTransformingSerializer<Project>(Project.serializer()) {
    override fun transformSerialize(element: JsonElement): JsonElement =
        // Filter out top-level key value pair with the key "language" and the value "Kotlin"
        JsonObject(element.jsonObject.filterNot {
            (k, v) -> k == "language" && v.jsonPrimitive.content == "Kotlin"
        })
}                           

fun main() {
    val data = Project("kotlinx.serialization", "Kotlin")
    println(Json.encodeToString(data)) // using plugin-generated serializer
    println(Json.encodeToString(ProjectSerializer, data)) // using custom serializer
}

{"name":"kotlinx.serialization","language":"Kotlin"}

{"name":"kotlinx.serialization"}

transformSerialize()를 override 하여 language 값이 "kotlin"이라면 생락하도록 합니다.

Content-based polymorphic deserialization

polymorphic한 serialization을 할때 실제 어떤 serializer를 사용해야하는지를 판단하기 위해 type 이라는 key가 기본적으로 추가 된다는 것을 polymorphic편에서 언급했습니다.

그러나 type정보가 누락되더라도 json의 구성요소를 확인하여 (어떤 key값이 존재하는지 등) 어떤 model에 담아야 하는지를 구분할 수 있습니다.

json 구성 요소에 따라 serializer가 변경되야 하므로 serializer를 반환받는 selectDeserializer() 함수를 override 하도록 합니다. 이는 JsonContentPolymorphicSerializer를 상속하여 구현하며 아래 예제에서는 selectDeserializer() 함수 내부에서 특정 key값의 존재 여부를 통해 알맞은 serializer를 반환하도록 합니다.

@Serializable
abstract class Project {
    abstract val name: String
}                   

@Serializable 
data class BasicProject(override val name: String): Project() 

            
@Serializable
data class OwnedProject(override val name: String, val owner: String) : Project()

Project를 상속받은 두개의 class가 존재한다고 가정합니다.

이중 owner 속성은 OwnedProject에만 있으므로 이 속성으로 두개의 class를 구분하도록 합니다.

object ProjectSerializer : JsonContentPolymorphicSerializer<Project>(Project::class) {
    override fun selectDeserializer(element: JsonElement) = when {
        "owner" in element.jsonObject -> OwnedProject.serializer()
        else -> BasicProject.serializer()
    }
}

selectDeserializer() 내부에서 owner의 존재여부로 사용할 serializer를 반환합니다.

위와같이 구성한다면 runtime시에 동적으로 serializer가 선택됩니다.

fun main() {
    val data = listOf(
        OwnedProject("kotlinx.serialization", "kotlin"),
        BasicProject("example")
    )
    val string = Json.encodeToString(ListSerializer(ProjectSerializer), data)
    println(string)
    println(Json.decodeFromString(ListSerializer(ProjectSerializer), string))
}

[{"name":"kotlinx.serialization","owner":"kotlin"},{"name":"example"}]

[OwnedProject(name=kotlinx.serialization, owner=kotlin), BasicProject(name=example)]

type값이 존재하지 않더라도 owner type으로 알맞은 형태의 json과 객체로 serialize/deserialize 됨을 알 수 있습니다.

Under the hood (experimental)

위에서 언급된 Abstract serrializer class들을 상속받아 필요한 함수들을 override함으로써 원하는대로 serializer를 구현할 수 있습니다. 물론 transformSerialize, transformDeserialize, selectDeserializer 등의 함수로의 변형이 부족하다면 직접 KSerializer를 구현 하는것도 하나의 방법입니다.

Custom한 Serializer를 구현하기 전에 간단히 정리해 보면,

  • Json format을 사용하고 있다면 EncoderJsonEncoder로 casting될수 있으며 Decoder 역시 JsonDecoder로 casting 가능합니다.
  • JsonEncoderJsonDecoder는 각각 encodeJsonElement()decodeJsonElement() 함수를 가지고 있으며 현재의 stream에 element를 넣거나 반환하는 기능을 제공합니다.
  • JsonEncoderJsonDecoder 모두 json 속성을 통해 Json 객체에 접근 가능합니다.
  • Json 객체는 encodeToJsonEnlement(), decodeFromJsonElement() 함수를 제공합니다.

이제 위 내용을 토대로 Decoder -> JsonElement -> value 또는 value -> JsonElement -> Encode로 두개의 단계로 분리하여 변환하는 코드를 작성해 보겠습니다.

@Serializable(with = ResponseSerializer::class)
sealed class Response<out T> {
    data class Ok<out T>(val data: T) : Response<T>()
    data class Error(val message: String) : Response<Nothing>()
}

먼저 OKError 응답을 나타내는 sealed Reponse class를 작성합니다.

그리고 어떤 응답이 오든 serialize가 가능한 ResponseSerializer를 아래와 같이 생성합니다.

class ResponseSerializer<T>(private val dataSerializer: KSerializer<T>) :
                                                        KSerializer<Response<T>> {
    override val descriptor: SerialDescriptor =
                       buildSerialDescriptor("Response", PolymorphicKind.SEALED) {
        element("Ok", buildClassSerialDescriptor("Ok") {
            element<String>("message")
        })
        element("Error", dataSerializer.descriptor)
    }

    override fun deserialize(decoder: Decoder): Response<T> {
        // Decoder -> JsonDecoder
        require(decoder is JsonDecoder) // this class can be decoded only by Json
        // JsonDecoder -> JsonElement
        val element = decoder.decodeJsonElement()
        // JsonElement -> value
        if (element is JsonObject && "error" in element)
            return Response.Error(element["error"]!!.jsonPrimitive.content)
        return Response.Ok(decoder.json.decodeFromJsonElement(dataSerializer, element))
    }

    override fun serialize(encoder: Encoder, value: Response<T>) {
        // Encoder -> JsonEncoder
        require(encoder is JsonEncoder) // This class can be encoded only by Json
        // value -> JsonElement
        val element = when (value) {
            is Response.Ok -> encoder.json.encodeToJsonElement(dataSerializer, value.data)
            is Response.Error -> buildJsonObject { put("error", value.message) }
        }
        // JsonElement -> JsonEncoder
        encoder.encodeJsonElement(element)
    }
}

위와 같이 작성하여 각 응답에 따라 동작 방식을 분기하면 어떤 형태의 Reponse가 들어와도 serialize 가능해 집니다.

@Serializable
data class Project(val name: String)

fun main() {
    val responses = listOf(
        Response.Ok(Project("kotlinx.serialization")),
        Response.Error("Not found")
    )
    val string = Json.encodeToString(responses)
    println(string)
    println(Json.decodeFromString<List<Response<Project>>>(string))
}

[{"name":"kotlinx.serialization"},{"error":"Not found"}]

[Ok(data=Project(name=kotlinx.serialization)), Error(message=Not found)]

Maintaining custom JSON arributes

Custom 한 Json serializer의 좋은 예는 이 specific한 json 자체를 하나의 JsonObject로 묶어서 그대로 표현하도록 하는거라고 볼수 있습니다.

따라서 UnknownProject class를 아래처럼 만들어 specific 한 json object 자체를 묶어서 표현하는 방법을 알아봅니다.

data class UnknownProject(val name: String, val details: JsonObject)

details property에 넣고 싶은 데이터는 아래와 같습니다.

{"type":"unknown","name":"example","maintainer":"Unknown","license":"Apache 2.0"}

기본적으로 plugin-generated serializer를 이용한다면 Json object를 분리하여 넣어줘야 합니다.

하지만 이 string 자체를 통째로 담고 싶으니 KSeriliazer를 상속받아 custom하게 만들어 보도록 하겠습니다.

object UnknownProjectSerializer : KSerializer<UnknownProject> {
    override val descriptor: SerialDescriptor = buildClassSerialDescriptor("UnknownProject") {
        element<String>("name")
        element<JsonElement>("details")
    }

    override fun deserialize(decoder: Decoder): UnknownProject {
        // Cast to JSON-specific interface
        val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON")
        // Read the whole content as JSON
        val json = jsonInput.decodeJsonElement().jsonObject
        // Extract and remove name property
        val name = json.getValue("name").jsonPrimitive.content
        val details = json.toMutableMap()
        details.remove("name")
        return UnknownProject(name, JsonObject(details))
    }

    override fun serialize(encoder: Encoder, value: UnknownProject) {
        error("Serialization is not supported")
    }
}

 

  1. descripter에는 name과 detail 두개의 속성을 정의 합니다.
  2. deserialize에서는 Json format인지 확인한 후에 속성에서 name 값만 추출하고, 나머지는 JsonObject 형태로 만듭니다.
  3. serialize는 사용하지 않을예정이므로 error를 뱉습니다.
fun main() {
    println(Json.decodeFromString(UnknownProjectSerializer, 
    """{"type":"unknown","name":"example","maintainer":"Unknown","license":"Apache 2.0"}"""))
}

UnknownProject(name=example, details={"type":"unknown","maintainer":"Unknown","license":"Apache 2.0"})

 

Summary

총 여섯개의 chapter로 나눠서 Kotlinx serialization에 대해서 알아봤습니다.

원문에는 마지막에 Alternative and custom formats (experimental). chaper가 더 존재 하지만 이는 experimental 이므로 저는 여기서 마무리 지으려고 합니다.

사실 이전 포스팅에도 언급했지만 딱 fast apply 수준에서 사용하는게 가장 좋아 보이기는 합니다.

그정도 수준이 여러명이 같이 개발할때 학습 난이도도 낮으며, 코드만 보더라도 직관적으로 이해할 수 있기 때문입니다.

물론 경우에 따라서 serializer를 만들어야 필요가 있을 수 있고, Json의 특정 속성들을 enable시켜야 할수도 있습니다.

하지만 그럴때는 충분한 주석과 함께 표기하여 reviewer와 추후 수정할 사람에 대한 배려가 있었으면 하는 생각입니다.

반응형