4. 内容协商与序列化:JSON序列化、Gson/Jackson集成、自定义Content-Type

好,咱们今天聊点实在的。你在Ktor里写API,总得跟客户端交换数据吧?那数据怎么从对象变成字符串,再从字符串变回对象?这就是序列化和反序列化的活儿。说白了,就是让Kotlin对象和JSON字符串之间能互相“翻译”。

我个人习惯把这块叫做“API的翻译官”。翻译官要是水平不行,你传过去的数据客户端看不懂,客户端发来的数据你解析不了,那这接口就废了。所以,内容协商和序列化,是Ktor后端开发里绕不开的核心环节。

4.1 内容协商:让客户端和服务器“说同一种语言”

先聊聊“内容协商”这个概念。你想想看,客户端可能想要JSON,也可能想要XML,甚至可能想要Protobuf。服务器怎么知道该返回哪种格式?

答案就在HTTP请求头里。客户端会在 Accept 头里告诉服务器:“嘿,我能看懂JSON,你最好给我JSON。” 服务器收到后,会根据自己支持的能力,选择一个双方都懂的格式返回。

Ktor里实现这个机制,靠的是 ContentNegotiation 插件。它就像一个“格式路由器”,根据请求头自动选择序列化器。

install(ContentNegotiation) {
    json() // 默认使用kotlinx.serialization
}

就这么一行,Ktor就知道:客户端要JSON,我就用JSON序列化。客户端要别的?嗯,如果没配别的,那就只能返回406 Not Acceptable了。

核心要点: 内容协商不是“我支持什么就返回什么”,而是“客户端能看懂什么,我就返回什么”。这是RESTful API的基本素养。

4.2 JSON序列化:kotlinx.serialization

说到JSON序列化,Kotlin官方推荐的是 kotlinx.serialization。为什么?因为它是Kotlin原生支持的,编译期就帮你生成了序列化代码,运行时几乎没有反射开销。性能好,而且类型安全。

我在项目中遇到过一个问题:用Gson反序列化一个带默认值的data class,如果JSON里没传某个字段,Gson会直接赋null,而不是用你定义的默认值。但kotlinx.serialization就不会,它会老老实实走默认值。这一点,用起来放心很多。

4.2.1 基本用法

首先,你得在 build.gradle.kts 里加上依赖和插件:

plugins {
    kotlin("plugin.serialization") version "1.9.0"
}

dependencies {
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
}

然后定义你的数据类,记得加上 @Serializable 注解:

@Serializable
data class User(
    val id: Int,
    val name: String,
    val email: String = "default@example.com" // 默认值
)

在路由里直接用:

get("/user") {
    val user = User(1, "张三")
    call.respond(user) // 自动序列化为JSON
}

post("/user") {
    val user = call.receive<User>() // 自动反序列化
    // 处理逻辑...
}

嗯,这里要注意:call.receive<User>() 会尝试从请求体里解析JSON。如果格式不对,Ktor会返回400 Bad Request。我曾经在调试时忘了给请求体加Content-Type头,结果一直报错,排查了半天才发现是客户端没告诉服务器“我发的是JSON”。

4.2.2 自定义序列化行为

有时候,你不想把字段名原样输出。比如后端用下划线命名,前端用驼峰命名。这时候可以用 @SerialName 注解:

@Serializable
data class Product(
    @SerialName("product_id")
    val productId: Int,
    @SerialName("product_name")
    val productName: String
)

这样序列化出来的JSON就是 {"product_id": 1, "product_name": "手机"},而不是 {"productId": 1, "productName": "手机"}

小技巧: 如果你整个项目都统一用下划线命名,可以在 Json 配置里设置 ignoreUnknownKeys = true,这样反序列化时遇到未知字段不会报错,兼容性更好。

4.3 Gson/Jackson集成

虽然kotlinx.serialization很香,但有些老项目可能已经用了Gson或Jackson。Ktor也支持它们,集成起来很简单。

4.3.1 集成Gson

加依赖:

implementation("io.ktor:ktor-serialization-gson:$ktor_version")

配置:

install(ContentNegotiation) {
    gson {
        setPrettyPrinting() // 格式化输出
        serializeNulls()    // 序列化null值
    }
}

Gson有个特点:默认不序列化null字段。如果你希望接口返回 {"name": null} 而不是直接省略这个字段,记得加上 serializeNulls()

避坑指南: 我曾经用Gson反序列化一个带泛型的List<User>,结果因为类型擦除,Gson直接返回了List<Map<String, Any>>。后来必须用 TypeToken 显式指定类型。这一点kotlinx.serialization就做得更好,它通过内联函数保留了泛型信息。

4.3.2 集成Jackson

加依赖:

implementation("io.ktor:ktor-serialization-jackson:$ktor_version")

配置:

install(ContentNegotiation) {
    jackson {
        enable(SerializationFeature.INDENT_OUTPUT)
        disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    }
}

Jackson的配置项非常多,你可以按需调整。我个人觉得Jackson最大的优势是生态丰富,各种注解和模块都有。但缺点也很明显:反射调用多,性能不如kotlinx.serialization。

4.4 自定义Content-Type

有时候,你不想用JSON,也不想用XML。比如你想返回YAML格式,或者自定义的二进制格式。这时候就需要自定义Content-Type和序列化器。

Ktor允许你注册自己的 ContentConverter。举个例子,假设我想支持YAML:

class YamlConverter : ContentConverter {
    override suspend fun convertForSend(
        context: PipelineContext<Any, ApplicationCall>,
        contentType: ContentType,
        value: Any
    ): Any? {
        if (contentType != ContentType("text", "yaml")) return null
        // 将value序列化为YAML字符串
        return yamlMapper.writeValueAsString(value)
    }

    override suspend fun convertForReceive(
        context: PipelineContext<ApplicationCall, ApplicationCall>,
        contentType: ContentType
    ): Any? {
        if (contentType != ContentType("text", "yaml")) return null
        // 从请求体读取YAML并反序列化
        val text = context.call.receive<String>()
        return yamlMapper.readValue(text, Any::class.java)
    }
}

然后注册它:

install(ContentNegotiation) {
    register(ContentType("text", "yaml"), YamlConverter())
}

这样,当客户端发送 Accept: text/yaml 时,Ktor就会用你的YAML转换器来处理。

关键点: 自定义Content-Type的核心是实现 ContentConverter 接口,并告诉Ktor你支持哪种MIME类型。Ktor会根据请求头的 AcceptContent-Type 自动匹配。

4.5 知识体系图

下面这张图帮你理清本章的知识结构:

内容协商与序列化 内容协商机制 Accept 请求头匹配 ContentNegotiation 插件 自动选择序列化器 JSON序列化库 kotlinx.serialization Gson 集成 Jackson 集成 自定义Content-Type 实现 ContentConverter 注册自定义MIME类型 YAML/Protobuf等格式 核心原则:客户端能看懂什么,我就返回什么 通过 Accept/Content-Type 头自动协商,无需硬编码

4.6 如何选择?

说了这么多,到底该用哪个?我个人的建议是:

  • 新项目: 直接用 kotlinx.serialization。性能好,类型安全,Kotlin亲儿子。
  • 老项目迁移: 如果团队已经熟悉Gson或Jackson,继续用也没问题。Ktor的集成很成熟。
  • 特殊格式: 比如YAML、Protobuf,那就自定义Content-Type,自己实现转换器。

嗯,其实没有绝对的好坏。关键是理解内容协商的原理,知道怎么配、怎么用。遇到问题能快速定位是序列化的问题还是内容协商的问题,这才是最重要的。

最后提醒一句: 不管用哪个库,记得在开发环境开启 setPrettyPrinting()INDENT_OUTPUT,调试的时候看着舒服。生产环境建议关掉,省带宽。

公众号:蓝海资料掘金营,微信deep3321