设计模式

我们常提到的设计模式源自 1994 年出版的 Design Patterns: Elements of Reusable Object-Oriented Software 一书。

书中用 C++ 描述了 23 种常用的软件设计模式,这些模式可以分类如下:

  • 创建型,关注对象如何创建

    1. 工厂方法 Factory Method
    2. 抽象工厂 Abstract Factory
    3. 建造者 Builder
    4. 原型 Prototype
    5. 单例 Singleton
  • 结构型

    1. 适配器 Adapter
    2. 桥 Bridge
    3. 组合 Composite
    4. 装饰器 Decorator
    5. 门面 Facade
    6. 享元 Flyweight
    7. 代理 Proxy
  • 行为型

    1. 责任链 Chain
    2. 命令 Command
    3. 解释器
    4. 迭代器
    5. 中介者
    6. 备忘录
    7. 观察者 Observer
    8. 状态 State
    9. 策略 Strategy
    10. 模版方法
    11. 访问者 Visitor

有些设计模式太常见,比如单例、迭代器。有些设计模式太偏,比如解释器、备忘录。
本文只会讲这些模式:工厂方法、装饰器、责任链、命令、中介者、状态、策略、访问者。

正文开始之前,需要说明的是:

有些设计模式是基于 C++/Java 的 OOP 模式而产生的,其他语言不一定适用,或者有别的实现方式。

本文旨在了解设计模式的思想,而不在于形式。

在编码时切忌生搬硬套,明白原理,再结合自己使用的编程语言,写出 SOLID 代码才是学习设计模式的目的。

设计模式的目的不是减少代码数量,而是用抽象层将常变和相对不常变的代码隔开,从而收敛代码改动范围。

引入抽象层则会让代码量增加。这也是很多人困惑的一点,怎么本来几行的代码用了设计模式之后增长到一百多行?

工厂方法

工厂方法是指用一个方法来构造对象,而不是直接调用构造方法。如下:

interface Vehicle {
}

class Benz implements Vehicle {}

class Camry implements Vehicle {}

class CarFactory {
  public static Vehicle create(String brand) {
    if ("camry".equals(brand)) {
      return new Camry();
    }
    return new Benz();
  }
}

class Main {
  public static void main(String[] args) {
    // 调用构造方法
    Vehicle car = new Benz();
    // 工厂方法
    // camry 甚至可以通过 args 传递进来
    Vehicle car2 = CarFactory.create("camry");
  }
}

为什么使用工厂方法呢?

因为 new SomeClass 来构建对象本质上属于硬编码,写死了类型。

为了将 object 的构建和使用分开,才引入了工厂函数作为中间抽象。

上面的例子中,使用工厂方法之后,还可以将 CarFactory.create 的参数写在配置文件,或者通过命令行传递。这样如果需要改变车辆品牌,只需要修改配置或者参数即可,不用修改编译代码。

仔细想一下,工厂方法其实和下面的代码没有本质区别。

final int MAX = 100;

// 引入抽象
// getMax 返回 int 类型
final int MAX = getMax();

之前 MAX 是硬编码的某个值,比如 100;
之后引入了函数 getMax,至于 getMax 返回的值是怎么来的我们不关心,只要是 int 类型即可。
getMax 的角色和工厂方法是一样的。

工厂方法在 Go 代码中随处可见,因为 Go 不支持 new SomeStruct 创建 object。比如:

type Person struct {

}

func NewPerson(params Params) *Person {
  return &Person{
    // ...
  }
}

装饰器模式

装饰器用于增强原有的类型,而不改变暴露的接口。

不去修改原有的类型,而是使用外挂或者是新类型。

如下:

interface Writer {
  int write(data byte[]);
}

class W implements Writer {
  int write(data byte[]) {
    // ... write data
  }
}

Writer w = new W();

现在需要对 write 进行增强,比如流控、批量等特性。可以保留 W 类不变,提供新的类型。如下:

class W2 implements Writer {
  private Writer w;

  W2(Writer w) {
    this.w = w;
  }

  int write(data byte[]) {
    System.out.println("cache ...");
    w.write(data);
    System.out.println("flush ...");
  }
}

Writer w = new W2(new W());

上面的代码用 W2 对 W 做了一些增强,然后将 W2 的实例暴露给变量 w。
对于 w 来说,自己收到的还是符合 Writer 接口的 object。

在支持装饰器的语言中,可以很容易的实现。比如 JavaScript:

class Cache {
  // clone 在其他地方实现
  // 将 cache 取出的数据 copy 一份
  @clone
  get(key: string) {
    if (xxx) {
      return xxx;
    } else if (xxx) {
      return yyy;
    }
    return zzz;
  }
}

