32 的热流只提供内存中的任务观察入口。接收外部任务之前还有一道不应省略的边界:JSON 能反序列化为 Kotlin 对象,是否说明这个对象可以成为业务任务?答案取决于**结构契约**与**领域不变量**是否都通过,二者不由同一个机制检查。

本篇冻结 Kotlin JVM/serialization 编译插件 2.2.21、kotlinx-serialization-json:1.9.0、Gradle 8.14、JDK 21、coroutines 1.10.2。序列化库 v1.9.0 tag SHA 16306f4c97e21e4d134bf1bb95fc20a61927f6b0,官方 CHANGELOG 注明该版要求 Kotlin 2.2。当前组合能构建和运行的依据是本机 Gradle 解析及测试日志,不是从版本号推断所有组合都兼容。文档依据为 Kotlin 官方序列化入口及 Json、ignoreUnknownKeys、explicitNulls API(2026-10-10 查阅);这些入口会滚动更新,实验只对应本篇锁定的依赖。

DTO 描述字段,不代表已接受的任务

累计工程新增 @Serializable TaskRequestDto(id, text, labels = emptyList(), note = null)。id 和 text 没有默认值,labels 与 note 有;@Serializable 需要 Kotlin 序列化编译插件生成相应序列化支持,kotlinx-serialization-json 则提供 JSON 格式实现。应用不是通过 HTTP 接收它:本篇只测试本地受控字符串的编解码。

1
2
3
4
5
6
7
8
9
10
@Serializable
data class TaskRequestDto(
val id: String,
val text: String,
val labels: List<String> = emptyList(),
val note: String? = null,
)

fun TaskRequestDto.toDraft(): TaskDraft =
makeTaskDraft(TaskId(id), text, labels)

TaskId 拒绝空白标识,makeTaskDraft 检查文本和标签并做局部标准化。DTO 的 note 目前没有映射到 TaskDraft 或 Store,不能在接收这一字段后声称它已被持久化。以后若添加 HTTP 请求接口,需要明确选择保留、拒绝还是丢弃该字段;静默丢弃不能代替契约。

缺键和显式 null 在哪里还能区分

用 Json { encodeDefaults = true; explicitNulls = true } 测试:输入 {"id":"task-33"} 缺少必填的 text,解码抛 SerializationException;补上 text 后,note 缺失和 "note":null 都能解码,并得到相等的 TaskRequestDto。但在解析后尚未投影成 DTO 的 JsonObject 上,containsKey("note") 能把两种原始输入区分开。重新编码这个配置下的 DTO 会明确写出 "note":null。本次断言输出 JSON required_missing_rejected=true optional_missing_distinct_from_null_in_json=true decoded_equal=true。

结构相同的 DTO 不保留“调用者未传”与“调用者显式清空”的差别。如果更新接口必须表达“不改现值、设为 null、设置具体值”三个操作,不能只靠这个 String? = null DTO 猜测。需要在入站边界保留字段存在性,或者定义明确的三态更新模型。explicitNulls 影响编解码规则,但不会让已经投影为同一个 DTO 的两种输入重新变得可区分。

多一个字段是兼容还是失误

另一路字符串含 "extra":"v2"。默认严格的 Json 拒绝未知字段;显式使用 Json { ignoreUnknownKeys = true } 才接收,并只得到已知的 id、text。测试还让 id 是数字或显式 null:两者在这个非空 String 字段上均被拒绝。输出 JSON unknown_strict_rejected=true unknown_compatible_accepted=true wrong_type_and_null_id_rejected=true。

是否宽容未知字段要在接口版本策略中决定,而不是在发现报错后全局打开。宽容读入有助于旧消费者忽略新字段,同时也可能掩盖调用方拼错的字段名;严格读入能及早发现错拼,却要求升级的客户端/服务端有明确先后顺序。本例分别创建严格与宽容的 Json 实例,没有把宽容模式悄悄推广给整条业务链路。

新增 TaskRequestV2Dto,只比旧版多一个带默认值的 priority: Int = 0。旧 JSON 被 V2 读取时得到 0;V2 写出的 JSON 带 "priority":2,旧版严格 DTO 抛未知字段异常,显式宽容读取才成功。输出 JSON old_to_v2_default_priority=0 v2_to_old_strict_rejected=true,说明“新版能读旧格式”不自动推出“旧版能读新格式”。priority 在本篇没有参与调度,仅用于固定这个版本演化窗口。

能解码的对象还不能直接提交

{"id":" ","text":"alpha"}、{"id":"task-33","text":" "} 和 {"id":"task-33","text":"alpha","labels":[" "]} 都满足 DTO 的字段类型:字符串或字符串列表,没有一处是 JSON 语法错误。这三个 DTO 分别在 TaskId 构造或 makeTaskDraft 中被拒绝。有效输入中的两侧空白则按已有领域函数的规则规范化,断言得到 TaskDraft(TaskId("task-33"), "alpha", listOf("Kotlin"))。原始输出 JSON decoded_blank_id_text_label_rejected_by_domain=true valid_draft=task-33。

这个区分有直接后果:HTTP 层以后不能将所有 SerializationException、IllegalArgumentException 统一写成“JSON 格式错误”;前者指结构/字段类型问题,后者可能是领域约束。它们都不等于数据库写入失败,也不等于任务开始执行。本文没有把 DTO 接到 CLI 的 submit 或 Store;CLI 的原有正反输入仍只是累计回归。

输入条件 本篇实测 后续接口需要的决策
text 缺失、非空 id 为 null/数字 结构解码拒绝 明确必填字段及类型错误响应
note 缺失与显式 null DTO 相等,原始 JSON 可区分 更新语义若需要三态须单独建模
新增未知 extra / V2 priority 严格拒绝;显式宽容读取 定义版本协商和拼写错误策略
id、text 或标签为空白 DTO 可解码,领域构造拒绝 在提交前验证,不误记为已受理

bash examples/kotlin-language/verification/33.sh 保存 35 组累计测试和旧 CLI 正反例;verification/33/TaskRequestDtoTest.txt 保留本篇四条 JUnit 的原始 XML,dependencies.txt 留解析树。累计工程归档含 Wrapper、版本账本与前篇原始证据,解包离线复跑方式见同名目录 README。下一篇才把请求、受理及查询连接到 Ktor 测试应用和真实端口;这里没有 HTTP、真实数据库或重启恢复的证据。