设计模式这东西,十个人里有八个是这么学的:背一遍二十三种GoF模式的定义、画一遍类图、抄一遍示例代码,然后到真正写业务的时候,脑子里只剩一个"单例好像用过"。市面上讲设计模式的文章、课程多如牛毛,但真正让你看完能上手、知道在什么场景下选哪个模式的,太少。我过去几年在几个不同类型的项目里摸爬滚打,也反复经历了"背了忘、忘了背"的循环,后来才慢慢琢磨明白:设计模式的核心根本不是那二十三种固定结构,而是一套识别问题、选择方案、落地实现的思路。这篇文章我不想再复述一遍各类模式的教科书定义,而是挑几个实际项目中最常用、最容易踩坑、也最能体现"模式思维"的典型模式,用完整的业务场景串联起来,讲清楚它们到底解决了什么问题、什么时候该用、用了之后代码长什么样、以及有哪些替代方案。同时,我也会顺带聊聊近两年多Agent系统设计里非常有意思的一个趋势——主从Agent模式,它本质上和传统设计模式里的"代理"思想同源,但应用场景完全换了一个维度。这篇文章适合所有写过一段时间业务代码、对设计模式似懂非懂、又想真正把它用起来的开发者。
1. 先别急着背模式:我理解的"模式思维"到底是什么
1.1 从一次尴尬的面试说起
有一次我面试一个三年经验的Java开发,简历上赫然写着"精通设计模式"。我问了一个很基础的问题:"你的项目里有没有哪个地方是自然生长出策略模式的?说说当时的演变过程。"他沉默了很久,然后开始背策略模式的UML图,说"Context里面维护一个Strategy接口,然后可以有多个实现类"。这其实就是典型的问题——你把设计模式的"形"记得很清楚,但完全没建立"问题场景"到"模式方案"之间的映射。面试官问的是"项目里怎么自然长出来的",而不是"定义是什么"。
后来我复盘这件事,发现我自己早期也犯过一模一样的错。设计模式之所以叫"模式",本质是因为特定问题在特定上下文中反复出现,解决方案被总结成了可复用的套路。模式的核心是"意图"——它解决什么问题、在什么约束下成立、代价是什么——而类图只是意图落地的外在形式。 理解了这一点,你再看那些模式,会发现很多模式之间其实就是同一种思想的不同变体。
1.2 模式思维的三步走
我在团队里带新人的时候,会反复强调一个判断框架:设计模式的使用不是"因为这个场景像某个模式所以我要套",而是反过来——先看代码里出现了什么"坏味道",再判断有没有对应的模式可以消除这个坏味道。
具体来说,判断一个地方是否需要引入设计模式,可以问三个问题:
- 变化点在哪? 如果一段代码将来大概率会因为新需求而修改,那就需要把变化的部分封装起来,隔离稳定部分。这是几乎所有设计模式的底层动机。
- 变化的维度是单个还是多个? 如果只有一个维度会变,可以用策略模式、模板方法模式;如果多个维度独立变化,就要考虑桥接模式或者组合多个模式。
- 引入模式的成本是否可控? 模式意味着增加类和抽象层级,如果项目只有三个if-else分支,硬套策略模式反而增加阅读负担。
说实话,这三步比记住二十三个模式的名字重要得多。下面我挑几个实战中最常出场的模式,用具体代码演进的过程来拆解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 代理模式与它在现代系统里的"远房亲戚们"
2.1 我项目中真实用到代理模式的场景
代理模式大概是所有模式里最"隐身"的一个,因为你可能天天在用,但没意识到那就是代理。Java里最典型的例子就是动态代理——Spring AOP、MyBatis的Mapper接口、Retrofit的接口代理,底层全是它。
说说我之前在电商后台系统里写过一个权限校验的模块。当时的需求是:后台有一批敏感操作接口,比如修改商品价格、批量上下架、操作优惠券,每次调用前都要校验当前操作者是否有对应权限。最早的实现是在每个Service方法里手动调用权限校验工具类,代码长这样:
java复制public class ProductService {
public void updatePrice(Long productId, BigDecimal newPrice, User operator) {
// 手动校验权限
permissionChecker.check(operator, PermissionType.PRICE_UPDATE);
// 业务逻辑
productDao.updatePrice(productId, newPrice);
}
public void batchOffline(List<Long> productIds, User operator) {
permissionChecker.check(operator, PermissionType.BATCH_OFFLINE);
// 业务逻辑
productDao.batchOffline(productIds);
}
}
问题很快就来了:新接口越来越多,漏写权限校验的情况时有发生;而且权限校验逻辑和业务逻辑耦合在一起,看代码的时候注意力被支离破碎的"check"调用打乱。
后来我引入了动态代理,把权限校验做成一个统一拦截器:
java复制public class PermissionProxy implements InvocationHandler {
private final Object target;
private final PermissionChecker permissionChecker;
public PermissionProxy(Object target, PermissionChecker permissionChecker) {
this.target = target;
this.permissionChecker = permissionChecker;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
PermissionRequired annotation = method.getAnnotation(PermissionRequired.class);
if (annotation != null) {
// args[0] 约定为操作者
User operator = (User) args[0];
permissionChecker.check(operator, annotation.value());
}
return method.invoke(target, args);
}
}
然后通过一个工厂方法给Service对象套一层代理:
java复制public static <T> T proxied(T target, PermissionChecker permissionChecker) {
return (T) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new PermissionProxy(target, permissionChecker)
);
}
改造之后,Service代码里删掉所有手动校验逻辑,只保留业务实现,方法上加上注解标明需要的权限级别。新加接口时,只要记得加注解,权限校验自动生效,漏校验的概率直线下降。这就是代理模式的经典价值:在不修改原有类代码的前提下,把横切关注点(权限、日志、事务、缓存)统一织入。
2.2 静态代理 vs 动态代理,怎么选?
很多初学者分不清静态代理和动态代理,觉得"反正都是代理,能用就行"。但用错地方会给自己挖坑。
静态代理是手动为每一个目标类编写一个代理类,代理类和目标类实现同一个接口。优点是简单直观,字节码层面就是普通类;缺点是每个类都要手写一个代理类,系统里代理类会爆炸式增长。我曾经看到过一个项目,为了给十几个Service加日志,写了十几个对应的代理类,代码重复度非常高。
动态代理则是运行时在内存中生成代理类,Java原生支持基于接口的Proxy,CGLib/ByteBuddy可以基于继承实现。优点是代理逻辑集中在一处,通用性强;缺点是调试相对困难,字节码对新手不太友好,而且基于接口的Proxy要求目标必须有接口。
我的建议是:如果只是给少数几个类加代理逻辑,静态代理反而更直白;如果是一批类都要加同一种拦截逻辑,果断上动态代理。Spring框架把这一步封装得更彻底——AOP切面本质上就是一个动态代理的工业化实现,你声明一个@Around通知,Spring容器在启动时就会给Bean生成代理对象,业务代码里完全无感知。
2.3 多Agent系统里的"主从模式",怎么就和代理模式同源了
近几年LLM应用开发里经常讨论多Agent架构,网上流行的方案里有一种是"主从模式"——一个主Agent负责拆解任务、调度子Agent,子Agent各自负责某个具体领域。有意思的地方在于,很多团队在设计主Agent调用子Agent时,并没有把子Agent当作"另一个Agent"来理解,而是把子Agent当作一个"特殊的工具"来调用——给它传一个prompt描述,内部逻辑完全封装起来,主Agent只关心输入输出。这恰好就是代理模式的思想内核:调用方(主Agent)通过一个统一的接口(工具调用/函数命名),去操作背后的真实实现(子Agent),并且可以在中间织入额外的逻辑(任务的规划、结果的校验、上下文的拼装)。
换个角度说,早年我们把一段复杂逻辑封装成一个Service接口,现在我们是把一段复杂的"智能决策过程"封装成一个Agent接口。抽象层次变了,但"通过代理/门面隔离复杂实现"的意图没有变。所以你说设计模式过时了吗?它并没有过时,而是换了一个马甲继续发挥着作用。如果你已经在用LangChain或者自己写过多Agent编排,回头再想想这里面的task scheduler、agent registry,其实都能找到传统设计模式的影子。
3. 策略模式:干掉代码里最让人头疼的if-else链
3.1 一个订单折扣计算的重构故事
业务系统里最常见的坏味道就是大段的if-else或者switch-case。有些写了几年的老项目,一个方法里能有三四十个分支,谁也不敢动。策略模式就是专门来收拾这种局面的。
我之前做过一个交易系统,订单折扣计算的早期代码大概是这样的:
java复制public BigDecimal calculateDiscount(Order order) {
BigDecimal discount = BigDecimal.ZERO;
String userLevel = order.getUserLevel();
if ("NORMAL".equals(userLevel)) {
discount = BigDecimal.ZERO;
} else if ("SILVER".equals(userLevel)) {
discount = order.getAmount().multiply(new BigDecimal("0.02"));
} else if ("GOLD".equals(userLevel)) {
discount = order.getAmount().multiply(new BigDecimal("0.05"));
// 黄金会员如果有优惠券,额外减10元
if (order.hasCoupon()) {
discount = discount.add(new BigDecimal("10"));
}
} else if ("PLATINUM".equals(userLevel)) {
discount = order.getAmount().multiply(new BigDecimal("0.08"));
// 铂金会员如果有优惠券,额外减20元
if (order.hasCoupon()) {
discount = discount.add(new BigDecimal("20"));
}
}
// 还有好几个等级...
return discount;
}
这段代码的问题是清晰可见的:新增一个会员等级,就要动这个方法;每个等级自己的规则嵌套在if-else里,测试的时候要构造各种组合;方法越来越长,谁也不敢动。
用策略模式重构,第一步是定义策略接口和各个策略实现:
java复制public interface DiscountStrategy {
boolean supports(UserLevel userLevel);
BigDecimal calculate(Order order);
}
@Component
public class NormalDiscountStrategy implements DiscountStrategy {
@Override
public boolean supports(UserLevel userLevel) {
return userLevel == UserLevel.NORMAL;
}
@Override
public BigDecimal calculate(Order order) {
return BigDecimal.ZERO;
}
}
@Component
public class GoldDiscountStrategy implements DiscountStrategy {
@Override
public boolean supports(UserLevel userLevel) {
return userLevel == UserLevel.GOLD;
}
@Override
public BigDecimal calculate(Order order) {
BigDecimal discount = order.getAmount().multiply(new BigDecimal("0.05"));
if (order.hasCoupon()) {
discount = discount.add(new BigDecimal("10"));
}
return discount;
}
}
第二步是把所有策略注入到一个Context里,由Context根据条件路由:
java复制@Service
public class DiscountContext {
private final List<DiscountStrategy> strategies;
public DiscountContext(List<DiscountStrategy> strategies) {
this.strategies = strategies;
}
public BigDecimal calculate(Order order) {
return strategies.stream()
.filter(s -> s.supports(order.getUserLevel()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No strategy found"))
.calculate(order);
}
}
这样改造之后,原来的calculateDiscount方法缩成了三行调用代码,新增一个会员等级只需要新增一个Strategy类并标注@Component,完全不需要改动已有代码。策略模式的核心收益就是把"变化的算法"和"使用算法的上下文"解耦。
3.2 但策略模式不是银弹,这三种情况你别硬套
我在实际操作中发现,策略模式虽然好,但有三类情况硬套反而坏事:
- 分支只有两三个且稳定不变。if-else两三个分支本身够清晰,硬写五六个策略类属于过度设计,阅读代码的人需要跳来跳去。
- 策略类之间共享大量状态。如果每个策略都依赖Context里的十几个字段,写起来很别扭,还不如在原有方法里分段处理。
- 没有运行时切换策略的需求。策略模式的隐含前提是"在运行时选择算法",如果某个逻辑在系统生命周期里只会执行一次且不会变,直接写死在方法里反而更直接。
还有一个常常被忽略的细节:策略的注册与路由。上面用的是Spring的List<DiscountStrategy>自动收集+supports()方法路由,这种写法在策略数量不大时最简单。但策略特别多、每个策略的判定条件复杂时,supports()方法会慢慢变成另一个if-else集中营。这时候可以考虑引入一个注册表(Map<UserLevel, DiscountStrategy>),在Bean初始化时把策略注册进去,路由时直接查Map,复杂度从O(n)降到O(1)。更简单一点,也可以直接用注解+反射扫描来构建映射表,不过会增加一些启动期开销。
4. 观察者模式:把"通知"从业务代码里剥离出去
4.1 支付成功后的那一连串操作
交易系统里另一个高发点,就是"一件事成功后要触发一系列后续动作"。比如支付成功之后,要发短信通知用户、要更新订单状态、要通知仓储系统出库、要把数据同步给财务系统、还要给推荐人发奖励……如果把这些写在支付回调方法里,这个方法的代码会越来越长,而且每加一个后续动作都要改动核心支付逻辑。
观察者模式的思路很直接:事件发布方不关心谁会处理这个事件,只负责把事件发出去;所有对事件感兴趣的处理方自己注册、自己处理。 用Java自带的ApplicationEvent加Spring的@EventListener可以实现得非常优雅。
事件类:
java复制public class PaymentSuccessEvent {
private final Long orderId;
private final Long userId;
private final BigDecimal amount;
// 构造方法、getter...
}
业务代码只负责发布事件:
java复制@Service
public class PaymentService {
private final ApplicationEventPublisher eventPublisher;
public PaymentService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void handlePaymentCallback(PaymentCallbackRequest request) {
// 1. 校验支付结果
// 2. 更新本地订单状态
String payStatus = request.getStatus();
if ("SUCCESS".equals(payStatus)) {
orderService.markAsPaid(request.getOrderId());
// 3. 发布支付成功事件,其他模块自己处理后续
eventPublisher.publishEvent(new PaymentSuccessEvent(
request.getOrderId(), request.getUserId(), request.getAmount()
));
}
}
}
各个后续模块通过监听器解耦:
java复制@Component
public class SmsNotificationListener {
@EventListener
public void onPaymentSuccess(PaymentSuccessEvent event) {
smsClient.send(event.getUserId(), "您的订单 " + event.getOrderId() + " 已支付成功");
}
}
@Component
public class WarehouseListener {
@EventListener
public void onPaymentSuccess(PaymentSuccessEvent event) {
warehouseClient.notifyShipment(event.getOrderId());
}
}
重构之后,支付流程的核心代码永远只保留"校验+更新状态+发布事件"三件事,任何新增的后续动作都只是新增一个监听器,这对系统的可维护性是质的提升。
4.2 同步还是异步?一个值得提前想清楚的问题
事件监听器的执行方式在Spring里默认是同步的——发布事件的方法会一直阻塞,直到所有监听器执行完毕。同步执行的好处是逻辑简单、出错了可以顺着调用栈直接排查;坏处是支付回调接口的响应时间会被拖慢,如果某个监听器执行很慢,比如调用外部仓储接口超时重试五秒,用户支付成功的回调就一直不返回,严重时会导致支付平台重发回调,产生重复处理。
我踩过这个坑之后,现在做事件设计时会先问三个问题:
- 监听器里做的事情,必须和主流程保持强一致吗?如果监听器挂了需要回滚主流程,那就同步。如果只是通知类操作,异步更合适。
- 可以接受最终一致性吗?大部分场景是可以的,订单状态更新完就返回,短信晚两秒发没问题。
- 异步执行之后如何保证可靠性?如果只是用
@Async直接丢线程池,应用重启时未执行的事件就丢了。对重要的异步事件,建议先落库(事件表),再通过定时任务或消息队列投递。
对于一般规模的项目,我的保守做法是:先同步,等真出现性能问题再改异步,但事件发布代码提前预留好异步切换的可能性,比如把监听器的调用封装在一个Executor里,将来改成MQ只需要把eventPublisher.publishEvent替换成mqTemplate.send。
4.3 观察者模式与消息队列的边界感
很多人会问一个问题:已经有了Kafka/RocketMQ,还需要观察者模式吗?我的理解是两者解决的是不同尺度的解耦。观察者模式解决的是进程内模块之间的解耦——同一个JVM里,订单模块不需要知道短信模块的类名。消息队列解决的是跨进程、跨系统的解耦——订单服务不需要关心仓储服务部署在哪台机器上。
如果项目已经上了消息队列,其实订阅发布本身就是观察者思想在分布式环境下的实现,你没必要再在应用内叠一层。但如果是单体应用或者模块化Monolith,直接使用Spring事件机制成本更低,不需要额外维护消息队列的Topic、消费组、重试策略那一堆基础设施。
5. 工厂模式家族:从new对象到容器管理
5.1 三种工厂的区别,用一次需求变化讲清楚
工厂模式是面试里问得最多的,也是实际代码里最容易被滥用的。我把简单工厂、工厂方法、抽象工厂的区别用一个实际演进过程讲明白。
假设你的系统需要支持多种数据库连接,最早是业务代码里直接new一个连接对象:
java复制// 到处都是这种代码
OracleConnection conn = new OracleConnection(config);
后来发现不行,要切换数据库的时候需要改一堆地方,于是加了一个简单工厂:
java复制public class ConnectionFactory {
public static Connection create(String dbType, Map<String, String> config) {
switch (dbType) {
case "oracle": return new OracleConnection(config);
case "mysql": return new MySqlConnection(config);
case "pg": return new PgConnection(config);
default: throw new IllegalArgumentException("unsupported db type: " + dbType);
}
}
}
简单工厂把所有创建逻辑集中到一个类里,客户端只需要传一个类型参数。但它的缺点是:新增数据库类型时需要修改这个工厂类,违反了开闭原则。 工厂方法模式就是来治这个病的——把创建逻辑延迟到子类:
java复制public interface ConnectionFactory {
Connection create(Map<String, String> config);
}
public class OracleConnectionFactory implements ConnectionFactory {
@Override
public Connection create(Map<String, String> config) {
return new OracleConnection(config);
}
}
客户端在使用时,先拿到具体的工厂子类实例,再调用create。新增数据库只需要新增一个工厂类,不需要动已有代码。但这里实际上是把"创建哪种对象"的问题推给了上一层——客户端自己还得知道该实例化哪个工厂。真正解决这个问题的,是依赖注入容器,也就是Spring IoC干的事情:所有工厂子类注册成Bean,容器根据类型自动注入合适的那个。
至于抽象工厂,很多教材讲得很玄乎,其实它就是解决"一族相关对象"的创建。比如一个跨平台UI组件库:Linux风格下需要LinuxButton和LinuxTextBox,Windows风格下需要WindowsButton和WindowsTextBox。抽象工厂就是定义一个接口,里面有一组创建方法:
java复制public interface UiFactory {
Button createButton();
TextBox createTextBox();
}
public class LinuxUiFactory implements UiFactory {
@Override
public Button createButton() {
return new LinuxButton();
}
@Override
public TextBox createTextBox() {
return new LinuxTextBox();
}
}
这样客户端拿到一个UiFactory,就能创建出风格一致的一整套组件,不会出现"按钮是Windows风格、输入框是Linux风格"的尴尬。
5.2 工作中最常用的其实是被Spring容器覆盖的"依赖注入"
聊到这里我得说句实在话:在Spring/Spring Boot统治Java后端的今天,你用工厂模式的地方远比想象中少。Spring的@Autowired、@Bean、@Configuration本质上已经把"根据配置创建对象、管理对象生命周期、按需注入依赖"这件事做掉了。你需要做的不是自己写一堆Factory类,而是把对象的创建声明交给容器。可以把IoC容器理解成一个全局的、带缓存和生命周期管理的抽象工厂。
但这不是说工厂模式就没用了。几个我实际用到的场景:
- 创建过程复杂且有多种实现时,
@Bean方法加@ConditionalOnProperty这类配置,比写工厂类更Spring风格。 - 创建的对象不是Bean(比如需要传入运行时参数),还是得用工厂。比如根据用户选择的支付渠道创建对应的支付客户端,每个客户端要带上当前用户的配置信息,这种情况靠
@Autowired注入不进来,必须手动new或通过工厂创建。 - 在非Spring项目里(比如写LeetCode、做设计模式大作业、写一个没有框架的工具类库),工厂模式依然是最可靠的对象创建方式。
5.3 工厂模式最常见的误用:为了模式而模式
我见过不少团队给每个Service都配一个Factory,不管有没有多种实现。这就非常没必要。一个接口只有一个实现类的时候,工厂纯属多余;就算是将来可能有多种实现,你也可以等第二个实现真正出现时再引入工厂,重构成本其实很低——把new挪到一个方法里,Java里甚至用Supplier<T>就能延迟到运行时再决定具体实例。
"等到出现两个及以上实现,且创建逻辑有差异时再上工厂",是我在实践中摸索出来的一个很实用的原则。设计模式是拿来解决问题的,不是拿来提前预防问题的。预防性设计往往比问题本身更昂贵,YAGNI原则在模式选择上同样适用。
6. 适配器模式:老系统改造时最温柔的方案
6.1 短信服务商SDK换血带来的接口灾难
老系统改造最痛的永远是接口不兼容。我之前做过一个项目,原本用的是某家短信服务商的SDK,代码里到处都是类似SmsV1Client.sendSms(mobile, content)的调用。后来因为成本和服务稳定性原因,换成了另一家服务商,新SDK的接口签名完全不一样,比如变成了SmsV2Gateway.deliver(String phone, String text, SmsPriority priority)。
如果直接全局替换,工作量巨大且风险极高,因为SDK的调用点可能散落在几十个类里面。适配器模式正好解决这个问题:
- 定义一个项目内部统一的短信发送接口(这是所有后续改造的锚点):
java复制public interface SmsSender {
void send(String mobile, String content);
}
- 为新的服务商SDK写一个适配器,把统一接口翻译成新SDK的调用:
java复制public class SmsV2GatewayAdapter implements SmsSender {
private final SmsV2Gateway smsV2Gateway;
public SmsV2GatewayAdapter(SmsV2Gateway smsV2Gateway) {
this.smsV2Gateway = smsV2Gateway;
}
@Override
public void send(String mobile, String content) {
smsV2Gateway.deliver(mobile, content, SmsPriority.NORMAL);
}
}
- 把原来所有调用
SmsV1Client的地方,改成注入SmsSender接口并调用send。至于最终是注入V1的旧实现还是V2的适配器,通过Spring配置切换即可。
改造之后的依赖方向反过来了——业务代码依赖项目内部的SmsSender抽象,而不是任何外部SDK。以后哪怕再换一家服务商,也只需要新增一个适配器类,业务代码一行不用改。适配器模式的本质就是"用一个中间层隔离不兼容的变化"。
6.2 适配器 vs 外观模式 vs 装饰器,实操里最容易混淆的三兄弟
这三个模式在很多文章里都被混为一谈,因为它们在代码结构上确实长得很像——都是"包装一个类"。但它们的意图完全不同:
- 适配器解决的是"接口不兼容":客户端期望接口A,实际对象只提供接口B,适配器把B翻译成A。
- 外观模式解决的是"接口太复杂":把一堆子系统接口封装成一个更简单的高层接口,客户端不需要知道子系统内部如何协作。
- 装饰器解决的是"行为需要扩展":在保持接口不变的前提下,给对象动态增加职责。
一个直观的例子:你去餐厅吃饭。
- 餐厅给你提供"点餐"这个简单接口,你不用关心后厨炒菜、传菜、洗碗这些复杂流程——这是外观模式。
- 你带了外国朋友,服务员提供翻译服务,把中文菜单翻译成英文——这是适配器。
- 你点了一份意面,要求多加一份芝士,厨师在原有意面上叠加芝士——这是装饰器。
这三个模式在代码里可能都是"持有一个被包装对象的引用",但在设计意图上南辕北辙。写代码的时候先问自己:我是为了兼容(适配器)、简化(外观)还是增强(装饰器)?
7. 怎么练设计模式,才能真正写进代码里
7.1 我的训练法:从"反向标注"到"场景重构"
很多学生朋友做设计模式大作业或者期末复习时最容易犯的毛病,就是对着UML图背模式,背完就忘。我推荐一个我自己用过、也带过很多人试过的训练方法——反向标注法:打开你项目里最常用的几个核心类,逐行看代码,每看到一个if-else、switch、重复出现的new、发散的调用链,就问自己"如果这个项目再活两年,这个位置最可能因为什么需求而变化?"然后试着把这个变化点用模式封装起来。
举个例子,如果你的Service方法里有一段日志记录的代码,每个方法都有一段重复的try-catch-finaly,那可以想想模板方法模式怎么消除重复。如果你的代码里有一堆if (type == A) doA(); else if (type == B) doB();,那自然会联想到策略模式。这个方法训练的是"从问题反推模式"的能力,而不是"从模式找应用场景"——后者在真实项目里几乎行不通。
7.2 用演进式重构代替一次性设计
我自己做设计时越来越倾向"演进式"而不是"设计先行"。其实这在行业里也是很成熟的思想——与其在项目初期花大力气预测变化点、设计完美的模式架构,不如先写一个清晰的、直白的版本,然后在第一个真实需求变化到来时,顺势把模式引入。
这一点在代码评审时尤其重要。我见过太多"为了设计模式而设计模式"的代码,类爆炸式增长,找业务逻辑要在五层继承树里翻来翻去。正确的心态是:模式是重构的方向,不是编码的起点。 你可以在一开始就识别出"这地方将来会变",但不必一上来就套模式——在变的那一天再做重构,成本通常是可控的,而且你还能在重构过程中验证模式的引入是否真的改善了代码结构。
另外,建议每个开发者都去读一下《重构:改善既有代码的设计》和《设计模式:可复用面向对象软件的基础》——尤其是第二本的引言部分,里面有一句话我记了很多年:"优先使用对象组合而不是类继承"。这句话几乎可以解释为什么组合模式、装饰器模式、策略模式在实际项目中比继承关系更稳健。
7.3 不要忽视设计模式的"代价"
设计模式不是免费午餐。每引入一个模式,都意味着增加至少一个类、一个抽象层级,以及读者需要额外理解的间接性。有些模式(比如抽象工厂、桥接模式)在实际业务代码里用到的机会其实很少,硬套只会增加复杂度。
我的建议是:优先掌握策略、观察者、工厂、单例(及其反模式)、适配器、模板方法这六个,它们几乎覆盖了日常开发里90%的需求。剩下的模式,知道它们解决什么问题、场景出现时能想起来"好像有个模式可以用"就够了,没必要看到UML图就紧张。学习设计模式最忌讳的是堆砌名词,最有价值的是把每一个模式都对应到自己写过的真实代码里,哪怕只是在重构一个三百行的方法时用上了模板方法模式,也比背完所有模式却无从下手强得多。
