设计模式 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...
设计模式 24:包装行为的先后顺序
报价先打九折再收 100 分服务费:1000 分变成 1000 分。如果合同改成“服务费也打九折”,结果是 990 分。只看最终类图很难发现错误,必须看到谁包装谁、哪个金额先进入折扣。若内部报价抛异常或持有待关闭资源,外层是否会吞掉错误或遗漏关闭? 顺序是计算合同的一部分 Before.quote(1000) 固定为“先折扣后加费”。After 让 Discount 与 Fee 都实现 quote() 和 close(),并持有同一种接口:外层 Fee(new Discount(new Base(1000))) 得 1000;外层 Discount(new Fee(new Base(1000))) 得 990。Alternative 直接在函数里按布尔参数选执行次序;只有两种固定方案、无需运行时组合对象时更加直白。 12After oldOrder = new Fee(new Discount(new Base(1_000)));After newOrder = new Discount(new Fee(new Base(1_000))); 同接口叠加新行为是 Decorator...
设计模式 23:组合商品如何统一报价
原来商品单价与平铺套餐分别走两个报价入口。增加“套餐里还能放套餐”之后,调用方必须先辨别是单品、列表还是嵌套列表,并自己递归。若所有节点都支持同一个 price(),谁负责保证空套餐与错误价格的边界? 单品和组合遵守同一读协议 labs/23 中的旧单品 200 分、平铺套餐 200+300=500 分在三种方案中保持不变。After 是受限的节点协议,Leaf.price() 返回非负单价,Group.price() 对子节点递归求和;空组合按本章约定返回 0。List.copyOf 固定孩子引用列表,防止调用者直接把节点塞进既有组合;加法用 Math.addExact 避免整数溢出静默回绕。 123After nested = new Group(List.of( new Leaf(200), new Group(List.of(new Leaf(300))))); Before 的两个单独入口没有对嵌套套餐定义方法;不把“旧版没实现”伪造成运行时出错。Alternative 先把所有单品展平再求和,若需求从来只需总价、不要求保留组边界,这比引入树...
设计模式 22:两条变化维度如何分开
通知原本有收据与告警两类,能发邮件和短信。旧实现分别列出四种 kind:channel 组合。现在加一个推送渠道,仅沿着旧思路复制分支,就得在两类通知各加一次,下一次增加通知类型又要给全部渠道各加一支。变化来源分别是“发送什么”和“怎样送出”。 组合两侧,不替另一侧做决定 Before.send("alert", "push", "A") 直接拒绝;旧四种组合的文本却是明确合同。After 让 Receipt、Alert 各自生成内容,通过构造参数持有 Channel 实现;新 push 通道只提供一个 message -> "push|" + message,无需再造 ReceiptPush 与 AlertPush 两个子类。 123Channel push = message -> "push|" + message;String receipt = new Receipt(push).send("A");String alert = new A...
设计模式 21:接口转换如何保留语义
订单侧的报价单位是分;一个模拟旧 SDK 的 charge(key, milli) 以十分之一分计量。若把 200 分原样交过去,接口调用并不报错,实际只记了 200 milli,订单端读取结果得到 20 分。这类故障不是改方法名就能修好:金额单位、拒绝原因和重复请求的语义都要一起核对。 边界上转换,而不是到处乘十 labs/21 的 Before 恰好复现错误:pay("a", 200) 返回 Payment(20),模拟 SDK 累计收费 200 milli。After 实现调用者定义的 PaymentPort,先检查非空请求键与正金额,用 Math.multiplyExact(cents, 10) 防止乘法溢出,再调用旧 SDK;将 NO_FUNDS 转成明确异常而不是金额为 0 的“成功”结果。失败断言还核对模拟累计收费保持 0。 1234Payment pay(String key, int cents) { validate(key, cents); return interpret(sdk.charge(key, Math....










