设计模式精读 ~ 单元测试的利器 ~ 抽象工厂

所属文章系列:寻找尘封的银弹:设计模式精读



【动机】

我所见过的代码中,使用设计模式的并不多。如果这些代码能够做到从容面对变化,那它依然是好代码。

但在实践中,当我们面对需求变化的时候,会发现每次应对变化都需要很大的代码改动量,而且很容易出错。再加上缺少单元测试的保护,只能靠人工测试来验证代码是否有效,有些隐蔽的bug就有可能从程序员、测试员手中溜过,而直接出现在用户那里。

我们都知道改bug的成本远比预防bug的成本要高,同时大部分程序员并不喜欢改bug,尤其是改那种“按下葫芦起了瓢”的bug,所以程序员急需找到一个方法来解决这些令人头疼的问题。

抽象工厂模式就是解决需求变化问题的一种方案。

我们先看一段未使用抽象工厂模式的代码,找找痛点在哪里:

void Client1::DoSomething() {

    file = FileAPI::CreateFile();

    ...

}

void Client2::DoSomething() {

    folder = FileAPI::CreateFolder();

    ...

}

void Client3::DoSomething() {

    configFile = FileAPI::CreateConfigFile();

    ...

}

注:Client1、Client2等是指系统中的某个类,它们使用FileAPI,就称它们为FileAPI的Client或叫客户代码。

从这段代码能看出,我们已经把文件系统的API作了封装,这很好。不过,当需求变化不断地到来时,这些看起来还不错的代码就遇到了麻烦:

1.第一次需求变化

我们现在遇到了一个新需求:为了提供安全机制,需要把系统中使用的所有文件都进行加密。

最直接的方法是:修改FileAPI类的每一个函数的实现代码,例如CreateFile、CreateFolder、CreateConfigFile,把每个函数中原有的不加密代码都删掉,新写一些加密的代码。如果有几十个这样的函数,那工作量就有点大了。

改过代码之后,又发现类名需要修改:FileAPI这个类的意义已经发生了变化,如果不改名,那么在其他程序员修改Clien1、Client2等处代码时,并不知道这些变化,还只是以为FileAPI只是对OS API进行了一个包装而已,那么就有可能写出错误的代码。所以应该把FileAPI类改为EncodedFileSystem,而且所有客户代码都跟着改一遍。

2.第二次需求变化

改完之后,测试通过,交给用户。过了一段时间,又有一个新需求要做:只有在用户设置为“需要加密”时才对文件加密,否则就不加密。

最直接的方法是:把刚才删掉的那些不加密的代码找回来,并在每个函数中加入if判断。就像下边的代码:

void EncodedFileSystem::CreateFile() {

    if (userConfig == ENCODED) {

        ...

    } else {

        ...

    }

}

此时,EncodedFileSystem这个类的意义已经发生了变化,所以类名应该再次修改,改为FileSystemWithPolicy。

面对第二次需求变化,大部分的代码修改都是重复性工作,谁喜欢这种编写代码的方式呢?所以有人就在想:有没有一种方法,当我们面对后续的需求变化时,让代码改动量保持最小、最安全?


【模式典型代码】

答案当然是有方法:使用抽象工厂模式。

为了实现抽象工厂,我们需要找到系统初始化部分的代码,例如类MyApplication,在这里写下工厂切换代码:

class MyApplication {

public:

    void Initialize() {

        if (userConfig == ENCODED)

            fileSystemFactory = EncodedFileSystemFactory::GetInstance();

        else

            fileSystemFactory = FileSystemFactory::GetInstance();

    }

    FileSystemFactory *GetFileSystemFactory() { return fileSystemFactory; }

private:

    FileSystemFactory *fileSystemFactory;

}

class FileSystemFactory {

public:

    virtual File *CreateFile();

    virtual Folder *CreateFolder();

    virtual File *CreateConfigFile();

    virtual File *CreateDataFile();

    ...

}

class EncodedFileSystemFactory : public FileSystemFactory {

public:

    virtual File *CreateFile();

    virtual Folder *CreateFolder();

    virtual File *CreateConfigFile();

    virtual File *CreateDataFile();

    ...

}

如此一来,再有切换文件系统策略的需求,例如一部分文件加密一部分不加密、文件压缩等,那么只需要增加新的实现类,老代码中只需要修改MyApplication::Initialize即可。

当然,客户代码也需要修改一下,例如:

void Client1::DoSomething() {

    file = myApplication->GetFileSystemFactory()->CreateFile();

    ...

}


【优劣对比】

有人会提出疑问:这次使用抽象工厂的代码修改量超过了未使用抽象工厂的代码量。

情况确实如此:

使用抽象工厂的代码量=系统初始化代码的修改 + 新需求引入的新工厂实现类 + 客户代码的修改

未使用抽象工厂的代码量=系统初始化代码的修改 + 新需求引入的已有类的代码修改。

与未使用抽象工厂的代码相比,使用抽象工厂的代码量确实多出了客户代码的修改部分,代码量虽然有点大,但并不难改,具体来说,把原来的FileAPI类或FileSystemWithPolicy类一删,就会导致编译错误,根据编译错误一一修改即可,简单快捷而且不会出错。

多做了这么一点工作,获得的回报却是很大的:

1.风险小:以后再有需求变化,只需改动系统初始化一处,最多是把新增的类加入进来。反观未使用抽象工厂的代码,它的修改量虽小,但它是在修改已有代码。而修改已有代码的风险远比新增代码要高、测试量也大,这是因为程序员需要花大量的时间去理解被修改代码的影响面,而这个影响面一般都比较大。

2.封装性好:通过GetFileSystemFactory()能看出,客户代码只需要知道有一个工厂来帮我CreateFile,而不需要知道用什么方式实现的,而原来的FileAPI的意思是它直接使用OS API,客户代码需要关心我处于的OS是什么以决定它的调用方式,或者什么时候该切换文件加密策略。

3.单一职责:每个工厂的实现代码非常清晰,互不影响,它只需要关心自己的实现即可。

4.方便单元测试:参见后文的详细讨论。


【模式定义】

抽象工厂模式(Abstract Factory):提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。

只有当我们希望通过工厂来构造对象时,才是抽象工厂模式,如果只是执行一个函数而不是构造对象,就可能是其他设计模式,例如策略模式。而策略模式也是解决需求变化问题的一种方案。


模式类图

注:该类图是在《设计模式》原书类图的基础上,增加了MyApplication,这样就能更清楚地表达出整个系统的运作关系。另外,AbstractFactory::CreateProductA和AbstractFactory::CreateProductB都应该像AbstractFactory那样以斜体字显示,但我在Visio工具中没有找到那个选项,请读者见谅!

在类图中,我们看到:

1.客户代码(Client)只关心两样东西:工厂、产品。而且这两样东西都是抽象的(Abstract),至于如何实现一个工厂、一个产品,客户不需要关心。

2.而关心使用哪个工厂实现(ConcreteFactory1)的,一般就是系统初始化部分(例如MyApplication::Initialize),也可能是某个设置界面的代码。

3.关心使用哪个产品实现(ProductA1)的,是某个工厂实现(ConcreteFactory1)。通过这种方式实现了一个工厂定制的是一个产品系列(即多个产品),换到另外一个工厂就是另外一个产品系列。

这样就实现了:从系统的某一个视角(例如Client)来看周围环境,它只关心最少的东西,也就是说,它知道的越少,受到各种变化的影响就越小。


【思维进阶(一):两个维度的变化】

每个设计模式背后都有一些原理在支撑。抽象工厂模式的背后是两个维度的变化:加密或非加密存储、切换文件访问策略。

注:此处的维度可以大致理解为方向。

Marin Fowler在《重构》中提到:“如果某个class经常因为不同的原因在不同的方向上发生变化,Divergent Change就出现了。”Divergent Change是指“发散式变化”,是该书中22种“代码坏味道”中的一种。

