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了。
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()。
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转换器来处理。
ContentConverter 接口,并告诉Ktor你支持哪种MIME类型。Ktor会根据请求头的 Accept 和 Content-Type 自动匹配。
4.5 知识体系图
下面这张图帮你理清本章的知识结构:
4.6 如何选择?
说了这么多,到底该用哪个?我个人的建议是:
- 新项目: 直接用
kotlinx.serialization。性能好,类型安全,Kotlin亲儿子。 - 老项目迁移: 如果团队已经熟悉Gson或Jackson,继续用也没问题。Ktor的集成很成熟。
- 特殊格式: 比如YAML、Protobuf,那就自定义Content-Type,自己实现转换器。
嗯,其实没有绝对的好坏。关键是理解内容协商的原理,知道怎么配、怎么用。遇到问题能快速定位是序列化的问题还是内容协商的问题,这才是最重要的。
setPrettyPrinting() 或 INDENT_OUTPUT,调试的时候看着舒服。生产环境建议关掉,省带宽。