ARTICLE DETAIL

资讯详情

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

外观模式(Facade):统一门面简化复杂子系统调用

外观模式(Facade):统一门面简化复杂子系统调用 1. 为什么需要外观模式从一个“点单式开发”的困境说起先聊一个我在实际项目中反复遇到过的场景。假设你在维护一个电商后台系统用户下单之后后台需要同时做三件事情扣减库存、调用支付接口、通知物流系统生成运单。如果你是第一次接手这种代码大概率会在Service层里看到一长串调用// 没有外观模式时的调用方式 inventoryService.deductStock(productId, quantity); paymentService.charge(userId, orderAmount); logisticsService.createShipment(orderId, address);表面上看这段代码没什么问题每个Service的职责也还算清晰。但随着业务演进你会发现麻烦来了新的开发同学要理解下单流程必须逐个去读库存、支付、物流三个模块的内部逻辑如果下单逻辑中需要增加一个赠送积分或发送通知的步骤你就要去修改所有调用这段逻辑的地方更麻烦的是如果这几个Service之间的调用顺序有依赖关系——比如必须先扣库存再支付——那么这些约定就散落在各个业务代码里越到后面越失控。这就是外观模式Facade Pattern要解决的核心问题当一个复杂系统由多个子系统组成而你希望为外部客户端提供一个简单、统一的入口时外观模式就是那个让复杂变简单的门面。外观模式属于结构型设计模式它的核心思想非常朴素为子系统中的一组接口提供一个一致的界面定义一个高层接口让子系统更容易使用。套用到上面的电商场景就是我们不再让Controller或者其他业务类直接面对库存、支付、物流三个Service而是引入一个OrderFacade把扣库存、支付、生成运单这段流程封装成一个方法外部只需要调用orderFacade.placeOrder(...)就好。这个概念对开发入门者来说一点也不陌生——你去餐厅吃饭不需要知道后厨有几个厨师、先炒菜还是先煲汤你只需要对着菜单点菜然后服务员把你的需求传递给后厨。服务员就是门面后厨就是子系统。你点的菜越多这个门面的价值就越明显。2. 外观模式的结构与一段能直接跑起来的代码先认清外观模式里面的三个角色别和后面要讲的适配器模式、代理模式搞混了。角色职责门面角色Facade对外提供统一的高层接口内部了解子系统的功能划分和调用顺序子系统角色Subsystem可以是多个类或模块各自实现独立的业务能力但彼此不直接依赖门面客户端角色Client只和门面打交道不再关心子系统内部实现关键点在于门面并不限制客户端绕过它直接访问子系统。外观模式的最大特点是提供一个简化入口而并非强制接管所有访问。这一点和后面要讲的代理模式有本质区别我会在对比环节详细展开。2.1 代码示例一个下单流程的门面封装以电商下单为例我们定义三个子系统类// 库存子系统 public class InventoryService { public boolean deductStock(String productId, int quantity) { System.out.println(扣减商品[ productId ]库存数量 quantity); return true; } } // 支付子系统 public class PaymentService { public boolean charge(String userId, double amount) { System.out.println(用户[ userId ]支付金额 amount); return true; } } // 物流子系统 public class LogisticsService { public String createShipment(String orderId, String address) { System.out.println(为订单[ orderId ]创建运单收货地址 address); return SF123456789; } }然后定义门面类把调用顺序和业务规则收拢到一个方法里// 门面角色 public class OrderFacade { private InventoryService inventoryService; private PaymentService paymentService; private LogisticsService logisticsService; public OrderFacade() { this.inventoryService new InventoryService(); this.paymentService new PaymentService(); this.logisticsService new LogisticsService(); } // 对外提供一个最简单的下单入口 public boolean placeOrder(String userId, String productId, int quantity, String address, double amount) { // 内部定义了完整的调用顺序 boolean stockResult inventoryService.deductStock(productId, quantity); if (!stockResult) { return false; } boolean payResult paymentService.charge(userId, amount); if (!payResult) { // 实际项目中还需要做库存回滚这里仅示意 return false; } String shipmentNo logisticsService.createShipment(orderId, address); System.out.println(下单成功运单号 shipmentNo); return true; } }客户端就清爽多了public class Client { public static void main(String[] args) { OrderFacade facade new OrderFacade(); facade.placeOrder(U001, P1001, 2, 北京市朝阳区, 199.00); } }从这段代码能看出外观模式最直接的收益客户端从知道三件事变成了只知道一件事子系统之间的耦合被集中到门面内部。以后下单流程要增加短信通知你只动OrderFacade一个类用户下单的入口、测试用例、前端接口都不需要大改。这就是结构设计模式里面高内聚、低耦合的典型实践。2.2 为什么门面构造时常用构造器注入而非直接new很多初学者会问门面类里面直接用new创建子系统实例不也一样跑得通吗对功能上没问题但有个隐患——不方便测试。假设你想给OrderFacade写单元测试想在测试环境里把支付系统mock掉如果门面内部写死了new PaymentService()你就没法替换。更建议的做法是让门面的构造器接收子系统对象public class OrderFacade { private final InventoryService inventoryService; private final PaymentService paymentService; private final LogisticsService logisticsService; // 通过构造器传入依赖方便替换与测试 public OrderFacade(InventoryService inventoryService, PaymentService paymentService, LogisticsService logisticsService) { this.inventoryService inventoryService; this.paymentService paymentService; this.logisticsService logisticsService; } }这样配合Spring的依赖注入就非常自然。外观模式的价值不只是少写几行代码而是让你的系统在演化过程中保持接口稳定。接口稳定意味着调用方不需要随子系统变化而频繁修改这在大团队协作中往往比一次性的代码量节省更有意义。3. 外观模式与适配器、代理模式三张容易混淆的“相似面孔”我在给团队做设计模式分享时发现很多同学背熟了23种设计模式的分类但一碰到具体场景就分不清该用外观、适配器还是代理。这里我给你一个非常实战化的辨析视角。3.1 和适配器模式的本质区别一个是“整合”一个是“翻译”适配器模式的目的是将原本不兼容的接口转换成客户端期望的另一个接口它的核心是转换比如你有一个220V电压的插头旧接口要插到一个110V的插座上新接口需要一个转换器让两者匹配。适配器通常只包装一个对象解决的是接口不兼容的问题。外观模式则完全不同它的核心是整合与封装多半涉及多个子系统或多个对象协同工作把一整套流程压缩成一个高层接口。适配器是把A翻译成B让两边能对话外观是把A、B、C整合成一个入口让客户端只面对一个出口。举个例子你引入了一个第三方短信SDK它提供sendTextMessage(String phone, String content)方法但你的业务层已经有自己的sendSms(Message msg)方法此时你需要的是适配器——把SDK接口翻译成自己系统的约定。但你若想把用户注册后同时发短信、发邮件、初始化账户、写日志这四步整合成一个registerUser()入口那就是外观模式的主场。3.2 和代理模式的本质区别一个是“简化”一个是“控制”代理模式的核心是在客户端和真实对象之间加一层控制比如权限校验、延迟加载、访问控制。代理和真实对象实现同一个接口客户端感知不到代理的存在代理可以决定放行还是拦截。外观模式的核心则是提供一个简化的访问入口它不需要和子系统实现相同的接口也不需要控制客户端的访问权限。客户端知道门面存在也明确知道自己是用门面来简化操作的而代理模式要求客户端尽量无感知让客户端以为自己在操作真实对象。用一个生活场景区分代理模式像个录音棚里的经纪人——外面的明星客户端找经纪人代理谈演出经纪人决定要不要接这个活儿明星真实对象始终隐藏在幕后外界看不到。外观模式则像酒店前台——住客知道前台的存在也明确知道前台背后有客房服务、餐厅、洗衣房等多个部门前台只是把入住登记、指引餐厅、帮约车这些服务整合成了一个联络点。这里有一个我用过多次的判别法如果需求是让多个类协作的流程更好调用选外观如果需求是不让客户端直接接触某个类选代理如果需求是让两个接口不一致的类能一起工作选适配器。4. 在主流框架中“找朋友”谁在背后用了外观模式外观模式在Java生态里无处不在只是很多时候你没意识到它就叫这个名字。4.1 SLF4J日志门面是最经典的案例你应该用过SLF4J它提供LoggerFactory.getLogger(...)来获取日志对象底层可以切换logback、log4j、java.util.logging等多种实现。SLF4J本身不实现日志功能它只是一个门面向调用方提供统一的logger.info()、logger.error()接口具体输出逻辑委托给绑定在classpath里的日志框架。这个设计的意义在框架层面体现得淋漓尽致业务代码只依赖SLF4J的接口无论底层把logback换成log4j2都不需要改业务代码。外观模式在框架设计中的价值就是让底层实现随便换上层接口稳如泰山。4.2 Spring JDBC 中的 JdbcTemplateJdbcTemplate也是一个很典型的外观角色。用传统JDBC写一条查询你得自己管Connection、Statement、ResultSet还要处理SQLException、事务提交回滚和资源关闭。而JdbcTemplate把这些步骤全部整合到了几个简洁的方法里——query()、update()、execute()。你调用一个方法它在内部用统一的模板完成建连、执行、处理结果集、清理资源这一整套流程。虽然它更多被归为模板方法模式的典例但门面的简化思想同样贯穿其中。4.3 Spring MVC 的 DispatcherServlet再往大了说DispatcherServlet作为Spring MVC的前端控制器把所有Servlet请求的处理流程都收纳到了自己内部它知道如何查找Controller、如何调用HandlerMapping、如何执行HandlerAdapter、如何解析视图、如何处理异常。外部请求只需要发到DispatcherServlet这一个入口剩下全让它内部协调。这就是典型的门面思想在架构层面的体现——一个复杂子系统的协调入口对请求方保持简单和一致。这也是我在面试初级开发时很喜欢考察的点你能不能从一个框架的入口类倒推出它整合了哪些子系统。5. 实战中的应用边界与那些需要避开的坑前端工具和后端框架都喜欢外观模式但真正在业务代码里用得好不是照着定义写一个类就完事。下面说说我踩过的几个坑和总结出的实践准则。5.1 坑一门面变成上帝对象最容易出现的问题是把所有子系统都塞进一个门面里最后门面类几百行依赖七八个Service谁都在调它它变得比原来的调用关系还难维护。表面上你做了一个统一入口实际上你做了一个耦合集中营。这个门面成了一切业务的总代理改任何一个业务逻辑都要动这个门面。我的建议是按业务场景拆门面而不是按模块做人设。比如电商系统有下单、退款、查询三个核心场景不要做一个OrderFacade把所有方法都堆进去而是考虑设计PlaceOrderFacade、RefundFacade、OrderQueryFacade或者至少按照业务域拆出OrderCommandFacade写操作和OrderQueryFacade读操作。每个门面的职责范围清晰只负责一条完整流程的编排才不会失控。5.2 坑二门面过度包装导致事务边界模糊这一点在Spring事务环境里尤其要小心。门面方法内部会调用多个Service而事务一般定义在Service层或者门面层。比如我遇到过一种情况门面方法标注了Transactional内部先调用库存Service扣库存再调用支付Service扣款然后支付失败抛异常事务回滚要求库存扣减也回滚。如果两个Service的方法各自也标注了Transactional且门面所在的事务管理器配置不对就会出现门面事务没有覆盖内层Service错误提交了部分操作的问题。实战建议门面方法最好作为事务边界的起点。当一个门面方法内包含多个写操作时确认事务的传播行为符合预期默认REQUIRED会沿用外层事务并且所有对数据库的写入尽量放到同一个事务边界内。如果某些子系统天然不属于当前事务管理范围比如调用外部HTTP接口要明确标注只读或异步策略避免把外部调用包进事务里造成长事务。5.3 坑三为了模式而模式过度设计设计模式最大的误用是为了套模式而写模式。如果你的场景非常直接比如Controller调用一个Service就完事那你再去弄个门面就是画蛇添足。这个判断说起来简单但很多团队评审代码时会陷入门面模式必须用的惯性。我的经验是当满足下面两条中的至少一条时门面才真正有价值客户端需要与两个以上的子系统进行协作且协作顺序和规则相对固定你希望为客户端隐藏子系统的复杂性让它只依赖一个稳定的简单接口。如果只是一个方法调用另一个方法那就老老实实直连。设计模式是帮你解决复杂度的不是给你增加复杂度的。5.4 实践准则外观模式与业务分层在我自己的项目里外观模式往往扮演的是业务编排层的角色。Controller尽量薄不承载业务逻辑Service做领域操作而门面类则放在Controller和Service之间的一层专门负责把多个Service组织成一组面向场景的用例。这样做的好处是层次边界更清晰Controller只需要面向门面Service不需要知道门面存在读者看代码时能通过门面快速理解一个业务场景的完整流程而不是在一个Service方法里去猜还有谁被调用了。6. 我的使用体会与进一步扩展这部分的经验不一定写进教科书但对你的实际项目很有参考价值。我个人的体会是外观模式最妙的地方在于它不改变系统的业务逻辑却显著改变了系统的可理解性和可维护性。你甚至可以在重构旧项目时才引入门面老代码里散落各处的xxxService.xxx()调用不去动只新增一个门面类把最核心的几条流程收拢然后逐步让新代码走门面入口。这种渐进式重构远比推倒重来要稳健得多。再分享一个小技巧给门面方法命名时多用业务动词短语比如submitOrder()、acceptRefund()、completeCheckout()而不是doAllOperations()。表面上看只是命名风格实际影响很大——门面本身就是面向场景和用例的抽象命名越贴近业务语言代码的可读性对非原作者就越友好。团队里新人接手老系统时如果能一眼从门面类的方法名看出系统能做什么事这门面就成功了一大半。如果你还想继续深入可以尝试把外观模式和简单工厂结合起来让工厂负责创建门面实例客户端连门面的构造细节都不用知道也可以配合观察者模式在下单完成后通知其他模块做异步操作。设计模式从来不是孤立的外观模式作为入口编排的前台角色很适合成为你组合多种模式的第一站。
返回列表