随笔 - 106  文章 - 2  评论 - 2017  0

各位小牛大牛老鸟菜鸟们好,欢迎参观我的设计模式世界。这个世界我已经总结多年了,现在才刚刚成型。But I have a dream,梦想所有开发者都能一夜之间认清所有设计模式,还幻想以后大家认识设计模式时,必首先google本文,嘿嘿。

前辈同仁们已经总结过很多,至今首页上设计模式的文章仍然层出不穷。但我总认为,在GOF的23个设计模式提出多年了,该需要些变化和扩展了。特别适用于.NET(或Mono)的设计模式,好像没有系统的总结。新年伊始,推出这篇总结,我个人不喜欢人云亦云,对设计模式的整体,以及其中一些模式,有独特的理解,欢迎指点。

引用阿彬同学对术和道的论述在真正好的程序员心中,设计模式是“术”,设计模式背后的用意才是“道”。为什么要用某个模式,是为了解决什么问题?紧耦合?可测性差?扩展性差?他们写代码时,心中已无设计模式,用心设计、简单设计;在可测性、可扩展性和复杂程度之间做巧妙的权衡取舍;当他们写完代码,已经不知不觉合理地使用了若干设计模式。——其实我这句话也说错了,代码永远没有“写完”的一天,他们会不断重构它,重构出设计模式,或者重构掉设计模式。

本文所列的设计模式基于维基百科,有些我认为是鱼目混珠,另外补充了几个模式。我认识设计模式的方式有点不同,不想照搬一个模式的定义,更关心它的特征,即长得像什么,能做什么,为什么要这样做,这应该更方便新人理解。此外,还设法注意不同设计模式之间的联系。由于时间篇幅关系,无法贴出太多代码,不过会指出多数模式在.NET BCL中的实现。

设计模式一般分为创建、构造、行为三类。我遵循这个分类,而做了一点调整,如Builder模式我放到构造分类中。 加上并发模式,共四类。并发类的模式稍后再补充。

除这个传统的分类方式外,我会从另外三个角度为设计模式概括总结。

第一个角度就是语言的角度,本文主要基于C#/VB.NET(Mono)。设计模式并非架构模式,它与语言特性紧密相关。不少模式在不同语言实现不同,比如单例模式。有些模式则是某种语言独有的,比如RAII是C++专用模式。

第二个角度,是开发领域角度。编程开发可简单分为两大领域,即框架开发和应用开发。明白这两个领域的区别和联系,对理解设计模式的实现非常重要。在框架开发中,设计模式应用频率要高得多,有些模式基本上只出现于框架中。做应用开发,经常会“应用”框架实现的设计模式,不少人用了而也稀里糊涂地不知道。

“应用”可能不算很确切的词儿,还是来个图吧,表示通常情况下,框架层和应用层合作的设计模式完整实现:

image

我还是先举个最简单的Entity Framework的例子吧,免得被鄙视。如今EF比较钟情Code First,代码如下:

public class PersonMap      //这是对抽象的扩展
: EntityTypeConfiguration<Person>   //这是EF提供的抽象
{
    public PersonMap()
    {
        this.HasKey(p => p.IDCard);  //设IDCard为主键属性
    }
}

public class MyDbContext : DbContext
{
    public MyDbContext() : base("Entities") { }

    /// <summary>
    /// 在这个方法里能做的事情,就是API
    /// </summary>
    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {           
        modelBuilder.Configurations.Add(new PersonMap());  //这自然就是“应用”
    }

    public DbSet<Person> Persons { get; set; }
}

 

至于这是哪种模式,希望把本文看完就明白了。

不是做应用开发就不必了解设计模式,恰恰相反,做应用开发因为接触得少,更需要主动了解设计模式,以免成日陷于业务海洋中成了砖家,专门搬砖盖永远盖不到顶的业务大楼。而且有一些模式,应用开发中用的更频繁。

不管是应用还是创建完整的实现,都可以说使用了这个模式。面试官问你用过哪些模式的时候,随便拾三两个例子就行,别让人家太崇拜你,嘿嘿。