看上面的代码,需求是将 cache 取出的数据深 copy 一份。

如果不用装饰器,我们需要修改 get 方法内部的逻辑。如果逻辑复杂,比如分支众多,很容易漏掉某个 return。

而用装饰器这种外挂,就不需要去关心 get 方法的实现,更简单也更干净。

责任链模式

责任链类似流水线,在一条流水线上可以随意增加/删除处理环节,常用于对网络请求的处理。

比如 Java 的 netty,JavaScript 的 express 都是这种模式。

Pipeline pipeline = new Pipeline();

pipeline.add(handler1);
pipeline.add(handler2);
pipeline.add(handler3);

pipeline.handle(request);

使用责任链模式,我们需要仔细思考的是:

  1. handler 抽象接口的格式,入参和出参的类型,以及异常的处理。比如:
// handler 需要实现的接口
interface Handler {
  handle(Request request, Response response) throws Exception;
}

上面的接口类型表示 handler 的异常由外层的 Pipeline 处理。

  1. handler 的流转与中断谁来控制。这个过程可以由外层的 Pipeline 来控制,也可以由 handler 自己来。比如:
interface Handler {
  // 调用 next 可以执行下一个 handler
  void handle(Request request, Response response, Next next);
}

在支持函数的语言,比如 Go 和 JavaScript 中,通常 handler 是一个函数。

// express
const app = express();

function handler1(request, response, next) {}

function handler2(request, response, next) {}

app.use(handler1);
app.use(handler2);

命令模式

命令模式用于将一组动作封装在一起,提供更简洁的接口。常用于事件相关的处理。

比如我们要依次执行 A B C 操作。那么可以把 A B C 封装成一个操作,暴露的接口如下:

interface Command {
  void execute();
}

class SomeCommand implements Command {
  // 执行命令依赖的 object,或者上下文
  private A a;
  private B b;
  private C c;

  SomeCommand(A a, B b, C c) {
    this.a = a;
    this.b = b;
    this.c = c;
  }

  execute() {
    a.doSomething();
    b.doSomething();
    c.doSomething();
  }
}

class Main {
  public static void main(String[] args) {
    // 封装了 A B C 的操作
    SomeCommand someCommand = new SomeCommand(a, b, c);
    someCommand.execute();
  }
}

上面的代码中本来应该由 Main.main 调用 a b c 来执行某种操作。但是现在它只需要创建 SomeCommand 命令,再执行一个方法即可。

通过 SomeCommand 的封装,Main.main 的逻辑变得更为清晰。在 Main.main 中增加更多的 command 也会更容易。

常见的应用:编辑器里复制按钮点击,需要执行复制操作。

// 复制按钮被点击了
CopyCommand command = new CopyCommand(editor);
// 具体的复制实现可以交给其他同学来完成
// 调用方不需要关心细节
command.execute();

中介者模式

当系统中多个模块相互耦合时,可以引入一个中间抽象,然后这些模块都依赖这个中间抽象,将网状拓扑简化成星型拓扑,依赖复杂度由 M*N 变成 N+1。

中介者典型的应用有 MVC 架构、EventBus、MessageQueue 或者 NodeJS 的 EventEmitter。

状态模式

假设一个 object 有 N 种状态和 M 种行为,状态和行为之间互相影响。比如:

enum State {
  // 电梯暂停
  Pause,
  // 电梯上下运行中
  Running,
  // 电梯门开
  Open;
}

// 电梯
class Lift {
  State state;

  void open() {
    if (state == State.Running) {
      throw new Error("Can't open when running");
    }
    System.out.println("open...");
    state = State.Open;
  }

  void close() {
    if (state == State.Running) {
      throw new Error("Can't close when running");
    }
    System.out.println("close...");
    state = State.Pause;
  }

  void run() {
    if (state == State.Open) {
      throw new Error("Close first");
    }
    System.out.println("run...");
    state = State.Running;
  }

  void pause() {
    if (state == State.Open) {
      throw new Error("Close first");
    }
    state = State.Pause;
  }
}

从上面的代码可以看到,电梯的状态和行为相互影响,都放在电梯类里。

目前电梯的状态和行为很少这么写问题不大,但是随着状态和行为的扩展,整个 Lift 类的代码交织在一起,会陷入失控。

引入状态模式可以解决扩展的问题:

  1. 将每个状态拆成独立的 class,它们实现共同的接口,包括几乎所有的 Lift 行为。
  2. Lift 只保存一个状态 object,而状态的流转和行为的控制则由这个 object 处理,因为每个状态知道自己的下一个状态是什么。

代码如下:

// 所有 state 类需要继承
abstract class State {
  // State 需要持有 Lift
  // 以便实现 lift 的状态流转
  protected Lift lift;

