设计模式预计阅读 20 分钟7681 字3176 次浏览

设计模式修炼之路 ⑪ 外观模式——给杂乱子系统一个入口

给杂乱子系统一个统一入口、只编排不干活、SLF4J 名字里的 Facade

目录

上一篇装饰器模式给它留了个悬念式的好评:"GoF 里最厚道的一个"。今天正主登场:外观模式(Facade Pattern),GoF 23 人名单正牌选手、结构型第六位。前面几位多少都带了点"设计感"——适配器要换接口、桥接要拆维度、装饰器要玩套娃——唯独它朴素到不像个模式:给你不想伺候的一大堆类,收拢成一个门面。子系统内部该多乱还多乱,客户端只见一个入口、一个方法、一句话办完。降复杂度不涨姿势,程序员和甲方都爱的那一款。

一、什么是外观模式?

先看定义:

为子系统中的一组接口提供一个一致的界面。外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。

翻译成人话:一堆类协作起来才能干成一件事,那就写一个类把"协作的流程"包起来——客户端只调门面的一个方法,门面在背后按顺序指挥子系统们干活。客户端从"自己当指挥"降级成"说一句话",中间差的那些步骤、顺序、依赖,全是门面替它扛的。

生活类比是 4S 店销售:你想买辆车,不用自己跑车管所上牌、跑保险公司买保险、跑加油站办油卡、跑银行办贷款——销售一句话:"包在我身上",转头他替你把全流程串起来。上牌、保险、贷款这些"子系统"照旧各干各的,销售这个"门面"什么新业务都没发明,他只是知道该找谁、按什么顺序找。

它只有两个角色,GoF 23 种模式里最少的一档:

  • Facade(门面):唯一的编排处。持有子系统的引用,对外提供"一句话"级别的粗粒度方法;
  • Subsystem(子系统):若干个真正干活的类。它们不知道门面的存在——这条单向依赖是外观模式最鲜明的特征:门面 import 子系统,子系统绝不 import 门面。

精髓一句话:门面只编排,不干活。它每个方法的方法体,本质都是"把子系统的方法按正确顺序各调一遍",没有一行新的业务逻辑——这既是它厚道的地方(加它不改变任何既有行为),也是判断一个"门面"写得对不对的试金石,第五节专门说。

二、外观模式的优缺点

优点

  1. 客户端与子系统解耦:客户端只认门面,子系统内部怎么重构、类怎么拆、实现怎么换,客户端一行不改——门面是挡在"变化"前面的墙。
  2. 复杂度收口:五步流程收成一句话,而且这份"流程知识"从 N 个客户端收回到 1 个门面里,全局只写一份。
  3. 不绑架任何人:门面是便捷层,不是隔离墙——需要细粒度控制时,客户端随时可以绕过门面直接调子系统,两者共存毫无障碍。对照着想想 API 网关"只许走网关"的强制风格,外观这份"不强制"格外难得。
  4. 天然分层:每层对外暴露一个门面、对内自由生长,这是它给大型代码库最值钱的贡献。

缺点

  1. 门面容易长成上帝类:什么都觉得"顺手包一下",三年后门面三千行、什么用例都往里塞——门面的边界感,比技术含量更考验人。
  2. 加步骤要动门面:流程里插一步"数据脱敏",门面方法体要改。好在多数时候是"加一个门面方法"而不是"改老方法",开闭原则算是守住了底裤。
  3. 多一层转发:严格说每调用一次多一跳,不过这层薄得可以忽略,真在意的话你大概不该用反射框架(笑)。

三、外观模式的应用场景

  • SLF4J(日志门面):全宇宙名字起得最诚实的框架——Simple Logging Facade for Java。LoggerFactory.getLogger() 背后,Logback、Log4j2、JUL 这些"子系统"随你替换,业务代码一行不动。为什么大厂规范都要求"只用 SLF4J 的 API、不许直接用实现框架"?就是在守护门面这堵墙。
  • Spring 的 JdbcTemplate:JDBC 原生那套——开 Connection、建 Statement、设参数、执行、遍历 ResultSet、关资源、转异常——六步_protocol 藏在 query()/update() 两个门面方法背后。你只管传 SQL 和回调,顺序和善后全是它编排。
  • MyBatis 的 SqlSession:一句 selectOne() 背后,Executor、StatementHandler、ParameterHandler、ResultSetHandler 一长串组件按管线协作,SqlSession 就是它们的总门面——本系列第五次在 MyBatis 里撞见老朋友。
  • Tomcat 的 RequestFacade:org.apache.catalina.connector.RequestFacade 包着内部 Request,只暴露 HttpServletRequest 接口,防止应用向下转型篡改容器内部状态——门面的一个变体玩法:保护性门面,重点从"简化"变成了"隔离越权"。
  • 各路 SDK 的 XxClient:云厂商 SDK 里那个 XxClient,init 几行配置,一个方法发出去,背后签名、鉴权、重试、序列化全包了——每个"好用的 SDK"背后都站着一个尽职的门面。

什么时候该用:一段"多步协作流程"被第二个客户端复制粘贴的那一刻,就是门面该出场的时刻——一个客户端时流程写哪都行,两个客户端就该收口了;或者子系统预期会换实现(日志框架、缓存实现),门面先把依赖钉死在接口层。

四、外观模式的实现方式

示例代码在 design-pattern 项目的 facade 包里:

design-pattern/src/main/java
└── facade                    // 外观模式
    ├── DataFetcher.java      // 子系统:查数据
    ├── DataAggregator.java   // 子系统:汇总指标
    ├── ExcelWriter.java      // 子系统:写 Excel
    ├── OssUploader.java      // 子系统:传对象存储
    ├── MailNotifier.java     // 子系统:发邮件
    ├── BadController.java    // 反面教材:Web 客户端自己编排五步
    ├── BadReportJob.java     // 反面教材:定时任务复制粘贴同样五步
    ├── ReportFacade.java     // 门面:唯一的编排处
    ├── WebController.java    // 正解客户端:Web 触发
    ├── ReportJob.java        // 正解客户端:定时任务触发
    └── FacadeTest.java       // 测试入口

场景:导出报表。完整流程五步——查数据、汇总、写 Excel、传 OSS、发邮件,五个类各管一步。这个流程有两个入口都要用:Web 接口(用户点导出按钮)和定时任务(每周一自动跑)。先看"每个客户端自己编排"的下场,再看门面怎么收口。

1. 反面教材:流程散落在客户端

五个子系统类先各就各位(实现都只有一行 println,略过),然后 Web 客户端亲自下场当指挥:

// 反面教材:Web 接口触发的导出,五步流程全写在客户端里
public class BadController {
    private final DataFetcher fetcher = new DataFetcher();
    private final DataAggregator aggregator = new DataAggregator();
    private final ExcelWriter excelWriter = new ExcelWriter();
    private final OssUploader ossUploader = new OssUploader();
    private final MailNotifier mailNotifier = new MailNotifier();

    public void exportReport(String bizId, String email) {
        System.out.println("[BadController] 导出 " + bizId + " 的报表:");
        List<String> raw = fetcher.fetch(bizId);
        String summary = aggregator.aggregate(raw);
        String file = excelWriter.write(bizId, summary);
        String url = ossUploader.upload(file);
        mailNotifier.sendMail(url, email);
    }
}

看起来还行?第二个客户端进来的那一刻,问题显形——定时任务也要导出报表,只好把五步原样复制一遍:

// 反面教材:定时任务也要导出报表,五步流程只好复制粘贴一遍
public class BadReportJob {
    public void runWeekly() {
        System.out.println("[BadReportJob] 每周一自动导出报表:");
        // ↓↓ 和 BadController.exportReport 一模一样的五步,复制粘贴来的
        DataFetcher fetcher = new DataFetcher();
        DataAggregator aggregator = new DataAggregator();
        ExcelWriter excelWriter = new ExcelWriter();
        OssUploader ossUploader = new OssUploader();
        MailNotifier mailNotifier = new MailNotifier();

        List<String> raw = fetcher.fetch("WEEK-2026-42");
        String summary = aggregator.aggregate(raw);
        String file = excelWriter.write("WEEK-2026-42", summary);
        String url = ossUploader.upload(file);
        mailNotifier.sendMail(url, "data-team@example.com");
    }
}

