设计模式 34:遍历协议隐藏了什么
订单展示方要依次读取 paper, pen。旧接口直接把内部 List 交出来,展示方可以在读取过程中添加元素,也不得不知道订单实际以列表保存。若以后用双端队列收集商品,调用方能否仍使用同一个遍历协议,且得到明确的空列表、末尾与修改语义? 让调用方依赖遍历而非存储 labs/34 的 Before.items 与调用者传入的 ArrayList 是同一个引用;测试追加 pen 后旧对象也多了一项。After 的 ListOrder 构造时复制输入,DequeOrder 从相同数据构造队列;两者都实现 Iterable<String>,调用者只需 for (String item : order)。示例约定新订单在构造时形成稳定快照,后续修改原列表不会改变结果。DequeOrder.iterator() 再提供不可修改的遍历快照;对空迭代器调用 next() 抛 NoSuchElementException,在迭代器上 remove() 抛 UnsupportedOperationException。 123for (String item : order) {...
设计模式 33:对象状态如何约束操作
订单刚建好还是草稿,不允许直接付款。旧对象却只有三个互不约束的布尔字段,pay() 无条件把 paid 置为真。再增加取消、重复确认和重复付款时,靠调用方记住全部组合很容易漏检查:草稿、已确认、已支付、已取消哪一步能做什么? 把迁移合同放到当前阶段 labs/33 的 Before 测试保留“草稿也能 paid=true”的错误反例。After 持有 Phase:草稿允许确认或取消;已确认允许支付或取消,重复确认返回原阶段;已支付允许重复支付但不再次做事,取消则拒绝;已取消不再付款。非法操作抛异常,失败后当前阶段不变。测试旧正常路径“确认 → 付款”仍达已支付,新增草稿付款拒绝、取消后付款拒绝、重复调用幂等均有断言。 123public void pay() { phase = phase.pay();} 此处 State 的变化来自同一个订单自身的生命周期:不同阶段决定允许哪些行为。与 28 的 Strategy 不同,后者由外部选定报价算法,本章阶段按操作迁移;与 29 的 Template Method 不同,这里并无固定的“读/写”骨架等待子类覆...
设计模式 32:审核何时停止传递
订单审核先查风险、再查金额上限。被风险规则拒绝的订单不应该被后面的金额规则重新批准。旧代码遍历所有处理器,把每一次决定写入同一个 last,最后的批准覆盖了前面的拒绝。问题不是处理器数量,而是“何时必须停止传递”没有合同。 三种结果决定链路去向 labs/32 的 Before.review(blocked=true, amount=500) 先得到 DENIED,最后返回 APPROVED;断言保留这个错误和 fraud,limit 两步轨迹。After 约定处理器只可能返回 NEXT、DENIED 或 APPROVED;一旦得到明确终态立刻返回,链尾全是 NEXT 时返回 UNHANDLED。因此同样的受阻订单只跑 fraud,后续金额规则不会意外翻案。正常 500 分仍批准;1001 分与空链都未处理,不被默认为通过。 12345for (Reviewer reviewer : reviewers) { Decision decision = reviewer.review(request); if (decision != NEXT) return ...
设计模式 31:谁管理订阅和失败
订单付款后要通知日志与开票两个本地回调。第一个回调抛错时第二个还需收到事件;开票回调又可能在处理 paid 时发布 invoiced。如果直接遍历订阅列表,回调失败会阻断后续通知,重入时顺序还可能让人意外。同步事件的顺序与失败到底由谁规定? 订阅表不等于交付保证 Before.publish 直接遍历监听器;在第一个回调抛 IllegalStateException 的反例中,发布调用抛错,后面的回调不会执行。After 维护订阅列表与一个待处理事件队列:事件按发布顺序入队,正在派发时的重入事件留到当前事件的所有订阅者处理完再派发;每轮取订阅快照,监听器抛出的 RuntimeException 被记录到 failures(),其他监听器仍被调用。测试轨迹是 first:paid, second:paid, first:invoiced, second:invoiced。退订后再次发布不会给被退订者送达。 1234after.subscribe(event -> { if (event.equals("paid")) after.publi...
设计模式 30:请求对象能撤销什么
库存预留可以作为任务排队执行;操作员误触后希望恢复最后一次预留。将 stock::reserve 放进 Runnable 队列已经能排队,却没有“哪条操作成功执行过、能否撤销一次”的信息。撤销的前提、顺序和范围由谁记录? 执行成功后才加入历史 Before.enqueueAndRun 运行一次预留,初始 1 件变 0 件,这一旧合同保持不变。After 命令封装 Stock 与本次是否执行成功的状态:execute() 只允许一次成功预留,库存不足时抛错且不记完成;undo() 仅在成功执行后允许一次释放。Queue.run 在 execute() 完成后将命令压入栈,undoLast() 按后进先出撤销。测试初始 2 件、连续预留两次、按逆序撤销后恢复 2 件;未执行前撤销、重复撤销、空队列撤销都抛错。 123Scenario.Queue queue = new Scenario.Queue();queue.run(new Scenario.After(stock));queue.undoLast(); Alternative 直接暴露 reserve(stock) 和 rel...
设计模式 29:哪些报告步骤必须固定
一份报表原本只生成 csv:A,现在审阅方要 json:A。加载数据失败时绝不能继续格式化,更不能留下“已写入”的轨迹。用一个可继承的流程固定“读取 → 格式化 → 记录写入”,比单纯为两种前缀各复制一份流程更合理吗? 骨架固定,局部步骤交给子类 Before.report("A") 直接返回旧 CSV 文本。After.run() 是 final,依次记录 read、调用 read(),记录 format、调用 format(raw),最后记录 write 并返回文本;Csv、Json 子类分别提供读值和格式化行为。这里的 write 仅是内存轨迹标记,并没有落盘。测试断言 CSV 旧文本保持,新增 JSON 文本是 json:A,完整轨迹恰为三步。 123456trace.add("read");String raw = read();trace.add("format");String result = format(raw);trace.add("write");return result; ...
设计模式 28:三种报价如何共享契约
报价原来只有原价与会员九折,现在新增清仓八折。只把三段算式拆成三个类,不检查它们是否都遵守同一输入约束,调用方仍无法放心替换算法。更重要的是谁选择“会员还是清仓”:策略对象并不会自己知道当前订单属于哪一类。 共用入口、分别验证算法 labs/28 的 Before 在一个 switch 处理原价和会员;未知清仓抛错。After 注入 Rule.apply(cents),入口统一拒绝负价,再交给对应算法。共同合同套件将原价、九折、八折分别作为参数,逐个断言输入 1000 分分别返回 1000/900/800,0 分均返回 0,负数均在入口拒绝。Alternative 保留一个三分支的直接实现,同一组参数化测试核对结果。 12Scenario.After quote = new Scenario.After(Scenario::clearanceDiscount);int clearance = quote.quote(1_000); Strategy 的意图是把可替换的算法置于稳定调用合同之下;注入的 lambda 也可承担策略角色,无须为三行纯算式创建三个类。金额合同规定非负整数...
设计模式 27:谁能读取延迟加载的库存
库存查询的构建成本较高;访客又不允许查询。旧对象一创建就调用加载器,随后查询时才核对身份,结果是未授权请求已经触发了一次昂贵加载。能否做到先拒绝、授权后首次使用才加载,并让后续查询复用已加载对象? 控制访问与加载时机 Before 在构造器里调用 loader.get();反例断言访客随后被拒绝,加载次数却已经是 1。After 实现与真实 Inventory 相同的 available(sku) 接口,在构造时绑定本章教学角色。调用入口先检查是否为 stock-reader,然后首次使用加载对象、再次使用复用;测试分别断言未授权 0 次加载、授权两次读取只加载 1 次、加载器的异常向外传播。Alternative 用一个普通函数先查角色再加载,适用于只查一次、无需复用实例的场合。 12345public int available(String sku) { authorize(role); if (loaded == null) loaded = loader.get(); return loaded.available(sku);} 此...
设计模式 26:哪部分对象值得共享
两个订单请求都买 paper,第一个数量 1,第二个数量 9。为了少分配对象,把整条“商品 + 数量”放进同一个缓存会怎样?第一个调用者握着的对象随后读到数量 9。共享身份本身不是问题,问题是把每次请求才有的可变数量一起共享了。 把内在数据和请求数据分开 Before.line("paper", 1) 返回缓存中的可写对象;第二次请求同一 SKU 数量 9,反例断言两次拿到同一引用,第一个对象的 quantity 也变成 9。After 只在本地 Map 中共享只读 Descriptor(sku, description);每次调用新建只读 Line(descriptor, quantity)。测试断言描述对象引用相同,两个行对象不是同一引用,第一个数量仍为 1、第二个是 9,本地描述缓存总数为 1。非法数量在插入缓存之前拒绝,失败不会意外新增缓存条目。 123Descriptor descriptor = shared.computeIfAbsent( sku, key -> new Descriptor(key, "name:...
设计模式 25:用例入口如何暴露失败
下单先报价、再预留库存、最后模拟扣款。旧调用者只拿到一个布尔值:缺货是 false,扣款被拒也同样是 false。更危险的是扣款失败前库存已经减了 1。给这一串调用加一个简单入口,能否让错误变得可辨识?会不会误让读者以为它保证了事务回滚? 收敛入口,不隐瞒局部副作用 labs/25 的 Before.checkout 直接依次调用 Quote.price、Stock.reserve、Payment.charge。After 把协作收在 checkout(base) 一处,返回 Result(Status, cents):成功、NO_STOCK、PAYMENT_FAILED 是三种可区分状态。报价 1000 分得 900 分的旧成功合同不变;0 分报价在预留之前就抛错,不会扣库存。扣款失败时测试明确断言 PAYMENT_FAILED 且余额从 1 变为 0:入口没有自动恢复库存。 123if (!stock.reserve()) return new Result(NO_STOCK, cents);if (!payment.charge(cents)) return new Res...















