1. 结构型设计模式的底层逻辑与整体思路
写代码这事,做到后期你会发现一个特别有意思的现象:真正的复杂度往往不在某个单独的算法有多难写,而在于一堆类、一堆对象之间怎么组织、怎么协作。你今天加一个功能要动三个类,明天改一个需求要牵连五个文件,代码能跑,但改起来像拆炸弹——这就是结构没设计好的典型症状。
结构型设计模式(Structural Design Patterns)解决的就是这个问题:它关注类和对象的组合方式,告诉我们如何在不破坏封装、不引入不必要耦合的前提下,把现有的类组织成更大的结构。说白了,就是让你代码里的"积木"怎么拼更稳、更省、更灵活。
这7种武器分别是:适配器模式(Adapter)、桥接模式(Bridge)、组合模式(Composite)、装饰器模式(Decorator)、外观模式(Facade)、享元模式(Flyweight)、代理模式(Proxy)。它们各自解决一类特定的结构问题,但很多人学完之后脑子里是散的,遇到实际场景根本想不起来该用哪个。
我先把这7种模式的核心定位用一张表给你理清楚,后面再逐个拆解:
| 模式 | 核心问题 | 一句话类比 |
|---|---|---|
| 适配器(Adapter) | 接口不兼容 | 苹果耳机插安卓手机,需要转接头 |
| 桥接(Bridge) | 抽象与实现各自变化 | 遥控器和电视,两者独立发展 |
| 组合(Composite) | 整体与部分需要一致对待 | 文件夹里套文件夹,统一用"打开"操作 |
| 装饰器(Decorator) | 动态增强功能 | 给咖啡加奶、加糖,咖啡本身不变 |
| 外观(Facade) | 子系统过于复杂 | 前台小姐姐,你只需跟她说需求即可 |
| 享元(Flyweight) | 大量细粒度对象导致内存膨胀 | 共享单车,大家骑同一批车 |
| 代理(Proxy) | 访问控制与延迟加载 | 明星的经纪人,所有邀约先过他 |
适合谁看?刚把Java语法学完、开始写实际项目的初中级开发者,以及有一定经验但总觉得代码"哪里不对劲"的同学。这篇文章不会跟你念定义,我会结合真实业务场景,把每个模式的原理、代码骨架、适用边界全部拆透。
2. 适配器模式与桥接模式:处理接口变化的两条不同路径
2.1 适配器模式:让不兼容的接口互相协作
适配器模式是我见过日常开发中出现频率最高的结构型模式,只是很多时候你用了却不知道。它的核心诉求很简单:把一个类的接口转换成客户端期望的另一个接口。
举一个我印象很深的真实场景。某次对接一个第三方支付SDK,对方的接口长这样:
public class ThirdPartyPayService { public boolean payByThirdParty(String orderId, double amount, String sign) { // 第三方支付逻辑 return true; } }但我们系统内部统一的支付接口是这样定义的:
public interface PaymentService { void pay(Order order); }两边的接口完全不兼容。这时候你有三个选择:改SDK(不可能,人家是jar包)、改系统里所有调用方(牵扯几十处代码,风险极高)、写一个适配器(正确解法)。
适配器的实现方式有两种:类适配器和对象适配器。类适配器用继承实现,Java里因为类是单继承,用得少。绝大多数情况下你只需要用对象适配器——持有被适配者的引用,实现目标接口:
public class ThirdPartyPaymentAdapter implements PaymentService { private final ThirdPartyPayService thirdPartyPayService; private final SignService signService; public ThirdPartyPaymentAdapter(ThirdPartyPayService thirdPartyPayService, SignService signService) { this.thirdPartyPayService = thirdPartyPayService; this.signService = signService; } @Override public void pay(Order order) { String sign = signService.generateSign(order); boolean result = thirdPartyPayService.payByThirdParty( order.getOrderId(), order.getAmount(), sign); if (!result) { throw new PaymentException("支付失败"); } } }这就是适配器模式最标准的形态。适配器的关键在于它改变了接口,但不改变核心行为和职责。它只是做了一层"翻译",让原本不匹配的双方能协作。
2.2 桥接模式:把抽象和实现各自独立演化
桥接模式是我认为最容易被忽视、但在架构设计里价值极高的模式。它解决的是另一个问题:当抽象部分和实现部分都需要独立扩展时,继承已经无能为力了。
听我讲一个反面案例。假设你做一个消息推送系统,消息类型有普通消息和加急消息,渠道有邮件和短信。如果按传统继承思路,你会建4个类:普通邮件消息、加急邮件消息、普通短信消息、加急短信消息。后续再加一个微信渠道,就是6个类;再加水下消息,就是9个类。类数量呈爆炸式增长,这就是典型的"类爆炸"问题。
桥接模式的做法是:将抽象部分(消息类型)与实现部分(发送渠道)分离,让它们各自独立变化,中间用组合的方式搭一座桥。
// 实现部分:消息发送渠道 public interface MessageSender { void send(String content); } public class EmailSender implements MessageSender { @Override public void send(String content) { System.out.println("通过邮件发送: " + content); } } public class SmsSender implements MessageSender { @Override public void send(String content) { System.out.println("通过短信发送: " + content); } } // 抽象部分:消息类型 public abstract class AbstractMessage { protected MessageSender sender; public AbstractMessage(MessageSender sender) { this.sender = sender; } public abstract void sendMessage(String content); } public class NormalMessage extends AbstractMessage { public NormalMessage(MessageSender sender) { super(sender); } @Override public void sendMessage(String content) { sender.send(content); } } public class UrgentMessage extends AbstractMessage { public UrgentMessage(MessageSender sender) { super(sender); } @Override public void sendMessage(String content) { sender.send("[加急] " + content); } }以后你加一个微信渠道,只需要新增WechatSender实现MessageSender;加一个定时消息,只需要继承AbstractMessage。消息类型和发送渠道两个维度各自独立扩展,互不影响,组合关系由客户端在运行时决定:
AbstractMessage msg1 = new NormalMessage(new EmailSender()); AbstractMessage msg2 = new UrgentMessage(new SmsSender());关于「桥接 vs 适配器」的认知误区,我多说几句。这两个模式经常被混淆,但本质完全不同:适配器是为了解决"接口不匹配"的问题,是两个已经存在的、设计不兼容的东西被迫协作;桥接是为了解决"抽象与实现需要各自扩展"的问题,是为未来变化做设计的。如果你能理解这两句话的区别,你在设计模式上就超过一半的人了。
2.3 两种模式的实际取舍经验
适配器模式在实际项目中几乎没有坏处,因为它本质上是"修补"手段。你唯一要注意的是:如果一个适配器需要写大量的转换逻辑,不仅仅做接口映射,那说明两边的数据模型可能差异太大,适配器会变成一个渗漏的中间层。这种情况下,更好的做法是引入一个防腐层(Anti-Corruption Layer),把适配器的职责范围扩大为"隔离外部系统的复杂性",而不仅仅是一对一接口转换。
桥接模式在动手之前一定要确认:两个维度是否真的会独立变化?如果变化频率不对称,比如消息类型基本不变,只有渠道频繁增加,那直接用组合+策略模式就能解决,不需要上桥接。桥接模式是"多重笛卡尔积问题"的解法,用之前先算算你的类数量是不是在膨胀,膨胀了再用。
3. 组合模式与装饰器模式:树形结构与功能扩展大杀器
3.1 组合模式:让整体和部分拥有一致的行为
组合模式解决的核心问题是:树形结构中的"容器节点"和"叶子节点"对客户端来说应该是无差别的。换句话说,你操作一个文件夹和操作一个文件,应该用同样的方式。
Java的Swing和AWT里到处都是组合模式的影子。但更贴近日常的例子是:后台管理系统的菜单权限树、组织架构树、商品分类树。
我先给你看一段"错误示范"的代码长什么样:
// 没用组合模式之前的写法 public class MenuFolder { private String name; private List<MenuFolder> childFolders = new ArrayList<>(); private List<MenuLeaf> childLeaves = new ArrayList<>(); } public class MenuLeaf { private String name; private String url; }问题在于:客户端要遍历一棵菜单树时,必须区分当前节点是文件夹还是叶子,代码里全是if-else判断,而且每次新增一种节点类型都要改动遍历逻辑。
组合模式的核心套路是:让叶子节点和容器节点实现同一个抽象接口:
public abstract class MenuComponent { protected String name; public MenuComponent(String name) { this.name = name; } public void add(MenuComponent component) { throw new UnsupportedOperationException("叶子节点不支持添加"); } public void remove(MenuComponent component) { throw new UnsupportedOperationException("叶子节点不支持删除"); } public abstract void display(int depth); } public class MenuItem extends MenuComponent { private String url; public MenuItem(String name, String url) { super(name); this.url = url; } @Override public void display(int depth) { System.out.println(" ".repeat(depth) + "- " + name + " -> " + url); } } public class MenuFolder extends MenuComponent { private List<MenuComponent> children = new ArrayList<>(); public MenuFolder(String name) { super(name); } @Override public void add(MenuComponent component) { children.add(component); } @Override public void remove(MenuComponent component) { children.remove(component); } @Override public void display(int depth) { System.out.println(" ".repeat(depth) + "+ " + name); for (MenuComponent child : children) { child.display(depth + 2); } } }客户端调用就变得非常优雅了。你可以在根节点上调用display(0),整棵树一次性渲染完成,不管中间有多少层、多少类型,完全不需要if-else:
MenuComponent root = new MenuFolder("系统管理"); MenuComponent userMenu = new MenuFolder("用户管理"); userMenu.add(new MenuItem("用户列表", "/user/list")); userMenu.add(new MenuItem("新增用户", "/user/add")); root.add(userMenu); root.add(new MenuItem("系统日志", "/log/list")); root.display(0);这里有一个非常关键的设计细节:在抽象类中提供默认抛异常的实现。这看起来反直觉,但恰恰是组合模式的标准做法,它让叶子节点和容器节点共用同一个抽象类,同时叶子节点不需要实现add/remove这些无意义的方法。
3.2 装饰器模式:不改源码动态增强功能
装饰器模式解决的痛点是:不能让类无限膨胀的情况下,动态地为对象增加功能。它的名字叫"装饰器",本质思想却是"包装"——用一个装饰类包住原有类,在保持原有类接口不变的前提下扩展行为。
最有名的例子当然是Java I/O体系。BufferedInputStream就是装饰器,它包装了一个InputStream,在原有读取能力上增加了缓冲功能。这让你可以动态组合能力:
InputStream inputStream = new BufferedInputStream( new DataInputStream( new FileInputStream("data.txt") ) );每一层包装都增加了新能力,但对外仍然是一个InputStream,客户端完全感知不到包装层次。
在实际业务中,最经典的应用场景是权限校验和日志增强。我之前写过一个订单服务的增强逻辑:
public interface OrderService { Order createOrder(Order order); } // 原始实现 public class OrderServiceImpl implements OrderService { @Override public Order createOrder(Order order) { // 核心业务逻辑 return order; } } // 装饰器:加日志 public class LoggingOrderServiceDecorator implements OrderService { private final OrderService delegate; public LoggingOrderServiceDecorator(OrderService delegate) { this.delegate = delegate; } @Override public Order createOrder(Order order) { long startTime = System.currentTimeMillis(); Order result = delegate.createOrder(order); System.out.println("创建订单耗时: " + (System.currentTimeMillis() - startTime)); return result; } } // 装饰器:加权限控制 public class AuthOrderServiceDecorator implements OrderService { private final OrderService delegate; private final UserContext userContext; public AuthOrderServiceDecorator(OrderService delegate, UserContext userContext) { this.delegate = delegate; this.userContext = userContext; } @Override public Order createOrder(Order order) { if (!userContext.isAdmin()) { throw new PermissionDeniedException("无权限创建订单"); } return delegate.createOrder(order); } }客户端可以像堆叠积木一样组合这些装饰器:
OrderService orderService = new AuthOrderServiceDecorator( new LoggingOrderServiceDecorator( new OrderServiceImpl() ), userContext );3.3 组合模式与装饰器模式的常见误区
组合模式最常见的坑有两个。第一个是容器节点和叶子节点的接口设计过细或过粗:过细会让叶子节点实现一堆抛UnsupportedOperationException的方法,过粗则会模糊两者的区别。建议的做法是:公共操作定义在抽象类里,叶子不支持的方法在抽象类里给默认异常实现。第二个是遍历时出现死循环。如果你在add方法里没有判断是否添加自己,或者容器的children里意外引用了父节点,递归遍历就会栈溢出。实际开发中可以在add时加一层防循环检查。
装饰器模式最经典的坑是类型遮蔽问题。假如你有两层装饰器,内层装饰器增加了功能A,外层装饰器增加了功能B,客户端如果想直接访问功能A,就会很尴尬,因为外层的类型把内层的特定功能给遮住了。这时候有两种解法:要么定义更细的接口层次,让每一层装饰器的增强都体现在接口上;要么接受现实,装饰器适合流水线式的增强,不适合"我要动态选某个特定能力"的场景。后者更适合用策略模式或者建造者模式来实现。
4. 外观模式与享元模式:从接口简化和资源优化两个维度重构代码
4.1 外观模式:给复杂的子系统安一个"前台"
外观模式(Facade)的价值极其容易被低估,因为它太简单了,简单到很多人觉得"这不就是封装一个方法嘛"。但它解决的其实是一个很重要的问题:当你的系统内部很复杂、有十几个类在协作时,客户端不应该知道这些细节。它只应该面对一个干净利落的入口。
我举一个真实的场景。某公司内部有一个下单系统,一笔订单从创建到完成需要经过:库存服务扣减库存、订单服务创建订单、优惠券服务核销优惠券、积分服务增加积分、消息服务发送通知。客户端如果直接调这五个服务,代码会变成:
// 不使用外观模式 —— 客户端需要了解所有内部协作逻辑 public void createOrder(OrderRequest request) { stockService.deductStock(request.getSkuId(), request.getCount()); orderService.createOrder(request); couponService.consumeCoupon(request.getCouponId()); pointsService.addPoints(request.getUserId(), 100); messageService.sendNotification(request.getUserId(), "下单成功"); }问题很明显:客户端对系统的耦合度太高了。如果未来下单流程中需要增加风控校验、增加发票服务,所有调用方代码都得跟着改。
用了外观模式之后,客户端只依赖一个OrderFacade:
public class OrderFacade { private final StockService stockService; private final OrderService orderService; private final CouponService couponService; private final PointsService pointsService; private final MessageService messageService; public OrderFacade(StockService stockService, OrderService orderService, CouponService couponService, PointsService pointsService, MessageService messageService) { this.stockService = stockService; this.orderService = orderService; this.couponService = couponService; this.pointsService = pointsService; this.messageService = messageService; } public void createOrder(OrderRequest request) { // 校验库存 if (!stockService.checkStock(request.getSkuId(), request.getCount())) { throw new StockException("库存不足"); } // 核心流程编排 stockService.deductStock(request.getSkuId(), request.getCount()); orderService.createOrder(request); if (request.getCouponId() != null) { couponService.consumeCoupon(request.getCouponId()); } pointsService.addPoints(request.getUserId(), request.getAmount()); messageService.sendNotification(request.getUserId(), "下单成功"); } }后续无论子系统内部怎么调整流程,客户端代码始终只调用orderFacade.createOrder(request)一行,完全不需要感知内部变化。这就是外观模式的核心价值:接口隔离与编排收敛。
需要提醒的是:这里要区分外观模式和之前提到的装饰器模式。装饰器是在不改变接口的前提下增强功能,外观是提供一个简化后的接口,两者关注点完全不同。外观模式强调的是"简化",不是为了加功能,而是为了降低使用复杂度。
4.2 享元模式:当"大量的对象"成为内存噩梦时
如果说外观模式解决的是接口复杂度的问题,那么享元模式解决的就是对象数量膨胀的问题。它的核心思想是:把对象的属性分为内部状态(可以共享、不会变化)和外部状态(随环境变化、由客户端维护),通过共享内部状态来减少对象总数。
最经典的例子当然是Integer缓存。JVM中valueOf()在-128到127范围之间返回缓存对象,超出范围才新建对象。所以:
Integer a = 100; Integer b = 100; System.out.println(a == b); // true,同一缓存对象 Integer c = 200; Integer d = 200; System.out.println(c == d); // false,两个不同对象这就是享元模式在Java核心库里的实践。
我在业务代码里踩过一个相关的坑。当时做一个棋牌类游戏的棋子系统,每个格子都创建一个棋子对象,一局游戏有几千个甚至上万个格子。每个棋子对象里包含棋子类型、颜色、位置信息。直观写法会导致大量重复对象,内存耗用非常高。
用享元模式重写的做法是:棋子类型和颜色作为内部状态(享元对象),位置作为外部状态(由客户端持有):
public class ChessPiece { private final String type; // 内部状态:可共享 private final Color color; // 内部状态:可共享 public ChessPiece(String type, Color color) { this.type = type; this.color = color; } public void display(int x, int y) { System.out.println(type + " at (" + x + ", " + y + ")"); } } public class ChessPieceFactory { private static final Map<String, ChessPiece> piecePool = new HashMap<>(); public static ChessPiece getPiece(String type, Color color) { String key = type + "_" + color; return piecePool.computeIfAbsent(key, k -> new ChessPiece(type, color)); } } // 客户端使用时,同一个"black_rook"只会有一个对象 ChessPiece piece1 = ChessPieceFactory.getPiece("rook", Color.BLACK); ChessPiece piece2 = ChessPieceFactory.getPiece("rook", Color.BLACK); System.out.println(piece1 == piece2); // true这里的关键在于:享元对象必须是不可变的,或者至少内部状态永远不会变化。如果内部状态能变,就会产生共享对象被意外修改的问题,这是享元模式最大的隐患。实际项目中,连接池、线程池、字符串常量池本质上都是享元模式的应用。用一个共享池来管理重复资源,而不是无休止地创建新对象,这背后的思想是相通的。
4.3 外观模式和享元模式的取舍要点
外观模式要考虑"外观层的厚度"。如果一个Facade把所有逻辑都塞进去了,它就变成了一个巨大的上帝类,里面全是编排代码,反而更难维护。我踩过这个坑:刚开始用外观模式时觉得什么都往里放很爽,后来那个类涨到上千行,比原来不用模式还难改。合理的做法是:外观层只做流程编排和参数组装,不承载具体业务逻辑;具体逻辑留在各个子系统内部。如果一个子系统的编排逻辑特别长,可以考虑拆出多个小Facade,按领域划分职责。
享元模式有一个原则:如果不是真的存在大量重复对象,不要用享元。因为享元带来的复杂度是实实在在的:你需要维护享元池、需要区分内部和外部状态、客户端需要额外维护外部状态。一个对象池引入后,清空、销毁、线程安全问题都随之而来。只有当你确认创建大量细粒度对象会严重影响性能时,才值得引入享元。
5. 代理模式:控制访问,还是增强逻辑,这是一个问题
5.1 静态代理到动态代理的演进逻辑
代理模式的结构和装饰器模式特别像,都是"持有一个被代理对象的引用,在外面套一层"。但它们的意图完全不同:装饰器是为了增强功能,代理是为了控制访问。一句话说清:装饰器是给你加戏,代理是替你挡事。
先看静态代理的写法。假设我们有一个图片显示接口,真实图片加载很慢,我们希望延迟到真正需要显示时才加载:
public interface Image { void display(); } public class RealImage implements Image { private final String filename; public RealImage(String filename) { this.filename = filename; loadFromDisk(); } private void loadFromDisk() { System.out.println("加载高清图片: " + filename); } @Override public void display() { System.out.println("显示图片: " + filename); } } public class ImageProxy implements Image { private final String filename; private RealImage realImage; public ImageProxy(String filename) { this.filename = filename; } @Override public void display() { if (realImage == null) { realImage = new RealImage(filename); // 延迟到需要时才真正加载 } realImage.display(); } }客户端创建的是ImageProxy,而不是RealImage。图片资源只有在第一次调用display()时才真正加载到内存,这就是延迟加载(Lazy Loading)的经典实现。
但静态代理有个硬伤:代理类和目标类是一一对应的。一个类写一个代理类,系统里代理类比业务类还多,维护成本极高。于是就有了动态代理——运行时自动生成代理类。
5.2 JDK动态代理与CGLIB的选型
JDK动态代理是Java原生支持的,它要求目标类必须实现至少一个接口:
public class LogProxy implements InvocationHandler { private final Object target; public LogProxy(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long startTime = System.currentTimeMillis(); Object result = method.invoke(target, args); System.out.println(method.getName() + " 耗时: " + (System.currentTimeMillis() - startTime)); return result; } @SuppressWarnings("unchecked") public static <T> T createProxy(T target, Class<T> interfaceType) { return (T) Proxy.newProxyInstance( target.getClass().getClassLoader(), new Class<?>[]{interfaceType}, new LogProxy(target) ); } } // 使用示例 OrderService proxy = LogProxy.createProxy(orderService, OrderService.class); proxy.createOrder(order);但如果你有一个类没有实现接口,JDK动态代理就无能为力了。这时候需要CGLIB——它通过生成目标类的子类来实现代理,所以不需要接口。Spring框架的AOP机制里,如果目标类实现了接口,默认用JDK动态代理;如果没有,会自动切换到CGLIB。
关于「Spring AOP为什么默认用JDK动态代理」,这个问题的答案牵扯到Java世界的设计哲学。JDK动态代理是标准库自带的,不需要引入额外依赖,而且生成的代理类基于接口,天然符合面向接口编程的思想,所以Spring默认倾向使用它。但CGLIB因为是通过继承实现,对final类、final方法无能为力,还会额外生成一个子类,在类加载机制上有更多限制。不过随着Spring Boot版本迭代,很多场景已经默认使用CGLIB了,因为实际业务中"只有实现类没有接口"的情况太常见了。
5.3 代理模式的适用边界与常见坑
代理模式的经典应用场景包括:远程代理(RMI)、虚拟代理(延迟加载)、保护代理(权限校验)、日志代理(方法调用记录)、缓存代理(方法返回结果缓存)。
接下来我想分享一个我实际踩过的坑:有段时间我特别喜欢用代理模式,觉得它"高级"。给某个Service写了JDK动态代理做日志和权限,后来又觉得缓存也需要,于是在代理里又加了缓存逻辑。再到后来,你想单独关掉日志、保留缓存,你就得维护两套代理类。代理类本身变成了一个大杂烩。
后来我用装饰器模式重构了这部分代码,每个增强逻辑独立一个装饰器,再用装饰器组合代替原来一个代理类做所有事。那个代码瞬间就清爽了。这里想提醒你的是:如果代理类需要做多件事,用装饰器;如果代理只是想控制访问(延迟、权限、远程调用),用代理。
6. 模式实战选型与排坑实录
6.1 7种模式的选择决策清单
面对一个实际场景时,怎么快速判断该用哪个模式?我给自己整理过一个"决策清单",可以分享一下:
| 场景特征 | 推荐模式 | 判断依据 |
|---|---|---|
| 两个已有的类接口不匹配,需要让它们协作 | 适配器 | 核心是"转接",不改行为 |
| 一个类有两个维度的变化,组合关系需要自由搭配 | 桥接 | 核心是"分离抽象与实现" |
| 有树形递归结构,需要统一处理整体和部分 | 组合 | 核心是"树形结构+统一接口" |
| 需要给对象动态叠加功能,且叠加顺序可控 | 装饰器 | 核心是"包装+增强" |
| 一堆子系统太复杂,想给客户端一个简单入口 | 外观 | 核心是"简化接口" |
| 大量相同或相似的对象导致内存紧张 | 享元 | 核心是"共享+池化" |
| 需要控制访问、延迟加载、日志埋点 | 代理 | 核心是"挡事+拦截" |
这里有个特别好的记忆口诀:把每个模式都想象成一个"动词"。适配器是"转换",桥接是"分离",组合是"归树",装饰器是"包装",外观是"门面",享元是"共享",代理是"拦截"。在接到一个设计任务时,先问自己这个任务的核心动词是什么,再选对应模式,基本不会跑偏。
6.2 真实项目中的模式组合技巧
这7种模式在实际项目中很少单独使用,更多是组合出现。我见过非常经典的组合方案:
适配器 + 外观:你在做一个集成方案,外部系统接口不兼容用适配器转换,内部子系统逻辑太复杂用外观做门面隔离。这两者组合起来,外部接入干干净净,内部调用清清爽爽。
组合 + 装饰器:树形结构中的每个节点都需要增加权限校验、日志记录等能力时,可以用装饰器包装组合节点。遍历树的时候,每个节点都已经被装饰过,无需在递归逻辑里埋权限判断。
代理 + 享元:当大量对象需要延迟加载且本身可以共享时,先用享元池管理共享对象,代理只负责拦截访问并触发真正加载。这种组合在图片缓存框架里非常常见。
我并不是说"代码必须用模式",但实际开发中,合理的模式组合会让代码的扩展性上一个台阶。关键点是:模式不是用来堆砌的,是用来消解复杂度的。如果引入一个模式反而让代码更难懂了,那一定是选型出了问题。
6.3 高频问题速查:这些坑我都替你踩过了
整理一下我在实际项目中遇到过的、学员或者同事也反复踩的高频问题,做成一个速查表:
问题1:适配器变成了"万能类"
症状:适配器里有大量if-else,根据不同类型做不同的转换逻辑,甚至带上了业务判断。
排查:适配器只做接口转换,如果出现了业务决策逻辑,说明防腐层没有做好。把业务决策逻辑抽到独立的策略类中,适配器只保留"翻译"职责。
问题2:桥接模式的"桥"没搭搭稳
症状:抽象类依赖了具体的实现类,而不是依赖抽象的MessageSender接口。
原因:没有理解桥接模式的核心约束——抽象部分必须只依赖实现部分的接口。如果依赖具体类,桥就断了,后续扩展还是得改代码。
问题3:组合模式递归溢出
症状:把组合节点添加到了自己的children里,或者添加了它的祖先节点,导致无限递归。
方案:在add方法中增加循环引用检查,校验component == this或component是否已经在当前子树中。
问题4:装饰器的顺序敏感
症状:日志装饰器和事务装饰器的顺序不同,导致行为完全不同。
方案:在多个装饰器组合时,明确记录装饰顺序。一般原则是先加"通用增强"(日志、监控),再加"业务增强"(权限、事务),但具体取决于业务需求,要有意识地在代码注释中写明顺序约定。
问题5:享元池的线程安全
症状:使用HashMap作为享元池,在高并发场景下出现对象重复创建问题,甚至出现并发修改异常。
方案:使用ConcurrentHashMap,或者给共享池加锁初始化逻辑。
问题6:动态代理在Spring中的使用失控
症状:某天启动项目后,发现某个Service被代理了多层,每次调用都经过N层代理链,性能损耗严重。
排查:使用@EnableAspectJAutoProxy和切面时,注意切面的执行顺序。如果多个切面匹配同一个方法,用@Order注解控制切面优先级。
6.4 设计模式的"度":什么时候不应该用模式
最后这一点,是我想反复强调的。设计模式不是越多越好,甚至有时候不用模式就是最好的设计。我见过一些代码,为了"秀技术"把所有模式硬塞进去,结果整个项目变成了一个"模式博物馆",后期接手的人痛苦不堪。
什么时候不应该用模式?
第一,如果项目规模小、业务简单,比如一个CRUD接口,你硬生生套一个适配器加一个外观,只会增加代码量和阅读成本。第二,如果团队里其他成员不熟悉这些模式,强行引入会导致理解成本剧增,这时候应该以团队的整体水平为基准来选型。第三,如果引入模式的收益无法量化衡量,不急着上,先写朴素的代码,等变化真正出现时再重构。模式解决的是"变化"的问题,没有变化,就没有模式生存的土壤。
我自己在实践中的体会是:学会模式最好的方式是先把代码写出来,让问题自然暴露,然后回头用模式重构它。勉强提前预判所有变化,设计出来的模式往往用不到,反而浪费了复杂度。真正的高手是"该重构的时候果断重构",而不是"一开始就堆满所有模式"。
另外,如果你刚开始学结构型模式,我建议这样学:先挑两个最容易上手的,适配器和外观,在日常项目中刻意使用;再挑两个最需要组装的,组合和装饰器,结合树形结构场景练手;最后再啃桥接和享元这两个需要更多抽象思考的。代理模式可以单独练,因为它和Spring AOP的关联度最高,多研究一遍源码会更值。
等你把这7种模式都内化了,再回来看那些"结构优雅"的框架源码,你会明显发现自己看得懂每一层的设计意图了。到那个时候,写代码就不再是"把功能堆出来",而是"把结构雕出来"了。