深入 Ruby 26:JSON、CSV 与领域输入边界
系列导航
导读 · 上一篇:25:RBS、Steep 与静态检查边界 · 下一篇:27:CLI、OptionParser 与退出码 · 完整源码包
合法 JSON 仍然可能是非法任务
下面三段文本都是可以讨论语法结构的输入,但对 Taskbook 来说只有最后一种形状可能进入任务校验:
1 | |
1 | |
1 | |
第一段顶层不是任务数组;第二段缺少字段,priority 还是字符串;第三段拥有约定字段与基本类型,之后仍要检查 ID 正数、标题非空、优先级范围、标签内容、状态枚举和集合内重复 ID。
解析器回答的是“字节能否按这种格式解释为值”,领域校验回答的是“这些值是否符合这个程序的输入合同”。把 JSON.parse 成功当成整个导入成功,会把后续每个业务方法变成意外的数据清洗点。
本篇前置是编码、集合、异常、文件和测试。实现位于 lib/taskbook/io.rb,验证入口为 labs/26/run.rb,使用 json 2.13.2、csv 3.3.5 与 CRuby 3.4.11。输入只包含本地合成任务。
在分配复杂对象之前限制输入
Taskbook 的 IO 层接收一个调用者拥有的流,首先读取最多上限加一字节:
1 | |
多读的一字节用于区分“刚好等于上限”和“至少已经超过”。这个策略不要求把剩余所有输入读完,避免为了报告过大而先无限制分配。64 KiB 是教学工具的明确合同,真实应用应根据数据规模与资源预算调整。
dup 避免修改调用者可能共享的字符串编码属性,force_encoding 选择解释方式,valid_encoding? 才检查字节是否有效。编码检查应发生在解析前,这样可以把坏字节与坏格式分开理解。这个函数不关闭传入流,因为资源所有权仍属于调用方。
体积上限不等于所有资源风险都消失。深层嵌套和极多短记录会给解析与校验增加不同成本,因此 JSON 还设置最大嵌套深度。本例上限足够小,可以一次读入;若需要处理大文件,应改成有记录边界的流式格式与有界缓冲,而不是简单删除限制。
解码只生成普通数据
JSON 分支明确关闭对象扩展构造,并限制嵌套:
1 | |
这里需要的是数组、Hash、字符串、数字、布尔和 nil 等普通值,不需要从外部输入恢复任意 Ruby 对象。输入格式如果可以指定对象类型和构造逻辑,就会把数据权限扩大为代码相关能力。Taskbook 不使用 Marshal 加载外部任务,也不对外部文本执行 eval。
普通数据本身仍可能错误。JSON 数值与字符串有区别,"3" 不应在 JSON 分支被静默当成整数。格式已经能够表达数字,因此选择严格类型可以尽早发现上游合同变化。把所有值都 to_s 再 to_i 会使非法输入悄悄变成零或截断后的整数。
JSON 项目文档 介绍了基本数据映射与选项。本文只使用被固定版本验证的普通解析模式,不把 JSON 格式称为“天然安全”;安全性仍取决于大小、层级、领域校验及后续如何消费这些值。
字段映射应限制可进入的名称
外部 JSON 使用字符串键,内部领域记录使用符号键。转换没有采用对所有输入键调用 to_sym,而是逐一读取白名单字段:
1 | |
字段集合比较同时拒绝缺少与多出字段。未知字段策略需要明确:某些公共 API 为兼容未来扩展会忽略未知字段,但这应是有意的合同,而不是解析器顺便接受。教学工具选择拒绝,便于发现拼写错误和数据来源变化。
状态映射只允许两个已知值。即使现代 Ruby 对符号有回收机制,也没有理由让不可信名称直接变成动态方法名、常量名或任意符号索引。限定映射的主要价值是维护领域集合和调用权限,而非依赖某个过时的符号内存论断。
字段映射后统一调用 Taskbook.validate。这样 CLI、HTTP 和文件导入共享同一套领域规则;如果 IO 层复制一份稍有差异的规则,某种入口迟早会接受另一种入口拒绝的记录。解析边界负责格式差异,领域边界负责业务不变量。
CSV 的类型转换是格式合同的一部分
CSV 原始单元格主要表达文本,因此 id 与 priority 在该分支执行严格整数转换。使用 Integer(value, 10) 可以拒绝不是十进制整数的内容;to_i 的宽松降级不适合需要发现输入错误的字段。
表头必须按指定顺序为 id、title、priority、tags、status。tags 单元格使用 JSON 数组文本,避免再设计一个逗号分隔标签的二级转义规则。CSV 自身负责单元格里的逗号、引号与换行,内部 JSON 负责标签数组,两层格式各处理自己的结构。
1 | |
不应使用 line.split(',') 替代 CSV 解析器。标题包含逗号时,简单拆分就会改变列数;字段内换行还会破坏“每行就是一条记录”的假设。CSV 文档 是格式行为的依据,往返测试则检验实际数据是否保持意义。
空文件和只有表头的文件也需要约定。本工具严格要求表头存在;只有表头时可以得到空记录集,完全空文件无法满足表头合同。JSON 的空数组与 CSV 的仅表头文件是不同格式对同一领域空集合的表达。
序列化之前再确认领域状态
导出函数先调用领域校验,再生成格式:
1 | |
导出路径也校验,是因为内部调用者可能直接传入任意 Ruby 值。输入曾经合法不代表经过后续可变操作后仍然合法。Taskbook 使用深复制隔离输入别名,但公开函数仍对自己的输入负责。
序列化结果不是调试字符串。inspect 的作用是便于查看 Ruby 对象,不是稳定的跨语言交换协议。JSON 中 status 变为字符串,CSV 中 tags 经过 JSON 编码,这些变换都应有往返断言。输出末尾换行方便 CLI 和文本工具使用,不能随意把人类说明文字拼进同一 stdout。
CSV 被电子表格打开时,还可能存在公式解释等消费端风险。一个合法 CSV 字符串并不规定所有消费软件如何展示它。本篇导出以机器往返和文本解析为目标,未承诺面向电子表格的公式转义;若产品增加此用途,应另设导出模式并验证消费端行为,不能擅自改写原始标题后仍声称无损往返。
失败分类决定调用者能够做什么
语法解析异常被转换为公开输入错误,原因链仍可用于内部定位。未知格式、坏字段和体积超限也返回 ArgumentError。IO 层不打印错误、不退出进程,由上层 CLI 或 HTTP 决定如何表达失败。
一个错误响应不应包含完整任务输入或本地内部路径。领域标题可能来自用户,日志如果直接记录整份输入,就把排障需要扩大成数据泄露面。后续日志章节采用字段白名单,保留操作与结果而不是无条件保存全部内容。
需要部分导入时,应设计显式结果,如成功集合与逐行错误集合,并说明事务边界。本工具采取整批拒绝:任一记录非法,整个导入不返回部分业务结果。这样筛选和导出只处理完整合法集合。把解析过程中已经构造的一部分数组泄漏给调用者,会改变这个合同。
报错粒度与数据兼容策略
整批拒绝时,错误消息可以指出字段或记录位置,但不要为了方便直接输出原始记录。记录序号和公开字段名通常足够帮助调用方定位;原始标题、标签和凭据字段则可能不适合进入共享日志。诊断信息应从允许展示的上下文构造,而不是对完整输入做一次字符串插值。
未来增加可选字段时,旧读者是否忽略它、旧写者是否丢掉它,需要提前决定。严格拒绝未知字段适合封闭的小工具,公开长期交换协议可能需要版本号或扩展字段区。兼容策略没有由 JSON 或 CSV 自动提供,必须由应用给出迁移与验证方法。
格式往返也应解释相等的层次。JSON 空白、对象键顺序和 CSV 引号选择可能变化,但领域记录仍然相等。若合同要求保持原始字节,例如审计留存,就应同时保存原文和解析结果,不能期待重新序列化恢复最初排版。本工具只承诺所支持字段的领域意义往返,不承诺字节级还原。
编码约定应该随格式一起说明。相同文件扩展名并不保证字节编码相同,CSV 尤其常见不同来源编码。本工具只接受有效 UTF-8,其他编码应在外部进行明确转码并保留失败证据,不能通过修改编码标签假装已经转换。
运行与练习
1 | |
同一行为套件检查 JSON/CSV 往返、缺字段、多字段、错误类型、坏编码、未知状态、超大输入与非法表头,记录在 evidence/26/run.txt。解析成功与领域成功的断言分别存在。
练习一:为标题添加逗号、双引号和换行,给 tags 添加中文,确认两种格式往返不变。练习二:把未知字段策略改成显式允许一个可选字段,先决定内部是否保留,再同时修改映射、签名和反例;不要通过删除字段集合检查让所有未知字段一起进入。
