一文理清21种设计模式:用实例分析和对比

默认分类,编程 2018-03-13 访问: 578 次

设计模式无论是对于最底层的的编码实现还是较高层的架构设计都有着重要的指导作用。所谓光说不练假把式,今天我就把项目中常见的应用场景涉及到的主要设计模式及其相关设计模式总结一下,用实例分析和对比的方式在一片文章中就把最常见的21种设计模式梳理清楚。

Redis发布订阅

在项目中常常使用redis的发布/订阅功能,用来实现进程间通信甚至IM等业务。
使用 jedis 实现频道订阅的模式一般如下:

try( Jedis jedis =  RedisClient.getJedis() ) {
    JedisPubSub listener = new MySubListener();
    // 订阅
    jedis.subscribe(listener, "channel");
}

其中 MySubListener

class MySubListener extends JedisPubSub {
    // 取得订阅的消息后的处理
    public void onMessage(String channel, String message) {
        logger.info("频道:{},收到消息:{}",channel,message);
    }
    // 初始化订阅时候的处理
    public void onSubscribe(String channel, int subscribedChannels) {
        logger.info("订阅:{},总数:{}",channel,subscribedChannels);
    }
    // 取消订阅时候的处理
    public void onUnsubscribe(String channel, int subscribedChannels) {
        logger.info("取消订阅:{},总数:{}",channel,subscribedChannels);
    }
}

这里使用了策略模式对算法的封装,把使用算法的责任和算法本身分隔开,委派给不同的对象管理。策略模式通常把一系列的算法包装到一系列的策略类里面,作为抽象策略类的子类)。
图:
222.png

本例中,JedisPubSub抽象策略类,定义不同事件发生时的响应模式亦即所支持的算法的公共接口;MySubListener是一个具体策略类定义了一种具体的事件响应方式(简单的打印);jedis是就是Context,负责维护调用者与策略之间的联系。这样不同的调用者只需要传入不同的事件响应具体算法如MySubListener1、2、3等即可(而不是去修改已有算法),实现了对扩展开放,对修改关闭的开闭原则
jedis 发布事件的代码如下:

try {
    jedis.publish("channel","message to be published");
}

说到这就不得不说说状态模式当一个对象内在状态改变时允许其改变行为, 这个对象看起来像改变了其类
图:
235.png

Context定义客户端需要的接口, 并且负责具体状态的切换
State接口或抽象类,负责对象状态定义,并且封装Context以实现状态切换;
ConcreteState每一个具体状态必须完成两个职责:就是本状态下要做的事情,以及本状态如何过渡到其他状态
状态模式和策略模式都是为具有多种可能情形设计的模式,把不同的处理情形抽象为一个相同的接口,符合开闭原则。但是状态模式将各个状态对应的操作分离开来,即不同的状态由不同的子类实现具体操作,状态切换由子类实现,当发现传入参数不是自己这个状态所对应的参数,则自己给Context类切换状态,也就是说客户端并不知晓状态;而策略模式是直接依赖注入到Context类的参数进行选择策略,不存在切换状态的操作,也就是说状态和策略是由客户端自己定的

回到本例,发布/订阅本身就是观察者模式定义对象间一种一对多的依赖关系,使得每当一个对象改变状态,则所有依赖于它的对象都会得到通知并被自动更新)的运用。
图:
223.png

可以结合Redis设计与实现查看redis实现发布订阅的原理。本例中,JedisPubSub抽象观察者MySubListener具体观察者抽象主题没有显式定义,但是我们知道它的标准就是能够添加、删除、通知观察者(如调用onMessage方法),具体主题就是redis里面包含"channel"这个模式的频道。这就把消息生产者和消费者解耦了,消费者不用管生产者如何产生消息,生产者不用管消费者如何处理消息,两者直接是松耦合的,也就是说两者仅依赖于通知机制进行交互而不知道对方的实现细节,这样只要保持通知机制,双方都可以随意扩展。

