Java常用类库-37-有界商品目录导入与归档流水线
把边界放进同一条实际路径
商品目录导入不是把 CSV 转成几个对象就结束。输入可能超大或包含非法金额,详情服务可能在第二个商品上失败,取消也可能发生在报表已经写出以后。如果成功路径只展示数据流转,而失败路径依赖临时目录以后再清理,工程仍然没有完整的交付边界。
本章实现一个同步、单批次的 CatalogPipeline:有界读取严格 UTF-8 CSV,验证商品身份和价格,形成不可变查询索引,再通过 HttpClient 访问真实 localhost 服务取得 JSON 标签,最后写出 report.json 与 report.zip。调用成功返回拥有临时目录的 Export,关闭 Export 即删除结果;失败或取消则由流水线清理已创建的工作目录。
实验复用系列已有的 Product、Catalog、BoundedIo、SafePaths 与 PrivateArchive,不另建框架。固定 Guava 33.5.0-jre、Commons CSV 1.14.1、Jackson 2.20.1、HttpClient 5.6.4 和 Commons Compress 1.28.0;完整代码以 Java 8 编译,在 Zulu 8u472 与 Corretto 21.0.11 各执行六项集成测试。
输入协议先于容器选择
CSV 头部必须按顺序为 merchant、sku、price。每条数据恰好三个字段,商户与商品编号采用一至六十四位字母、数字、下划线或连字符。价格只接受最多十位整数和最多两位小数的非负十进制文本,不接受指数、负数或三位小数。这个语法是本案例的领域政策,不是 Commons CSV 对所有输入的要求。
输入先经过 BoundedIo.copy 读取到有限字节数组,再用显式 UTF-8 解码器处理。解码器对错误字节采用 REPORT,避免替换字符悄悄进入商品身份。预算边界通过额外读取最多一个字节判断是否超限,超出部分不会被加入结果。完整输入受字节上限约束,因此后面的字符串、记录和对象规模有来源约束,但内存占用不等于输入字节数本身。
Commons CSV 负责引号、字段与记录解析,业务层检查实际表头、字段数量和字段语义。空行不被自动忽略,以免本应报告的缺失字段消失。读取第一个超出行预算的记录时直接拒绝整批,不能只读取前若干行后把截断结果当作完整成功。
固定 CSVParser 源码中的迭代与 getRecords 都受格式配置影响;某些限制用于停止产生记录,不等同于报告超量。本文显式比较已接收记录数,避免把“最多返回几条”误当成“超过几条就失败”。Commons CSV 固定源码
Product 继续验证两位金额和非负约束。输入层额外限制原始文本,使 1.000 也被拒绝,即使它可以无损规范化为 1.00。字符串语法与数学精度是两项约束;如果业务希望接受任意数量的末尾零,需要修改输入协议和对应测试,而不是悄悄绕过校验。
身份、计数与价格查询来自同一份数据
样本包含 A/one=9.99、A/two=10.00、B/one=0。相同商品编号可以出现在不同商户,唯一身份是商户和商品编号的组合。Catalog.groupByMerchant 在分组时拒绝同商户重复商品,随后才构造嵌套不可变索引:外层按商户,内层按商品编号。
这个结构不用字符串拼接制造复合键,因此不需要处理分隔符冲突。Product 的字段也不可变,价格使用 BigDecimal,避免容器不可变但内部价格仍可被修改。测试对内层索引调用 clear,确认不允许修改,同时检查 A/two 的价格仍为规范化的 10.00。
ImmutableMultiset 记录商户商品频次,得到 A 两项、B 一项。它统计的是通过唯一性校验后的商品数量;如果先把重复行加入计数,再在索引阶段覆盖,频次与目录内容就会分歧。所有派生结构都从同一份已验证产品列表生成,避免每个阶段各自决定去重规则。
价格查询使用 Range.closedOpen(0,10),因此 9.99 与 0 在区间内,10.00 不在。测试得到两项。BigDecimal 的自然比较用于区间判断,不能把这里的大小关系和 Map 键的 equals 规则混用。输出报告仍保留规范化价格,查询条件不改变商品值。
本批次每个唯一商品只富化一次,没有重复查询需求,因此没有添加缓存。为了“综合”而加入缓存,会引入过期、负缓存和失效时序,却没有减少这份负载中的任何 HTTP 调用。后续如果出现同一目录重复读取,再根据实际查询契约增加缓存并单独验收。
真实 HTTP 只接受有限且完整的详情
测试使用 JDK HttpServer 监听 127.0.0.1 的临时端口,HttpClient 真正发送请求。服务返回只有 label 字段的 JSON 对象,内容为字符串且长度最多一百二十八个 UTF-16 单元。URI 的商户和商品参数来自先前限制过的编号,端点属于可信应用配置,不接受任意用户提供的请求地址。
客户端禁用自动重试和重定向,使一个商品对应一次明确的物理请求。主程序设置连接、连接池租借与响应等待超时;run 方法借用外部客户端,其调用方必须提供适合自身场景的客户端配置。两个入口的所有权不能因为方法名相同就混淆。
HTTP 状态必须为 200 且存在实体。响应体使用实际读取计数限制字节数,不依赖 Content-Length 声明。读取完成后严格解码 UTF-8,再解析为 JsonNode;重复 JSON 字段与尾随第二个 JSON 值都被拒绝。最后检查对象形状及 label 类型,避免把语法合法等同于领域数据合法。
Jackson 的 FAIL_ON_TRAILING_TOKENS 在顶层绑定完成后检查后续 token;重复字段检测作用于解析器层。本例使用固定版本的顶层 readTree 路径,分别注入重复 label 和第二个对象验证,不能只凭配置已经开启就认定所有自定义反序列化路径都相同。固定 ObjectMapper
客户端通过 executeOpen 取得显式拥有的响应,在 try-with-resources 中关闭响应与实体流。官方 API 对常规用法推荐响应处理器;本例选择明确的打开响应路径,便于展示实体读取和资源归还的责任。测试使用容量为一的连接池,失败后检查 leased 为零,成功后还用同一客户端再次请求,证明流水线没有关闭借来的客户端。HttpClient 固定接口
字节预算约束的是应用实际保存和解析的内容,不是对网络上传输总量的证明;关闭实体也可能涉及连接处理。响应超时同样不是从 CSV 导入到 ZIP 完成的统一墙钟期限。若要求端到端截止时间,需要继续向每次请求和各处理阶段传递剩余预算,本案例没有声称实现这个更强契约。
工作目录与结果的所有权
全部 CSV 校验通过后,流水线才在可信父目录下创建新的临时工作目录。文件名固定为 report.json 和 report.zip,通过 SafePaths.resolveNewFile 确认新目标,再使用 CREATE_NEW 打开。父目录和工作目录由本进程控制;这项前提不能外推到攻击者能够并发替换路径的共享目录。
JSON 报告写入有计数上限的 OutputStream,超过预算就失败,而不是先生成无限字节数组后再检查大小。ZIP 同样有独立输出上限,里面只放一个固定名称 report.json。复制报告进入 ZIP 时复用 BoundedIo,实际压缩包再由 PrivateArchive 解开,比较报告字节完全一致。
报告上限与压缩包上限不是同一个数。压缩头、目录与压缩效果都会改变大小,不能简单假定 ZIP 一定比输入更小。测试分别把报告和 ZIP 上限设为八字节,确认两种中途写入失败都会删除整个工作目录,临时半成品不会被当作成功结果返回。
成功的 Export 仍然拥有文件。调用方需要在 close 前读取、复制或以自己定义的发布协议接管结果;示例 main 打印报告和归档大小后关闭 Export,因此运行结束不保留临时包。这个设计把实验清理责任写进类型,没有把临时路径伪装成已经永久发布的文件。
| 资源 | 打开或创建者 | 关闭或删除责任 |
|---|---|---|
| run接收的输入流 | 调用方 | 调用方 |
| run接收的HttpClient | 调用方 | 调用方 |
| 每次响应与实体流 | 流水线 | 当前请求结束时 |
| 失败工作目录 | 流水线 | 抛出前尝试清理 |
| 成功工作目录 | 流水线,交给Export | Export.close |
清理异常通过 suppressed exception 附到原始失败,避免用删除失败覆盖真正的处理错误。当前实验中的删除都成功,没有验证权限损坏或磁盘故障下的清理结果;生产环境仍需能够发现和处理清理失败,不能把 finally 等同于删除必然成功。
取消发生在可协作的检查点
循环读取前后、CSV 记录之间、HTTP 富化前后、报告与归档阶段之间都检查取消标记或线程中断标记。命中后抛 InterruptedIOException,并沿同一条失败路径释放资源。三条实验分别在导入前、服务器准备首个响应时、report.json 已存在但 ZIP 尚未创建时触发取消。
第三条尤其容易遗漏:写出了报告,并不代表整个导出已经成功。测试在检查点观察到报告存在后请求取消,最终可信父目录为空,既没有 ZIP,也没有残留 JSON。这样成功返回才是结果所有权转移的唯一边界。
这些检查不会中断正在阻塞的 InputStream.read,也不会把本地取消变成服务端事务撤销。服务器在返回响应前设置标记,只证明客户端下一次检查会响应取消;没有模拟无限阻塞读取,更没有证明中断能随时终止任意 I/O。需要更及时的请求取消时,应使用具体客户端的取消机制并增加连接、线程和远端副作用测试。
实验矩阵与运行方式
六项测试在 Java 8 和 Java 21 均通过,实际包含多种失败输入。所有 localhost 服务都有 finally 清理路径,线程池关闭后等待终止,不让测试服务器留到后续运行。第一次沙箱运行因不允许本地监听而失败,放开本机测试限制后才取得有效结果,两份记录分别保留。
| 场景 | 实际观察 |
|---|---|
| 三商品成功路径 | HTTP3次、A=2/B=1、价格区间2项、ZIP还原一致 |
| 金额、重复键、表头、列数、UTF-8非法 | HTTP0次,无工作目录 |
| 行数和输入字节超限 | 整批拒绝,借入输入流仍由调用方关闭 |
| 第二次富化失败 | 首次成功也不发布,连接leased=0,目录删除 |
| 三个阶段取消 | 不留下临时报表或归档 |
| 报告/ZIP预算与main | 写入失败回滚,独立main成功后清理 |
完整可编译类为CatalogPipeline.java,包含全部 imports 和 main;集成测试提供真实 localhost 服务与断言,运行说明给出工程和资源文件入口。主程序接收 CSV 路径、可信详情 URL、可信输出父目录三个参数,运行期间使用临时结果。
1 | |
手算题:行预算为一,输入有两条合法商品,应返回第一条成功吗?本案例拒绝整批,因为预算定义是完整导入的上限,而非截断规则。改动练习:在报告写出与归档之间增加正式发布步骤,先定义覆盖、原子移动和失败恢复政策,再补相应实验;不要直接把临时路径当作发布协议。
| 可迁移做法 | 适用边界 |
|---|---|
| 先校验整批身份,再构造全部查询派生结构 | 导入、分组、计数与索引一致性 |
| 成功返回才移交结果所有权 | 多步骤文件生成、失败回滚、取消 |
| 用真实资源计数验证清理 | HTTP连接池、输入流、目录与线程生命周期 |
这个案例没有把所有类库都放进主流程。留下的组件分别承担解析、领域索引、HTTP、JSON 和有界资源操作;缓存与重试因没有对应需求而未加入。后续扩展应继续围绕新增业务契约增加实验,而不是以组件数量衡量工程完整度。
