外观模式与组合模式

一个用户注册的 20 个调用

我们团队有个注册流程,最初很简单:

public void register(User user) {
    userDao.save(user);
}

后来加了一堆功能:

public void register(User user) {
    // 1. 保存用户
    userDao.save(user);

    // 2. 发送欢迎邮件
    emailService.sendWelcome(user.getEmail());

    // 3. 发送欢迎短信
    smsService.send(user.getPhone(), "Welcome!");

    // 4. 初始化积分账户
    pointsAccountService.create(user.getId());

    // 5. 初始化优惠券
    couponService.grantNewUserCoupon(user.getId());

    // 6. 记录审计日志
    auditService.log("USER_REGISTER", user.getId());

    // 7. 发送大数据埋点
    dataService.track("user_register", user.getId());

    // ... 一共 20 个调用
}

业务代码里满是细节,任何一个新人都要花一周才能搞懂注册流程到底做了什么。

外观模式解决的就是这个问题:为一组复杂的子系统调用提供一个统一的高层接口,让客户端不需要关心内部细节。


二、外观模式🔴

2.1 标准写法

// 子系统A:邮件服务
class EmailService {
    void sendWelcome(String email) { /* ... */ }
    void sendResetPassword(String email, String token) { /* ... */ }
}

// 子系统B:短信服务
class SmsService {
    void send(String phone, String message) { /* ... */ }
}

// 子系统C:积分服务
class PointsService {
    void createAccount(Long userId) { /* ... */ }
    void addPoints(Long userId, int points) { /* ... */ }
}

// 子系统D:优惠券服务
class CouponService {
    void grantNewUserCoupon(Long userId) { /* ... */ }
}

// 外观类
class UserFacade {
    private final EmailService emailService;
    private final SmsService smsService;
    private final PointsService pointsService;
    private final CouponService couponService;

    // 一个方法封装所有注册相关操作
    public void register(User user) {
        // 1. 核心:保存用户
        userDao.save(user);

        // 2. 通知:邮件
        emailService.sendWelcome(user.getEmail());

        // 3. 通知:短信
        smsService.send(user.getPhone(), "Welcome to our platform!");

        // 4. 初始化:积分账户
        pointsService.createAccount(user.getId());
        pointsService.addPoints(user.getId(), 100); // 新用户100积分

        // 5. 初始化:优惠券
        couponService.grantNewUserCoupon(user.getId());

        // 6. 审计日志
        auditService.log("USER_REGISTER", user.getId());
    }

    // 解封:只发邮件
    public void resendWelcomeEmail(User user) {
        emailService.sendWelcome(user.getEmail());
    }

    // 解封:只发短信
    public void sendSms(String phone, String message) {
        smsService.send(phone, message);
    }
}

2.2 客户端使用

// 客户端代码:以前需要知道 20 个子系统
class ClientCode {
    void registerUser() {
        // 需要了解所有子系统的 API
        emailService.sendWelcome(...);
        smsService.send(...);
        pointsService.createAccount(...);
        // ...
    }
}

// 现在:只需要调用外观
class ClientCode {
    private final UserFacade userFacade;

    void registerUser() {
        userFacade.register(user); // 一行搞定
    }
}

2.3 外观模式 vs 直接调用

维度直接调用外观模式
客户端复杂度高(需要了解所有子系统)低(只需了解外观类)
子系统耦合客户端直接依赖所有子系统客户端只依赖外观类
调用顺序客户端负责外观类负责
灵活度高(可以精确控制)中(通过外观类暴露)
适用场景简单场景复杂子系统

【架构权衡】 外观模式不限制客户端直接访问子系统——它只是提供一个"默认的方便入口"。高级用户仍然可以绕过外观,直接调用具体子系统。这是一种可选的简化,而不是强制封装。


三、组合模式🟡

3.1 问题的引入

我们的菜单系统需要支持:

菜单
├── 菜品A
├── 菜品B
└── 套餐(包含多个子菜品)
    ├── 菜品C
    └── 菜品D

文件和文件夹的树形结构也是类似:

根文件夹
├── 文件1.txt
├── 文件2.txt
└── 子文件夹
    ├── 文件3.txt
    └── 文件4.txt

核心问题:如何用统一的接口操作单个对象和组合对象?

3.2 组合模式结构

// 组件接口:所有对象的统一接口
interface FileSystemComponent {
    String getName();
    long getSize();
    void print(String indent); // 打印结构
}

// 叶子节点:文件
class File implements FileSystemComponent {
    private final String name;
    private final long size;

    public File(String name, long size) {
        this.name = name;
        this.size = size;
    }

    @Override
    public String getName() { return name; }

    @Override
    public long getSize() { return size; }

    @Override
    public void print(String indent) {
        System.out.println(indent + "- " + name + " (" + size + "KB)");
    }
}

// 组合节点:文件夹
class Folder implements FileSystemComponent {
    private final String name;
    private final List<FileSystemComponent> children = new ArrayList<>();

    public Folder(String name) {
        this.name = name;
    }

    @Override
    public String getName() { return name; }