运行结果:

===== 反面教材:流程散落在客户端 =====
[BadController] 导出 ORDER-1001 的报表:
查数据:拉取到 ORDER-1001 的 5 条明细
汇总:明细 5 条,总金额 1280.00
写 Excel:生成文件 ORDER-1001-报表.xlsx
传 OSS:ORDER-1001-报表.xlsx 已上传,URL: https://oss.example.com/ORDER-1001-报表.xlsx
发邮件:报表已发送到 boss@example.com

[BadReportJob] 每周一自动导出报表:
查数据:拉取到 WEEK-2026-42 的 5 条明细
汇总:明细 5 条,总金额 1280.00
写 Excel:生成文件 WEEK-2026-42-报表.xlsx
传 OSS:WEEK-2026-42-报表.xlsx 已上传,URL: https://oss.example.com/WEEK-2026-42-报表.xlsx
发邮件:报表已发送到 data-team@example.com

能跑,但三颗雷已经埋下:

  1. 流程知识全局重复:五个步骤、它们的顺序、每步的入参出参怎么衔接——这套"编排知识"被复制成了两份。明天流程中间加一步"数据脱敏"?两个客户端挨个改,漏改一个就是一个线上事故;
  2. 客户端被迫认识全世界:BadController 得 import 五个子系统、知道五个类的职责边界和协作顺序——它本该只关心"导出报表,返回 URL"这一件事;
  3. 子系统失去重构自由:哪天想把 DataFetcher 和 DataAggregator 合并成一个类?所有编排过它们的客户端全部遭殃——子系统想动一动,得先数数全世界有几个客户端。

病根只有一个:"流程该怎么走"这份知识,写在了每个客户端里,而它本该只有一份。

2. 外观版:一个门面,一句点单

门面登场,它唯一的本事是"知道该找谁、按什么顺序找":

// 门面:唯一的编排处——五个子系统怎么配合、什么顺序,只有它知道
public class ReportFacade {
    private final DataFetcher fetcher = new DataFetcher();
    private final DataAggregator aggregator = new DataAggregator();
    private final ExcelWriter excelWriter = new ExcelWriter();
    private final OssUploader ossUploader = new OssUploader();
    private final MailNotifier mailNotifier = new MailNotifier();

    // 客户端只需要一句话,五个子系统在门面背后鱼贯而出
    public String exportAndSend(String bizId, String email) {
        List<String> raw = fetcher.fetch(bizId);
        String summary = aggregator.aggregate(raw);
        String file = excelWriter.write(bizId, summary);
        String url = ossUploader.upload(file);
        mailNotifier.sendMail(url, email);
        return url;
    }
}

注意方法体:和反面教材一模一样的五步,一个字没多、一个字没少——门面没有发明任何新逻辑,它只是把这段编排从 N 个客户端收回到 1 个类里。然后两个客户端集体瘦身:

// 正解客户端:Web 接口触发的导出,编排细节一概不知
public class WebController {
    private final ReportFacade facade = new ReportFacade();

    public void exportReport(String bizId, String email) {
        System.out.println("[WebController] 导出 " + bizId + " 的报表:");
        String url = facade.exportAndSend(bizId, email);
        System.out.println("[WebController] 返回给前端:" + url);
    }
}

// 正解客户端:定时任务触发的导出,同样一句话
public class ReportJob {
    private final ReportFacade facade = new ReportFacade();

    public void runWeekly() {
        System.out.println("[ReportJob] 每周一自动导出报表:");
        facade.exportAndSend("WEEK-2026-42", "data-team@example.com");
    }
}

运行结果:

===== 正解:外观模式 =====
[WebController] 导出 ORDER-1001 的报表:
查数据:拉取到 ORDER-1001 的 5 条明细
汇总:明细 5 条,总金额 1280.00
写 Excel:生成文件 ORDER-1001-报表.xlsx
传 OSS:ORDER-1001-报表.xlsx 已上传,URL: https://oss.example.com/ORDER-1001-报表.xlsx
发邮件:报表已发送到 boss@example.com
[WebController] 返回给前端:https://oss.example.com/ORDER-1001-报表.xlsx