请注意上面代码的Jedis jedis = RedisClient.getJedis()是一个静态工厂方法模式或者说简单工厂模式通过专门定义一个类使用静态方法来负责创建其他类的实例,被创建的实例通常都具有共同的父类或者父接口)的使用。
图:
226.png

RedisClient主要代码如下:

public final class RedisClient {

    private static JedisPool jedisPool;

    public static void construct(Properties p){
        try {
            JedisPoolConfig config = new JedisPoolConfig();
            config.setMaxTotal(Integer.parseInt(p.getProperty("jedis.pool.maxTotal")));
            jedisPool = new JedisPool(config,p.getProperty("redis.host"), Integer.parseInt(p.getProperty("redis.port")),
                    Integer.parseInt(p.getProperty("redis.timeOut")),p.getProperty("redis.auth"), Integer.parseInt(p.getProperty("redis.db")));
        }
    }
    public static Jedis getJedis(){
        return jedisPool.getResource();
    }
    public static void destruct(){
        jedisPool.close();
    }
}

本例中类RedisClient就是Creator,返回的redis客户端Jedis就是ConcreateProduct,由于目前只用了 jedis 这一种 java redis client 所以没有设置抽象的Product,如果有多种client那么就要设置抽象的Product(这些Product都要有set、hset等redis通用操作),然后再在getJedis函数中去根据需要产生不同的client(if else 或者 switch case)。
静态工厂方法的好处在于:增加新的Product类(比如新的java redis client)的时候老的类不需要改变,调用者由于只依赖于接口(抽象的Product)也不用改变,亦即把变化封装到工厂内部了;可读性更强(比如getJedis你就知道他要干啥,而不是使用不知所以的构造函数);缓存增强性能(比如上面的jedisPool就一直存在着,避免每次获取连接时新创建连接池);代码简洁等等。

静态工厂方法模式的缺点就是新加入一个Product类的时候,其工厂方法本身需要改变(比如多一个判断的case分支),解决办法就是采用每种具体Product对应一个具体工厂的工厂模式定义一个用于创建对象的接口, 让子类决定实例化哪一个类。 工厂方法使一个类的实例化延迟到其子类
图:
227.png

工厂模式把每一种 product 类的实例化过程都封装到了一个对应的工厂类中,新加入product的时候不需要改任何的旧代码,只需要同时添加对应的具体工厂类即可。高层模块只需要知道产品的抽象类,其他的具体实现类都不需要关心,符合迪米特法则,依赖倒置原则,里氏替换原则

然后就不得不说说抽象层次更高、更具一般性的抽象工厂模式为创建一组相关或相互依赖的对象提供一个接口, 而且无须指定它们的具体类)了
图:
228.png

抽象工厂与工厂方法的区别就是可能有多个抽象Product,也就是说每一个具体工厂能够生产一个产品族而不只是一个产品,可以把抽象工厂简单理解为工厂方法+简单工厂,每一个具体工厂都是一个简单工厂。

说到工厂模式就不得不说原型模式用原型实例指定创建对象的种类, 并且通过拷贝这些原型创建新的对象
图:
238.png

原型模式的核心是Override Object 类的 clone 方法,通过该方法进行对象的拷贝,由于是内存二进制流的拷贝,所以比直接new性能好很多,特别是要在一个循环体内产生大量的对象时,原型模式可以更好地体现其优点。在实际项目中,原型模式很少单独出现,一般是和工厂方法模式一起出现,通过clone的方法创建一个对象, 然后由工厂方法提供给调用者。

继续观察上面的RedisClient类,我们知道,连接池jedisPool在整个应用中是只需要一个实例的,也就是说我们要使用单例模式确保某一个类只有一个实例, 而且自行实例化并向整个系统提供这个实例
图:
229.png

所以我们的代码要修改一下:

public final class RedisClient {

    private static volatile JedisPool jedisPool;