我们从上图可见,设计模式实现分为四部分。API和抽象可以合算一部分,应该称之“采用某设计模式的解决方案”,简称解决方案吧。然而不是所有模式的解决方案都需要抽象部分,没有抽象自然也没有扩展。根据设计模式是否提供扩展机制这点,可以分为扩展模式和非扩展模式,这就是第三个角度。可扩展的模式理解难度略大,不过也更有趣。

一般地讲,构造模式都是可扩展的,行为模式都是非扩展的,而创建模式二者兼有。

一、创建模式 Creational patterns

1. 工厂方法模式 Factory Method

这个模式很简单,就是不直接通过构造函数(用new关键字),而是通过调用某个方法(偶尔用属性)得到一个对象。比如WebRequest.Create(url)方法,返回HttpWebRequest对象。

为什么要用方法而不是new一个对象呢?三种原因,一就是不想创建新的对象;二是创建新对象的逻辑比较复杂,复杂导致出错创建失败概率较高,而构造函数应该尽量避免异常(疑似沿自C++的潜规则),所以习惯上推荐用方法创建,如Image类FromFile和FromHandle方法;三是为了方便,比如 Stopwatch.StartNew()方法和Task.Factory.StartNew(action)方法。

下面许多其他模式都会用工厂方法,因为我们往往不知不觉就在用它,就不列举了。这是一个最基础最底层的模式。

灵活应用泛型可以让大大减少创建工厂方法的工作,如用Comparer<T>.Create(Comparion<T>)方法,不用写不同类型的比较器类,就能创建出针对不同类型的比较器。

2. 抽象工厂模式 Abstract Factory

工厂就是指那种专门提供工厂方法的类。如果业务逻辑复杂起来,而如果想根据需要,分别得到某一批而不是某一个类的实例,就需要有相应的一批工厂类。如果用抽象类或接口规定了这批工厂类的工厂方法,就是抽象工厂模式。一般来说,若工厂源自抽象工厂,其“产品”也继承自某抽象类或接口。

此模式比上个复杂得多,但其实很好理解。曹操曾吟,对酒当歌,人生几何。何以解忧,唯有杜康。他一定想不到如今还有拉菲产葡萄酒,茅台产白酒,青啤产啤酒。如果曹公再世,粉丝想为他写个程序,就可以定义一个抽象工厂:酒厂,拉菲等品牌继承自酒厂,产品白酒啤酒都是酒,继承自酒类。

上面提到的Task.Factory,是一个TaskFactory对象。像TaskFactory这种独立工厂不多见,因为若只要得到某个特定类的实例,通常像WebRequest/Image那样在自身加一个静态工厂方法即可。

这是个大家最熟悉的模式,被广泛应用在框架设计中,下表列了几个BCL的例子。这种模式很好识别,因为工厂类名多带Factory,和接口开头的I,成了标准,不过也有一些深藏不露。

抽象工厂

工厂类

产品定义

产品类

IHttpHandlerFactory

PageHandlerFactory

IHttpHandler

Page, StaticFileHandler  等

IControllerFactory

DefaultControllerFactory

IController

Controller

DbProviderFactory

SqlDbFactory
OleDbFactory

DbCommand, DbDataAdapter 等

SqlCommand/OleDbCommand…
SqlDataAdapter/OleDbAdapter等

IEnumerable<T>

List<T>, HashSet<T>等

IEnumerator<T>

Listt`1+Enumerator<T>
HashSet`1+Enumerator<T> (编译器实现)

IEnumerable<T>接口和List/HashSet,虽然很多人没想到,却是完全标准的抽象工厂及实现。上个模式提到的Compare<T>,其实也是抽象工厂的变种实现,实现IComparer<T>的类,如 StringComparer/GenericComparer<T>,不是产生对象,而是对对象作处理统计,其实算另一种设计模式,也可以认为是一个“工厂”。

传统上,该模式还需要一个获取具体工厂实例的工厂方法。在应用开发中,使用StructureMap等IoC框架可以替代这种方法,甚至无须明确调用工厂类。

3. 延迟加载模式 Lazy initialization

这也是我们不知不觉在用的模式。顾名思义,声明某个属性或变量时不立即初始化,直至用到才加载。这个模式用几行代码就能说明:

class ObjectWithLargeObjectProperty 
{ 
    LargeObject largeObjectField;

