Scala 36:纯业务规则接入文件与异步服务
把一个订单文件变成可判定的进程结果
本章完成一个小型批量订单校验 CLI。输入文件每行包含订单标识和数量,例如 A,2。数量必须是一到一百;所有行合法后,程序通过显式报价接口取得金额并汇总。正常输入输出已接受条数和总额,非法输入、报价失败、文件错误使用不同退出码。
这组需求会同时触及纯函数、错误模型、文件生命周期、Future 与进程边界。把所有逻辑写在 main 里虽然能运行,但很难分别验证“规则正确”“外部依赖失败被翻译”“资源关闭”“退出码正确”。本章保留少量明确函数,不预先引入通用效果框架。
实验使用 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11。报价端是可注入故障的假实现,不访问真实网络。文件则真实创建、读取和删除,CLI以独立子进程运行,因此退出码和标准输出不是通过直接调用函数推测出来的。
最终四种场景分别得到零、二、三、四。每个场景都保存原始命令、stdout、stderr和退出码,便于检查它们是否互相一致。正常路径的 accepted=2 total=500 对应两条数量分别为二和三的订单,单价固定为一百个最小货币单位。
纯核心只接受值并返回值
领域结构与解析结果如下:
1 | |
parse 不读文件、不打印、不启动 Future。它把一行字符串与行号转换成订单或错误。这样测试可以直接给出输入并比较完整返回值,失败不依赖临时目录、线程调度或控制台捕获。
解析先按逗号拆成两段,保留末尾空字段;标识不能为空,数量必须能转成整数且位于允许范围。字段形状错误返回 format,数量错误返回 quantity。行号由外层按原输入位置提供,使错误能回到具体行。
这不是完整 CSV 解析器。它不支持引号、字段内逗号或多行字段;实验格式就是两个简单字段的行协议。接口若需要接入通用 CSV,应替换解析边界并保留领域规则,不能把一个 split 样例包装成完整格式支持。
数量使用整数,金额使用整数最小单位的 Long。固定假报价中 quantity.toLong * 100L 避免经过浮点数计算。它没有实现多币种、折扣或舍入;这些规则应在需求出现后以明确类型和测试加入,而不是靠变量名暗示已经支持。
纯函数不会自动使规则完整。当前标识只检查非空,没有去重或空白规范化,空文件也可形成空订单列表。文章按实际规则说明这些行为,避免把“校验完成”扩展成业务上所有可能限制都已实现。
批量校验选择积累错误
validate 对每行调用 parse,同时保留原始顺序。它收集所有错误;若存在错误,返回整个错误列表,否则返回全部订单。这个策略让同一文件中的数量错误和格式错误在一次执行中一起报告。
测试输入 A,0 与 bad 得到第一行数量错误、第二行格式错误。程序没有在第一行失败后停止读取或只返回一个错误,因此可以验证错误积累与行号映射两个性质。
这一层暂时形成 List[Either[InputError, Order]],随后按结果分类。对于小型示例,直接代码比引入通用遍历抽象更容易核验。若以后多个领域共享同类组合需求,可以再评估抽象,而不是为了展示类型技巧先增加框架。
错误积累适用于各行独立校验。若后一行合法性依赖前一行状态,例如命令必须按顺序执行,就不能无条件平行判定后再拼接。应根据业务依赖选择积累、短路或状态转换模型。
校验失败时程序不进入报价分支。这个控制流边界避免把已知无效订单提交给外部依赖。测试不仅检查错误文本,还通过没有正常成功输出、退出码为二来确认进程向调用者表达了失败。
报价依赖在函数签名中可见
核心汇总函数不直接创建网络客户端,而接收一个 Quotes:
1 | |
函数签名说明它需要报价能力和执行上下文,返回异步总额。假实现正常返回数量乘一百;开启故障时抛出普通 IllegalStateException,在 Future 中形成失败。
显式依赖使正常与失败输入都可以控制,不需要真的让外部服务故障。它也让核心不必知道连接地址、认证或重试策略。未来替换为真实客户端时,需要另外验证网络契约,但当前业务组合可以继续用假实现回归。
Future.traverse 会根据输入创建并组合报价计算,不自动限制并发数量。本章文件夹具很小,程序尚未设置行数或并发上限。若用于大批量输入,必须增加容量设计,不能把小样例通过解释成任意规模都可承受。
当某个报价失败时,已经创建的其他任务仍可能继续。第 32 篇已经验证组合失败与取消不同。本章没有重试、取消或事务回滚,因此报价接口只适合当前无外部写入的假计算,不提供真实分布式事务保证。
文件应在进入异步阶段前结束使用
文件加载在 Using 作用域中调用 getLines().toList。所有行先物化,Source关闭之后才进入纯校验和异步报价。这样报价任务不捕获文件迭代器,也不会在文件关闭后继续读取。
这与前一资源章节的逃逸反例形成直接对应:返回惰性迭代器会把读取延后,返回已物化字符串列表则把当前读取阶段完成。异步报价只持有订单值,与文件句柄生命周期脱离。
物化有内存成本。当前实验只处理固定小文件,没有施加最大行数或总字节限制。生产入口应明确输入上限,或者采用作用域内流式处理;如果同时需要错误积累和异步报价,还要定义缓冲、背压与失败策略。
本章不把 toList 当成所有文件处理的默认答案。它用于把两个边界分开,先验证有限输入的业务契约与资源关闭,后续结课工程再增加容量约束。实验中的简单性来自受控输入,而不是资源成本消失。
文件构造失败时,Using 返回失败,程序进入文件错误分支。没有成功取得Source就不存在可关闭的那个对象,日志应报告未获取,不能声称已经关闭。读取或关闭阶段失败则是取得资源后的另一类状态。
关闭尝试和关闭成功分别记录
资源包装器保留三个状态:acquired 在Source创建成功后设为true;closeAttempted 在调用close之前设置;closed 只在close正常返回后设置。
这个区分来自一次审阅修复。原先在finally里无条件设置closed,会把“控制流程经过关闭函数”误报为“底层关闭成功”。最终实现让日志能够区分未获取、已经获取、关闭失败与正常关闭。
本次四个场景实际覆盖正常关闭和文件不存在;没有人为注入底层Source.close异常。因此 close-failed 分支是根据异常路径保留的状态表达,不能列为本章已经执行过的第五个场景。第 33 篇用显式抛错资源验证关闭异常合并,第 39 篇还有相应结课回归。
正常加载时断言closed为true。文件失败时保留实际资源状态,再输出 io-error。这使状态文本的含义与返回结果一致,而不是仅为了让测试出现一个固定字符串。
生命周期日志属于诊断输出,放在stderr。正常业务数据放在stdout,便于脚本消费。stderr出现资源信息不代表进程失败,最终失败判断仍应结合退出码与错误消息,而不是把stderr非空当成通用错误规则。
错误翻译集中在执行外层
纯校验返回 Either,文件加载返回 Try,报价返回 Future。CLI外层把这些阶段的结果翻译成进程行为,而不要求每个领域函数都知道系统退出码。
文件错误映射为四,输入错误为二,假报价的已知失败为三,成功为零。它们是本实验的应用约定,不是Scala语言或操作系统为这些错误规定的唯一数值。调用方应根据CLI文档理解这些码。
报价恢复只匹配本例注入的 IllegalStateException,返回 quote-error。未知异常、测试保护超时等没有被统一翻译为同一个业务错误。本章不能据此声称任意外部客户端异常都得到完整分类;替换真实客户端时应定义并测试其错误集合。
把所有Throwable捕获后返回零,会让脚本把失败当作成功;把所有异常都返回相同空结果,又会丢失原因。集中翻译允许内部模型保持具体,同时让外部接口使用稳定的有限错误约定。
错误文本也不泄露完整异常堆栈给业务消费者。本例日志只包含约定标识;更完整的应用可以另外记录内部原因和关联标识。这里没有实现日志平台接入,不能把简短stderr等同于完整可观测性系统。
线程池由CLI入口管理
只有校验成功才创建固定双线程池,执行上下文由该池适配。报价完成后,入口使用有限等待取得最终结果,再在finally里关闭池并等待终止。
Await 在这里位于进程入口,用于决定什么时候输出最终结果和退出。它不被放进报价工作线程里同步等待同池任务,避免引入上一章讨论的自我阻塞结构。异步业务函数仍返回Future供外层组合。
五秒是当前实验的保护上限,不是正式服务延迟承诺。真实外部客户端通常还需要自己的超时配置和取消策略,不能仅依赖入口等待。入口等待失败也不自动停止已经提交的工作。
System.exit 只在 cli 返回之后执行,所以正常受控路径中的文件释放和线程池finally已经结束。不能在领域函数或报价回调中随意提前退出,否则会让清理与结果汇总难以保证。
无参数运行时,Chapter36执行纯规则断言,供全部章节统一构建。带文件参数时才运行真实CLI流程。独立模块脚本负责带参数的子进程场景,两种入口用途明确,不能用纯断言入口代替进程验收。
四个子进程覆盖不同的外部观察
正常文件包含 A,2 与 B,3。stdout恰为 accepted=2 total=500,stderr记录资源关闭,退出码零。金额与条数都参与断言,避免仅有“程序没崩溃”的弱检查。
非法文件包含数量零与缺少分隔字段的行。stderr分别报告第一行quantity和第二行format,资源正常关闭,退出码二。这里验证的是多错误积累,没有把第一项错误遮住后一项。
在正常文件上加入 --fail-quote,输入校验仍通过,报价端产生异步失败。stdout没有成功数据,stderr有quote-error与资源关闭,退出码三。这个场景能够区分外部依赖失败与输入失败。
不存在的文件路径返回io-error,资源状态not-acquired,退出码四。它验证获取失败时不会错误地宣称执行了关闭。四个场景分别使用实际进程退出码,不以Scala函数返回数字代替操作系统层面的观察。
脚本在临时目录创建夹具并结束后清理,每个场景使用完整CLI命令。证据JSON分别保存stdout与stderr,方便确认输出通道;合并日志便于阅读,却不能代替分通道检查。
为什么纯核心有助于修改规则
如果数量上限从一百改成两百,首先受影响的是parse及对应边界测试,不需要改文件关闭或线程池代码。如果报价规则变化,可以替换Quotes实现并保留校验行为。如果输出协议变化,则修改CLI翻译与子进程断言。
这些边界并不意味着文件越多越好。本章代码仍放在一个有限大小的对象中,因为当前需求很小;职责可通过函数签名和数据流区分,未必需要立即拆成多层目录和通用接口体系。
纯核心也使错误积累可以单独测试。给定两行固定字符串,结果中的错误顺序与行号不依赖文件系统。CLI场景再验证文件内容确实接到同一函数,形成规则与连接方式两个层次的证据。
这并非把所有副作用都赶到一个巨大main函数。执行外层仍有明确步骤和资源作用域;随着真实客户端、配置或日志加入,可以按实际边界继续拆分。抽象应该解决已经出现的替换、测试或所有权问题。
还需要哪些约束才能成为更完整应用
输入格式、规模和错误策略需要明确。本章没有通用CSV支持、去重规则、行数上限或流式背压,也没有对任意Long总额溢出建立证明。固定夹具和数量上限让当前结果可手算,不等于这些扩展风险都已处理。
假报价提供确定结果与受控失败,能够证明组合和错误翻译,但不能证明真实HTTP客户端的认证、序列化、连接池或远程超时行为。替换依赖后应增加真正的适配器集成测试,并保留现有纯规则与CLI回归。
关闭状态修复说明诊断字段本身也需要语义。一个布尔名称容易让实现者把“尝试过”当成“成功了”;拆分状态后,代码和正文可以分别说明执行到哪里。错误模型不只是面向业务的Either,也包括实验与运行日志中的状态描述。
这些限制直接决定后续工程要补什么,而不是泛泛的生产警告。结课篇会在累计工程中继续加入输入容量与更多故障矩阵,本章先交付可重跑的小应用边界,保留每个结论的适用范围。
从嵌套结果类型读出执行阶段
文件读取转换成Either之后,右侧仍可能是校验失败或校验成功,因此中间值具有嵌套的结果结构。外层Left表示I/O阶段未完成,内层Left表示文件已读出但内容不符合规则,最内层Right才包含订单列表。
这种嵌套在小程序中可以直接模式匹配。它让两个失败来源不会因为消息都是字符串而混淆。若以后错误种类增加,可以定义统一的应用错误枚举再映射,但应保留阶段信息,不要只为消除一层括号把所有失败压成同一种值。
异步报价则发生在最后一个成功分支。由控制流可以手推:文件缺失时不会创建线程池,输入非法时也不会创建报价任务;只有完整有效列表才进入total。子进程实验验证外部结果,纯规则断言验证输入分类,两者共同覆盖这条路径。
这个设计还使成功输出只有一个位置。若在读取每行时就打印成功,随后另一行校验失败,调用方会收到混合结果,却无法从单一退出码知道哪些行已经处理。当前契约选择整批校验后再汇总,因此stdout只在最终报价成功时输出。
测试夹具与真实输入的区别
独立脚本在运行时写出两个小文件,再把路径传给真实CLI。它没有模拟Source接口,因此可以验证路径参数、字符解码、物化读取和进程输出的连接关系;但文件内容仍是固定夹具,不能代表所有编码与平台差异。
测试不包含文件在读取过程中被外部程序修改、权限变化或磁盘错误。这些不是本章已经通过的场景。若应用需要相应保证,应增加故障夹具或系统级测试,而不是从正常临时文件推导。
夹具路径由临时目录生成,日志保存当时完整路径。目录清理后路径不再存在是预期行为,复跑脚本会重建输入。长期证据是夹具生成代码、命令和原始输出,不是要求读者继续访问一次运行留下的临时文件。
复跑与练习
本章为 LAB_VERIFIED:
1 | |
最终纯规则记录在 examples/scala-lab/evidence/20261002-ch36-r2/,完整CLI记录在 20261002-ch36-module-r3/。后者的 normal、invalid、quote-failure、missing-file 均有命令、退出码及输出JSON和日志。旧r1/r2用于保留具名资源类型与关闭状态的修复过程,不作为最终正文证据。
手算题:输入 A,0 与 B,2,是否应该先给B报价再报告A错误?按本章契约不会,全部校验成功后才进入报价。若需求改成部分成功,应重新定义结果模型和退出码,而不是只删掉一个条件判断。
第二题:日志打印resource=closed是否能靠finally无条件赋值证明?不能,只有实际close正常返回才支持该状态;尝试关闭与关闭成功应分开。
执行练习增加最大行数限制,并用超过上限的真实文件验证退出码、错误输出和关闭状态。再加入会同步抛错的Quotes实现,检查它与返回失败Future的区别,明确外层应如何统一翻译。答案需要新增失败前的观察和修复后的重跑记录,不能只修改接口签名。
参考
Future固定源码提供traverse与组合规则;Using固定源码提供资源作用域行为;JDK21 System.exit定义进程退出接口。前面的测试与失败重放提供回归方法,下一篇把相同业务规则放入两套编译器检查迁移。
