1. 介绍
当你初次接触 kotlinx.serialization 时,可能会有这样的感触:“太棒了!现在我可以把任何 JSON 转成 data class 了,一切都很美好。”但在实践中,情况通常更复杂:有时报告格式与你的模型不匹配,有时你只需要返回部分字段,或者需要添加模型中根本不存在的聚合数据。这时,手动构建 JSON 就从“令人讨厌的临时方案”变成了“实用的工具”。
想象一下我们的实用控制台项目(一个假设的费用追踪器):在程序内部,我们有标准的 Kotlin 结构(Expense,列表,Map),我们希望导出 JSON 格式的“费用报告”以便与其他系统集成。但报告并不是简单的单一 Expense,它是一个包含以下内容的结构:
- 总金额;
- 按类别统计的金额;
- 操作列表;
- 服务类 meta 部分(格式版本、货币、报告作者)。
如果我们尝试“直接序列化费用列表”,就会丢失聚合数据。如果尝试“创建一个巨大的 Report data class”,它会变得过于臃肿,且报告格式会与领域模型强耦合。因此,今天我们学习像搭建乐高积木一样构建 JSON:仔细、可预测,且无需手动拼接字符串(字符串拼接是导致漏掉逗号的万恶之源)。
2. 心智模型:作为流水线的 JSON 报告
在编写代码之前,最好先建立一个认知:这里存在两个完全不同的世界——“计算”和“打包成 JSON”。如果你把它们混在一起写进一个巨大的函数里,就会得到一锅乱炖:不仅难以阅读,调试时也像用漏勺舀汤一样费劲。因此,我们保持一个简单的流水线:先在 Kotlin 中计算数据,然后将其打包成 JSON 树。
我们要反复默念的方案(是的,程序员也喜欢“咒语”;我们称之为“模式”):
flowchart TD
A[Kotlin 中的数据: List⟨Expense⟩] --> B[计算: 总额, 按类别统计]
B --> C[构建 JsonElement: buildJsonObject/buildJsonArray]
C --> D[编码为字符串: Json.encodeToString]
D --> E[输出/保存]
从 Kotlin 的角度来看,buildJsonObject { ... } 和 buildJsonArray { ... } 是带有接收者的 DSL 构建器(lambda with receiver):在代码块内,你就像身处构建器对象内部,可以直接调用其方法。在底层,编译器会尽力进行类型推断,让你少写“废话”——这与 apply/with 等“惯用写法”精神一致。如果深入底层,Kotlin 中的这类构建器 DSL 依赖于 Lambda 内部的类型推断机制(builder-style inference)。
3. 准备工作:JsonElement、模型与转换器
手动构建 JSON 的基础模块
要手动构建 JSON,关键在于改变思维模式:不要想“我在写 JSON”,而要考虑“我在创建 JsonElement”。这大大降低了出现语法错误的可能性,因为错误现在变成了“类型级别”的(编译器会报错),而不是“字符串级别”的(用户会报错)。
以下是我们今天需要的最基础组件:
| 目标结果 | 表示方式 |
|---|---|
JSON 对象 |
→ |
JSON 数组 |
→ |
| 字符串/数字/布尔值 | |
|
|
让我们从一个非常简单的例子开始,感受一下语法:这里没有领域逻辑,只有“构建对象 → 打印输出”。
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun main() {
val obj: JsonObject = buildJsonObject {
put("status", JsonPrimitive("ok"))
put("count", JsonPrimitive(3))
put("note", JsonNull)
}
println(obj) // {"status":"ok","count":3,"note":null}
}
注意:put 接受一个 JsonElement,这意味着我们需要将“原始的” Int 和 String 转换为 JsonPrimitive。虽然代码稍微多了一点,但它极具表现力且严谨。
项目模型:Expense 与报告数据
为了让例子不脱离实际,我们继续之前的项目线。假设我们有一笔费用:包含标识符、标题、类别和金额(为了避免分数和舍入问题,我们以货币的最小单位存储)。
如果你的项目中字段名称略有不同也不要紧:今天重点是风格,而非具体的名称。
data class Expense(
val id: Int,
val title: String,
val category: String,
val amount: Int
)
现在我们创建一个简单的费用列表,以便在 main 函数中构建报告。在实际项目中,该列表来自你的存储层(文件或内存),但目前重要的是“如何构建 JSON”。
fun sampleExpenses(): List<Expense> = listOf(
Expense(id = 1, title = "咖啡", category = "饮食", amount = 220),
Expense(id = 2, title = "交通", category = "通勤", amount = 75),
Expense(id = 3, title = "披萨", category = "饮食", amount = 540)
)
单个实体的 JSON:expenseToJson(...)
在构建报告时,几乎总是会有重复的部分:“一个元素在 JSON 中长什么样”。如果不将其提取到一个函数中,put("id", JsonPrimitive(...)) 的复制粘贴代码就会散布在整个项目中——然后当你更新格式时,只修改了一个地方却忘了另一个。这就是经典的“真相的双重版本”错误——没有高深哲学,只有 bug。
让我们创建一个将单个费用转换为 JsonObject 的转换器。它不是“魔法般”地序列化,而是显式地排列字段——这是一个加分项,因为你完全掌控了格式。
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun expenseToJson(e: Expense): JsonObject = buildJsonObject {
put("id", JsonPrimitive(e.id))
put("title", JsonPrimitive(e.title))
put("category", JsonPrimitive(e.category))
put("amount", JsonPrimitive(e.amount))
}
从现在起,报告可以像乐高一样组装:通过 expenseToJson 将费用列表转换为 JSON 对象数组,而无需每次都重新手写字段。
4. 构建报告:聚合数据与条目数组
现在是最有趣的部分:报告通常包含“所有内容”。如果你不尝试在 buildJsonObject 内部直接进行聚合计算,这其实一点也不可怕。我们采用理性的做法:先计算,后打包。
在 Kotlin 中计算聚合数据
首先,总金额:
fun totalAmount(items: List<Expense>): Int =
items.sumOf { it.amount }
然后是按类别统计。当然,实现方式多种多样;我们采用一种简单易读的:分组,然后对每组求和。
fun totalByCategory(items: List<Expense>): Map<String, Int> =
items.groupBy { it.category }
.mapValues { (_, list) -> list.sumOf { it.amount } }
将聚合数据打包成 JSON
现在我们组装最终的 JsonObject。在内部,我们将包含 total、byCategory 对象和 items 数组。
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
fun buildExpensesReport(items: List<Expense>): JsonObject {
val total = totalAmount(items)
val byCat = totalByCategory(items)
return buildJsonObject {
put("total", JsonPrimitive(total))
put("byCategory", buildJsonObject {
for ((category, amount) in byCat) {
put(category, JsonPrimitive(amount))
}
})
put("items", buildJsonArray {
for (e in items) add(expenseToJson(e))
})
}
}
这里重要的是理解:byCategory 是一个嵌套的 JSON 对象,items 是一个嵌套的 JSON 数组。我们不是在“打印 JSON”,而是在构建一个元素树。
5. 非标准报告结构
报告很少是“数据库的直接副本”。通常格式会有特殊要求:重命名键、合并字段、条件分支或嵌套结构。手动构建正是为此而生:你可以直接在代码中描述格式,而无需强制其适应领域模型。
重命名键与“易读”字段
假设我们希望报告使用 name 代替 title,并添加一个 amountText 字段(作为字符串),因为这样在 UI 上展示更方便。
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun expenseToPublicJson(e: Expense): JsonObject = buildJsonObject {
put("id", JsonPrimitive(e.id))
put("name", JsonPrimitive(e.title))
put("amount", JsonPrimitive(e.amount))
put("amountText", JsonPrimitive("${e.amount} ₣ "))
}
注意:领域模型本身没有改变,但报告格式改变了。这是一个好的信号:你没有为了导出数据而“破坏”业务逻辑。
JsonNull 与缺失字段的区别
一个非常常见的细微差别:“字段缺失”和“字段存在但值为 null”是两码事。即使在你看来它们一样,但在数据链路的另一端,对方(人或服务)可能会有不同解读。因此,我们需要能够同时处理这两种变体。
让我们模拟一个 comment 字段:有时它存在,有时不存在。
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun expenseWithCommentJson(e: Expense, comment: String?): JsonObject = buildJsonObject {
put("id", JsonPrimitive(e.id))
put("title", JsonPrimitive(e.title))
if (comment == null) {
put("comment", JsonNull) // 字段存在,但它是 null
} else {
put("comment", JsonPrimitive(comment))
}
}
现在看看“如果没有评论,则完全没有该字段”的变体。这是完全不同的协议契约。
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
fun expenseWithOptionalCommentJson(e: Expense, comment: String?): JsonObject = buildJsonObject {
put("id", JsonPrimitive(e.id))
put("title", JsonPrimitive(e.title))
if (comment != null) {
put("comment", JsonPrimitive(comment)) // 如果 comment 为 null,则该字段缺失
}
}
从表面上看,这些 JSON 几乎一样,但意义截然不同。这种差异正是手动构建有时比“自动序列化”更真实的原因。
6. 混合方法:部分手动,部分通过 encodeToJsonElement(...)
有时你想手动构建报告的“包装层”,但同时将内部的某个部分直接序列化,因为该部分已经通过 @Serializable 完美描述了。这是一个非常合理的策略:你不需要在“纯手动”和“纯 data class”之间二选一。
让我们创建一个小的报告元信息对象(格式版本和货币)。它简单且稳定,是 @Serializable 的理想候选。
import kotlinx.serialization.Serializable
@Serializable
data class ReportMeta(
val formatVersion: Int,
val currency: String
)
现在我们通过 Json.encodeToJsonElement(...) 打包 meta,其余部分手动处理。
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
fun buildReportWithMeta(items: List<Expense>): JsonObject {
val meta = ReportMeta(formatVersion = 1, currency = "RUB")
val total = totalAmount(items)
return buildJsonObject {
put("meta", Json.encodeToJsonElement(meta))
put("total", JsonPrimitive(total))
}
}
为什么这很方便?因为 ReportMeta 是一个可以重用的“小数据模型”,而报告本身是一个“非标准结构”,手动控制它更灵活。
7. 如何将 JsonElement 转为字符串
当你构建好 JsonObject 或 JsonArray 后,一个简单的问题出现了:“好的,我该如何获取标准的 JSON 文本?”在 Kotlin 中,使用 .toString() 很诱人——没错,它确实会产生 JSON 表示,但通常是一个压缩后的单行字符串。这对于日志记录没问题,但对于人类阅读的报告,通常希望格式更“漂亮”。
让我们创建两个版本:压缩版和格式化版。
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.serializer
fun main() {
val report: JsonObject = buildJsonObject { put("status", JsonPrimitive("ok")) }
println(report.toString()) // {"status":"ok"}
val prettyJson = Json { prettyPrint = true }
val text: String = prettyJson.encodeToString(JsonElement.serializer(), report)
println(text) // 格式化后的多行 JSON
}
重要提示:我们直接编码 JsonElement,因为我们的报告已经是一个 JSON 树了,而不是一个 @Serializable 类。这很合逻辑:今天我们是在“以结构形式”构建 JSON,而不是“以模型形式”。
8. 手动构建 JSON 时的常见错误
错误 #1:将 JSON 作为字符串拼接。
当你想要用 "{\"total\": $total}" 时,请记住你已经知道如何构建 JsonObject 了。字符串拼接在引号、换行符、转义以及“这里是不是要加逗号”等问题上极易出错。使用 buildJsonObject,至少编译器是你的盟友。
错误 #2:将报告计算逻辑和 JSON 打包混在一起。
如果在 buildJsonObject { ... } 内部开始做 groupBy、sumOf、排序和过滤,代码很快会变成一团乱麻。先将一切计算好存入 Kotlin 变量(val total,val byCat),然后冷静地打包。这样调试计算逻辑和 JSON 格式都会容易得多。
错误 #3:混淆“无字段”与“字段为 null”。
如果你有时省略 key,有时填入 JsonNull,外部的消费端逻辑可能会出错。这不仅仅是一个“小细节”,它是格式契约的一部分。确定一条规则并始终坚持执行。
错误 #4:在项目中到处复制硬编码的字符串作为 key。
今天你写 "total",明天写 "Total",后天写 "sum"——结果你的报告“时而解析正常,时而报错”。即使在小的学习项目中,最好也将 key 统一放在一处(例如 object ReportKeys { const val TOTAL = "total" })并使用常量,避免滋生随机的拼写错误。
错误 #5:在对象中放入非 JsonElement 类型的数据,导致得到“伪 JSON”。
有时初学者会尝试 put("count", 3),然后惊讶地发现编译器报错。手动构建时,你必须在 JsonElement 级别操作,因此需要将数字和字符串转换成 JsonPrimitive。虽然代码多了几个字符,但格式在 API 层面实现了严格类型化。
错误 #6:试图一开始就设计出“完美的通用格式”。
报告是一种契约。今天你只需要 total 和 items,明天要添加 byCategory,后天要加 meta。手动构建的优势在于你可以“手术式”地修改格式,但这并不意味着你应该“以防万一”塞进所有东西。按需构建,逐步扩展。
GO TO FULL VERSION