Scala 39:订单批处理与故障诊断
一份订单文件怎样得到可信的结果
订单文件中的一行 one,2,12.00,CNY 表示编号、数量、单价和币种。程序按 5% 费率得到 25.20,再输出带行号的回执。算式很短,但外部输入可能缺列、数量为负、价格不是数字,报价可能拒绝或迟迟不返回,文件也可能在读取和关闭时分别失败。批处理需要把这些结果保留下来,调用者才能决定哪些命令可以重试。
结课工程将前面的数据类型、错误组合、上下文参数、Java 互操作、Future 和资源作用域放进同一条执行路径。模型只接受人民币订单;输入最多一万行;重复编号仅在当前批次拒绝;报价用本地实现替代外部服务。这些限制让每一种故障都有可重复的触发条件,也限定了“成功”究竟证明了什么。
冻结组合为 Scala 3.3.7、Scala CLI 1.9.1、Amazon Corretto JDK 21.0.11。状态为 LAB_VERIFIED。实验已经实际编译并运行 CLI,核验正常输入、非法字段、重复命令、报价拒绝、同步与异步报价失败、超时后任务继续、空批次、行数超限、读失败、关闭失败,以及两种失败同时出现时的异常保留。金额核心另在 Scala 2.13.16 和 3.3.7 下运行同一回归。完整代码在 examples/scala-lab/snippets/39/,独立入口在 modules/39/run.py,原始命令、退出码与日志在 modules/39/evidence/20261002-ch39-complete/。RUN.md 提供重跑步骤。
外部字符串进入模型的条件
编号采用 opaque type:类型定义之外不能直接把任意 String 当作 OrderId。伴生对象的解析函数检查一至三十二个 ASCII 字母、数字、下划线或连字符,只有成功分支返回领域编号。
1 | |
opaque type 控制外部代码的类型关系,正则表达式决定当前编号规则。两者缺少任何一个,保障都会变弱:普通类型别名不能阻止直接传字符串,opaque type 内部的无检查构造又可能制造不合法值。解析函数提供的是这份输入协议的保证,并未验证编号在某个数据库中存在。
订单还包含数量、单价和 Currency.CNY。数量范围为一至十万,单价范围为零至一百万,允许最多两位小数。这里检查 BigDecimal.scale,所以表示成 1.000 的数值会被拒绝,即使它与 1 数值相等。这是有意选择的输入格式约束;若协议只限制舍入后的金额,应改校验规则并补回归。
模型复用累计工程的 OrderLine,外部边界由 decode 负责。库内其他代码仍可能直接构造 OrderLine;当前工程没有把所有构造器改成私有。因此“文件路径中的订单经过校验”和“任意程序路径都无法构造非法对象”是两个不同命题。前者已有实验,后者需要调整 API 才能成立。
独立字段错误按稳定顺序累积
输入用 split(",", -1) 保留末尾空列,再逐字段去除空白。列数必须恰好为四;符合列数时,编号、数量、单价、币种各自独立解析。四个 Either 都计算完之后才决定能否生成订单。
1 | |
bad,-1,no,USD 同时违反三个字段规则,结果按数量、单价、币种的顺序保留错误。这里的累积来自四项独立计算及显式收集,并非 Either.flatMap 自动转换了行为。数量字符串的整数解析和数量范围检查存在依赖关系,所以这两步仍然短路:解析失败时没有整数可做范围判断。
稳定错误顺序便于逐行展示,也让回归断言具有明确含义。若改成并发校验后按完成时间收集,字段相同但顺序可能波动;若产品需要这种并发,应给错误增加字段序号,在输出前恢复协议顺序。
这种输入只支持无引号、无嵌入换行的四列文本。逗号不是完整 CSV 语法解析器;包含带引号逗号的字段不会按 RFC CSV 规则处理。编号语法已经排除逗号,因此当前示例的合法输入不需要这一能力。扩展输入协议时,应替换解析器并保留领域校验,而不能仅放宽编号正则。
金额计算与输出各检查一次
费率由上下文参数 Policy 提供,构造时要求位于零至一之间。计算使用十进制 BigDecimal:数量乘单价得到小计,再乘 1 + feeRate,最后按两位小数和 HALF_EVEN 舍入。用字符串构造十进制常量,避免把二进制浮点数先近似后转成金额。
1 | |
using Policy 使依赖在签名中可见,调用地点需要有对应实例。它不能证明费率来自正确的租户,也不能证明配置没有过期;那些业务事实需要另外的配置来源和标识。本实验固定 5%,回执中的 25.20 可以直接由输入复算。
报价接口返回的金额仍属于外部边界,即使返回类型已经是 BigDecimal。程序拒绝负金额和超过两位小数的结果,防止错误报价绕过输入检查。Java 回执函数再使用 setScale(2, UNNECESSARY) 与 toPlainString:显示时可以补零,但不允许隐式舍入。这两次检查承担不同责任,前者接受或拒绝报价,后者维护输出格式。
Java 静态方法的参数是 java.math.BigDecimal,Scala 调用时传 total.bigDecimal。独立 runner 先编译 Scala,再显式 javac 输出回执类,最后用真实 java -cp 运行。这样实际覆盖了 Java 类在运行类路径中的存在;仅让 Scala 编译器读取 Java 方法签名,不能证明部署产物已经包含那份 class。
文件先读完,再启动报价
FileInput 用 UTF-8 BufferedReader 读取文件,接口以 AutoCloseable 表达关闭操作。Using.resource 的函数体内消费迭代器并生成 Vector,返回函数体之后再调报价服务。
1 | |
多取一行用于发现超限,不会把超过一万行的文件静默截断成成功批次。文件内容在资源作用域内消费完,随后关闭;异步结果只持有已经读出的字符串,不依赖已经关闭的 reader。实际事件日志为 acquire -> close -> quote:ok -> quote:reject -> quote:slow -> slow-completed-after-timeout,并有断言检查报价启动前已出现关闭事件。
一万行限制控制记录数量,不能控制单行字节数。该实现也会把整个允许批次保留在内存中,并同时为有效订单启动报价。它适合有界演示文件;大文件或远程报价需要行长度限制、并发上限及流式资源协议。仅把 Vector 改成 Iterator 会让后续消费离开资源作用域,并不能完成这种改造。
若打开文件本身失败,open() 没有返回资源,程序直接报告输入失败。若读取失败后关闭也失败,Using 按异常严重程度选择主异常,并保留另一个为 suppressed exception。同等级的普通异常保留先发生者。本例读失败是 IllegalStateException,关闭失败是 IllegalArgumentException,因此断言主消息为 read failed,suppressed 中有 close failed;这份结果不能外推到所有异常种类。冻结的 Using 源码
重复命令在哪个范围被拒绝
解析后的合法订单进入当前批次的可变集合。seen.add(order.id) 第一次返回 true,后续相同编号返回 false,后者形成 Refused。非法行不进入集合,因此一行编号合法但价格非法的记录,不会提前占用编号。
去重判断在构造异步任务的串行遍历中发生。集合没有被并发报价回调修改,因而本例不需要给它额外加锁。结果集合保留输入行号,Future.sequence 按原序列组成结果,即使报价完成顺序变化,输出仍能对应文件中的位置。
集合只存在于这次调用。重新运行同一个文件,或者同时运行两个进程,都可能再次执行相同编号。这里没有数据库唯一键、幂等令牌存储或分布式锁,不能将局部去重写成跨批次幂等。如果报价对应扣款等副作用,重试协议必须先定义操作标识与持久化边界。
超时决定观察结果,不停止底层任务
报价类型为 Future[Either[Issue, BigDecimal]]。正常成功进入 Right,业务拒绝进入 Left;同步抛出的普通异常和 Future 的普通失败被转成报价问题。定时器与完成回调共同竞争一个 Promise,先完成的一方决定调用者看到的结果。
1 | |
完成回调执行 tryComplete 后取消定时器。即使定时器已经触发,第二次完成也不会覆盖 Promise 的结果。这里用 ExecutionContext.parasitic 执行很短的回调,避免已经完成的报价还要等待拥堵的工作线程池才通知 Promise;回调只转译结果并取消定时器,不执行文件读取或复杂业务。
超时之后,原始 Future 仍可能运行。实验中的慢报价先等待闩锁;结果已经显示 timeout 后才释放闩锁,随后记录 slow-completed-after-timeout。这条轨迹直接区分了“调用者停止等待”和“工作停止执行”。CLI 的慢报价使用未完成 Promise 来稳定重现超时;真实继续执行的证据来自独立的 Chapter39 闩锁实验。Future 官方指南
100 毫秒是本实验的观察期限,不是对所有调度环境的精确实时承诺。定时器也需要 CPU 调度。接口还要求 quote 方法快速返回 Future;若方法在返回前同步阻塞,构造任务的线程仍会被占用。非致命异常可以转成普通失败,线程中断及其他严重异常不应靠一个 catch 抹平。
行级结果与进程退出码
Result 是三分支 enum:Invalid 带字段错误,Refused 带合法编号和业务问题,Quoted 带编号与金额。模式匹配生成一行回执,调用者不必靠字符串猜测内部结果属于哪一种。
| 情况 | 退出码 | 已验证的输出 |
|---|---|---|
| 全部行报价成功 | 0 | 1 QUOTED one CNY 25.20 |
| 有非法行、重复命令、拒绝或超时 | 2 | 对应行的 INVALID / REFUSED |
| 输入文件缺失等输入失败 | 3 | INPUT_FAILURE NoSuchFileException |
| 参数数量错误 | 64 | usage: BatchCli |
合法空文件按“全部行均成功”的判断返回 0。是否允许空批次属于产品协议;如果要求至少一条订单,应在读取阶段拒绝并补退出码回归。当前 CLI 只接受一个文件参数,没有写入结果文件,也没有承诺持久化、原子提交或崩溃恢复。
线程池和调度器在 finally 中关闭。实验还等待二者终止,避免只看到成功断言却遗留非守护线程。实际远程请求若不能取消,退出过程仍需定义等待上限和连接关闭行为;本地 Future 的停止观察不能代替网络客户端的资源管理。
迁移回归覆盖哪一层
迁移脚本在两个冻结编译器下分别运行 MigrationRegression.scala。共享代码采用两边都能编译的语法,验证 25.20,以及 0.105 -> 0.10、0.315 -> 0.32 的 HALF_EVEN 边界,并拒绝零数量。两个进程均实际退出 0,保留同样的业务输出。
这证明这份金额纯核心在 Scala 2.13.16 与 3.3.7 的指定 JDK 下得到相同结果。它没有把 opaque type、enum、整个 Future 管线或 Java ABI 当作自动迁移成功。迁移项目仍需逐个处理语法、依赖坐标、宏与插件,并用业务回归验证改写结果。
调试时可以先查看命令 JSON 的退出码,再看对应日志:编译失败、缺类和业务拒绝发生在不同阶段。本实验历史中保留了扩展方法未导入造成的编译失败,以及缺少 Java 输出类造成的运行失败;最终 20261002-ch39-complete 单独记录补齐报价失败与行数边界断言后的全部通过结果,旧失败没有被改写成成功。
练习
先手算 bad,-1,no,USD 的错误字段顺序,以及 5% 费率下 2 × 12.00 的回执金额。为 readAll 增加“空文件非法”和“单行长度超限”规则,保持文件关闭后才启动报价的断言。再把报价改成最多两个并发任务,验证输出仍按输入顺序,并记录超时任务是否继续占用底层资源。若增加跨批次幂等,需要先说明存储中哪一个操作会原子地决定编号已使用。