    // 文件夹的大小 = 所有子项的大小之和
    @Override
    public long getSize() {
        return children.stream().mapToLong(FileSystemComponent::getSize).sum();
    }

    @Override
    public void print(String indent) {
        System.out.println(indent + "+ " + name + " (" + getSize() + "KB)");
        for (FileSystemComponent child : children) {
            child.print(indent + "  ");
        }
    }

    // 组合操作
    public void add(FileSystemComponent component) {
        children.add(component);
    }

    public void remove(FileSystemComponent component) {
        children.remove(component);
    }
}

3.3 使用示例

// 构建文件树
Folder root = new Folder("root");
Folder docs = new Folder("documents");
Folder images = new Folder("images");

docs.add(new File("resume.pdf", 100));
docs.add(new File("report.docx", 500));
docs.add(new File("notes.txt", 10));

images.add(new File("photo1.jpg", 2000));
images.add(new File("photo2.jpg", 1500));

root.add(docs);
root.add(images);
root.add(new File("readme.md", 5));

// 统一接口操作
root.print("");
// 输出:
// + root (4105KB)
//   + documents (610KB)
//     - resume.pdf (100KB)
//     - report.docx (500KB)
//     - notes.txt (10KB)
//   + images (3500KB)
//     - photo1.jpg (2000KB)
//     - photo2.jpg (1500KB)
//   - readme.md (5KB)

// 计算总大小
System.out.println("Total: " + root.getSize() + "KB"); // 4105KB

3.4 组合模式的透明性与安全性

// 透明性方法:客户端不需要知道是叶子还是组合
interface FileSystemComponent {
    void add(FileSystemComponent c); // 叶子也有这些方法
    void remove(FileSystemComponent c);
}

// 安全性方法:接口不暴露组合操作
interface FileComponent {
    long getSize();
    void print();
}

interface CompositeComponent extends FileComponent {
    void add(FileComponent c);
    void remove(FileComponent c);
}
方法透明性安全性
接口暴露 add/remove✅ 透明性好,客户端无需区分❌ 安全性差,File 对象也可以调用 add(空实现)
接口不暴露 add/remove❌ 透明性差,需要类型判断✅ 安全性好,叶子没有 add 方法

Java 的 java.awt.Component 用的是透明性方法(Component 都有 add/remove),而 JDK 的 Collections 用的是安全性方法(ListMap 分开)。


四、外观与组合的结合🟡

4.1 文件系统 + 外观模式

class FileSystemFacade {
    // 隐藏内部结构,提供高层 API
    public void copyFile(String source, String dest) {
        FileSystemComponent src = findComponent(source);
        // 复制实现
    }

    public void deleteFolder(String path) {
        FileSystemComponent component = findComponent(path);
        if (component instanceof Folder) {
            // 删除文件夹(递归删除子项)
        }
    }

    public long calculateSize(String path) {
        FileSystemComponent component = findComponent(path);
        return component.getSize(); // 组合模式自动计算
    }
}

4.2 菜单系统

// 菜单组件
interface MenuComponent {
    String getName();
    void print();
}

// 菜单项
class MenuItem implements MenuComponent {
    private final String name;
    private final double price;
    private final String description;

    @Override
    public void print() {
        System.out.println("  " + name + " - $" + price);
        System.out.println("    " + description);
    }
}

// 菜单组
class MenuGroup implements MenuComponent {
    private final String name;
    private final List<MenuComponent> items = new ArrayList<>();

    @Override
    public void print() {
        System.out.println("\n== " + name + " ==");
        for (MenuComponent item : items) {
            item.print(); // 递归打印
        }
    }
}

五、生产避坑清单

5.1 外观模式滥用

// ❌ 错误:把外观类变成万能类
class GodFacade {
    // 什么都有,完全违背单一职责
    void doA();
    void doB();
    void doC();
    // ...
    void doZ();
}

// ✅ 正确:按领域划分外观
class UserFacade { /* 用户相关 */ }
class OrderFacade { /* 订单相关 */ }
class PaymentFacade { /* 支付相关 */ }

5.2 组合模式的递归陷阱

// ❌ 错误:递归调用导致栈溢出
class Folder {
    @Override
    public long getSize() {
        long total = 0;
        for (FileSystemComponent child : children) {
            total += child.getSize(); // 如果有循环引用,这里会无限递归
        }
        return total;
    }
}
⚠️

组合模式要注意循环引用的问题。如果目录 A 包含目录 B,目录 B 又包含目录 A,递归操作会栈溢出。解决方案:使用迭代器模式或限制递归深度。


六、面试总结

6.1 核心追问

  1. "外观模式和适配器模式的区别?" —— 外观简化调用,适配器转换接口
  2. "组合模式的透明性和安全性怎么选?" —— 透明性好但安全性差,Java AWT 选透明性
  3. "什么场景下用外观模式?" —— 子系统复杂、客户端需要简化

6.2 级别差异

级别期望回答
P5能写出两种模式的基本结构
P6能说出透明性/安全性权衡,能结合 Spring 分析
P7能识别循环引用陷阱,能在复杂系统中正确选型