13、外观模式:模式动机与定义、角色与结构、C++代码实现、子系统解耦

13.1 模式动机:为什么需要外观?

先聊聊我自己的经历。几年前我接手过一个遗留系统,那是一个多媒体播放器的底层库。你想想看,要播放一段视频,客户端代码需要依次调用:初始化解码器、打开音频设备、同步时钟、处理字幕、管理缓冲池……光初始化步骤就有七八个接口要按顺序调用。

我当时就一个感觉:这玩意儿谁用谁崩溃。每次写测试代码,我都得翻半天文档,搞清楚到底先调哪个、后调哪个。更别提如果某个步骤失败了,还得手动回滚前面的操作。

说白了,这就是典型的「子系统耦合过深」问题。客户端直接和一堆细粒度的子系统打交道,就像你去餐厅吃饭,非要自己跑到后厨跟厨师、配菜员、洗碗工挨个沟通一样。你其实只想吃一盘菜,对吧?

外观模式要解决的,正是这个痛点。它给复杂的子系统提供一个统一的高层接口,让客户端只跟这个「外观」对话,不用关心子系统内部有多复杂。

核心动机: 为子系统中的一组接口提供一个一致的界面,简化客户端的使用成本,降低客户端与子系统之间的耦合。

13.2 模式定义与角色结构

外观模式的定义其实很直白:为子系统中的一组接口提供一个统一的、更简单的高层接口。它没有改变子系统的功能,只是把复杂的调用流程封装起来。

它的角色结构也很清晰,就三个角色:

  • Facade(外观类):知道哪些子系统类负责处理哪些请求。它把客户端的请求委派给相应的子系统对象去处理。
  • Subsystem classes(子系统类):实现子系统的具体功能。它们不知道外观的存在,彼此之间也可以直接通信。
  • Client(客户端):只和外观类交互,不直接调用子系统。

嗯,这里要注意:外观类并不是子系统的「管理者」,它更像一个「调度员」。子系统类之间该怎么协作还是怎么协作,外观只是把最常用的流程打包好。

外观模式结构图 Client Facade SubsystemA SubsystemB SubsystemC methodA1() methodB1() methodC1() 调用关系 子系统协作

13.3 C++代码实现:一个播放器的例子

回到我前面说的那个播放器项目。假设我们有三个子系统:解码器、音频输出、字幕渲染。客户端想播放一个视频,需要做这些事:

// 子系统类
class VideoDecoder {
public:
    void open(const std::string& file) {
        std::cout << "打开视频文件: " << file << std::endl;
    }
    void decodeFrame() {
        std::cout << "解码一帧数据" << std::endl;
    }
    void close() {
        std::cout << "关闭解码器" << std::endl;
    }
};

class AudioOutput {
public:
    void init() {
        std::cout << "初始化音频设备" << std::endl;
    }
    void playSound() {
        std::cout << "播放音频数据" << std::endl;
    }
    void stop() {
        std::cout << "停止音频输出" << std::endl;
    }
};

class SubtitleRenderer {
public:
    void load(const std::string& srtFile) {
        std::cout << "加载字幕文件: " << srtFile << std::endl;
    }
    void render() {
        std::cout << "渲染当前字幕" << std::endl;
    }
};

如果没有外观模式,客户端代码会写成这样——我当年就是这么写的,现在回头看真是惨不忍睹:

// 客户端直接操作子系统 —— 耦合严重
VideoDecoder decoder;
AudioOutput audio;
SubtitleRenderer subtitle;

decoder.open("movie.mp4");
audio.init();
subtitle.load("movie.srt");

// 播放循环
for (int i = 0; i < 100; ++i) {
    decoder.decodeFrame();
    audio.playSound();
    subtitle.render();
}

decoder.close();
audio.stop();

你发现问题了吗?客户端不仅要了解所有子系统的接口,还得自己控制调用顺序。如果哪天音频初始化失败了,解码器已经打开了,谁来负责回滚?

现在用外观模式重构一下:

// 外观类 —— 封装播放流程
class MediaPlayerFacade {
private:
    VideoDecoder decoder;
    AudioOutput audio;
    SubtitleRenderer subtitle;

public:
    void play(const std::string& videoFile, const std::string& srtFile = "") {
        try {
            decoder.open(videoFile);
            audio.init();
            if (!srtFile.empty()) {
                subtitle.load(srtFile);
            }

            // 播放循环
            for (int i = 0; i < 100; ++i) {
                decoder.decodeFrame();
                audio.playSound();
                if (!srtFile.empty()) {
                    subtitle.render();
                }
            }

            // 清理
            decoder.close();
            audio.stop();
        }
        catch (...) {
            // 异常时统一清理
            decoder.close();
            audio.stop();
            std::cerr << "播放异常,已清理资源" << std::endl;
        }
    }
};

客户端调用就变成了这样:

// 客户端只和外观交互
MediaPlayerFacade player;
player.play("movie.mp4", "movie.srt");

是不是清爽多了?客户端只需要知道「播放」这一个接口,剩下的细节全部交给外观去处理。

我的习惯: 外观类里我通常会加一个「事务性」的处理逻辑。比如上面代码中的 try-catch,确保任何一个子系统失败时,已经初始化的资源都能被正确释放。这个习惯帮我避免过好几次线上崩溃。

13.4 子系统解耦:外观模式的核心价值

外观模式对子系统解耦的贡献,主要体现在三个方面:

维度 没有外观模式 使用外观模式
客户端依赖 依赖所有子系统类 只依赖外观类
接口数量 N 个子系统接口 1 个外观接口
变更影响 子系统接口变化 → 所有客户端都要改 子系统接口变化 → 只改外观类
调用顺序 客户端自己控制 外观类统一编排

我曾经在一个项目中遇到过一个坑:子系统 A 的接口从同步改成了异步,结果全公司十几个业务方都要跟着改调用代码。后来我们加了一个外观层,把异步逻辑封装在里面,对外暴露的接口不变,业务方一行代码都不用动。

注意: 外观模式不是「万能胶」。不要为了用外观而外观,把本来简单的接口也包一层。我见过有人给一个只有两个方法的类也套外观,那就过度设计了。外观模式适用于子系统复杂、调用流程固定的场景。

13.5 避坑指南与最佳实践

最后分享几个我踩过的坑:

  • 不要试图用外观封装所有功能。 外观只封装「常用流程」,如果客户端需要更细粒度的控制,应该允许它直接访问子系统。外观不是「门面」,它不阻挡客户端直接调用子系统。
  • 外观类不要变成「上帝类」。 如果一个外观类包含了太多子系统的逻辑,考虑拆分成多个外观,或者使用「抽象外观」接口。
  • 注意外观类的线程安全。 如果外观类被多个线程共享,内部对子系统的调用需要加锁保护。我早期就吃过这个亏,两个线程同时调 play(),结果音频设备初始化了两次。

嗯,外观模式其实很简单,但用好它需要一点经验。记住一句话:外观是给客户端减负的,不是给子系统加锁的。保持外观类的轻量,让它只做「编排」这一件事。

总结: 外观模式通过提供一个统一的高层接口,将客户端与复杂的子系统解耦。它不改变子系统的功能,只改变客户端使用子系统的方式。在大型系统中,外观模式是降低模块间耦合度的利器。
公众号:蓝海资料掘金营,微信 deep3321