C语言中的手动功能切换
正在编辑 - LMP 在企业组织中应用Scrum框架时,功能切换是在每次冲刺后提供潜在可发布软件的关键。
早在2007年,我就写过一篇文章,介绍如何在不篡改需要保持原样的遗留代码的情况下为重构代码做准备。最近,我发现这种设计模式可能对其他用途很有用,特别是Martin Fowler所描述的功能切换。在企业组织中应用Scrum框架时,功能切换是在每次冲刺后提供潜在可发布软件的关键。
下面是我在为Systematic开发C和C++软件时写的原始文章的修饰版。尽管代码样本是用C语言写的,但其基本模式适用于从Cobol到Java的所有编程语言。
问题的范围
想象一下:由于一些奇怪的原因,你最终陷入了这样的境地:你有一大堆意大利面条代码(也被称为遗留代码),而你有一个功能请求,要从根本上扩展这一大堆代码的功能。你是怎么做的呢?很明显,至少有以下三种方法可以解决这个问题:
- 拒绝这个请求
- 黑掉遗留的代码,以应对这个请求
- 重构遗留代码以满足新的编码标准
案例1和2有一个直接的短期效果。案例1:没有赚到钱,客户可能永远失去。案例2:钱是安全的;但是,进一步的开发和维护将(随着时间的推移)是非常痛苦的。案例3是最理想的解决方案,也是大多数开发者的选择(也是大多数CEO的最坏情况):它对短期经济有根本性的影响,并可能将开发过程拖得很长。
然而,也有第四种选择:
- 将传统的 "接口 "变成Facade和Adapter模式的组合。
- 引入一个数据驱动的运行时配置,根据上下文切换功能
这个方案将保留大部分的传统代码库,而只是引入一个轻量级的抽象层。这在目前听起来可能很模糊,但在本文的剩余部分,我将对如何在不重写代码的情况下重构遗留代码进行介绍。
回到案例2,我们会重构代码库以适应新发现的需求,在新功能代码的位置和所有调用应用程序的位置都会引入变化。这是一个繁琐的解决方案,而且很可能会在过去工作无误的程序部分引入错误。然而,本文所描述的方法将尝试描述一种在不篡改遗留接口的情况下实现新功能的方法。这意味着所有的调用程序保持不被修改,但对新功能代码的访问被一个门面所隐藏。
设计模式
首先,什么是门面模式?在维基百科上搜索,我们会发现以下定义。
"在计算机编程中,门面是一个对象,它为更大的代码体(如类库)提供一个简化接口。它可以:] [它可以
- 使软件库更容易使用和理解,因为门面有方便的方法来完成普通任务。
- 出于同样的原因,使使用该库的代码更易读。
- 减少外部代码对库的内部工作的依赖,因为大多数代码都使用门面,从而使系统的开发更加灵活。
- 用一个设计良好的API来包装一个设计不良的API集合"。
其次,什么是适配器模式?再次,搜索维基百科会告诉我们。
"在计算机编程中,适配器设计模式(有时被称为包装器模式,或简单地称为包装器)将一个类的一个接口'改编'为一个客户所期望的接口。适配器通过将自己的接口包裹在一个已经存在的类的接口上,使通常不能一起工作的类也能一起工作。"
这两种结构模式根据定义是通用的,可以应用于任何开发的代码。在范围层面上,最适合在设计和实现一个组件时部署这样的模式,而不是在给现有组件添加功能时部署。许多程序员不得不处理在设计模式之前的时代编写的源代码,因此没有刻意地应用任何模式。引入或识别这样的模式往往需要昂贵的重写或对代码库进行重大重构。在下面的章节中,我们将讨论以最小的代价进行重构的可能方法。
这两种结构模式按定义是通用的,可以应用于任何开发的代码。在范围层面上,最适合在设计和实现一个组件时部署这样的模式,而不是在给现有组件添加功能时部署。许多程序员不得不处理在设计模式之前的时代编写的源代码,因此没有刻意地应用任何模式。引入或识别这样的模式往往需要昂贵的重写或对代码库进行重大重构。在下一节中,我们将讨论以最低成本进行重构的可能方法。
引入模式
在为新组件功能准备遗留代码库的过程中,第一步是确定所有可行的入口点。看一下遗留的代码库,有两个基本的结构,即代码是如何交互的。
1.1.多个客户端和一个入口点
多个客户端,一个入口点,展示了最简单的情况,一个具有(或多或少)明确定义的接口的代码库。这个接口可能由一系列的自由函数组成,或者集中在一个普通的类中。在这两种情况下,代码结构已经拥有了Facade模式的派生,并且已经准备好进行修改。
2.2.多客户和多入口点
多个客户端和多个入口点,显示了一系列客户端如何通过许多入口点与一个共享组件进行交互。这是更困难的情况,必须执行以下任务。
- 确定入口点(可以由链接器以编程方式完成,即从链接器选项中删除遗留的代码对象)。
- 在以下解决方案中做出决定。
- 决定遗留代码中的入口点是否足够接近,可以移到一个共同的位置(也许甚至是一个共同的类)。
- 如果入口点之间的差距太大,确定修改入口点的底层代码可能产生的副作用,并隔离出独立的接口。
案例2.a的一个原始例子是一组用于字符串操作的自由函数,它的实现分散在代码库中。将接口和实现移到一个共同的位置,就可以为入口点提供一个共同的接口。然而,它也引入了在一个中心位置修改底层代码的可能性,同时保持接口的完整性。
情况2.b的一个例子是一组用于字符串操作的自由函数和一组用于数据库访问的自由函数。这些函数在逻辑上相距太远,最好是分成两个独立的接口。
在确定了遗留代码的入口和接口之后,我们应该重新考虑接口的问题,并可能对其进行更新。在源代码中引入渐进式的 "改头换面 "是非常有意义的,也就是说,偶尔重构一下,让它们与使用情况保持同步。在案例2.b的例子中,可能不可能将这两块分开,因此,一个适配器可能会派上用场。
添加新功能
在为新功能准备好遗留代码及其接口后,我们现在来看看如何引入该功能。
公共接口是本文上一节中介绍的接口入口点。为了抽象出这个点下面的代码,我们引入一个适配器,解释公共接口并处理对底层实现的请求。适配器持有对遗留代码和新功能代码的引用或实例。在传统代码和新特性代码之间交替使用的机制被放在适配器中。可能有必要对现有的数据结构进行扩展,以保留其来源信息,即该值是来自于遗留代码还是新特征代码。
之前的代码
Java
typedef struct { char *text; size_t length; } data_t;
void str_analyze(data_t *data) { /// Put code here
}
之后的代码
Java
typedef struct { char *text; size_t length; unsigned char origin; } data_t;
void str_analyze(data_t *data) { switch (data->origin) { case LEGACY: str_analyze_old(data); break; case FEATURE: str_analyze_new(data); break } }
前面的代码样本说明了如何添加交替适配器来处理遗留代码和新特征代码。请注意,数据结构已经用一个origin 变量进行了更新,而函数则保留了它原来的接口。在这里,函数"str_analyze"作为一个适配器,因为它翻译传入的请求,但也作为一个门面,因为它也负责委托工作。适配器是运行时数据驱动的,并根据上下文启用该功能。
如果...
如果不是所有的遗留代码都要为新增加的功能进行更新呢?在一个庞大的遗留代码库中工作,我们必然会有许多通用的功能,例如,将文件内容读成字符串或类似的通用功能。
在上一节中,我们引入了一个适配器层来处理传入的请求。修改这一点使代码可以直接访问遗留的代码,同时仍然为未来的实现保持代码的开放。这在下面的代码示例中有所说明。
之前的代码
Java
typedef struct { char *text; size_t length; } data_t;
data_t * str_readf(const char* filename) { /// Put code here
return data; }
之后的代码
Java
typedef struct { char *text; size_t length; unsigned char origin; } data_t;
data_t * str_readf(const char* filename) { return str_readf_old(filename); }
结束语
简而言之,这篇文章提供了一个小例子,说明如何用新的和闪亮的功能来扩展现有的代码库。在处理商业代码时,我们经常会遇到这样的挑战:在非常古老和非常混乱的遗留代码库中实现一个新的功能。这些代码很可能是在一个不重视设计模式和可维护性的时代写成的。按照本文的简单指导,应该可以在不破坏现有功能的情况下无缝扩展遗留的代码库。使用本文描述的方法,我们将不得不处理以下问题:
- 可维护性:更新或修改遗留代码库或新功能代码都是可能的,而不会篡改对方。
- 可测试性:引入Adapter和Facade模式会带来一层抽象,使得用单元测试来测试底层代码成为可能。
- 灵活性:Facade模式允许开发者在不改变接口的情况下改变底层代码、基础设施等。
- 功能切换:允许特定的功能对选定的用户或环境可见。