    public LargeObject LargeObject 
    { 
        get 
        {             
            if (largeObjectField == null) largeObjectField = new LargeObject(); //核心是该行 
            return largeObjectField; 
        } 
    } 
}

在用户较多的应用系统中,要考虑并发的影响。应该尽量采用Lazy<T>类(.NET 4.0+),可以选择并发时的创建对象策略。

4.单例模式 Singleton

让一个类只能有一个实例,一般通过静态属性提供。这种类都是管理系统全局运行时信息,有时也可以用静态类代替,而用单例主要考虑扩展性和可测试性。 Comparer<T>.Default就是典型的单例模式。过去以为像单例的是HttpContext.Current,只是一个像工厂的静态属性。

这种模式很常见。至于如何实现,首先必须让构造函数变成私有的private。我看到许多例子用Lazy Initilization,还有双重锁,个人很不推荐。因为这种全局单例肯定是系统运行必须且在一般系统启动时就会初始化,所以简单用静态构造函数或静态字段初始化即可,并保证并发性,就是Terry Lee的文章最后一种实现。

5.对象池模式 Object Pool

在应用开发中很少见,但框架开发中较多,因为使用该对象池的类一般涉及管理非托管资源。 想获取某个类的实例,优先从池中取,若需要新建实例,也放入池中,用完后只是标志其为空闲状态以备用。

ThreadPool类是一个摆明典型实现。还有DbConnection及其子类,使用连接池。在不是.NET,但息息相关的IIS中会用到ApplicationPool。

所有Object Pool都有一个大小限制。无论是实现还是引用该模式,都要注意尽可能地释放池中对象。对于数据库连接要合理关闭,对于Asp.NET可以考虑用 IHttpAsnycHandler和IAsyncController(MVC)减少ApplicationPool的负载。

6. 享元模式 Fly Weight

觉得该模式类似对象池,都是为了复用对象,多用于框架设计中。是一种创建模式,所以在放在这里。区别一是该模式不会指定对象数量上限,二是保存共享对象的数据结构类似于字典,通过工厂方法传递过来的key,找到字典的既有对象,若没找到则新建并存入字典中。

这是应用缓存的一种场景。如果有过期控制需求的话,可以直接用MemoryCache类保存对象。

最典型的应用就是String类型。为了节约内存,相同的字符串将指向相同的String实例(也有例外)。复用String的存储器有个专用名字-拘留池,不过没有数量大小限制。

7. 其他

多例模式 Multition:应于单例,但从没见过哪里有标准的实现。一个类提供多个返回自己的静态属性倒很常见,如Color.Blue, Brushes.Red, 但不应该禁止别人创建新的实例。

原型模式 Prototype:把一个类实例当原型,通过复制创建新的实例。BCL中没见过,而应用开发或许偶尔用一下不足以称得上模式。因为一般像FlyWeight那样引用到那个“原型”就可以了。

Resource Acquisition Is Initialization:简称RAII。以前没听说过,看代码是C++中的模式。

 

构造模式 Structral Patterns

1. 组合模式 Composite

这种模式将某些对象由许多元件,按树状层级组合而成。应用很广泛,各种UI界面(Winform/WPF/Asp.Net Page),LINQ2XML,Lamda表达式等等,想必大家都很熟悉,不多作介绍了。

同样该模式识别性也很高,甚至不论是否有开发经验都能抽象出来,大家对树状结构太熟悉了。

但是该模式实现容易,却有许多深入的挑战:要考虑树状结构如何最优雅地初始化,最简捷明了地增删改查。XLINQ这方面非常友好,值得借鉴,当然如果实现JQuery那种选择器就更给力了。有的树状结构,比如地理信息,还要考虑如何存储。

2.  建造者模式 Builder

传统定义:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。

个人理解该模式做了扩展,传统定义重点是要构建的复杂对象,我理解的该模式只应关注Builder“构建者”。只要符合这样特征:有一个定义干Build流程,及组织这些流程的方式的抽象(类或接口),和将这个抽象各步流程具体实现的Builder类。

为什么必须扩展对Builder模式的理解呢,原因有三:

