CodeGym /课程 /Kotlin SELF /手动构建 JSON —— 报告与非标准结构

手动构建 JSON —— 报告与非标准结构

Kotlin SELF
第 49 级, 课程 3
可用

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 对象
{...}
buildJsonObject { ... }
→
JsonObject
JSON 数组
[...]
buildJsonArray { ... }
→
JsonArray
字符串/数字/布尔值
JsonPrimitive(...)
null
JsonNull

让我们从一个非常简单的例子开始,感受一下语法:这里没有领域逻辑,只有“构建对象 → 打印输出”。

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。手动构建的优势在于你可以“手术式”地修改格式,但这并不意味着你应该“以防万一”塞进所有东西。按需构建,逐步扩展。

1
任务
Kotlin SELF, 第 49 级, 课程 3
已锁定
操作状态
操作状态
1
任务
Kotlin SELF, 第 49 级, 课程 3
已锁定
个人资料问卷
个人资料问卷
1
任务
Kotlin SELF, 第 49 级, 课程 3
已锁定
导出支出
导出支出
1
任务
Kotlin SELF, 第 49 级, 课程 3
已锁定
类别报告
类别报告
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION