函数式编程32:纯核心与外部端口
三件商品的单价是一百二十五分,总额应为三百七十五分。这个计算不需要文件句柄、线程池或网络客户端。实际服务还要查询单价、取得时间、保存结果;把这些动作都放进金额函数,测试就必须同时控制外部世界,才能回答乘法是否正确。
Functional Core / Imperative Shell 的实用边界是:核心接收已经取得的值,返回决策;外层安排取得输入与执行动作。纯核心并不要求整个应用没有副作用,也不要求每个函数返回 IO。本章先用 Java 21 的三个窄端口,把计算与执行责任分开。
完整实现为 Main.java,result.json 记录编译、运行和断言输出。案例只操作本地临时文件,报价是可替换函数,不访问外部服务。
参数里应该出现哪些事实
Input 保存标识与数量,Decision 保存标识、金额和报价时刻。核心 decide 接收 Input、单价以及 Instant。这三个参数已足以计算本章输出;函数内部不再读取当前时间,也不捕获可变费率表。
1 | |
金额单位固定为分,不引入浮点换算;乘法使用 multiplyExact,超出 long 表示范围时不静默回绕。这个函数在合法输入域上按值确定结果,非法输入以异常拒绝,因此并非对任意 Java 引用都返回 Decision 的总函数。null 不属于本实验的正常输入域。
把现在时间改成显式参数后,同一业务输入可以配合不同环境快照运行。这里“相同输入得到相同结果”中的输入包含时间和单价,不能只比较订单标识与数量。参数显露了原先隐藏的依赖,没有取消这个依赖。
是否应把 Clock 直接传给核心?本章选择传 Instant,因为核心只需要一个已确定的时刻。接收 Clock 会让核心拥有再次读取时间的能力,两个读取点之间可能发生变化。边界只读取一次,再将值交给计算,能更清楚地表达“此决策使用同一个时刻”。
JDK 21 Clock 文档 说明 fixed、offset 等时钟可用于替换时间来源。本实验分别固定 UTC 时刻与偏移一秒的时刻,验证时间依赖在输出中可见。这个检查不涉及操作系统时钟校准或跨机器一致性。
外层负责动作顺序
Quote 只提供单价,Sink 只保存 Decision。外层 service 的顺序是输入初检、报价、取时刻、纯计算、保存,最后返回相同决策。签名没有把全部基础设施暴露给核心。
1 | |
这两个接口都可能包含副作用。一个方法写成 lambda 并不意味着它纯;Quote 可以查网络,也可以修改计数。测试替身在事件列表中记录 quote、save,让外部动作成为可比较的观察。业务结果与动作顺序分别断言,避免“总额正确但保存了两次”的实现混过去。
正常测试使用数量三、单价一百二十五,保存列表中必须恰好出现一个 Decision。事件序列必须是 quote, save。如果将保存移到报价之前,或者为了记录日志重新调用 Quote,测试都会失败。端口替换的价值落在这些可观察约束上,不在接口数量上。
输入初检与核心校验有一小部分重复。初检阻止已知无效数量触发远程报价;核心校验保证直接调用核心时仍不能产生非法决策。这种重复各自保护一个入口。若规则扩大,可以提取纯 validate 函数返回合法输入,再让两处复用,但不应仅为消灭两行条件就建立通用验证框架。
服务选择在查询报价后取时刻,表示 Decision 的时间接近获得单价后的决策时间。另一业务可能要求“请求进入时刻”,那就必须改变读取位置与对应断言。时钟变得可注入,只解决可控制性;业务语义仍要通过读取位置表达。
失败之前发生了什么
数量零或标识为空白时,service 必须抛出输入异常,报价事件列表保持为空,保存列表保持原值。实验用 RecordingClock 包装固定时钟,在 instant 中累计读取次数;两类无效输入共享的时钟计数必须保持零,正常输入的独立观察点则恰好读一次。固定返回值本身不能证明未读取时钟,计数断言才对应输出中的 invalid-clock-reads=0 与 valid-clock-reads=1。只检查抛异常不够:程序可能先读时钟或调用报价,再发现输入非法。
报价端抛出 quote-down 时,保存列表数量必须保持原值。异常不被翻译成零金额,也不返回一个看起来成功的 Decision。当前实验用异常表达技术失败,与后续显式业务拒绝模型有意分开;失败类别是否值得进入结果类型,取决于调用方如何恢复。
保存失败是另一个边界。纯计算能够重复得到相同 Decision,不代表保存动作可以随意重试。本实验没有模拟“已经写入但回包失败”的不确定结果,也没有实现幂等存储;因此不能用核心确定性推出端口重试安全。真实持久化仍需事务、唯一键或回执协议。
如果把 Sink 的异常全部吞掉,只返回 Decision,调用方就无法分辨“决策已经算出”与“决策已经保存”。本章 service 返回成功之前实际执行 save,失败向上传播。这个契约虽然简单,却比只返回布尔值且没有动作记录更容易核验。
替身与真实文件适配器
内存 Sink 适合检查调用次数和完整值,但不能证明文件适配器写出的文本正确。实验另建临时文件,将数量三、单价二百分、偏移一秒的时间写成一行,再通过 Files.readString 读回。实际文本必须为 A,600,2026-10-03T00:00:01Z。
这个场景同时替换了三个依赖:Clock 决定时刻,Quote 决定单价,Sink 决定输出方式。核心代码没有变化,读回值也与内存场景明显不同,因此测试能够识别某个依赖是否被硬编码。
文件在 finally 中删除,生命周期由测试外层承担。读回只证明当前文件内容正确,不证明写入具有数据库事务语义、崩溃持久性或目录级原子替换。边界测试应围绕适配器真实承诺展开;给一个教学文本文件附加“可靠持久化”标签会超过证据。
端口设计还需要考虑返回信息。当前 Sink 返回 void,足够表达本地同步写入成功或抛异常。若存储端生成版本号,签名就应返回版本,调用方不能用日志猜测它。若报价包含币种和有效期,Quote 返回 long 也会过窄,需要替换为带这些字段的值对象。
从混合服务拆分时保留什么
混合服务通常把条件、取时刻、查报价和写文件放在同一个方法。首次拆分可先提取乘法与 Decision 构造,再把时刻和单价作为参数传入,最后提取外部端口。每一步比较原先的返回值、错误优先级和外部动作,避免一次重排所有控制流。
这里没有为纯核心增加 DI 容器。函数参数足以表达当前依赖;构造注入适合长期复用的服务对象,两者都能保持明确边界。应用若已经使用成熟容器,也没有必要为了“函数式”删除它,真正需要查看的是依赖是否进入了不该拥有它的计算层。
纯函数可以是静态方法,也可以是不可变对象的方法。若接收者中只保存不可变规则,方法不读取外界、不改变可观察状态,它仍然适合按值测试。把所有方法机械改成 static,不会自动消除可变全局依赖。
相反,外层使用局部变量、try/finally 和顺序调用并不是缺陷。它的职责本来就是安排动作。将每行包装进自制 IO,若没有增加取消、资源或错误组合能力,只会让执行路径更难读。复杂异步运行时的引入应对应实际需求。
旧文承接与验收边界
Scala 纯核心与应用边界 的“文件应在进入异步阶段前结束使用”讨论先物化输入再启动 Future 的资源边界。本章承接其端口思路,聚焦固定时间、动作次序与 Java 同步接口;没有将它的 Future.traverse 当作有界执行实现。
函数式领域建模 的“纯函数之外的一致性责任”指出两个调用者可能从相同快照计算出冲突决策。本章同样只验证本地计算与端口调用,不给共享存储增加并发保证。旧文及其原实验保持原归属。
程序的通过条件包括核心结果等价、quote/save 顺序、非法输入零外部动作、报价失败零新增保存、时间与费率替换、真实文件读回。运行 node examples/functional-programming/run.mjs 32 会刷新所链接的 result.json;只有实际退出零才支持这些场景已通过。
类型题与修改题
类型题:把 decide 写成 Input -> UnitPrice -> Instant -> Decision 时,最后结果为何不需要 Future?因为这三个输入已经是值,乘法不等待外部工作。Quote 若改成异步端口,异步类型应先出现在获取单价的那一层,核心仍可保持相同签名。
修改题:增加满三件九折规则,并明确最小货币单位上的舍入方式。将数量二与三放在阈值两侧,测试非整分结果;保留所有原有外部动作断言。合格修改只增加纯规则与对应例子,不因折扣而重复查询报价。
进一步可让 Sink 返回存储版本,然后验证 service 返回的是实际保存回执,而非自行构造的版本号。故意让 Sink 抛出异常,确认调用方收不到成功回执。这个练习能区分值计算、执行动作和确认结果三个阶段。
依赖的粒度影响可测试性
把整个应用上下文传给 decide,表面上同样是参数注入,实际上仍允许核心读取任意服务。测试虽然可以伪造一个大对象,却难以从签名知道计算真正需要什么。当前只传单价和时刻,让依赖粒度与计算所需事实一致;新增规则需要新的事实时,签名变化也能提醒调用方补齐输入。
如果一次决策要使用多份报价,取值时刻与一致性需要重新定义。依次查询两个报价可能得到不同版本,而把两个 long 传入纯核心并不能保证它们属于同一个快照。可以在边界返回带版本号的报价集合,并让核心检查版本政策;这是输入契约的扩展,不是纯函数自动解决的分布式一致性。
测试替身同样要防止过度简化。永远返回一百二十五的 Quote 无法发现服务是否传错商品标识,因此完整实验还通过事件和不同报价替换观察依赖。实际接口有参数时,应断言传入值,而不仅仅统计调用次数。调用次数正确、调用对象错误,仍然可能产生错误账单。
恢复策略也应放在拥有足够信息的位置。核心只知道数值,无法判断网络异常是否可重试;报价适配器知道请求是否发出,却未必知道整个订单命令是否已提交。应用层需要把这些结果组合起来,不能在每个小函数中各自增加无条件重试。