1.应用设计模式是为了在OCP原则下尽量复用代码,将不变的部分抽象,对可能变化的部分,提供封装途径。

2. 如果对象复杂到需要用Builder创建,不可能地每步都产生组件。一定会有许多处理过程,虽然不产生组件,但提供注册,通讯,验证,测试,存档等功能,这些过程也是可以被抽象出来定义的,但传统的Builder模式却忽略了这些“无足轻重”的过程。

要么那是一个理想化的模型,要么或许现实中真有那种创建时根本无须和外界打交道的对象(对象越复杂,可能性越小)。可能是我个人经验太浅,还想象不到。

3. 既然需要在Builder中实现不产生组件的过程, 那么渐渐地,如果生产“产品”占整个业务比重变得很小甚至为0, 显然这仍然是Builder模式。

传统认为Builder一定要出“产品”,每一步产生“产品”的一个组件。然而,在抽象工厂模式中所述,工厂并不一定要有“产品”,只须包含加工处理逻辑。这里的“产品”其实指编程语言可以描述的对象,如果一个处理过程不出“产品”,并不代表没有成果,可能在修改数据库,操作文件,调用第三方API等等,这些事不能也不必描述为“产品”对象。

3. 装饰模式 Decorator

该械一般通过装饰类的构造函数或工厂方法传入待装饰的对象,得到增加了新功能的对象。 这个模式也很好理解,我觉得可以和”适配器“合并。

常用的例子就是DeflateStream装饰FileStream和MemoryStream。

.NET语言应该提供更好的支持,可以允许复制一个对象的属性到另一个对象上,只要类型和名称相同。

4. 适配器模式 Adapter/Wrapper/Translator

传统定义的构造模式大都晦涩难懂,只有这个简单,就是用现有的类型,再继承一个接口,创造的新类型。为了增加功能,这种做法再自然不过,算一个模式很勉强。

我想不出更好例子:不知道大家知不知道数据集,.NET2.0就有了,现在在VS中添加新项还能选它。它会连接一个数据库,生成一些继承DataTable 的实体类集合,就是AEF的DbSet<T>一样,并实现IEnumerable<T>接口。

还有就是.NET 4.0,最常用的List<T>类现在实现两个新接口: IReadOnlyList<T>和IReadonlyCollection<T>,这是为了兼容WinRT。不过由于新接口的内容已经包含在实现过的IList<T>和ICollection<T>中,只是做为标记。

流行的名称叫适配器模式,不过值得注意的是BCL中有两个叫Adapter的类:ControlAdapter和DbDataAdapter,其实和该模式的没任何关系。然而,我感觉这两个Adapter命名倒用得更恰当,但适配器更适合于下一个模式,叫Wrapper的话又容易和Decorator混淆,或许 Translator最好的名称。ControlAdapter其实是下面桥接模式的一个实现,DbDataAdapter则可以算是服务模式。

一个对象如果要在List中排序,须实现IComparable接口,以适配.NET的潜规则。

5. 桥接模式 Bridge

原定义是:将抽象部分与实现部分分离,使它们都可以独立的变化。说白了,就是老鼠吃大米大象吃树叶,现在不是有转基因了吗,动物各器官都变成可以嫁接的了。 把两者的胃分离出来,使之动态变化。 我认为这种才是真正的适配器模式。

正如上面的ControlAdapter,如果我们在DataTable加入DbDataAdapter就完全符合该模式。

其实这个模式,还隐含这样一层意思,负责实现的这个Adapter,或者Handler,或者Operator,对象主要的业务逻辑都由其处理。不然也不必用这种方式,可能采用别的模式了,因为对象提供修改自身逻辑的扩展方式有许多种,下面模式会涉及到

6. 代理模式 Proxy

一看到Proxy这个词,就想到用得最多就是通过VS工具自动生成的Service的代理类。还有一种情况调用COM API,我们得自己声明一个代理方法,其实真可以也由VS自动生成。对于NET来说,如果只是调用本域的托管资源,无须此模式。

这个模式由两部分组成:代理定义以及代理的实现机制。.NET优势是,对于应用开发,极少碰到在需要自己实现代理机制,经常我们用了代理都没觉得,比如跨域(AppDomain)调用。然而,我们也要了解不同代理方式的特性,比如WCF只支持DataContract的类型传输。

