前年冬天我接手过一个订单模块,OrderService 单个类 2187 行,我用 wc -l 数了两遍,因为第一遍我不信。最长的那个方法叫 createOrder,从一个 if (source == 1) 开始,最深嵌套四层,中间 60 多个分支判断,覆盖六个渠道、四种币种、预售/现货/拼团三种形态。当时我的第一反应特别标准:上策略模式。
花了大概三周,我把这些分支拆成了 47 个 Strategy 实现类,外加 1 个工厂、1 个枚举注册表、1 个 Context。上线那天我觉得自己挺了不起的。三个月后新来的同事要加一个渠道,他在群里问:我该改哪个文件?没人答得上来——包括我。
这是我第一次意识到,设计模式最危险的地方不是学不会,是学会了以后手痒。
后来我把 GoF 那本书翻出来重看了一遍,注意到一句以前直接跳过去的原话,大意是:每个模式描述的是「在我们的环境里反复出现的问题」,以及这个问题解法的核心,而不是一份可以直接抄的代码。关键词是「在我们的环境里」。那 47 个策略类在书上是标准答案,在我们的环境里是灾难——因为我们的变化点根本不在「算法」上,而在「渠道差异」上,而渠道差异里的大部分其实是配置,不是逻辑。我拿一个模式的形状,去套了一个并不存在的问题。
我现在的判断标准只有一条:这个间接层,如果半年之后我删不掉,那它一开始就不该存在。听着有点极端,但很顺手。策略引出的接口,如果永远只有一个实现被调用,它就是装饰品;工厂引出的那一层,如果创建逻辑只有一行 new,它就是收费站。模式本身没问题,问题是没人给它设退出条件,最后它自己长成了负债。
说回最常被拿来对比的这两个:策略和工厂。
它们经常一起出现,但解决的完全是两件事。工厂回答「对象从哪来」,策略回答「这次用哪一个」。前者关心创建过程和生命周期,后者关心运行时的选择。很多人(包括当年的我)会把工厂塞进策略里,结果是同一个 if-else 先被工厂判一遍、再被策略判一遍、最后在 Context 里被 switch 又判一遍——判定逻辑扩散到三个类,加一个渠道要改五个地方。
一个粗糙但好用的分界线:如果分支的返回值是对象,用工厂;如果返回值是行为结果,用策略。两个都要,就先做一张 key 到枚举的映射表,让选择这件事只发生一次,别让它长出三层。
框架源码里其实到处都是这些模式,但用法比教程朴素得多。JDK 的 InputStream 是装饰器:new BufferedInputStream(new FileInputStream(path)),一层套一层。这里有个细节——FilterInputStream.close() 会去关掉被它包住的那个流,所以关最外层就够,不用自己挨个关。这个点我是在一个线上句柄泄漏的工单里学到的。Spring 的 AbstractApplicationContext.refresh() 一共 12 个步骤,obtainFreshBeanFactory、invokeBeanFactoryPostProcessors、finishBeanFactoryInitialization……标准的模板方法:骨架写死,只留 onRefresh() 给你。Netty 的 ChannelPipeline 是责任链,Tomcat 的 FilterChain 也是。
看多了会发现,它们的共同点不是「用了模式」,而是变化点的边界划得很清楚:JDK 只让你决定套哪几层,Spring 只让你改那一个钩子,Netty 只让你往管道里加 handler。多一寸都不给。
至于单例,我态度很明确:在用了 Spring 的项目里手写单例,九成是给自己找麻烦。Spring 的 Bean 默认就是单例,你再包一层 getInstance(),测试的时候 mock 不掉,启动顺序还会咬你一口。真要写,枚举是成本最低的形式——Effective Java 第 3 版里就是这么建议的,顺带还防住了反射和序列化这两条歪路。另外 JDK 5 之后的双重检查锁必须加 volatile,因为 JSR-133 才把 volatile 的语义补全,这个坑到今天还有人在踩。
回到开头那个模块。去年我又动了一次,47 个策略类合并成 6 个,其余 41 个降级成了数据库里的一张配置表。删掉的那天下午,心情挺好。
所以现在的我,写代码会先老老实实写 if-else,等第三次出现结构相同的分支,再考虑抽象。不是不学模式,是把它当工具而不是当考卷——考卷上的答案越标准越好,工具握在手里,得知道什么时候该放下。