---
导语
糟糕的物理设计是对遗留大型系统中进行重构的非常棘手的一个问题,本文相机阐述了遗留系统中存在哪些糟糕的物理设计,它们对重构所带来的哪些恶略影响,以及我们在重构过程中应该如何处理这些问题。文中后面还介绍了关于物理设计的一些工具,其中包括本人开发的自动化头文件拆分工具。
1.物理设计VS逻辑设计
-
物理设计
物理设计主要是软件设计中的物理实体(文件)的设计,例如某个函数定义应该放在哪个文件中、某个函数是否需要Inline等,从物理设计看到的是系统中的大量文件实体。 -
逻辑设计
逻辑设计主要针对软件设计中的逻辑实体关系的设计,例如类之间的关系,Has a/use a/is a的关系,从逻辑设计看到的是大量的逻辑实体,如类,函数,结构体。
物理设计的主要目标是减少文件的物理依赖,而逻辑设计的主要目标是减少逻辑依赖。物理依赖更多的体现为编译时的依赖和链接时的依赖,物理依赖受逻辑依赖影响,但是又不局限于逻辑依赖,在一个大型软件系统中,物理设计和逻辑设计是完全不同的范畴,也是一个需要重点关注和考虑的问题。很多人认为物理设计主要考虑头文件的设计,其实是非常错误的,正确的物理设计不仅要考虑头文件的设计,还要考虑源文件的设计,从而达到编译单元的物理依赖设计。
2.糟糕的物理设计有哪些
2.1 巨型文件
我们这里谈论巨型文件,并不单指巨型头文件,就像物理设计并不是单指头文件的物理设计一样。巨型源文件和头文件一样也是一种非常糟糕的物理设计。在遗留的大型系统中,巨型头文件和巨型源文件随处可见,正是因为这种糟糕的物理设计导致我们的系统中代码构成一个巨大的网状物理依赖系统,如下图所示
物理依赖.jpg2.2 糟糕的文件封装
这里并不是谈论C++的域名空间概念,为了清楚的说明文件封装的概念,我们先介绍如下几个概念。
- 声明
- 定义
- 编译单元
- 内部链接
- 外部链接
一个声明将一个名称引入一个程序,而一个定义提供了一个实体,在程序中唯一描述。编译单元通常指编译过程中编译器看到的一个单位,在C/C++中,通常是以每个源文件为一个编译单元。如果一个名字对于他的编译单元是局部的,并且连接时与其他编译单元中定义的标示符名称不冲突,那么这个名字就是内部链接的。如果一个名字有外部链接,会产生外部符号,那么在多文件系统编译链接过程中,这个名字可以和其他编译单元交互。
结构体定义具有内部链接性,所以在每个需要使用当前结构体定义的编译单元都需要显示包含结构体定义的头文件,本质上每个编译单元中都有一个完整的结构体定义。同样C+ +中的类定义也是具有内部链接性,每个使用了类定义的编译单元都需要包含类定义的头文件,但是类定义中的非内联函数属于函数声明,所以类的非内联方法定义会产生外部符号,而类的内存布局定义并不会产生外部符号。根据以上分析可以发现,类定义本质上比较像结构体定义+ 函数方法的声明。我们在平时的C++项目中,经常看到的链接错误看到的经常是找不到某个类方法的定义而不是找不到某个类的定义,因为找不到类定义属于编译错误。
在讨论清楚这些之后,我们再看一下C语言系统中哪些具有内部链接,哪些具有外部链接:
1)内部链接:结构体定义,宏定义,typedef, enum, union
2)外部链接:全局变量定义,全局函数定义。
我们提倡内部链接的东西尽量放到源文件中,同时尽量减少定义具有外部链接名字,然而在遗留系统中经常并关注这些概念,从而导致系统中存在大量的这些问题。
- 全局变量随处可见
- 全局函数不加管制
- 宏使用泛滥
- 全局定义随处可见
2.3 巨型接口头文件
巨型接口头文件从严格意识上将并不会引起系统内各个模块的物理依赖,但是它也是一种非常糟糕的物理设计。
在我们的业务代码中,在出现下面几种现象时都需要包含一整个公共接口头文件。
- 如果只使用了公共头文件接口中的一个结构体,我们需要包含这个头文件。
- 如果只使用了公共头文件接口中的一个宏定义,我们需要包含这个头文件。
- 当包含的某个公共头文件编译又依赖于另外的公共接口头文件,那么我们还需要包含依赖的相应头文件。
- 公共接口头文件的结构体定义限制了前置声明,导致我们只使用公共头文件中某个结构体的指针或者引用的情况下也需要包含整个头文件。
前面几条规则很好理解,这里单独解释一下第四条。当我们在重构过程新增加的代码中,如果只使用某个结构体的指针或者引用的时候,通常情况下只需要前置声明即可,并不需要包含相应的头文件,这是C++减少物理依赖的一个非常重要的手段。但是现有的公共头文件中的结构体定义形式有下面两种:
typedef struct
{
WORD32 dwValue;
}T_StructName1;
typedef struct tagStructName2
{
WORD32 dwValue;
}T_StructName2;
显然上面这两种结构体定义是C语言的遗产,不能很好的支持前置声明。第一种方式T_StrictName1属于typedef重定义的名字,是不支持前置声明的。第二种方式仅支持tagStructName2的前置申明,但是与真实使用名字T_StructName2不一致,同样编译器会报错。所以下面给出的这种方式是很好的兼容老式的C语言和C+ +的一种方式,具体定义形式如下:
typedef struct StructName2
{
WORD32 dwValue;
}StructName2;
3.糟糕的物理设计的影响
3.1 复用已有功能模块困难
一般情况下,我们谈复用的时候,的确是希望复用一些软件中的一些逻辑实体,看似和物理设计无关,其实不然。真实的复用肯定要承载一定的物理实体上,如果我们期望复用某一个逻辑实体,需要把承载相应的逻辑实体的相关物理文件编译链接进来。
如下图所示,虽然functionA()与functionB(), functionC()在逻辑上没有任何关系,但是由于糟糕的物理设计,我们想复用functionA(),就必须把两个编译单元fileA.C和fileB.c两个编译单元作为一个整体才能够被复用。
//fileA.c
void functionA()
{
printf("hello world\n");
}
int functionB()
{
return functionC();
}
//fileB.c
int functionC()
{
return 10;
}
在理想状态下,我们期望我们系统中的开发的功能和特性每一个块都是可以独立复用的,而不是所有的功能和特性作为一个整体才可以复用,如下图所示,左侧系统的可复用性是优于右侧的。但是针对遗留系统而言,由于糟糕的物理设计导致系统各个编译单元之间经常是右侧网状依赖,从而导致了系统所有功能实体必须做为一个统一的整体才能被复用。
分层依赖设计.jpg而我们在对大型遗留系统进行重构的过程中,并不是刚开始就对整个系统进行重构,而是选择其中的一部分模块进行重构,那么就需要复用其他模块,而遗留系统中网状的物理依赖关系导致我们想复用已有系统的每个模块都非常困难。
3.2 系统难以理解
软件功能自从诞生依赖,可理解性都一直扮演着非常重要的角色,自从简单设计四原则被提出来之后,大家对可理解性有更深一步的认识,但是遗留系统的可理解性通常都非常差。
可理解性不等于注释,遗留系统中经常增加了很多注释,但是这些注释对可理解性方面收效甚微。相反,遗留系统中随处可见的全局变量,不加控制的全局方法,导致我们在阅读和理解代码的过程中很难搞清楚每个模块真正对外提供的接口是什么,同时也就非常难理解模块真正干了哪些事情。
3.3 构建测试用例困难
易复用的东西通常是易测试的,而遗留系统糟糕的物理设计导致可测试性极差。相对而言针对遗留系统而言,构造系统级别的FT测试会容易一些,但是FT测试在前期有较大收益但是存在一定的局限性。通常FT测试可以覆盖开发系统的大部分功能,但是系统中还存在一些功能使用FT测试非常困难同时成本也是非常大。所以我们需要构建UT, FT, SAT等一整套测试体系,那么糟糕的物理设计对构造UT测试来说简直就是噩梦。
通常构造UT级别测试的过程中,需要做的事情有一下几个方面:
- 构造测试输入输出
- 依赖边界打桩,
- 借用mockcpp帮助测试
通常情况下我们可以针对系统中每个可复用单元来构造UT测试,如果系统可复用单元粒度比较小,那么测试构造就会非常容易,UT测试的编译和开发都会比较小,那么使用Testngpp测试框架就可以非常容易的构造UT用例。如果系统中的可复用单元比较大,为了构造测试用例,我们需要构造的输入输出上下文成本就会变大,经常就不得不使用mockcpp或者自己构造的桩函数,然后糟糕的物理设计,会显著增加我们在这方面的成本。
3.4 编译时间过长
在大型遗留系统重构项目中,为了消除重复和达到更好的可理解性,我们更期望去开发一些更小的,且具有单一职责的类。这个时候,一个奇怪的问题出现了,随着我们重构代码量越大,新开发的类越多,编译时间越来越长!
仔细分析之后,发现又是巨型接口头文件惹的祸。遗留系统中公共接口头文件通常都非常大,有的甚至超过3000行。大家应该都知道C++编译期间,默认都是以每个cpp文件为一个编译单元,然后把头文件在cpp文件中的位置展开并进行编译。所以我们在重构过程中增加的一些很小的cpp文件,看似非常小,但是真实编译的时候如果包含了接口头文件,那么编译起来也很长。
编译时间长会严重的影响重构的节奏,使得重构变得非常困难。很多人可能会说,我们可以使用并行编译呀,make的时候加一个-j
就搞定了呀!那我告诉你没有最快,只有更快。对于期望编译时间可以到达秒级的程序员来说,编译时间没有上限。
还有人可能会说,我们可以用联合编译呀,把多个cpp文件打包成一个大文件来进行编译呀,我不得不承认这个方法的确可以在很大的程度上改善巨型头文件引入的编译时间过长问题,但是我这里必须郑重的提醒你一下,我们一定要慎用联合编译,因为联合编译破坏了文件封装性,导致原来文件中的static 定义,匿名namespace的就变得不再是本文件内可见,所以一定要慎用。
4.重构中如何应对糟糕的物理设计
4.1将物理依赖层次化
在遗留系统中,循环物理依赖随处可见,很大程度上影响了可理解性和可复用性,然而针对C语言的遗留系统中,消除循环物理依赖可以通过层次化物理依赖来解决。
//AB.c
void function A()
{
functionB();
functionD();
}
void function B()
{
}
//CD.c
void function C()
{
functionB();
functionD();
}
void function D()
{
}
如上代码所示,编译单元AB和编译单元BD之间存在循环依赖的情况,可以通过拆分分层把B和D拆分到单独的编译单元中,来消除互相循环依赖的情况,如下图所示,将不再存在循环依赖的情况。
循环依赖.jpg经常调整物理设计和物理依赖,可以把原有系统的网状物理依赖可以调整为分层的物理依赖,如下所示:
分层依赖设计.jpg分层的物理依赖主要原则是,每一个层的编译单元只物理依赖于它下层的编译单元。当系统的物理设计满足分层架构的情况下,不仅非常有利于增量式测试,也有利于代码复用和重构。如上图所示,每个编译单元都可以与它下层依赖的编译单元组合起来为一个可复用单元,那么我们就可以针对每个可复用单元设计包围测试并进行重构了。
4.2提升文件封装性
上面小节主要谈论物理依赖,更多的谈论是编译单元之间的物理依赖,而这里讨论的文件封装性是另外一个层面,主要是针对介绍提升文件的封装性,减少对外部链接域的污染,减少对全局名字域的污染,同时提升已有功能模块的可理解性。
具体措施有如下一些:
- 消灭遗留C系统中的extern 关键字。
- 可以内部链接的方法都需要static
- 结构体定义尽量的移入到源文件中
举一个简单的例子,代码重构前:
//oldModule.h
typedef struct TYPE_A
{
int a;
int b;
}TYPE_A
typedef struct TYPE_B
{
int c;
char d;
}TYPE_B
extern int g_openswitch;
void funA(TYPE* A);
int funB(TYPE* B);
//oldModule.c
#include "oldModule.h"
int g_openswitch;
int funB(TYPE* B)
{
return b.c* b.d;
}
void funA(TYPE* A)
{
TYPE_B b;
b.c =3;
b.d = '4';
a. b = funB(&b);
}
重构后
//oldModule.h
typedef struct TYPE_A
{
int a;
int b;
}TYPE_A
bool isSwitchOn();
void funA(TYPE* A);
//oldModule.c
#include "oldModule.h"
static int g_openswitch;
bool isSwitchOn()
{
return g_openswitch == 1;
}
static int funB(TYPE* B)
{
return b.c* b.d;
}
void funA(TYPE* A)
{
TYPE_B b;
b.c =3;
b.d = '4';
a. b = funB(&b);
}
如上所示,C语言并不规定所有的结构体定义必须要放到头文件中,也不是所有的函数都需要声明。为了体现更好的实现封装性,我们需要把不需要对外暴露的结构体放到源文件中,把不需要对外暴露的接口static到源文件中,同时消除全局变量。这样头文件可以看做对外暴露较少的外部链接接口,只应该看到必要的结构体定义和公共函数接口声明。
我们在对遗留系统中某个编译单元或者模块进行重构的过程中,首先需要理解原有系统。要理解原有系统,第一步必须要清楚系统中的每个模块哪些是对外公共的接口,哪些是内部实现。然后我们才可以针对外部公共接口构造包围测试,重构相应的编译单元或者模块。
4.3 消灭巨型接口头文件
在对遗留系统进行重构的过程与开发新的系统有很多的差异,因为遗留系统对重构增加了很多内在的约束,如下所示:
- 原有子系统的公共接口头文件通常我们并没有所有权,那就意味着我们不能做任何修改。
- 重构团队经常需要与原有团队同步前进,重构团队需要不断同步适应公共接口头文件的变更。
优秀的公共接口头文件应该满足如下要求:
- 单独可以编译通过(自满足)。
- 每个接口头文件中应该只包含单个结构体定义。
- 接口中的结构体定义支持前置声明。
- 公共接口头文件中只包含必要的头文件。
其他条目很好理解,这里单独解释一下第二条:”每个接口头文件应该只包含单个结构体的定义“,这个应该完全是我自己提出的概念,存在少许争议。我们平时提到的接口隔离原则,要求针对不同的客户定义独立的接口,从而不让客户看到它不关心的接口,但并没有严格要求到每个接口头文件只包含单个结构体定义。我提出这个规则是基于这样的一个前提,如果重构项目中类的职责很单一,大部分都是很小的类,那么通常情况下只使用某一个结构体是常态,如果在一些特殊情况下,需要使用多个结构体,那么包含多个头文件也并无大碍。
为了解决遗留巨型接口头文件对我们重构的制约,最容易想到的方式就是新增加头文件,然后把结构体按照我们的期望的格式添加到里面,然后在我们重构的新代码中,使用新增加的头文件即可。这时我们碰到了三个新问题,第一个问题就是,我们破坏了结构体的dry原则,我们新增加的头文件中的结构体与原来公共头文件的结构体本质上是一个结构体,但是却用两个定义。第二个问题,我们的公共接口头文件中,一共有800多个结构体定义,如果每个结构体都拆分一个头文件,那就需要拆分800次。我们也经常发现,当新开发一个小类的时候,拆分头文件时间远远大于开发新代码的时间。第三个问题,如果我们能够一次搞定也就罢了,但是我们重构版本经常需要跟着大版本一起前行,如果大版本中的公共头文件接口发生修改,我们怎么保证和内部新增加的头文件中的结构体还是一致的,难道需要再把800个结构体重新拆头文件一次吗?
为了解决这里问题,需要借助自动化工具,请参考5.2自动化头文件拆分工具。
5物理设计相关工具集
5.1 biicode
biicode 是一个支持多平台的 C/C++ 依赖管理器,可以很方便集成到 Visual Studio 和 Eclipse CDT 中,目前已经开源了客户端的代码在github上面, 官方称会逐渐开源全部代码.
- 官方博客: blog.biicode.com/biicode-open-source-client/
- 项目主页: biicode.github.io/biicode/
这个项目算是弥补了C/C++一直没有一个像样的包管理器的缺陷,当你代码使用第三方库的过程中,原则上你可能只使用库中的一部分代码实现,但是我们通常是把这个库文件完整的编译进你的系统中。使用biicode之后,如果你只包含了库中的某个头文件,那么它只会把库中和你相关的实现编译到你的系统中。这个时候物理设计就扮演着非常重要的一个环节,如果你系统有良好的物理设计,那么biicode就会发挥比较大的价值。
未完待续。
5.2自动化头文件拆分工具
本章节主要介绍本人开发的自动化头文件拆分工具的实现,以及如何解决遗留系统中的巨型头文件问题。
5.2.1消除预编译宏
我们想做的第一件事情就是去除头文件中的预编译宏。如下面头文件,我们重构的时候只关注某个单板类型,但是在头文件里面,我们却看到了很多我们不关心的产品的结构体定义;另外一方面结构体中过多的预编译宏也着实让我们很难受,所以我们首先要消灭它。
typedef struct {
WORD16 wGid;
#if (_LOGIC_BOARD == _LOGIC_XXX_BOARD_1)
UCHAR ucSimultANAndSRS;
UCHAR aucRsv0[3];
#endif
#if (_LOGIC_BOARD == _LOGIC_XXX_BOARD_2)
UCHAR aucRsv2[12];
#endif
} T_PucchRbScheInfo;
刚开始我首先想到是使用ruby脚本去解析这些预编译宏来判断到底哪些代码是我们关注的。但是我很快的发现头文件中有很多很复杂的预编译宏,而且有很多包含嵌套,我很认真的分析各种嵌套关系,最后终于在考虑了5层嵌套的情况下搞定了这些预编译宏,同时也针对新写脚本增加了测试用例。但是我还是对这些复杂的脚本代码极度的不信任,这时一位团队成员给了我一个很重要的建议,利用编译器来做这些事情,它们更专业。
一语中的,那就让编译器来帮我来做吧。这里首先给大家介绍一个概念吧,那就是预编译。我们的编译器在编译之前首先会做一次预编译,预编译工作主要包括头文件原位置插入和宏替换,同时也消除了预编译宏和注释。再补充一点,我们团队有一套比较强大的makefile,同时支持模块级别的,子系统模块集成级别,以及单板级别的make.当然也支持针对每个文件的预编译了,那后面的事情就非常简单了!
实现上面功能的主要代码如下,由于编译器的预处理只会对cpp文件进行预编译,所以我们还要先对头文件进行一些预处理,并转换为cpp文件,构造一个在命令行上的make命令,然后执行make,具体ruby代码如下所示。
def generate_make_cpp()
remove_old_file(@make_cpp_path)
make_cpp = File.open(@make_cpp_path, "w")
lines = File.open(@header_path,"r").readlines
lines.each do |line|
if ((line.include?"#include") || (line.include?"#define"))
next
end
make_cpp.puts line
end
make_cpp.close
end
def run_gcc()
gccCmd = @gcc + @make_cpp_path + " > " + @make_i_path
run_cmd(gccCmd)
end
大家可能发现我们我在头文件转换为cpp文件时候,把头文件中的宏定义,还有包含头文件的行都删除了,仔细想想很容易就能得到答案。删除#define主要是为了确保我们拆分的结构体中的宏不会变成魔术数字,而删除#include主要是为了不让我们重复对一个结构体进行重复的拆分。经过预编译处理之后的中间文件帮我们消除了预编译宏,同时也消除了那些杂乱无章的注释,代码干净漂亮如下所示,我们就可以基于这样的代码进行拆分了。
typedef struct {
WORD16 wGid;
UCHAR aucRsv2[12];
} T_PucchRbScheInfo;
5.2.2有效识别接口文件中的结构体
要做到针对每个结构体拆分单独的头文件,那么首先需要准确的识别结构体或者枚举,并生成拆分头文件的文件名。这里大家可能会有疑惑,我们为什么没有把结构体按照我们的期望的驼峰式进行重新命名,道理很简单因为这些结构体在以前遗留代码中还在使用,我们并不想因为这点就去就修改遗留代码。
要做到上面这点,我们需要在接口头文件中识别一个结构体的开始定义,结构体的名字,还有结构体定义的末尾。要做到这些也很容易,因为每个脚本语言都具有非常强大的正则表达式模式匹配功能,当然ruby也不例外。让我们看看ruby是如何做到的。
def is_type_def_begin(line)
(line.include?"typedef")
end
def is_type_def_end(line)
/[}][\s]*[TE]/.match(line)
end
def get_struct_name(line)
struct2 = /[TE][\w]+/.match(line.force_encoding("gb2312"))
struct2[0]
end
同时我们还需要根据结构体的名字,生成拆分的头文件名字,同样我也可以利用正则表达式。
def generate_header_file_name_from(structname)
header_file_name = structname.gsub(/[ET]_/,"ce_");
header_file_name = header_file_name.gsub(/[a-z][A-Z]/){|s| s[0] + '_' + s[1]}
header_file_name = header_file_name.gsub(/2/, "_to_")
header_file_name = header_file_name.gsub(/4/, "_for_")
header_file_name = header_file_name.gsub(/[A-Z][A-Z][a-z]/){|s| s[0]+'_'+s[1]+ s[2]}
header_file_name = header_file_name + ".h"
header_file_name.downcase
end
当生成头文件名字之后,再根据头文件生成头文件保护宏就很容易了,这里不做说明了。
5.2.3按照要求生成拆分后的头文件
为了保证我们的拆分生成的头文件是自满足的。通过上面分析,我们期望的每个结构体的头文件定义中所需要包含的头文件如下:
- 包含基本类型定义的头文件
- 包含结构体中定义需要的宏定义
- 仅包含结构体中嵌套的子结构体的头文件
由于已经提前确定了基本类型的头文件和宏定义的头文件,在生成拆分结构体的接口头文件的时候只有需要在最开始的时候包含进来就可以了。在处理的过程中,为了达到一次遍历遗留系统的巨型头文件,在扫描的过程中新建文件@test_struct_include_path,然后把结构体中包含的子结构所需要的头文件先临时添加到这个文件中,新建临时文件@test_struct_file_path,把结构体的定义内容拷贝到这个文件内,最后结束之后再把两个临时文件拼接到一起生成最终我们需要的头文件,下面为核心代码逻辑样例。
def generate_for_struct()
lines = File.open(@spliter_header_path,"r").readlines
lines.each do |line|
if (line.include?"#")
next
end
if is_type_def_begin(line)
@write_file = File.new(@test_struct_file_path,"w")
@include_file = File.new(@test_struct_include_path, "w")
@is_write_able = 1
@is_struct_type = line.include?("struct")
if(@is_struct_type)
@include_file.puts "#include \"l0-infra/base/BaseTypes.h\""
@include_file.puts "#include \"ce_defs.h\""
end
end
if(@is_write_able == 1)
@write_file.puts line
end
if is_contain_struct(line)
include_struct_name = get_struct_name(line)
add_include_file_path(include_struct_name)
end
if is_type_def_end(line)
structname = get_struct_name(line)
do_generate_head_file(structname)
@is_write_able = 0
end
end
end
最后我们还需要处理一种特殊情况,就是对结构体的名字进行重定义,代码如下:
def do_generate_redefine(line)
struct = /[TE]_[\w]+/.match(line)
include_struct_name = struct[0]
add_include_file_path(include_struct_name)
temp = line.gsub(struct[0], " ")
struct = /[TE]_[\w]+/.match(temp)
struct_name = struct[0]
do_generate_head_file(struct_name)
end
5.2.4在原项目中的效果
1)拆分之后的头文件首先按照原来的不同子系统之间进行目录隔离,如下:
1228_1.jpg2)然后每个目录里面包含了原来公共接口下所有拆分的结构体头文件,如下:
1222_4.jpg3)拆分之后每个头文件的形式如下:
1222_3.jpg使用这套ruby的自动化脚本之后,只需要在命令行中敲入命令1秒之后,原来子系统之间所有的接口头文件都已经按照自己的要求拆分完成。当我开发完成这套脚本之后,我们团队也在第一时间使用了,后续团队在开发和版本同步升级过程中再也不需要手动拆头文件,很大的节省团队这上面的时间浪费。
结束语
在对遗留系统进行重构的过程中,首先需要解决一些糟糕的物理设计问题,通过可以经过一些初级的重构从而改善遗留系统的物理设计问题,然后才可以基于此之上才开始构建测试体系,然后再深度重构。
网友评论