[ReportJob] 每周一自动导出报表:
查数据:拉取到 WEEK-2026-42 的 5 条明细
汇总:明细 5 条,总金额 1280.00
写 Excel:生成文件 WEEK-2026-42-报表.xlsx
传 OSS:WEEK-2026-42-报表.xlsx 已上传,URL: https://oss.example.com/WEEK-2026-42-报表.xlsx
发邮件:报表已发送到 data-team@example.com

对照反面教材逐条验收:

  • 五步编排从两份(N 份)变回一份,明早加"数据脱敏"?改 ReportFacade 一个类,两个客户端睡他们的觉;
  • 客户端只 import 一个 ReportFacade,五个子系统从它的世界里彻底消失——想认识它都不需要;
  • 子系统重获自由:合并 DataFetcher 和 DataAggregator、给 OssUploader 换供应商,只要门面方法的签名不动,客户端零感知;
  • 两个客户端的职责也各自归位了——WebController 回去专心管"参数校验、返回响应",ReportJob 专心管"调度",编排这种脏活谁也不该抢。

五、你天天在写外观,只是没叫它名字

外观模式最容易被小看,因为它看起来"没技术含量"。但把视角拉高你会发现:三层架构里的 Service 层,本职工作就是 DAO 层的门面——Controller 调一个 orderService.createOrder(),背后 DAO 存订单、扣库存、记流水,那不就是一个标准的门面方法?再说细一点,工具类里那些把复杂 API 包一层的静态方法、SDK 里那个什么都管的 XxClient,全是门面。你天天在写外观,只是没叫它名字。

正因为天天在写,才更该有几个判断标准,防它长歪:

  1. 门面只编排,不干活:门面方法体里若出现了大段业务逻辑——if 分支、循环计算、字符串拼接——它已经开始从"门面"堕落成"业务类"。一个狠一点的检验法:把门面类删掉,它的每个方法体能否在子系统里原样重演?能,它是门面;不能,它偷偷藏了私货。
  2. 子系统不知道门面存在:子系统类 import 门面?依赖方向反了。门面可以认识所有子系统,子系统一个门面都不许认识——这条单向依赖一旦破坏,门面就成了网状依赖的中心节点,第五节的标题就该换成"中介者模式"了(那个模式㉒ 再聊,先把账记下:门面是单向的传话筒,中介者是双向的交通枢纽)。
  3. 一个门面只管一类用例:ReportFacade 管导出,就别把"用户注册"也塞进来顺手。门面长成三千行上帝类,几乎都是从"顺手包一下"开始的——门面的粒度对齐的是用例,不是"整个子系统"。
  4. 门面不是唯一入口:客户端偶尔需要绕过门面直接调子系统(比如只要查数据、不要导出全程)?完全可以,外观从不拦路。真想拦,那是上一条里 Tomcat RequestFacade 那种保护性门面的活儿,得明确知道自己为什么要拦。

写在最后

回顾这一篇:外观模式给一组子系统类提供一个一致的入口——门面持有子系统、按序编排、不添一行新逻辑,子系统对门面毫不知情;反面教材"流程散落在客户端"三宗罪——编排知识全局重复、客户端被迫认识全世界、子系统失去重构自由;SLF4J、JdbcTemplate、SqlSession、各路 SDK 的 Client,全是它的地盘;而"只编排不干活、单向依赖、用例粒度、不绑架客户端"四条标准,守的是门面和上帝类之间那条细线。

下一篇 ⑫ 享元模式,结构型阵营只剩它和代理压轴。前面几位省的是"代码的复杂度",它省的是"货真价实的内存"——同样的对象造一万份,享元说:留一份共享,其余都指过来。Integer.valueOf 里 -128 到 127 的缓存池、字符串常量池里那个"字面量只存一份"的规矩,全是你天天踩在脚下却没低头看过的享元。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论