    public static void construct(Properties p){
        try {
            JedisPoolConfig config = new JedisPoolConfig();
            config.setMaxTotal(Integer.parseInt(p.getProperty("jedis.pool.maxTotal")));
            if(jedisPool==null){
                synchronized (RedisClient.class){
                    if (jedisPool==null){
                        jedisPool = new JedisPool(config,p.getProperty("redis.host"), Integer.parseInt(p.getProperty("redis.port")),
                            Integer.parseInt(p.getProperty("redis.timeOut")),p.getProperty("redis.auth"), Integer.parseInt(p.getProperty("redis.db")));
                    }
                }
            }
        }
    }     
}

这里用volatile+双重检查来实现单例模式,和标准的单例模式区别是,本例并不需要返回jedisPool实例,而是返回了一个jedis连接。

上面的JedisPool用到了享元模式使用共享对象来有效地支持大量的细粒度的对象
图:
237.png

JedisPool就是FlyweightFactoryjedis 就是 ConcreteFlyweight抽象的Flyweight在本例没有设置,但是我们知道它肯定是封装了常见的redis操作接口的,UNsharedConcreteFactory也没有对应设置,因为jedis对客户端都是一样的,所以所有部分都不是不可分享的。通过池操作,使得固定数量N(甚至更少)的jedis对象可以服务于远超N个的客户端对象,达到共享和复用的目的。

Netty的应用

我们启动Netty服务器的时候,服务端使用ServerBootstrap,一般而言代码如下:

NioEventLoopGroup group = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(group) 
    .channel(NioServerSocketChannel.class) 
    .localAddress(new InetSocketAddress(port)) 
    .childHandler(new ChannelInitializer<SocketChannel>() { 
        @Override
        public void initChannel(SocketChannel ch)
            throws Exception {
            ch.pipeline().addLast(
                new EchoServerHandler());
        }
});
ChannelFuture f = b.bind().sync();

这里使用了建造者模式将一个复杂对象的构建与它的表示分离, 使得同样的构建过程可以创建不同的表示
图:
225.png

本例中ServerBootstrap具体建造者,其继承的AbstractBootstrap抽象建造者,返回的ProductChannelFuture,我们的调用代码是Director。通过建造者模式,我们在构建复杂对象的时候不必一次性确定全部参数(硕大的构造函数),而是根据需要一步一步构建一个完整的对象(也比一个一个调用setter的方式节省代码而且美观),所以每一个构造过程函数都要返回一个完整的对象this。说到这就不得不说一说装饰器模式动态地给一个对象添加一些额外的职责。就增加功能来说, 装饰模式相比生成子类更为灵活
图:
224.png

装饰器模式的ConcreateDecorator可以在不改变ConcreateComponent给其添加一些新功能或者新特性,就像建造者模式,每一步建造过程都在给自身添加新功能或者新特性,也就是说如果看做装饰器模式,那么ConcreateDecoratorConcreateComponent都是Builder自身,而且添加过程和得到的结果都相对稳定,所以建造者模式是一种特殊的装饰器模式。装饰器模式在java的IO类中应用广泛。
与装饰器模式非常相似的模式有适配器模式将一个类的接口变换成客户端所期待的另一种接口, 从而使原本因接口不匹配而无法在一起工作的两个类能够在一起工作
图:
231.png

代理模式为其他对象提供一种代理以控制对这个对象的访问
图:
232.png

外观模式要求一个子系统的外部与其内部的通信必须通过一个统一的对象进行外观模式提供一个高层次的接口,使得子系统更易于使用
图:
233.png

这4个模式都是将原本的对象进行包装转换实现另一些功能,不同的是:

  • 装饰器模式关注于在一个对象上动态的添加方法,增加新的行为,实现新功能
  • 适配器模式关注于将一个类的接口转换成客户希望的另外一个不同的接口,使得原本接口不兼容而不能一起工作的那些类可以兼容
  • 代理模式关注于为其他对象提供一种代理以实现对这个对象的访问控制,代理与被代理对象实现相同的接口
  • 外观模式关注于为子系统中的一组接口提供一个一致的界面,此模式简化接口,使得子系统更加容易使用

