1. 为什么“从前慢”这三个字,恰好点中了设计模式的学习命门
“从前慢”这个前缀放在设计模式系列的第二篇上,我第一反应不是文艺,而是精准。设计模式这东西,学第一遍的时候几乎所有人都会经历一个错觉:看懂了。单例、工厂、建造者,每个模式翻过去都觉得“就这?”,代码也就二三十行,结构图一目了然。可真到了项目里,面对一个已经写了三千行的业务类,让你判断这里该不该抽一个策略出来,绝大多数人是懵的。问题不在于模式本身难,而在于模式是慢功夫,它需要你在脑子里反复咀嚼“什么场景下不这么写会痛”,而不是背下“策略模式定义了一系列算法”。
这一篇作为系列的第二篇章,我理解它的定位是承上启下:第一篇大概率把创建型模式的地基打了,第二篇要往结构型和行为型的深水区走,同时把“为什么慢”这件事讲透。我打算按自己带人、做代码评审、重构老系统的实际经验来拆,不搞教科书式的定义罗列。适合谁看?如果你已经能写 Java、能看懂接口和抽象类,但每次写业务代码都是 if-else 一路平推,或者你接手过一个“改一处崩三处”的老项目,那这篇就是给你准备的。核心关键词我会反复落在设计模式、解耦、可扩展性、重构、开闭原则这几个点上,因为它们才是模式存在的真正理由。
先说一个我自己的判断:设计模式最大的价值不是“让代码更优雅”,而是让未来的修改成本可控。优雅是结果,不是目的。很多人学模式学偏了,就是奔着优雅去的,结果在只有两个分支的地方硬套一个策略模式,多出四个类,维护的人骂娘。所以这篇我会花大量篇幅讲“什么时候不该用”,这比讲“怎么用”重要得多。慢下来,先想清楚变化的边界在哪,再决定动不动刀,这才是“从前慢”真正想传达的东西。
2. 结构型模式:在“组合”与“继承”之间做一次清醒的取舍
2.1 继承是快刀,但刀口容易卷刃
刚入行的前两年,我特别爱用继承。父类抽一个模板方法,子类重写几个钩子,感觉代码复用率拉满。直到有一次做一个订单导出功能,需求是导出 Excel、CSV、PDF 三种格式,每种格式又要支持“全量”和“按条件筛选”两种模式。我当时的写法是抽象一个BaseExporter,然后ExcelExporter、CsvExporter、PdfExporter各继承一份,筛选逻辑再各自复制一遍。六个类,看起来挺整齐。
结果第三周产品说:PDF 要加个水印,Excel 不要;CSV 的筛选条件要跟另外两个不一样。我改BaseExporter怕影响全部,改子类又要复制粘贴。这就是继承的典型陷阱——继承是编译期绑定的强耦合,父类一抖动,所有子类跟着震。结构型模式里的桥接模式就是专门治这个病的:把“导出格式”和“筛选维度”拆成两个独立的继承体系,用组合把它们桥接起来。格式那边三个类,筛选那边两个类,总共五个类,但任意组合都不需要新增类,加一种格式只动格式体系,加一种筛选只动筛选体系。
这里的关键认知是:继承表达的是“is-a”,组合表达的是“has-a”,而业务里绝大多数变化其实是“has-a”维度的变化。导出器“拥有”一种筛选策略,而不是“是一种”筛选策略。想通这一层,桥接模式就不需要背了,它是自然推导出来的。
2.2 装饰器与代理:长得像,用错了就是灾难
这两个模式在代码结构上几乎一模一样,都是持有一个同类型的引用,都在调用前后做点手脚。我见过太多人把它们混用,结果埋下大坑。给你一个最直白的区分标准:装饰器是“叠加能力”,代理是“控制访问”。
举个生活化的例子。装饰器像给一杯咖啡加奶、加糖、加肉桂,每一层都是在你原有饮品上增加新特性,加几层都行,顺序还能换。代理则像公司前台,你要见老板必须先过前台,前台决定让不让你进、要不要登记、要不要转接。前台不改变老板的能力,它控制的是“你能不能接触到老板”。
落到代码上,Java 的InputStream体系是装饰器的教科书案例:BufferedInputStream包FileInputStream,加的是缓冲能力,你还可以再包一层DataInputStream加类型读取能力。而代理的典型场景是远程调用、权限校验、懒加载——比如一个图片查看器,真实图片对象很大,先用代理占位,真正要显示时才去加载。我踩过的一个坑是:在一个需要记录方法耗时的场景里,我用装饰器包了三层,结果每层都记了一次耗时,日志里同一个方法出现三条记录,排查了半天。装饰器会叠加,代理通常只该有一层拦截,这个区别在写日志、埋点、事务这类横切逻辑时尤其要命。
2.3 外观模式:给混乱的子系统留一扇干净的门
老系统重构时,外观模式是我用得最频繁的一个,没有之一。原因很现实:你不可能一次性把一坨乱麻理顺,但你可以先给它套一个门面,让新代码不再直接依赖那坨乱麻。具体做法是新建一个XxxFacade类,把子系统里七八个类的调用顺序、参数组装、异常处理全部收口到门面里,外部只调门面的一两个方法。
这么做的好处是把“脏”隔离在一个可控的边界内。门面内部可以继续乱,但至少乱的范围不再扩散。等哪天有精力了,只改门面内部实现,外部调用方一行不用动。我做过一个支付对账的模块,底层涉及账单拉取、格式解析、差异比对、结果落库四个子系统,每个子系统又有自己的初始化顺序。我加了一个ReconcileFacade,对外只暴露reconcile(date)一个方法,新来的同事再也不用去研究那四个子系统怎么串的。这里有个实操心得:门面方法不要设计得太“万能”,我见过有人把门面做成一个巨型参数对象,什么都能传,结果门面内部又是一堆 if-else,等于把混乱往上挪了一层,白干。门面应该是“窄接口”,一个方法对应一个明确的业务动作。
3. 行为型模式:把散落各处的 if-else 收编成可管理的结构
3.1 策略模式:不是消灭 if-else,而是给变化一个安放之处
关于策略模式,网上最多的说法是“用策略模式替代 if-else”,这话对一半。我明确告诉你:两个分支的 if-else 用策略模式是过度设计,五个以上且未来还会增加的分支才值得考虑。判断标准不是分支数量,而是“这个分支集合会不会变、变的频率高不高、每个分支的逻辑重不重”。
我做过一个优惠券核销的功能,最初是 if-else 判断券类型:满减、折扣、立减、兑换。四个分支,每个分支逻辑就三五行的算术,我忍住了没抽策略。后来业务加了“阶梯满减”“限品类折扣”“新客专享”,分支涨到七个,每个分支开始涉及库存校验、叠加规则、互斥判断,单个分支代码涨到七八十行。这时候再 if-else,一个方法四百多行,改一个分支要滚半天鼠标。这时候抽策略才是对的。
抽的时候有个细节很多人做错:策略的入参和出参必须统一。我见过有人每个策略类的calculate方法签名都不一样,调用方还得先判断类型再强转,等于把 if-else 挪到了调用方,毫无意义。正确做法是定义一个DiscountStrategy接口,apply(OrderContext ctx)返回DiscountResult,所有实现类严格对齐。然后配一个简单的工厂或者枚举映射,根据券类型拿到对应策略。这里可以用一个Map<String, DiscountStrategy>在启动时注册好,比反射优雅得多,也更好调试。
提示:策略模式最常见的误用是“策略类里又写了 if-else 判断上下文”。如果你的策略实现内部还在根据订单金额分情况,那说明策略的粒度切错了,应该继续往下拆,而不是在一个策略里再套分支。
3.2 责任链模式:让审批流、过滤器这类场景不再层层嵌套
责任链是我认为最被低估的一个模式。很多人只在学 Servlet Filter 的时候见过它,觉得离业务很远。实际上但凡涉及“多个处理者按顺序尝试处理,谁处理得了谁处理,或者每个都处理一遍”的场景,责任链都是首选。
我做过一个内容审核的链路:先过敏感词,再过图片识别,再过人工抽检标记,最后落审核结果。如果用传统写法,就是三层嵌套的 if,每层还要处理“上一环节已拒绝则跳过”的逻辑,代码像俄罗斯套娃。用责任链之后,每个审核环节是一个Handler,持有next引用,handle方法里要么处理完调next,要么直接中断返回。主流程只有一行chain.handle(context),清爽得不像话。
实现责任链有两个关键决策要想清楚。第一是中断语义:是“有一个处理成功就停”,还是“每个都要过一遍”?前者像审批流(经理批了就不用总监批),后者像过滤器(每个都要执行)。这两种语义对应不同的handle写法,别混。第二是链的组装方式:可以硬编码a.setNext(b).setNext(c),也可以用配置或者注解扫描自动装配。我倾向后者,因为审核环节经常增删,硬编码每次都要改主流程。用注解标记顺序、启动时扫描排序组装,加一个环节只需要新增一个类加个注解,符合开闭原则。
3.3 观察者模式:解耦“事件发生”和“后续动作”的利器
观察者模式的价值在于时间解耦和空间解耦:事件发生的代码不需要知道谁会响应,响应者也不需要知道事件从哪来。这在订单创建这种“一件事触发一堆后续动作”的场景里特别好用。下单成功后要发短信、加积分、更新库存、推消息,如果全写在createOrder方法里,这个方法会变成一个什么都干的上帝方法,而且每加一个后续动作就要改它一次。
用观察者之后,createOrder只负责发布一个OrderCreatedEvent,短信服务、积分服务各自订阅,各干各的。加新动作不动下单主流程,这是实打实的收益。但这里有个我必须提醒的坑:同步观察者会拖慢主流程。如果短信服务响应慢,下单接口就跟着慢。所以生产环境里,观察者列表里但凡有 IO 操作,基本都要配合异步线程池或者消息队列。我早期图省事用同步,结果一次短信网关抖动,下单接口 P99 直接飙到三秒,被用户投诉。后来改成异步发布,主流程只负责把事件丢进队列,响应时间立刻回到几十毫秒。
另一个细节是异常隔离。一个观察者抛异常,不能影响其他观察者执行,更不能影响主流程。所以发布事件的循环里必须 try-catch 包住每个观察者的调用,记录日志后继续。这个点看起来小,但线上出过一次积分服务报错导致整个下单回滚的事故,就是没做隔离。
4. 从“知道模式”到“用对模式”:一套可复用的判断流程
4.1 先识别变化点,再谈模式选型
我评审代码时最常问的一个问题是:“这段逻辑里,哪个部分未来最可能变?”答不上来的人,往往就是模式乱用的人。设计模式的本质是封装变化,如果你连变化在哪都不知道,套模式就是瞎套。所以我的实操流程是:拿到一段待重构的代码,先用注释标出“稳定的部分”和“易变的部分”,稳定部分保持直白,易变部分才考虑抽象。
举个具体例子。一个计算运费的函数,里面包含“基础运费计算”(稳定,按重量乘单价)和“会员折扣规则”(易变,经常调整)。那正确的做法是把折扣规则抽成策略,基础计算留在原地。而不是把整个运费计算都抽成一个大策略,那样每次改折扣都要动整个策略类,反而更麻烦。抽象要沿着变化的边界切,切多了切少了都是错。
4.2 模式选型对照表:什么症状吃什么药
下面这张表是我自己整理常用的,把“代码症状”和“对应模式”直接挂钩,比背定义好用得多。注意这只是起点,具体还要结合项目约束判断。
| 代码症状 | 优先考虑的模式 | 判断理由 | 慎用情形 |
|---|---|---|---|
| 大量 if-else 分支且分支逻辑较重 | 策略模式 | 分支可独立演化,便于单测 | 分支少于三个且逻辑简单 |
| 多层嵌套的“处理-判断-再处理” | 责任链模式 | 处理环节可增删、可重排 | 环节固定且极少变化 |
| 一个对象状态变化引发多方联动 | 观察者模式 | 发布订阅解耦,符合开闭 | 响应方有强顺序依赖 |
| 同一功能需要多种维度自由组合 | 桥接模式 | 避免类爆炸,组合优于继承 | 维度只有一个且稳定 |
| 需要在不改原类的前提下增强功能 | 装饰器模式 | 动态叠加,运行期灵活 | 增强逻辑固定且唯一 |
| 需要控制对某对象的访问 | 代理模式 | 访问控制与业务分离 | 无访问控制需求时属多余 |
| 子系统调用复杂、顺序敏感 | 外观模式 | 收口复杂度,隔离脏代码 | 子系统本身已足够简单 |
这张表我建议打印出来贴在工位上,每次想抽模式之前先对一眼。对不上症状就别抽,这是防止过度设计最有效的一道闸。
4.3 一个完整的重构实录:从 600 行上帝类到四个协作对象
说个我实际做过的重构。一个促销计算类,六百多行,方法里套方法,核心是一个calculatePrice方法,里面处理了会员等级、活动类型、商品品类、时间窗口四个维度的组合判断,if-else 嵌套了四层。需求方每次改活动规则,开发都要花半天理解这段代码,改完还经常出回归 bug。
我的重构分三步走。第一步,先加测试。这一步不能省,没有测试的重构等于赌博。我针对现有逻辑写了二十多个用例,覆盖各种组合,确保重构前后输出一致。第二步,识别维度。四个维度其实是正交的,会员等级决定基础折扣,活动类型决定额外减免,品类决定是否参与,时间窗口决定是否生效。这天然适合桥接加策略的组合:会员折扣一个策略族,活动减免一个策略族,品类和时间做成前置校验的责任链。第三步,逐步替换。我没有一次性推倒重写,而是先抽出一个维度,跑测试,绿了再抽下一个,每步都可回滚。
重构完的结果是:主类从六百行降到八十行,四个维度各自独立成类,新增一个活动类型只需要加一个策略实现加一行注册,测试用例全部复用。最直观的收益是改需求的时间从半天降到半小时,而且心里有底。这里的心得是:重构的节奏比重构的方案更重要,小步快跑、每步验证,比一次性设计一个完美架构靠谱得多。
5. 实操中反复踩到的坑与排查清单
5.1 模式用错比不用更痛:三个真实翻车现场
第一个翻车现场是单例滥用。早期我图方便,把配置管理、缓存客户端、线程池全做成单例,结果单元测试互相污染,一个测试改了单例状态,另一个测试就挂。后来学乖了,单例只用于真正全局唯一且无状态的东西,有状态的依赖一律走注入。第二个是工厂模式套娃,工厂里再套工厂,最后为了创建一个对象要追五层调用栈,调试时想死的心都有。工厂的职责应该单一,创建逻辑复杂就拆到建造者,别在一个工厂里堆。第三个是观察者顺序依赖,两个观察者之间有隐式先后要求,但代码里没体现,某次线程池调度顺序变了就出 bug。凡是观察者之间有依赖,就该合并成一个观察者或者显式编排,别指望发布顺序。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 处理建议 |
|---|---|---|---|
| 加了策略后调用方还要判断类型 | 策略接口不统一 | 检查各实现类方法签名 | 统一入参出参,用映射表取策略 |
| 责任链某环节不执行 | 上一环节未调 next 或提前 return | 在每个 handle 入口打日志 | 明确中断语义,补日志 |
| 观察者导致主流程变慢 | 同步执行 IO 操作 | 看主流程耗时与观察者耗时占比 | 改异步,加异常隔离 |
| 装饰器日志重复记录 | 多层包装重复埋点 | 数日志条数与包装层数 | 埋点只放最外层或改代理 |
| 门面类越来越臃肿 | 门面承担了业务逻辑 | 看门面方法是否超过百行 | 门面只做编排,逻辑下沉 |
| 重构后行为不一致 | 边界条件遗漏 | 对比重构前后测试用例 | 补边界用例,小步回滚 |
5.3 几条不带感情色彩的经验
第一,先写测试再重构,这条没有例外。我见过太多人自信满满直接改,结果线上出问题回滚都回不干净。第二,一个类只引入一种模式,一个类里又策略又责任链又观察者,读的人会疯,该拆就拆成协作对象。第三,模式名不要写进类名,OrderStrategy这种命名除了暴露实现细节没有任何好处,叫OrderDiscountCalculator更贴近业务。第四,注释写“为什么这么设计”而不是“这是什么模式”,半年后接手的人不关心你用了什么模式,只关心为什么不能简单改。第五,能不用模式就不用,直白的代码永远是最好的代码,模式是给复杂度准备的药,没病别吃药。
6. 慢下来之后,我真正想说的
写这个第二篇章的过程中,我反复想起自己刚学设计模式那会儿的状态:恨不得每个类都套一个模式,觉得这样才显得专业。后来被现实教育了几年才明白,设计模式是给“已经痛了”的代码准备的,不是给“可能会痛”的代码准备的。提前抽象和过度设计,本质上是用确定的复杂度去赌不确定的未来,赌输的概率还不低。
“从前慢”这个系列名,我现在的理解是:慢在判断,快在动手。花时间想清楚变化的边界、想清楚这次抽象值不值、想清楚测试够不够,这些想明白了,动手改反而快。反过来,上来就套模式,改完发现方向错了,返工的时间够你想十遍。我个人的习惯是,每次想抽模式之前,先问自己三个问题:这个变化半年内会发生几次?如果不抽,最坏的情况是什么?抽了之后,新同事要花多久理解?三个问题答完,该不该抽基本就有答案了。
最后分享一个我最近在用的笨办法:在项目里建一个design-notes目录,每次做了一次模式相关的重构,就写一页纸记录“当时什么症状、为什么选这个模式、放弃了哪些方案、后续观察点是什么”。半年后回头看,哪些抽对了、哪些抽早了,一目了然。这个习惯比读十本模式书都管用,因为它逼你把模式和真实场景绑在一起思考。慢一点,记录一点,下次判断就准一点,这大概就是“从前慢”最实在的落地方式。