前文未使用抽象工厂的FileAPI类代码,受到两个方向的需求变化,即加密或非加密存储、切换文件访问策略的影响,当任意一个需求发生变化时,这个类都要进行修改。它符合“发散式变化”坏味道的定义。

发现了坏味道,就应该去修改,不要让坏味道演变成发酵甚至腐烂。而抽象工厂就是去除“发散式变化”这种坏味道的一种方式。加入工厂代码之后,工厂实现类如EncodedFileSystemFactory只负责加密算法,系统初始化部分如MyApplication::Initialize只负责切换文件访问策略。

在两个维度变化的背后就是单一职责原则,本文不展开对单一职责的讨论。


【思维进阶(二):灵活运用】

前文的代码,有两点与标准的抽象工厂模式有所区别:

1.把工厂类FileSystemFactory实现为单件。

2.抽象工厂基类FileSystemFactory并不只是一个接口,也包括一个默认实现。

这两条都体现了设计模式的灵活运用方式:并不是完全套用设计模式的标准形式。就像《设计模式》书中62页提到的:

注意MazeFactory仅是工厂方法的一个集合。这是最通常的实现Abstract Factory模式的方式。同时注意MazeFactory不是一个抽象类;因此它既作为AbstractFactory也作为ConcreteFactory。

解释一下:

按照前文类图的定义,AbstractFactory是指抽象基类,它并没有实现代码,ConcreteFactory是指抽象工厂的实现类。


【如何用于单元测试】

工厂模式对于单元测试来说,非常实用。

单元测试,既然叫“单元”,一般只测试一个类,一般是白盒测试。而在实践中,它可以测试多个类,有的测试框架做得比较好,可以让整个应用系统运行起来,就像是用户打开应用程序在使用时一样。

这时,单元测试就变成了集成测试,那我们可测试的范围就大大增加,从而可以模仿用户的行为来测试系统的整体行为,也就是可以使用黑盒测试的手段,此时,白盒测试与黑盒测试结合起来,效果非常好。

为了保证这种系统级别的单元测试代码可以运行起来,不单单需要测试框架的支持,还需要让被测试代码能够使用一些测试数据,而这些测试数据的来源就可以使用偷梁换柱的方法:把真实对象偷偷换成假对象,而这个假对象会提供测试数据,这就是业内流行的Fake或Mock的方式。例如:

class MyApplicationTest {

public:

    void Initialize() {

        fileSystemFactory = FileSystemFactoryMock::GetInstance();

    }


    FileSystemFactory *GetFileSystemFactory() { return fileSystemFactory; }

private:

    FileSystemFactory *fileSystemFactory;

}

class FileSystemFactoryMock : public FileSystemFactory {

public:

virtual File *CreateFile() { //返回一些假数据,例如File::name = “test1” };

    virtual Folder *CreateFolder();

    virtual CreateConfigFile();

    virtual CreateDataFile();

    ...

}

void TestCreateFile() {

    MyApplicationTest::Initialize();

    Client1::DoSomething();

    ASSERT(Client1::GetFile()->GetName() == “test1”); //注意:我并不使用完全真实的代码,因为这样表达意图更为明确

}


作于2018-5-11

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,633评论 18 139
  • 设计模式汇总 一、基础知识 1. 设计模式概述 定义:设计模式(Design Pattern)是一套被反复使用、多...
    MinoyJet阅读 3,922评论 1 15
  • 今天做各种肉的亚硝酸盐的测定。 老师早上拿来一份资料让我看。 我很高兴,因为看的过程中我有扣出不懂的概念弄清楚,然...
    亲爱的吴小仙阅读 75评论 2 0
  • 做了法律这行当,总得时时注意细节,假若忽视了细节,可能会生出严重的错误或者难以解决的麻烦,到那时便后悔莫及。 有一...
    夜语山林阅读 535评论 0 0
  • 雨落枫叶一片阅读 275评论 0 0