【设计模式精讲】14.外观模式(Facade)
【摘要】:把一份源文件编译成中间码,本是一行需求;直接调用子系统,却变成五步固定仪式------预处理器、词法器、语法器、语义检查、IR 生成各有接口与状态,每个调用方都要背下组装顺序。本文从编译器前端的编排成本讲起,给出外观模式的 GoF 意图与「只编排、不决策」的定性,辨析它简化调用而非隐藏扩展点;现代 C++ 部分讲 PIMPL 编译防火墙与外观的正交叠加、分层「附加外观」对上帝对象的防御。文末对照
std::filesystem、POCO Crypto 对 OpenSSL 的门面化、Abseil 的命令行解析与 AOSP 的 NDK 媒体接口。读完你能判断一个外观是变成了系统边界,还是长成了上帝对象。【关键词】:外观、子系统、简化调用、附加外观、PIMPL、编译防火墙
【代码基准】:C++17
1. 编译一个文件,怎么要写五行调用
给老项目加「源码体检」功能:读入文件,产出中间码做后续分析。子系统是现成的,五个类各司其职:
cpp
// 说明性片段(省略 Token/Ast/IrModule 的定义)
Preprocessor pp;
std::string src = pp.expand(file); // 1. 宏展开
Lexer lexer(src);
std::vector<Token> toks = lexer.tokenize(); // 2. 切词
Parser parser(toks);
Ast ast = parser.parse(); // 3. 语法树
SemanticChecker checker;
checker.check(ast); // 4. 语义检查
IrGen irgen;
IrModule ir = irgen.lower(ast); // 5. 降 IR
五步顺序不能错、中间产物缺一不可,而且错误处理三种风格混着:预处理返回 std::string(空串算失败?)、语义检查抛异常、词法悄悄吞掉非法字符。这段五行仪式很快被复制进体检工具、构建脚本、IDE 插件三个调用方。半年后子系统升级------宏展开加了缓存、错误统一成错误码------三个调用方挨个返工,其中一个还是照着旧代码抄的,漏改了缓存开关。
问题不在子系统设计:每类保持单一职责是第 2 篇的正确结论。问题在于 常见路径没有入口:90% 的调用方只想「把文件编译了」,却被迫知道 100% 的内部组装。把「编一个文件」这个高频意图封装成一个操作,就是外观模式要解决的事。
2. 模式意图与定义
- 一句话定义 :为一组子系统接口提供统一的高层入口,让子系统更容易使用。
解决的问题:调用方与子系统内部类的直接耦合、每次使用都要重写的组装仪式。 - GoF 原文意图 :Provide a unified interface to a set of interfaces in a subsystem. Facade defines a higher-level interface that makes the subsystem easier to use. (为子系统中的一组接口提供一个一致的界面。)GoF 强调它包装的是 「一组」 接口------一个入口对着多个子系统类,这决定了它和一对一改形状的适配器(第 10 篇)在结构上的根本区别。
- Refactoring Guru 的表述 :外观是为复杂子系统提供的简化接口,它能隐藏系统的复杂性并不无道理------但隐藏的是「组装的繁琐」,不是「系统的能力」。
三条定性,条条都是防走偏的边界:
- 简化调用,而非隐藏扩展点 。外观是 可选 的便捷层:子系统类照常 public,需要细粒度控制的调用方(比如 IDE 要拿语法树做补全)绕过外观直接用
Parser没有任何问题。一旦开始「为了整洁把子系统藏起来、只留外观一条缝」,外观就从捷径变成了牢笼。 - 只编排,不决策。外观方法里应该只有「按什么顺序调谁、错误怎么汇总」,不该长出业务规则。判断一个外观是否健康,看它删掉后子系统是否依旧完整可用------答案是「是」,外观才合格。
- 外观 ≠ 上帝对象。上帝对象囤积职责,人人依赖它改它;外观只做转发与聚合,理想中无状态。两者长得像,分界线就是第 2 条。
3. UML 图 + 结构说明
#mermaid-svg-FLH17fUocsUZccEK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FLH17fUocsUZccEK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FLH17fUocsUZccEK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FLH17fUocsUZccEK .error-icon{fill:#552222;}#mermaid-svg-FLH17fUocsUZccEK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FLH17fUocsUZccEK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FLH17fUocsUZccEK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FLH17fUocsUZccEK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FLH17fUocsUZccEK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FLH17fUocsUZccEK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FLH17fUocsUZccEK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FLH17fUocsUZccEK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FLH17fUocsUZccEK .marker.cross{stroke:#333333;}#mermaid-svg-FLH17fUocsUZccEK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FLH17fUocsUZccEK p{margin:0;}#mermaid-svg-FLH17fUocsUZccEK g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-FLH17fUocsUZccEK g.classGroup text .title{font-weight:bolder;}#mermaid-svg-FLH17fUocsUZccEK .cluster-label text{fill:#333;}#mermaid-svg-FLH17fUocsUZccEK .cluster-label span{color:#333;}#mermaid-svg-FLH17fUocsUZccEK .cluster-label span p{background-color:transparent;}#mermaid-svg-FLH17fUocsUZccEK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FLH17fUocsUZccEK .cluster text{fill:#333;}#mermaid-svg-FLH17fUocsUZccEK .cluster span{color:#333;}#mermaid-svg-FLH17fUocsUZccEK .nodeLabel,#mermaid-svg-FLH17fUocsUZccEK .edgeLabel{color:#131300;}#mermaid-svg-FLH17fUocsUZccEK .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-FLH17fUocsUZccEK .label text{fill:#131300;}#mermaid-svg-FLH17fUocsUZccEK .labelBkg{background:#ECECFF;}#mermaid-svg-FLH17fUocsUZccEK .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-FLH17fUocsUZccEK .classTitle{font-weight:bolder;}#mermaid-svg-FLH17fUocsUZccEK .node rect,#mermaid-svg-FLH17fUocsUZccEK .node circle,#mermaid-svg-FLH17fUocsUZccEK .node ellipse,#mermaid-svg-FLH17fUocsUZccEK .node polygon,#mermaid-svg-FLH17fUocsUZccEK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FLH17fUocsUZccEK .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK g.clickable{cursor:pointer;}#mermaid-svg-FLH17fUocsUZccEK g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-FLH17fUocsUZccEK g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-FLH17fUocsUZccEK .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-FLH17fUocsUZccEK .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-FLH17fUocsUZccEK .dashed-line{stroke-dasharray:3;}#mermaid-svg-FLH17fUocsUZccEK .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-FLH17fUocsUZccEK #compositionStart,#mermaid-svg-FLH17fUocsUZccEK .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK #compositionEnd,#mermaid-svg-FLH17fUocsUZccEK .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK #dependencyStart,#mermaid-svg-FLH17fUocsUZccEK .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK #dependencyStart,#mermaid-svg-FLH17fUocsUZccEK .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK #extensionStart,#mermaid-svg-FLH17fUocsUZccEK .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK #extensionEnd,#mermaid-svg-FLH17fUocsUZccEK .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK #aggregationStart,#mermaid-svg-FLH17fUocsUZccEK .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK #aggregationEnd,#mermaid-svg-FLH17fUocsUZccEK .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK #lollipopStart,#mermaid-svg-FLH17fUocsUZccEK .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK #lollipopEnd,#mermaid-svg-FLH17fUocsUZccEK .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-FLH17fUocsUZccEK .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-FLH17fUocsUZccEK .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FLH17fUocsUZccEK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FLH17fUocsUZccEK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FLH17fUocsUZccEK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 编排
编排
编排
编排
编排
FrontendFacade
+compile(file) : IrModule
Preprocessor
+expand(file) : string
Lexer
+tokenize() : vector<Token>
Parser
+parse() : Ast
SemanticChecker
+check(ast) : void
IrGen
+lower(ast) : IrModule
三个参与者:
- 外观(Facade):知道该调哪些子系统类、以什么顺序调;本身通常是薄薄一层;
- 子系统类(Subsystem):实现各自功能,不知道外观存在,也不被外观限制可见性;
- 客户端:走常用路径时只面对外观;需要深控时直接面对子系统类。
结构与适配器对照着看最清楚:适配器是「一对一」的变压器------一个目标接口对接一个被适配者;外观是「一对多」的漏斗------一个新入口收拢一组子系统接口。GoF 还补充了 附加外观(Additional Facade) 的概念:子系统太大时,避免单一外观膨胀,可以按用途再拆出多个外观(本文第 5 节改进二落地)。
4. 传统 C++ 写法(C++11 之前)
GoF 原书选的示例恰好就是编译器:Compiler 类的 Compile 方法内部创建 Scanner 与 Parser,用 ProgramNodeBuilder 收集 Parser 产出的 ProgramNode 树(组合模式,第 12 篇的主角),再交给代码生成器遍历输出------客户端只递进一个输入流。照这个骨架写 C++98 版:
cpp
// C++98/03 写法
// 说明性片段(省略 Token/Ast/IrModule 的定义)
#include <string>
#include <vector>
// ---- 子系统:每类一个职责,接口完整公开 ----
class Preprocessor {
public:
std::string expand(const std::string& file);
};
class Lexer {
public:
std::vector<Token> tokenize(
const std::string& src);
};
class Parser {
public:
Ast parse(const std::vector<Token>& toks);
};
class SemanticChecker {
public:
void check(const Ast& ast); // 失败抛异常
};
class IrGen {
public:
IrModule lower(const Ast& ast);
};
// ---- 外观:组合子系统,只做编排 ----
class FrontendFacade {
public:
IrModule compile(const std::string& file) {
std::string src = pp_.expand(file);
std::vector<Token> toks =
lexer_.tokenize(src);
Ast ast = parser_.parse(toks);
checker_.check(ast); // 异常直接上抛
return irgen_.lower(ast);
}
private:
FrontendFacade(const FrontendFacade&); // 禁拷贝
FrontendFacade& operator=(
const FrontendFacade&);
Preprocessor pp_; // 值成员:
Lexer lexer_; // 生命周期归外观管,
Parser parser_; // 客户端零感知
SemanticChecker checker_;
IrGen irgen_;
};
两条传统写法的要点:子系统用值成员组合 (对照第 10 篇适配器持有的「一个」被适配者,外观持有「一排」;构造顺序即依赖顺序,析构按成员逆序自动完成);错误策略在外观处收口 (本例选择让子系统异常穿透,外观不加料------收口不等于吞掉,第 5 节升级零给出另一种策略)。GoF 特别提醒的一点同样适用于今天:子系统不知道外观存在 ------Parser 的头文件里找不到 FrontendFacade 的任何痕迹,依赖是单向的。
5. 现代 C++ 进阶写法
升级零:把错误模型收拢成返回值 。五个子系统五种错误风格,正好在外观处统一。顺手用上 C++17 的 std::string_view 接参数(子系统签名不动,换算发生在外观内------第 10 篇的翻译思想在门面层的复用):
cpp
// 节选(子系统类定义同第 4 节,略)
#include <string>
#include <string_view>
struct CompileResult {
bool ok = false;
std::string error; // 失败时的人类可读原因
IrModule ir; // ok == true 时有效
};
class Frontend {
public:
CompileResult compile(std::string_view file) {
CompileResult r;
try {
std::string src =
pp_.expand(std::string(file));
Ast ast = parser_.parse(
lexer_.tokenize(src));
checker_.check(ast);
r.ir = irgen_.lower(ast);
r.ok = true;
} catch (const std::exception& e) {
r.error = e.what();
}
return r;
}
private:
Preprocessor pp_;
Lexer lexer_;
Parser parser_;
SemanticChecker checker_;
IrGen irgen_;
};
调用方拿到的是一个「要么成功要么带原因」的值对象,不用再同时防返回码与异常两套。
改进一:外观 + PIMPL,编译防火墙。外观解决的是「运行期耦合」(调用方依赖子系统类型),PIMPL 解决的是「编译期耦合」(调用方依赖子系统头文件)。两者正交,叠加后调用方连子系统头文件都不必包含:
cpp
// frontend.h ------ 调用方唯一要包含的头文件
#pragma once
#include <memory>
#include <string>
#include <string_view>
struct CompileResult; // 前向声明足以声明返回值
class Frontend {
public:
Frontend();
~Frontend(); // 析构必须在 cpp
CompileResult compile(std::string_view file);
Frontend(Frontend&&) noexcept; // 移动五件套:
Frontend& operator=(Frontend&&) // 声明在头,
noexcept; // 定义在 cpp
private:
struct Impl; // 子系统们
std::unique_ptr<Impl> impl_; // 全部藏进这
}; // 一个指针
cpp
// frontend.cpp(节选)
// #include "frontend.h"
// #include "preprocessor.h" ......五个子系统头
// struct Frontend::Impl {
// Preprocessor pp_; Lexer lexer_; Parser parser_;
// SemanticChecker checker_; IrGen irgen_;
// };
// Frontend::Frontend()
// : impl_(std::make_unique<Impl>()) {}
// Frontend::~Frontend() = default;
// CompileResult Frontend::compile(
// std::string_view file) {
// // 原样转发给 impl_->compile(file)
// }
为什么析构、移动四件套都要「声明在头、定义在 cpp」?unique_ptr<Impl> 析构时需要 Impl 完整------而完整定义只在 cpp 里可见,于是任何会销毁 Impl 的成员函数都必须与 Impl 同处一个编译单元。这是 PIMPL 最著名的坑,也是它换来编译防火墙的代价:子系统改一个私有成员,全仓库只需重编一个 cpp。大型库里「每类一个 Impl」的密度,正说明这层外观化的价值。
改进二:附加外观,防上帝对象 。管线变长(加优化、加后端)后,一个 CompilerFacade 会开始吞操作。按 GoF 的「附加外观」拆:
cpp
// 说明性片段(结构与 Frontend 同型,略去实现)
class Frontend { // 前端外观:源码 → IR
public:
CompileResult compile(std::string_view file);
};
class Optimizer { // 优化外观:IR → IR
public:
IrModule run(IrModule ir, int level);
};
class Backend { // 后端外观:IR → 目标码
public:
std::vector<char> emit(const IrModule& ir);
};
// 顶层外观只做一件新事:把三个外观串起来
class Compiler {
public:
std::vector<char> build(std::string_view f);
};
三个小外观各自可独立使用(体检工具只借 Frontend),顶层 Compiler 只是「外观的外观」------层次可以有,业务不能有。这条纪律比任何模式结构都更接近外观模式的成败线。
展望 :C++20 模块从语言层面削减了头文件耦合,PIMPL 的「编译防火墙」职责会部分被 module interface 分离取代;但「给复杂子系统一个稳定入口」的意图不会过时------它依赖的是结构划分,不是头文件机制。
6. 优缺点与适用场景
- ✅ 优点(GoF 后果清单):屏蔽子系统组件 ,减少客户端处理的对象数量、让子系统用起来更简单;实现子系统与客户端的弱耦合 ,子系统内部变化不外溢(配合 PIMPL 连编译都不外溢);分层入口本身形成架构边界------每个子系统一个外观,就是每层一个 API 面。
- ❌ 缺点:外观容易成为 上帝对象的起点 ------所有请求涌向一个类,它就从「捷径」变成「审批层」,每加一个子系统操作都要改它;过厚的门面 拖慢发现:性能问题、语义细节隔着一层转发更难看见;团队可能因为「有外观」而放弃暴露细粒度接口,把不该藏的能力也藏了。
- 🎯 适用场景:分层架构的层间入口(编译前端、网络协议栈、存储引擎);库的高层易用 API(POCO Crypto 之于 OpenSSL);遗留系统渐进重构时先套一层新门面,再逐段掏换内部实现;多步固定仪式(初始化/清理、编解码管线)的收口。
〔辨析〕外观 vs 适配器:适配器 一对一换形状 (接口不合),外观 一对多造新口 (入口太繁)------一个改「旧接口的样子」,一个造「新入口的样子」。外观 vs 中介者(第 22 篇):外观是 单向 的「客户端进、子系统出」,子系统之间不因外观相识;中介者是 双向 的「让一组对象互相协作」。外观 vs 代理(第 16 篇):代理不造新接口,与真实对象同接口地站岗;外观的接口是全新发明的。
7. 开源项目中的身影
标准库:std::filesystem 是操作系统差异上的门面 。路径拼接、目录遍历、拷贝删除、权限查询,在 POSIX 与 Win32 上是两套类型两套语义;std::filesystem 用 path/directory_iterator 一套接口盖住了这层差异------内部大量使用平台适配(第 10 篇的活),对外只露出门面(本篇的活)。跨平台文件代码从 #ifdef _WIN32 的散布,变成一个干净的头文件。
POCO:给 OpenSSL 盖一层 C++ 门面 。OpenSSL 的摘要计算要求开发者自己编排 EVP_MD_CTX 的创建、EVP_DigestInit_ex/Update/Final 三段调用与上下文清理;POCO Crypto 把它收进 DigestEngine 的小门面:
cpp
// 说明性片段(需链接 PocoCrypto 与 OpenSSL)
#include <Poco/DigestEngine.h>
#include <Poco/MD5Engine.h>
Poco::MD5Engine md5;
md5.update(data, size);
std::string hex =
Poco::DigestEngine::digestToHex(md5.digest());
点评:三行换五步,异常安全与上下文清理全由门面兜底。这是「外观 + RAII」的标准搭配------门面管简化,RAII 管「简化之后不泄漏」。注意 POCO 没有藏 OpenSSL:需要握手细节的调用方仍可直接下到 OpenSSL 层,符合「简化调用而非隐藏扩展点」。
Abseil:一次调用收口命令行解析 。Abseil 的 flags 子系统里,各翻译单元用 ABSL_FLAG 声明旗帜、模块间还可校验彼此;main 里一行 absl::ParseCommandLine(argc, argv) 完成注册表汇总、参数解析、类型转换、校验执行与 --help 输出:
cpp
// 说明性片段(需链接 absl::flags)
#include <absl/flags/parse.h>
int main(int argc, char* argv[]) {
auto args = absl::ParseCommandLine(argc, argv);
// 此后所有 ABSL_FLAG 变量已是解析后的值
}
点评:典型的「多翻译单元子系统 + 一个入口」形态------每个 ABSL_FLAG 是子系统零件,ParseCommandLine 是唯一门面方法。它同时示范了外观的另一个侧面:门面方法常常是 程序装配点,与第 6 篇「注册表在 main 前自装配」形成两种风格的对照。
AOSP:NDK 媒体接口是框架上的门面层 。Android 的媒体编解码在 framework 内部是 MediaCodec/ACodec/BufferChannel 等一串对象的协作图;面向 native 应用的 NDK 只暴露 AMediaCodec_* 这组 C 门面:
cpp
// 说明性片段(节选自 NDK media/NdkMediaCodec.h,
// 签名有简化)
AMediaCodec* codec =
AMediaCodec_createDecoderByType("video/avc");
AMediaCodec_configure(codec, fmt, surface,
nullptr /*crypto*/, 0);
AMediaCodec_start(codec);
点评:三个调用完成「建组件、连 surface、协商格式、进数据循环」的整段装配,应用不必知道内部协作图。AOSP 大量 NDK API(AAssetManager、AMediaExtractor)都是这种「framework 门面」------也解释了为什么 Google 文档反复提醒:门面没有覆盖的高级用法,请回 Java 层的完整接口去找------门面不该成为能力的天花板。
本篇小结
高频路径没有入口,调用方就会被迫背诵子系统的组装仪式;外观用「一个新发明的高层接口」收拢一组子系统类,把耦合从「客户端 × 五个类」降到「客户端 × 一个门面」。它的三条边界决定成败:只简化调用、不隐藏扩展点,子系统照常可达;只编排、不决策,删掉外观子系统依旧完整;可以有附加外观的层次,不可以有囤业务的上帝对象。C++ 工程里外观与 PIMPL 天生一对------运行期解耦与编译防火墙正交叠加,子系统改私有成员不再全仓库重编。下次评审一个两三百行、什么都做的 XxxManager 时,先问一句:它是门面,还是披着门面外衣的上帝对象?
本文模式定义与角色划分参考了 Refactoring Guru《设计模式》中文版「外观」一章,意图译文、编译器示例、附加外观与后果清单参考了 GoF《Design Patterns》第 4 章 Facade 一节。