我有个大胆的设想,既然WCF可以整合各种网络服务,甚至还有进程通讯,不知道能不能推出一种代理框架,把COM,跨域,网络的访问全部整合。

7. 泛型模式

这是一种利用.NET对泛型的强大支持,使子类型的泛型成员无须强制转换类型使用。 这很早就有人提出,我也有文章论述过。这是.NET独有的模式,虽然有人说这是一种反模式,却优雅又好用。

8. 其他

门面模式 Facade: 我实在没瞅出这怎么算模式,哪里抽象了不变,哪里封装了可变。

Front Controller:表示MVC或MVVM的架构模式,而非代码层的设计模式。

三、行为模式 Behavior Patterns

这类模式数量较多,因语言差异很大。下面列举这些常用模式,可能还有不少漏掉的,欢迎补充。

1. 责任链模式 Chain of responsibility

以往对这个模式没什么印象,研究代码后,感觉描述了一个自动状态机。

2. 解释器模式 Interpreter

这个比较常见,我理解就是将一系列逻辑或一个对象的描述存储在一个字符串中,例如ConnectionString格式化输出。虽然增加了出错的可能,好处嘛,就是一目了然,便于保存。像ConnectionString有工具帮忙生成,格式化输出倒是比较纠结,用的时候往往要查。

3. 中介模式 Mediator

看定义,只要我们项目的BL层根据Model,把业务封装分为不同的helper就是中介模式。这种模式是贫血实体设计的必然方案,虽然称得上模式也很勉强,不过还有一些项目连这一点都做不到,比如会把所有业务代码几千几万行地塞进一个类里。

个人观察,中介者与桥接(适配器)区别是,后者业务实体内部含有适配器,前者没有规定;还有前者应该负责对象全部的与外部交互,后者只负责一方面。

4. 命令模式 Command

意思就是用传递不同命令,改变系统的处理逻辑或策略,也可以说包含了Strategy模式。状态改变,策略改变的例子很多,这种命令在.NET中常常以枚举形式出现,比如LoadOptions和SaveOptions(Linq to XML) ,SearchOptions(IO),还有ConflictMode和RefreshMode(EntityFramework),好像这种枚举多以Options或Mode为后缀。

5. 策略模式 Strategy & 状态模式 State

为什么把这两种模式一起说呢,因为实在这两种模式没有本质区别,都是通过设置某个属性,来改变一个类处理逻辑(静态或实例均可)。但与命令模式不同的是,这两种模式是静态的,即在改变类逻辑后,以后所有的逻辑处理都遵循此次改变,直到再次改变。而命令模式是动态的,即在调用某方法时动态传递命令参数。每次调用之间命令参数不必相同,一次调用不影响下一次调用。

改变类逻辑的属性,如果状态模式,一般是枚举,比如Control.ClientIDMode属性;如果是策略模式,一般是委托或接口类型,比如Dictionary.Comparer属性。不过在BCL中,一般这种策略只在构造函数中定义。

值得注意的是,在多数非.NET语言中,策略模式也包含动态调用。对于.NET,这种模式部分已被优雅的委托取代,比如Linq里面许多方法,用处如此之广以致称不上一种模式了。没有委托的C++,若要传递处理逻辑或方法,要么通过指针(没语法支持),要么用此模式。Java既无委托又无指针,此模式成了生活必需品。

6. 规格模式 Specification

这种模式运用场合一般限于建立业务查询条件,是种非常优雅的模式,老赵的文章有详细论述。在我看来,算是Composite和Strategy模式的组合应用。对于非.NET来说,实现起来就没那么舒服了。

7. 空对象模式 Null object

提供一个默认的空对象,避免对null判断,消除空引用异常,如String.Empty和Stream.Null。对于集合类,这么做很有意义,我觉得.NET可以提供更好的语法支持。对于领域实体类,好像只是让代码变得更流畅(Fluent),应该还有发展潜力,参考当一个对象什么也没干时

我发现在ORM中,Null-Object能起到很好的作用。在加载实体时,为性能考虑我们常不会加载其外键属性实体,但现实中这些属性是存在的,这就与代码中读取属性一个光秃秃的null形成了矛盾。使用Null Object,是一个很好的妥协,防止业务逻辑变化时导致理解错误引起bug,参考上篇。