  public State(Lift lift) {
    this.lift = lift;
  }

  public abstract void open();
  public abstract void close();
  public abstract void run();
  public abstract void pause();
}

// OpenState 只关注 open 状态下的逻辑
// 这样单个 State 的逻辑就很清晰
class OpenState extends State {
  public void open() {
    System.out.println("Alread open");
  }

  public void close() {
    System.out.println("Close");
    // 流转到下一个状态
    lift.setState(new PauseState(lift));
  }

  public void run() {
    throw new Error("Close first");
  }

  public void pause() {
    throw new Error("Close first");
  }
}

class RunningState extends State {}

class PauseState extends State {}

class Lift {
  // 初始状态
  private State state = new RunningState(this);

  public void setState(State state) {
    this.state = state;
  }

  // 所有行为委托给 state 处理
  void open() {
    state.open();
  }

  void close() {
    state.close();
  }

  void run() {
    state.run();
  }

  void pause() {
    state.pause();
  }
}

可以看到,每个状态专注自身的逻辑。每增加一个状态,只需要增加一个对应的 State 类即可。

也有小小的缺点:每增加一个行为,则要修改全部的 State 类。

比较:你是愿意在一个大箱子里面找东西?还是愿意在分类更清晰的三个小箱子里面找东西?

策略模式

设想这种场景:一份数据可能会做不同的处理。

这种情况下,数据一般是相对不易改变的部分,这里指的是数据的结构,而非具体的数值。对数据的处理逻辑可能会经常改变。

还是那句话:在不常变和常变之间做出区分,引入中间抽象把它们隔离开。

把数据的处理提取成接口,剥离处理方法的具体实现,这种方式被成为策略模式。

最简单常见的策略模式如下:

const nums = [4, 3, 0, 8, 2];

// 从小到大
nums.sort(function (a, b) {
  return a - b;
  // 从大到小
  return b - a;
});

// 从大到小
nums.sort(function (a, b) {
  return b - a;
});

nums 数组提供了一个 sort 方法,具体怎么排序则由调用方自己决定,只要排序函数满足接口即可。

这样 nums 就不用提供 sortMinToMax/sortMaxToMin 等排序方法,调用方爱怎么排序就怎么排序,自己来。

策略模式还有一个很经典的应用场景:消息通信中,对不同的消息做不同的处理。

class Message {
  public int code;
  public byte[] data;
}

对消息的处理可以抽象成共同的接口:

abstract class MsgHandler {
  // 可以处理的消息 code
  public int[] codes;
  public abstract void handle(Message msg);
}

// 消息处理的实现
class MsgHandler404 extends MsgHandler {
  MsgHandler404() {
    super();
    this.codes = {
      404,
    };
  }

  public void handle(Message msg) {
    // ... doSomething
  }
}

class Handlers {
  private Map<Integer, List<MsgHandler>> handlers = new Map<>();

  // register 提供消息处理器的插拔机制
  public void register(MsgHandler handler) {
    for (int i = 0; i < handler.codes.length; i++) {
      int code = handler.codes[i];
      List<MsgHandler> list = handlers.get(code);
      if (list == null) {
        list = ArrayList<MsgHandler>();
        handlers.put(code, list);
      }
      list.add(handler);
    }
  }

  // 不再使用 switch (msg.code) 的方式处理消息
  public void handle(Message msg) {
    List<MsgHandler> list = handlers.get(msg.code);
    for (int i = 0; i < list.size(); i++) {
      MsgHandler handler = list.get(i);
      handler.handle(msg);
    }
  }
}

访问者模式

访问者表示对数据的访问和处理。

访问者模式和策略模式有些像,都是强调数据本身与数据的处理逻辑相分离。

但是访问者模式通常适用于对数据做出比较复杂的处理。

class Data {

}

class Visitor {
  private Data data;

  Visitor(Data data) {
    this.data = data;
  }

  SomeInfo report() {
    // ... 处理数据
  }
}

可以把访问者看作对数据的视图,或者 PhotoShop 的蒙板。
圆形的蒙板展示的数据是圆形,方形的蒙板展示的数据则是方形。

比如对于同一份字节数据,可以解析成不同的结果:

//
class ByteBuffer {
  private byte[] data;
}

// 将 bytes 解析成 uint8 类型
class Uint8View {
  private ByteBuffer buffer;
}

// 将 bytes 解析成 int16 类型
class Int16View {
  private ByteBuffer buffer;
}

以上就是本文的全部内容了,欢迎评论点赞分享,或者关注公众号【写好代码】,谢谢。


june
842 声望24 粉丝

飞书内推:[链接]