十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

行为型设计模式实战:Command、Observer、Mediator、责任链的取舍

行为型设计模式实战:Command、Observer、Mediator、责任链的取舍 我2017年接手的权限模块里有一段经典代码一个Service方法里套了三百多行if-else每加一个角色就要改一次。那时候我才开始认真啃行为型设计模式先是把断言逻辑改成了策略再把动作处理拆成了Command后来发现请求的链路还可以用责任链收敛。就是从那个项目起我意识到Command、Observer、Mediator、Chain of Responsibility这四个行为型模式才是日常开发里出镜率最高的——但也是误解最多的。这篇文章不会把设计模式当教科书概念复述一遍我更想说的是这四个模式分别解决什么样的“沟通问题”在代码里会以什么样的坏味道出现以及我踩过哪些坑。适合手里有真实项目、写代码时总是纠结“这里该不该用模式”的同学。1. 先从最痛的问题说起对象之间究竟该怎么说话1.1 行为型设计模式解决的就是“谁调用谁”的问题行为型设计模式这个分类的名字有点唬人其实搞清楚一件事就够了它关心的是对象之间的“通信方式”和“职责分配”。想象一下你写了一个订单服务支付成功后要扣库存、发短信、送积分。最直接的写法是public void pay(Order order) { // ...支付逻辑 inventoryService.decrease(order); smsService.send(order.getUser(), 支付成功); pointService.add(order.getUser(), 100); }这段代码的问题不在于它运行不了而在于订单服务必须亲手认识库存服务、短信服务、积分服务而且每加一个“支付后要做的事”就要改一次订单服务。三个依赖还看得过来三十个呢行为型设计模式的本质就是让这些“调用关系”不再硬编码在具体类里。至于怎么不硬编码各自给出了一套思路把请求封装成对象、把通知关系反转、把协调逻辑收拢、把处理者串成链。这就是Command、Observer、Mediator和Chain of Responsibility的核心分工。1.2 四种模式放在同一张表里看我先给你一张对比表把四种模式摆在一起之后每一章再拆开细讲模式一句话核心思想它解耦的是谁和谁最典型的场景Command把“动作”封装成对象延迟、撤销、重放都可以请求的发起者 与 请求的执行者撤销/重做、任务队列、宏录制Observer状态变化时一对多广播订阅者各自响应被观察者 与 多个观察者事件监听、消息通知、UI联动Mediator对象之间不直接互调统一经过一个“中介”协调多个互相引用的对象 与 对象之间的交互规则表单联动、对话框组件、消息中间件Chain of Responsibility请求沿着处理链传递直到有人处理或走到终点请求的发送者 与 可能的处理者过滤器、拦截器、审批流这张表你收好后面每一章都是它的展开。记住一个关键点这四个模式都不直接增加新功能它们优化的是“代码演进时你要改哪里”这个成本。如果未来需求一成不变用不用模式都行只要需求会变模式的价值就出来了。1.3 什么时候不需要这些模式这话可能有点反常识但设计模式不是越多越好。我见过不少项目类图画得很漂亮Command、Observer拉满结果新人入职三个月看不懂一个请求的实际走向。当你只有一个调用对象、未来也没有扩展计划的时候直接new一个Service就是最清晰的方案。设计模式要解决的问题是“变化”如果这个方向的变化根本不存在就不需要提前为它设计弹性。等到第二个变化点出现时再重构成本通常完全可接受。先把这条写在前面后面所有例子你都会发现一个共同规律模式的选择一定要和业务里“真实的变更点”对齐。2. Command模式把动作变对象撤销、排队、记录都顺手了2.1 核心思想请求不再是一次方法调用而是一个对象Command模式的核心就一句话把一次操作请求封装成一个对象。普通代码里调用方直接调接收者button.setOnClickListener(event - editorBold());Command模式改成了这样先把“加粗”这个动作做成一个Command对象按钮只负责调用Command的execute()而Command内部才知道具体该调谁。一个标准Command结构有四个角色Command接口、具体命令、Receiver真正的执行者、Invoker触发者比如按钮、快捷键。关键变化是按钮不认识加粗要怎么实现它只认识Command接口。当操作列表不止一个时这种解耦的价值立刻体现工具栏有十个按钮每个按钮都持有自己的Command对象加一个新按钮时不需要改Button的逻辑只要new一个对应的Command就完事。2.2 一个可以直接抄的撤销示例撤销是Command的经典应用因为命令对象化之后“做过的操作”可以被塞进Undo栈撤销时弹出栈顶调用undo()就够了。我给你一个完整但精简的例子import java.util.ArrayDeque; import java.util.Deque; // 1. Command接口 public interface Command { void execute(); void undo(); } // 2. Receiver真正干活的文档编辑器 public class TextEditor { private StringBuilder content new StringBuilder(); public void append(String text) { content.append(text); } public void delete(int length) { content.delete(Math.max(0, content.length() - length), content.length()); } public String getContent() { return content.toString(); } } // 3. 具体命令插入文字 public class InsertCommand implements Command { private TextEditor editor; private String text; public InsertCommand(TextEditor editor, String text) { this.editor editor; this.text text; } public void execute() { editor.append(text); } public void undo() { editor.delete(text.length()); } } // 4. Invoker维护操作历史和撤销栈 public class CommandHistory { private DequeCommand history new ArrayDeque(); public void executeCommand(Command cmd) { cmd.execute(); history.push(cmd); } public void undo() { if (!history.isEmpty()) { Command cmd history.pop(); cmd.undo(); } } } // 使用 TextEditor editor new TextEditor(); CommandHistory history new CommandHistory(); Command insertH new InsertCommand(editor, hello); history.executeCommand(insertH); history.undo(); // editor 内容又变回空这里最妙的地方是写“撤销”的时候你完全不用知道InsertCommand内部是怎么实现的你只调用history.undo()。如果需求从“撤销一步”变成“撤销十步”For循环undo十次即可甚至可以做恢复到某一时刻的多级撤销。宏录制也是同一个套路把一系列Command装进列表再包成一个MacroCommand这个MacroCommand自己也是Commandexecute时遍历执行内部命令。录制宏本质上就是“记录用户按下哪些按钮等价于记录这些按钮背后的Command”。2.3 适用场景清单我总结一下真正会用Command的场景基本上这几个方向需要撤销/重做、操作记录和审计日志的地方比如编辑器、设计工具、数据库变更记录。需要把操作延迟执行或者丢进队列的地方例如线程池里提交的任务、后台任务队列——Runnable本身就是一种轻量级Command。需要支持“操作组合”的地方如宏录制、批量操作。需要把一个操作传给远端执行或持久化后重放的地方比如把Command序列化成JSON发送给另一个服务。框架里到处都是Command的影子Android里Handler的Message、Spring的事务管理器本质上都在做“把动作包装起来再处理”这件事。有一类场景容易让人误判如果你的需求只是“一个按钮对应一个固定动作”直接写lambda表达式就够了不必硬造Command类。函数式接口是Command的轻量形态大多时候都能解决。只有当你要处理撤销、历史回放或队列持久化时才需要真正定义一个有状态的Command类。2.4 踩过的坑命令对象别写成上帝类我最初接手Command的时候犯过一个错把整个业务逻辑全塞进Command里一个Command类几千行最后和Service层完全重复。实际上Command是一个薄薄的一层“消息包装”真正干活的是Receiver。理由很简单多个Command往往操作同一个Receiver如果业务逻辑写在Command里你会在好几个Command里复制同一段“保存草稿”“发送通知”的代码。我现在的习惯是Command里只放“调用哪个Receiver的哪个方法”如果发现Command开始写复杂判断、调远程服务、操作数据库那就是业务逻辑漏到这一层来了赶紧往外抽。另一个坑是生命周期Command对象如果被History栈永久引用而它内部又持有Activity/Engine这类大对象的引用内存泄漏迟早找上门。所以我一般会让Command只持有必要数据的引用处理后及时从历史上移除不存在回收价值的命令别保留。3. Observer模式状态一变就想广播但订阅关系必须可控3.1 核心思想被观察者不关心谁在听Observer模式解决的问题和Command完全不一样Command是“动作的发起者和执行者解耦”Observer则是“一个对象状态变化时不需要知道有哪些对象关心这件事也能把变化告诉它们”。它的结构也很经典Subject被观察者保存一组Observer观察者状态变化时逐个调用observer.update()。观察者之间互相不认识处理权在Subject手里。举生活中的例子最好理解你关注的公众号发一篇文章公众号不会挨个私聊每一个粉丝它只需要一次群发操作订阅的粉丝就都会收到推送。Spring的ApplicationEvent、各类EventBus、Swing的Listener都是Observer的工程化变体。3.2 支付完成通知的代码例子回到开头订单支付的例子用Observer重构订单服务瞬间从“认识所有下游”变成“只发布一个事件”import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; // 1. 观察者接口 public interface OrderObserver { void onOrderPaid(Order order); } // 2. 被观察者订单服务 public class OrderService { private ListOrderObserver observers new CopyOnWriteArrayList(); public void attach(OrderObserver observer) { observers.add(observer); } public void detach(OrderObserver observer) { observers.remove(observer); } public void pay(Order order) { // 支付核心流程 order.setPaid(true); // 广播支付成功事件但不关心谁去处理 for (OrderObserver o : observers) { o.onOrderPaid(order); } } } // 3. 具体观察者A扣库存 public class InventoryObserver implements OrderObserver { public void onOrderPaid(Order order) { inventoryService.decrease(order.getSku(), order.getQuantity()); } } // 4. 具体观察者B发短信 public class SmsObserver implements OrderObserver { public void onOrderPaid(Order order) { smsService.send(order.getUser(), 支付成功); } }使用的时候组装发生在系统初始化阶段OrderService orderService new OrderService(); orderService.attach(new InventoryObserver()); orderService.attach(new SmsObserver());以后加了“送积分”需求不需要再动OrderService写一个PointObserver attach上去就行。这会把“改代码”的范围从中心点挪到边缘点回归风险小很多。3.3 适用场景清单Observer最常见的落地场景我列成清单你对照状态变化引发多个关联动作比如订单状态流转、用户登录成功、数据同步完成。分层之间的解耦比如界面层监听业务层的数据变化业务层不用持有View引用。事件驱动架构比如消息队列里的生产者消费者模型、分布式事件总线其实就是Observer的跨进程版本。UI框架里的事件监听几乎所有GUI框架的Listener机制都是这个思路。顺带说一句很多人第一次听到Observer这个词是在监控系统或者Kubernetes里但追根溯源这些工具里的“观察者/控制器”概念本质上都在用同样的发布订阅思想处理状态变化。概念跨领域通用的情况在软件工程里并不少见。3.4 稍不注意就内存泄漏和通知风暴Observer模式看似简单实际运行时翻车概率不低我至少踩过三类第一是内存泄漏。观察者注册到Subject里之后如果忘记反注册而Subject又被全局持有那么观察者对象就永远不被回收。JVM里最典型的场景是Activity被销毁但还挂在某个全局EventBus上于是Activity泄漏。解决的土办法就是成对出现attach的地方一定要有detach在销毁生命周期里成对注销。现在像Spring的事件机制框架会自动处理部分生命周期但你在手写Observer时一定要好好管理生命周期。第二是同步通知中单个观察者抛异常会中断整个通知链。如果短信接口挂了积分也会发不出去连带支付主流程都可能出现异常。要给每个观察者的update调用单独做try-catch或者考虑改成异步通知。异步后还要警惕另一种情况通知乱序——库存扣减和积分赠送如果依赖顺序异步就不可靠。第三是“观察者风暴”。观察者里又触发另一个Subject的变化一个变化引发连环反应最终调用链几十层debug时根本定位不到源头。这个问题的本质是观察者依赖没有收敛。我的经验是观察者里别再去改另一个被观察对象的核心状态如果一定要这样做就走事件总线并统一做事件幂等和去重。4. Mediator模式网状调用改星型调度交互规则集中管理4.1 核心思想组件之间不直接喊话先打电话给“调度中心”与Observer的“广播”思路不同Mediator要解决的是另一个典型病多个对象之间有复杂的交互关系代码里全是A调B、B调C、C又调A画出来像一张蜘蛛网。Mediator的做法很朴素大家别互相认识了把所有交互都发给一个中介者由中介者协调各方的行动。对象之间从“网状结构”退化成“星型结构”每个人只认一个中间人。这和现实里的场景一模一样机场塔台就是典型中介者每架飞机起飞、降落、滑行都不靠飞行员互相喊话而是统一找塔台协调防止撞机聊天室也是中介者用户A发消息给用户B不是A直接把消息塞到B的屏幕上而是通过服务器中转。4.2 表单校验与提交的典型例子Dialog对话框是GoF里Mediator的经典案例。我把它换成一个更常见的表单场景用户注册页有用户名输入框、密码输入框、协议勾选框和提交按钮。规则是三项都合法时按钮才可点用户名合法时隐藏“用户名已被占用”提示勾选协议后按钮文字改为“同意并注册”。如果让6个组件互相引用每个组件需要知道其他5个的状态那代码就炸了。用Mediator简化后是这样// 1. 组件接口所有组件持有中介者引用但彼此不认识 public abstract class Widget { protected DialogMediator mediator; public Widget(DialogMediator mediator) { this.mediator mediator; } } // 2. 中介者接口 public interface DialogMediator { void onUserInputChanged(Widget source); void onAgreementChecked(Widget source, boolean checked); } // 3. 具体组件用户名输入框 public class NameInput extends Widget { private String value; private boolean duplicate; public NameInput(DialogMediator mediator) { super(mediator); } public void setValue(String value) { this.value value; // 输入变化通知中介者由中介者决定联动 mediator.onUserInputChanged(this); } public boolean isValid() { return value ! null value.length() 3 !duplicate; } } // 4. 密码输入框、协议勾选框同理略 // 5. 具体中介者注册对话框 public class RegisterDialogMediator implements DialogMediator { private NameInput nameInput; private PasswordInput passwordInput; private AgreementCheckBox agreeBox; private SubmitButton submitBtn; public void onUserInputChanged(Widget source) { // 只有所有组件都合法提交按钮才可点 submitBtn.setEnabled(nameInput.isValid() passwordInput.isValid() agreeBox.isChecked()); } public void onAgreementChecked(Widget source, boolean checked) { submitBtn.setEnabled(nameInput.isValid() passwordInput.isValid() checked); submitBtn.setText(checked ? 同意并注册 : 同意协议后可注册); } }你会发现每个组件不需要知道NameInput和PasswordInput的存在按钮能不能点是由中介者统一算出来的。将来加一个“邮箱输入框”改动集中在中介者和新组件上旧的几个组件几乎不用动。4.3 适用场景清单一组对象之间的交互规则复杂且经常变化比如表单联动、对话框里的组件协作、权限矩阵判断。想移除对象之间的直接依赖比如多个业务模块都互相调想改成统一走中间层。分布式系统里的消息队列、注册中心、网关本质上都是超大号Mediator——服务之间不直接通信统一走中间件。MVC里的Controller也带一点Mediator色彩View不再直接依赖Model而是通过Controller转发用户意图。4.4 中介者最大的坑变成上帝对象Mediator模式被吐槽最多的一点是所有逻辑都汇到中介者里中介者慢慢变成一个知道一切、控制一切的“上帝对象”。一旦它变成几千行维护成本直接爆炸。我的应对方法有三条第一按业务域拆分中介者。一个中介者只协调一个业务闭环别把所有对话框塞进同一个类。比如表单类和弹窗类拆两个中间者甚至一个复杂表单可以拆成“基本信息协调器”“安全设置协调器”再组合。第二中介者内部可以引入Observer或EventBus作为它的“通信管道”不要让中介者直接操作各种业务Service。这样中介者专注做“流程编排”而不是把业务逻辑也搬进来。第三如果只有两三个组件交互就别硬套Mediator直接双向调用更清晰。Mediator是处理“多对多”复杂度的工具组件太少时它反而是负累。5. Chain of Responsibility模式请求自己路上找能处理它的人5.1 核心思想发送者不指定接收者让链自己动Chain of Responsibility的核心思想非常反直觉发送者根本不知道谁会处理自己的请求。它把请求交给链头链上每个节点判断自己能否处理能处理就处理不能处理就传给下一个直到链尾。命令链像一条流水线原材料从一端进入每个工位只做自己负责的那道工序做完传给下一道如果某个工位发现这道工序不该自己做就原样传给后面。和Observer的区别也在这里Observer是“一个事件广播给所有人”责任链是“一个请求沿链找人处理往往只有一个节点真正接手”。生活里的典型例子是各级领导审批员工提交报销只往上交如果有权限批就批没权限就逐级上抛——审批人永远不必知道后面还有谁链上每个节点都只关心“我能不能批”。5.2 一个可运行的“登录校验链”例子登录校验是责任链最常见的练兵场。登录请求进来先校验token再校验权限最后做参数修正// 1. 抽象处理器 public abstract class AuthHandler { protected AuthHandler next; public AuthHandler setNext(AuthHandler next) { this.next next; return this; } public abstract void handle(Request request); protected void pass(Request request) { if (next ! null) { next.handle(request); } } } // 2. Token校验处理器 public class TokenHandler extends AuthHandler { public void handle(Request request) { if (request.getToken() null || !tokenService.valid(request.getToken())) { throw new AuthenticationException(token无效); } System.out.println(Token校验通过); pass(request); } } // 3. 权限校验处理器 public class PermissionHandler extends AuthHandler { public void handle(Request request) { if (!userService.hasPermission(request.getUser(), request.getOperation())) { throw new ForbiddenException(没有操作权限); } System.out.println(权限校验通过); pass(request); } } // 4. 参数修正处理器 public class ParamFixHandler extends AuthHandler { public void handle(Request request) { request.setPage(Math.max(1, request.getPage())); System.out.println(参数修正完成); pass(request); } } // 5. 组装成链 AuthHandler chain new TokenHandler() .setNext(new PermissionHandler()) .setNext(new ParamFixHandler()); chain.handle(request);这个例子里的顺序有讲究先校验身份再校验权限顺序反了会出逻辑漏洞。链上任一节点抛异常就直接中断请求不会继续往后走也可以设计成“处理完并返回是否继续”的方式由节点自己决定是否break两种风格按业务选即可。5.3 中间件、过滤器与责任链的关系说到责任链很多人马上会联系到实际开发里的具体框架这个联想是对的。Servlet Filter、Spring MVC的Interceptor、Netty的ChannelPipeline、甚至消息中间件里的过滤器链本质都是责任链Java Servlet的FilterChain一个请求经过多个Filter每个Filter可以决定放行chain.doFilter或中断。Spring MVC的HandlerInterceptorpreHandle返回false就中断返回true就进入下一环。Netty的ChannelPipeline入站事件依次经过多个ChannelHandler每个Handler处理后把消息传给下一个。这些框架把责任链做到了极致链本身的组装由框架负责开发者只写一个“处理器”往里塞。这也是为什么你写业务时可能不明显感到自己在用责任链但框架层面早就铺满了。5.4 链路顺序和兜底处理责任链看起来简单但如果细节没扣好线上问题会很隐蔽。说几个我亲身踩过或者见过的问题第一个是顺序。责任链有强有弱强顺序意味着节点间顺序一旦定错逻辑就出错弱顺序则无所谓。登录链路就是强顺序校验逻辑出错经常导致“未登录用户混过第一关但第二关崩了”。做设计时要把强顺序的节点前置并把每一环的职责边界写清楚。第二个是兜底。如果整个链走完都没有节点能处理这个请求到底是异常、忽略还是返回默认值必须定义明确。我的习惯是链尾放一个兜底Handler用来处理“谁都不要”的请求打日志或抛业务异常避免悄悄吞掉。第三个是并发和状态。处理器尽量无状态不要在Handler里持有可变的全局字段。责任链在Web容器里被多线程并发调用一旦处理器内部有状态字段很容易出现A请求的数据串到B请求里的灵异现象。第四个是别把链拉太长。节点数量如果超过七八个调试时从入口追踪到出口会非常费劲。很多时候把两个强耦合的节点合并或者在链上做分组反而比一味“灵活”更好维护。6. 四个模式怎么选从问题特征反推答案6.1 一张判断表先去背四种模式的定义你会感觉哪个都能用。我自己的经验是别从模式出发而从问题现象出发。下面是一张我用过很多次的快速选择表出现哪个坏味道就先试哪个模式代码里出现的具体问题优先考虑一个动作有撤销、重做、回放、录制、审计的需求Command多个命令等待批量执行、延迟执行、队列执行Command队列对象A状态一变一堆对象都要跟着响应但A不想知道有谁Observer多个对象互相引用、互相调用画依赖图成了蜘蛛网Mediator一个请求有多个候选处理者运行时才决定谁处理Chain of Responsibility想给现有请求链路统一加“日志、鉴权、限流”的横切逻辑Chain过滤器模式另外还有一条非常实用的经验当你写代码时发现“new了某个Service的实例但根本不确定它该不该在这里出现”的时候多半是结构上应该用某种模式了如果只是“我要调一个方法”那还不到用模式的时机。6.2 最容易混淆的三组对比把四个模式放在一起有几个边界最容易糊。第一组是Observer和Mediator。两者看起来都是“中间有个人在传话”但核心区别在通信拓扑Observer是发布者→多个订阅者发布者发完就不管了订阅者之间没有任何交互Mediator是每个组件都直接跟中介者对话组件不能直接与其他组件通话。换句话说Observer解决的是“广播通知”Mediator解决的是“网状交互协调”。如果你要的是一个对象变化后大家各自做事用Observer如果多个组件要互相配合才能完成一个流程用Mediator。第二组是Command和Chain of Responsibility。两者都封装“请求”但Command有一个明确的执行者Receiver封装是为了撤销、排队、回放责任链没有明确的执行者请求会沿链移动直到有人认领。还有一个容易混的地方框架里的拦截器和过滤器链虽然内部可能会创建Command对象去执行动作但链本身属于责任链你别因为链上挂了一个“Command”就以为它是Command模式。第三组是Command和Observer。这两个组合起来很常见比如按钮点击事件触发命令执行本质是Observer负责事件传递Command负责动作执行。它们是不同层面解决不同问题的东西你在设计时可以先定“谁监听事件”再定“监听到之后做什么”两层职责分开。6.3 真正项目里的组合使用示例真实项目里很少单独使用其中一个模式它们往往串成一条链。我拿一个“创建订单”业务来串一遍请求进来先用责任链做参数校验、Token校验、幂等校验——每一环不通过就拒绝通过就放行。通过校验后框架创建一个CreateOrderCommand对象塞进事务消息队列或异步执行队列——这是Command模式的“延迟执行”。命令执行成功订单服务发布一个“订单已创建”事件。库存模块、积分模块、消息模块分别订阅这个事件——这是Observer模式的一对多广播。此时如果活动模块和推荐模块都要根据订单金额做联动调整但因为它们互相不信任、不能直接调用彼此于是统一向一个PromotionMediator发送“订单事件”由中介者协调活动规则和推荐规则的执行次序——这是Mediator模式。这种“责任链入口 Command执行 Observer广播 Mediator协调”的组合在很多流行框架里都是标配。比如Spring MVCDispatcherServlet分发请求Chain、HandlerAdapter执行方法Command、ApplicationEvent发布事件Observer、各种AutoConfiguration之间通过事件和中间层来做协调Mediator的影子。你看真正理解这些模式后你读框架源码会顺畅很多。6.4 我对设计模式使用尺度的个人看法说了这么多最后说点个人体会。设计模式是经验的沉淀不是考试题不是“用了四个模式就比用一个模式高级”。我见过最烂的代码不是没有模式而是把模式当装饰堆出一堆抽象类和接口真实需求来了没人敢动。我的工作习惯是先写直观代码出现第二个变化点再考虑模式。一次写到位的抽象大多靠猜猜错概率不低而“出现重复代码、出现频繁改动、出现过度耦合”这三个信号才是模式的真正召唤。真正老练的开发者不是模式背得多而是闻出坏味道的速度快。
返回列表