netty处理与客户端之间的消息往来使用的ChannelPipelineChannelHandler模型是一个典型的命令模式将一个请求封装成一个对象,从而让你使用不同的请求把客户端参数化,对请求、排队或者记录请求日志,还提供命令的撤销和恢复功能)的使用
图:
230.png

在java中,常常将ConcreteCommandReceiver合并为一个对象,这样每个命令都完成一个职责,而不是根据接收者的不同完成不同的职责,client调用时就不用考虑接收者是谁。模式如下:

//非俭省模式定义 接收者 和 命令
Receiver receiver = new ConcreteReciver1(); Command command = new ConcreteCommand1(receiver);
//俭省模式 只定义一个发送给接收者的具体命令
Command command = new ConcreteCommand1();
//首先声明调用者Invoker
Invoker invoker = new Invoker();
//把命令交给调用者
invoker.setCommand(command);
//执行
invoker.action();

java Runable就是一个绝佳的示例:

//定义一个具体的命令赋值给抽象的命令引用,里面的run可以理解为receiver
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println(2);
    }
};
//声明调用者Invoker并把命令交给调用者
Thread thread = new Thread(runnable);
//执行
thread.start();    

netty中一个ChannelInboundHandler收到消息后调用ChannelHandlerContext(继承了ChannelInboundInvoker)的fireChannelRead去调用下一个ChannelInboundHandlerchannelRead方法。
实际做法中,ChannelInboundInvoker是抽象的Invoker,而AbstractChannelHandlerContext才是真正的具体Invoker,其static void invokeChannelRead(final AbstractChannelHandlerContext next, Object msg)方法(也就是执行方法)调用了下一个(next)ChannelInboundHandler(也就是receiver)的channelRead方法(也就是具体命令

ChannelPipelineChannelHandler模型也是一个标准的责任链模式使多个对象都有机会处理请求,从而避免了请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止
图:
236.png

Handler就是netty中的ChannelHandler接口,消息处理的每一个ConcreteHandler(一般由我们自己实现)都会去调用下一个ConcreteHandler。

ChannelPipelineChannelHandler模型实际上还是一个非典型的模板方法模式定义一个操作中的算法的框架, 而将一些步骤延迟到子类中,使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤
图:
234.png

也就是说,netty规定了处理客户端的连接的算法是先用一些列抽象的ChannelInboundHandler处理(比如解码、解密),然后再由一系列抽象的ChannelOutboundHandler处理(比如编码、加密),但是具体的Handler实现是我们自己加入的,如上面代码改一下:

ch.pipeline().addLast(new DecodeHandler());
ch.pipeline().addLast(new EncodeHandler());
ch.pipeline().addLast(new BusinessHandler());

说他非典型主要是模板方法模式的算法的框架是确定的(比如确定了要解码、存储、编码三个步骤),不确定的只是细节,但是在netty中不仅细节,算法框架本身我们都可以自己修改(可以加入很多的Handler)。

其他

桥接模式抽象和实现解耦,使得两者可以独立地变化
图:
239.png

Abstraction的主要职责是定义出该角色的行为,同时保存一个对Implementor的引用,该角色一般是抽象类;
Implementor是接口或者抽象类,定义角色必需的行为和属性;
RefinedAbstraction引用Implementor对Abstraction进行修正;
ConcreteImplementor实现接口或抽象类定义的方法和属性。
所谓将抽象和实现解耦就是说抽象与实现不是直接通过继承来强耦合,而是通过对象组合构成的一座桥来实现弱耦合。
最经典的桥接模式就是JDBC,JDBC为所有的数据库提供通用的接口(Abstraction), 一个应用程序可以根据需要选择的驱动程序(Implementor), 通过具体的驱动程序(ConcreteImplementor)向的数据库发起请求. 这个过程就是Abstraction把行为委托给Implementor的过程,这样一来应用程序和具体的驱动程序都可以独立变化

中介模式用一个中介对象封装一系列的对象交互,中介者使各对象不需要显示地相互作用,从而使其耦合松散,而且可以独立地改变它们间的交互
图:
240.png

Mediator 定义统一的接口,用于各Colleague之间的通信;
ConcreteMediator 通过协调各Colleague实现协作行为,因此它必须依赖于各个Colleague;
Colleague 都知道Mediator,而且与其他的Colleague的时候,都通过Mediator协作。
中介者模式的优点就是减少类间的依赖,把原有的一对多的依赖变成了一对一的依赖,Colleague只依赖Mediator,降低了类间的耦合。
最经典的中介模式是MVC框架的运用,其中的C就是一个中介者,把M和V隔离开,协调M和V协同工作,把M运行的结果和V代表的视图融合成一个前端可以展示的页面,减少M和V的依赖关系

备忘录模式在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,这样以后就可将该对象恢复到原先保存的态
图:
241.png

Originator 记录当前时刻的内部状态,负责定义哪些属于备份范围的状态,负责创建和恢复备忘录数据;
Memento 负责存储Originator发起人对象的内部状态,在需要的时候提供发起人需要的内部状态;
Caretaker 对备忘录进行管理、保存和提供备忘录。
最经典的备忘录模式就是jdbc的事务功能,因为要提供回滚,所以必然要用备忘录模式。

访问者模式封装一些作用于某种数据结构中的各元素的操作, 它可以在不改变数据结构的前提下定义作用于这些元素的新的操作
图:
242.png

Visitor 是抽象类或者接口,声明访问者可以访问哪些元素,具体到程序中就是visit方法的参数定义哪些对象是可以被访问的;
ConcreteVisitor 影响访问者访问到一个类后该怎么干,要做什么事情;
Element 接口或者抽象类,声明接受哪一类访问者访问,程序上是通过accept方法中的参数来定义的;
ConcreteElement 实现accept方法,通常是visitor.visit(this),基本上都形成了一种模式了;
ObjectStruture Element产生者,一般容纳在多个不同类、不同接口的容器,如List、 Set、 Map等,在项目中,一般很少抽象出这个角色。
访问者模式可以将数据的构成与使用方法解耦,扩展性很好。

省略的设计模式

组合模式说白了就是个树形结构;
迭代器模式基本没有人会自己实现了;
解释器模式使用的很少;

附录——六大设计模式原则

所有的设计模式无非都是这几个原则的体现(当然有些会违背),这些原则指导着我们写出更健壮、稳定、易维护的程序。

  • 单一职责原则:应该有且仅有一个原因引起类的变更,但是这“一个原因”怎么定义需要我们根据业务自己拿捏
  • 里氏替换原则:所有引用基类的地方必须能透明地使用其子类的对象,记住要确实有is-a的关系才用继承,否则就使用依赖、聚集、组合的方式
  • 依赖倒置原则:高层模块不应该依赖低层模块(原子逻辑), 两者都应该依赖其抽象,抽象(接口)不应该依赖细节(实现类),细节应该依赖抽象,更加精简的说法就是“面向接口编程”
  • 接口隔离原则:类间的依赖关系应该建立在最小的接口上,也就是说接口尽量细化
  • 迪米特法则:也称最少知识原则,一个对象应该对其他对象有最少的了解,知道的越多耦合就越高就越不容易修改
  • 开闭原则:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭,就是说我们的功能变化要通过扩展来实现而不是通过修改已有代码实现,这样系统稳定性才更高,也更灵活

感谢设计模式之禅HeadFirst设计模式,这两本书随便选一本看完都可以。
阅读原文,作者:MageekChiu



本文由 mageek 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。
此文章共有条评论, 人参与 |Powerd By Angboo