所属文章系列:寻找尘封的银弹:设计模式精读
【动机】
我所见过的代码中,使用设计模式的并不多。如果这些代码能够做到从容面对变化,那它依然是好代码。
但在实践中,当我们面对需求变化的时候,会发现每次应对变化都需要很大的代码改动量,而且很容易出错。再加上缺少单元测试的保护,只能靠人工测试来验证代码是否有效,有些隐蔽的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