8. 销毁模式 Dispose

这是.NET独有的模式,目的是保证GC无法回收的非托管资源,在利用完后得到释放。其方案是CLR要求我们为需要回收非托管资源的类,实现IDispose接口,实现方式要如下(摘自《.NET框架设计》):

public class ComplexResourceHandler : IDisposable
{
    IntPtr buffer;      //非托管内存指针
    SafeHandle resource;    //托管资源
    bool disposed;      //销毁状态

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (this.disposed) return;

        if (disposing)  //有条件释放托管资源
        {
            resource.Dispose();
        }
        Marshal.FreeHGlobal(buffer);    //保证释放非托管资源(简化省略了判断)
        disposed = true;
    }

    ~ComplexResourceHandler()
    {
        Dispose(false);
    }
}

这样即使不使用using和try-finally语法,也能保证在GC回收对象时清理掉这些资源。 园子里的铁哥,应该常与之打交道。这些类从构造上说,其实符合适配模式。另外,按规范访问已经dispose的对象,应该抛出ObjectDisposedException。这些搞得这个模式有点繁琐无趣,.NET如果能增加更强力的语法特性就好了。

9. 试行模式 Try-Parse

对于.NET来说,在正常业务操作失败时,抛出适当的异常是一种规范做法。虽然有一点性能负担,但能使代码更易维护。

然而某些情况下对特别频繁一些操作,则采用这种模式避免抛出异常而影响性能,将转化结果传递给参数,将成功与否作返回值。 常用的如Int32.TryParse(string s, out int result)和Dictionary.TryGet(T key, out K value)。

对于不支持异常的如C语言等,这是最普通不过的做法了。可以再次看出设计模式与语言特性息息相关。

10. 自选模式 Optional feature

这也是《.NET框架设计》提到的模式,中文名是我自己起的。主要用于框架开发。书上举了Stream的例子,继承Stream的类有许多,特性各异。有的可读写可定位,如MemoryStream,NetworkStream只读,如HttpOutputStream只能写。一般来说,设计模式都是尽可能地将变化抽象出来。对于某些复杂业务的类如Stream,如果增加IRead/IWrite/ISeek,太多抽象会导致易用性降低,实际开发中又无API需要这些接口适配,且还无法保证这些接口只被用于修饰Stream。于是就有这种模式,在一个类中为全部业务操作提供可重写的默认实现,并可查询这些操作是否在子类中得到支持。

可以比较一下使用IRead接口和可选特性的调用方式:

static void Process(Stream stream, byte[] buffer)
{
    //使用IRead接口
    var readImp = stream as IRead;
    if (readImp != null)
    {
        var firstByte = readImp.Read(buffer, 0, buffer.Length);
    }

    //使用Optional feature
    if (stream.CanRead)
    {
        var firstByte = stream.Read(buffer, 0, buffer.Length);
    }
}

 

对于框架和应用整体系统而言,这是一种反模式。然而对应用开发而言,提高了易用性,降低了复杂性。注意到.NET4.5实现了支持async的一系列方法,这种模式更显得方便。比如一个小超市,虽然购销管理效率不及专门的菜市场和五金店,对于顾客却非常便利。

其他

服务者模式 Servant:例StringComparer。类似桥接模式中的桥,或者Adapter。跟中介者模式也很难区分。 

访问者模式 Visitor: 这其实是策略模式的动态变种。Visitor是业务逻辑类,操作共同的一类对象。不过在.NET中,如前所述,已经没有什么特别之处,也称不上模式了。

迭代器模式 Iterator: 被foreach和yield取代

观察者模式 Observer:已经被事件机制取代。

备忘录模式 Memento:这虽然是一种不错的解决方案,可是应用场合很稀有,好像没太大意义记住。

模板方法 Template Method:只是面向对象继承的基本特性。

 

把主要的设计模式一网打尽地笼统概括了下,希望能帮助大家不再困惑于.NET的设计模式,也希望大家能多提出意见。

posted on 2015-03-16 21:25  小城故事  阅读(...)  评论(...编辑  收藏