---
導(dǎo)語
糟糕的物理設(shè)計(jì)是對(duì)遺留大型系統(tǒng)中進(jìn)行重構(gòu)的非常棘手的一個(gè)問題,本文相機(jī)闡述了遺留系統(tǒng)中存在哪些糟糕的物理設(shè)計(jì),它們對(duì)重構(gòu)所帶來的哪些惡略影響,以及我們?cè)谥貥?gòu)過程中應(yīng)該如何處理這些問題。文中后面還介紹了關(guān)于物理設(shè)計(jì)的一些工具,其中包括本人開發(fā)的自動(dòng)化頭文件拆分工具。
1.物理設(shè)計(jì)VS邏輯設(shè)計(jì)
物理設(shè)計(jì)
物理設(shè)計(jì)主要是軟件設(shè)計(jì)中的物理實(shí)體(文件)的設(shè)計(jì),例如某個(gè)函數(shù)定義應(yīng)該放在哪個(gè)文件中、某個(gè)函數(shù)是否需要Inline等,從物理設(shè)計(jì)看到的是系統(tǒng)中的大量文件實(shí)體。邏輯設(shè)計(jì)
邏輯設(shè)計(jì)主要針對(duì)軟件設(shè)計(jì)中的邏輯實(shí)體關(guān)系的設(shè)計(jì),例如類之間的關(guān)系,Has a/use a/is a的關(guān)系,從邏輯設(shè)計(jì)看到的是大量的邏輯實(shí)體,如類,函數(shù),結(jié)構(gòu)體。
物理設(shè)計(jì)的主要目標(biāo)是減少文件的物理依賴,而邏輯設(shè)計(jì)的主要目標(biāo)是減少邏輯依賴。物理依賴更多的體現(xiàn)為編譯時(shí)的依賴和鏈接時(shí)的依賴,物理依賴受邏輯依賴影響,但是又不局限于邏輯依賴,在一個(gè)大型軟件系統(tǒng)中,物理設(shè)計(jì)和邏輯設(shè)計(jì)是完全不同的范疇,也是一個(gè)需要重點(diǎn)關(guān)注和考慮的問題。很多人認(rèn)為物理設(shè)計(jì)主要考慮頭文件的設(shè)計(jì),其實(shí)是非常錯(cuò)誤的,正確的物理設(shè)計(jì)不僅要考慮頭文件的設(shè)計(jì),還要考慮源文件的設(shè)計(jì),從而達(dá)到編譯單元的物理依賴設(shè)計(jì)。
2.糟糕的物理設(shè)計(jì)有哪些
2.1 巨型文件
我們這里談?wù)摼扌臀募?,并不單指巨型頭文件,就像物理設(shè)計(jì)并不是單指頭文件的物理設(shè)計(jì)一樣。巨型源文件和頭文件一樣也是一種非常糟糕的物理設(shè)計(jì)。在遺留的大型系統(tǒng)中,巨型頭文件和巨型源文件隨處可見,正是因?yàn)檫@種糟糕的物理設(shè)計(jì)導(dǎo)致我們的系統(tǒng)中代碼構(gòu)成一個(gè)巨大的網(wǎng)狀物理依賴系統(tǒng),如下圖所示

2.2 糟糕的文件封裝
這里并不是談?wù)揅++的域名空間概念,為了清楚的說明文件封裝的概念,我們先介紹如下幾個(gè)概念。
- 聲明
- 定義
- 編譯單元
- 內(nèi)部鏈接
- 外部鏈接
一個(gè)聲明將一個(gè)名稱引入一個(gè)程序,而一個(gè)定義提供了一個(gè)實(shí)體,在程序中唯一描述。編譯單元通常指編譯過程中編譯器看到的一個(gè)單位,在C/C++中,通常是以每個(gè)源文件為一個(gè)編譯單元。如果一個(gè)名字對(duì)于他的編譯單元是局部的,并且連接時(shí)與其他編譯單元中定義的標(biāo)示符名稱不沖突,那么這個(gè)名字就是內(nèi)部鏈接的。如果一個(gè)名字有外部鏈接,會(huì)產(chǎn)生外部符號(hào),那么在多文件系統(tǒng)編譯鏈接過程中,這個(gè)名字可以和其他編譯單元交互。
結(jié)構(gòu)體定義具有內(nèi)部鏈接性,所以在每個(gè)需要使用當(dāng)前結(jié)構(gòu)體定義的編譯單元都需要顯示包含結(jié)構(gòu)體定義的頭文件,本質(zhì)上每個(gè)編譯單元中都有一個(gè)完整的結(jié)構(gòu)體定義。同樣C+ +中的類定義也是具有內(nèi)部鏈接性,每個(gè)使用了類定義的編譯單元都需要包含類定義的頭文件,但是類定義中的非內(nèi)聯(lián)函數(shù)屬于函數(shù)聲明,所以類的非內(nèi)聯(lián)方法定義會(huì)產(chǎn)生外部符號(hào),而類的內(nèi)存布局定義并不會(huì)產(chǎn)生外部符號(hào)。根據(jù)以上分析可以發(fā)現(xiàn),類定義本質(zhì)上比較像結(jié)構(gòu)體定義+ 函數(shù)方法的聲明。我們?cè)谄綍r(shí)的C++項(xiàng)目中,經(jīng)??吹降逆溄渝e(cuò)誤看到的經(jīng)常是找不到某個(gè)類方法的定義而不是找不到某個(gè)類的定義,因?yàn)檎也坏筋惗x屬于編譯錯(cuò)誤。
在討論清楚這些之后,我們?cè)倏匆幌翪語言系統(tǒng)中哪些具有內(nèi)部鏈接,哪些具有外部鏈接:
1)內(nèi)部鏈接:結(jié)構(gòu)體定義,宏定義,typedef, enum, union
2)外部鏈接:全局變量定義,全局函數(shù)定義。
我們提倡內(nèi)部鏈接的東西盡量放到源文件中,同時(shí)盡量減少定義具有外部鏈接名字,然而在遺留系統(tǒng)中經(jīng)常并關(guān)注這些概念,從而導(dǎo)致系統(tǒng)中存在大量的這些問題。
- 全局變量隨處可見
- 全局函數(shù)不加管制
- 宏使用泛濫
- 全局定義隨處可見
2.3 巨型接口頭文件
巨型接口頭文件從嚴(yán)格意識(shí)上將并不會(huì)引起系統(tǒng)內(nèi)各個(gè)模塊的物理依賴,但是它也是一種非常糟糕的物理設(shè)計(jì)。
在我們的業(yè)務(wù)代碼中,在出現(xiàn)下面幾種現(xiàn)象時(shí)都需要包含一整個(gè)公共接口頭文件。
- 如果只使用了公共頭文件接口中的一個(gè)結(jié)構(gòu)體,我們需要包含這個(gè)頭文件。
- 如果只使用了公共頭文件接口中的一個(gè)宏定義,我們需要包含這個(gè)頭文件。
- 當(dāng)包含的某個(gè)公共頭文件編譯又依賴于另外的公共接口頭文件,那么我們還需要包含依賴的相應(yīng)頭文件。
- 公共接口頭文件的結(jié)構(gòu)體定義限制了前置聲明,導(dǎo)致我們只使用公共頭文件中某個(gè)結(jié)構(gòu)體的指針或者引用的情況下也需要包含整個(gè)頭文件。
前面幾條規(guī)則很好理解,這里單獨(dú)解釋一下第四條。當(dāng)我們?cè)谥貥?gòu)過程新增加的代碼中,如果只使用某個(gè)結(jié)構(gòu)體的指針或者引用的時(shí)候,通常情況下只需要前置聲明即可,并不需要包含相應(yīng)的頭文件,這是C++減少物理依賴的一個(gè)非常重要的手段。但是現(xiàn)有的公共頭文件中的結(jié)構(gòu)體定義形式有下面兩種:
typedef struct
{
WORD32 dwValue;
}T_StructName1;
typedef struct tagStructName2
{
WORD32 dwValue;
}T_StructName2;
顯然上面這兩種結(jié)構(gòu)體定義是C語言的遺產(chǎn),不能很好的支持前置聲明。第一種方式T_StrictName1屬于typedef重定義的名字,是不支持前置聲明的。第二種方式僅支持tagStructName2的前置申明,但是與真實(shí)使用名字T_StructName2不一致,同樣編譯器會(huì)報(bào)錯(cuò)。所以下面給出的這種方式是很好的兼容老式的C語言和C+ +的一種方式,具體定義形式如下:
typedef struct StructName2
{
WORD32 dwValue;
}StructName2;
3.糟糕的物理設(shè)計(jì)的影響
3.1 復(fù)用已有功能模塊困難
一般情況下,我們談復(fù)用的時(shí)候,的確是希望復(fù)用一些軟件中的一些邏輯實(shí)體,看似和物理設(shè)計(jì)無關(guān),其實(shí)不然。真實(shí)的復(fù)用肯定要承載一定的物理實(shí)體上,如果我們期望復(fù)用某一個(gè)邏輯實(shí)體,需要把承載相應(yīng)的邏輯實(shí)體的相關(guān)物理文件編譯鏈接進(jìn)來。
如下圖所示,雖然functionA()與functionB(), functionC()在邏輯上沒有任何關(guān)系,但是由于糟糕的物理設(shè)計(jì),我們想復(fù)用functionA(),就必須把兩個(gè)編譯單元fileA.C和fileB.c兩個(gè)編譯單元作為一個(gè)整體才能夠被復(fù)用。
//fileA.c
void functionA()
{
printf("hello world\n");
}
int functionB()
{
return functionC();
}
//fileB.c
int functionC()
{
return 10;
}
在理想狀態(tài)下,我們期望我們系統(tǒng)中的開發(fā)的功能和特性每一個(gè)塊都是可以獨(dú)立復(fù)用的,而不是所有的功能和特性作為一個(gè)整體才可以復(fù)用,如下圖所示,左側(cè)系統(tǒng)的可復(fù)用性是優(yōu)于右側(cè)的。但是針對(duì)遺留系統(tǒng)而言,由于糟糕的物理設(shè)計(jì)導(dǎo)致系統(tǒng)各個(gè)編譯單元之間經(jīng)常是右側(cè)網(wǎng)狀依賴,從而導(dǎo)致了系統(tǒng)所有功能實(shí)體必須做為一個(gè)統(tǒng)一的整體才能被復(fù)用。

而我們?cè)趯?duì)大型遺留系統(tǒng)進(jìn)行重構(gòu)的過程中,并不是剛開始就對(duì)整個(gè)系統(tǒng)進(jìn)行重構(gòu),而是選擇其中的一部分模塊進(jìn)行重構(gòu),那么就需要復(fù)用其他模塊,而遺留系統(tǒng)中網(wǎng)狀的物理依賴關(guān)系導(dǎo)致我們想復(fù)用已有系統(tǒng)的每個(gè)模塊都非常困難。
3.2 系統(tǒng)難以理解
軟件功能自從誕生依賴,可理解性都一直扮演著非常重要的角色,自從簡單設(shè)計(jì)四原則被提出來之后,大家對(duì)可理解性有更深一步的認(rèn)識(shí),但是遺留系統(tǒng)的可理解性通常都非常差。
可理解性不等于注釋,遺留系統(tǒng)中經(jīng)常增加了很多注釋,但是這些注釋對(duì)可理解性方面收效甚微。相反,遺留系統(tǒng)中隨處可見的全局變量,不加控制的全局方法,導(dǎo)致我們?cè)陂喿x和理解代碼的過程中很難搞清楚每個(gè)模塊真正對(duì)外提供的接口是什么,同時(shí)也就非常難理解模塊真正干了哪些事情。
3.3 構(gòu)建測試用例困難
易復(fù)用的東西通常是易測試的,而遺留系統(tǒng)糟糕的物理設(shè)計(jì)導(dǎo)致可測試性極差。相對(duì)而言針對(duì)遺留系統(tǒng)而言,構(gòu)造系統(tǒng)級(jí)別的FT測試會(huì)容易一些,但是FT測試在前期有較大收益但是存在一定的局限性。通常FT測試可以覆蓋開發(fā)系統(tǒng)的大部分功能,但是系統(tǒng)中還存在一些功能使用FT測試非常困難同時(shí)成本也是非常大。所以我們需要構(gòu)建UT, FT, SAT等一整套測試體系,那么糟糕的物理設(shè)計(jì)對(duì)構(gòu)造UT測試來說簡直就是噩夢(mèng)。
通常構(gòu)造UT級(jí)別測試的過程中,需要做的事情有一下幾個(gè)方面:
- 構(gòu)造測試輸入輸出
- 依賴邊界打樁,
- 借用mockcpp幫助測試
通常情況下我們可以針對(duì)系統(tǒng)中每個(gè)可復(fù)用單元來構(gòu)造UT測試,如果系統(tǒng)可復(fù)用單元粒度比較小,那么測試構(gòu)造就會(huì)非常容易,UT測試的編譯和開發(fā)都會(huì)比較小,那么使用Testngpp測試框架就可以非常容易的構(gòu)造UT用例。如果系統(tǒng)中的可復(fù)用單元比較大,為了構(gòu)造測試用例,我們需要構(gòu)造的輸入輸出上下文成本就會(huì)變大,經(jīng)常就不得不使用mockcpp或者自己構(gòu)造的樁函數(shù),然后糟糕的物理設(shè)計(jì),會(huì)顯著增加我們?cè)谶@方面的成本。
3.4 編譯時(shí)間過長
在大型遺留系統(tǒng)重構(gòu)項(xiàng)目中,為了消除重復(fù)和達(dá)到更好的可理解性,我們更期望去開發(fā)一些更小的,且具有單一職責(zé)的類。這個(gè)時(shí)候,一個(gè)奇怪的問題出現(xiàn)了,隨著我們重構(gòu)代碼量越大,新開發(fā)的類越多,編譯時(shí)間越來越長!
仔細(xì)分析之后,發(fā)現(xiàn)又是巨型接口頭文件惹的禍。遺留系統(tǒng)中公共接口頭文件通常都非常大,有的甚至超過3000行。大家應(yīng)該都知道C++編譯期間,默認(rèn)都是以每個(gè)cpp文件為一個(gè)編譯單元,然后把頭文件在cpp文件中的位置展開并進(jìn)行編譯。所以我們?cè)谥貥?gòu)過程中增加的一些很小的cpp文件,看似非常小,但是真實(shí)編譯的時(shí)候如果包含了接口頭文件,那么編譯起來也很長。
編譯時(shí)間長會(huì)嚴(yán)重的影響重構(gòu)的節(jié)奏,使得重構(gòu)變得非常困難。很多人可能會(huì)說,我們可以使用并行編譯呀,make的時(shí)候加一個(gè)-j就搞定了呀!那我告訴你沒有最快,只有更快。對(duì)于期望編譯時(shí)間可以到達(dá)秒級(jí)的程序員來說,編譯時(shí)間沒有上限。
還有人可能會(huì)說,我們可以用聯(lián)合編譯呀,把多個(gè)cpp文件打包成一個(gè)大文件來進(jìn)行編譯呀,我不得不承認(rèn)這個(gè)方法的確可以在很大的程度上改善巨型頭文件引入的編譯時(shí)間過長問題,但是我這里必須鄭重的提醒你一下,我們一定要慎用聯(lián)合編譯,因?yàn)槁?lián)合編譯破壞了文件封裝性,導(dǎo)致原來文件中的static 定義,匿名namespace的就變得不再是本文件內(nèi)可見,所以一定要慎用。
4.重構(gòu)中如何應(yīng)對(duì)糟糕的物理設(shè)計(jì)
4.1將物理依賴層次化
在遺留系統(tǒng)中,循環(huán)物理依賴隨處可見,很大程度上影響了可理解性和可復(fù)用性,然而針對(duì)C語言的遺留系統(tǒng)中,消除循環(huán)物理依賴可以通過層次化物理依賴來解決。
//AB.c
void function A()
{
functionB();
functionD();
}
void function B()
{
}
//CD.c
void function C()
{
functionB();
functionD();
}
void function D()
{
}
如上代碼所示,編譯單元AB和編譯單元BD之間存在循環(huán)依賴的情況,可以通過拆分分層把B和D拆分到單獨(dú)的編譯單元中,來消除互相循環(huán)依賴的情況,如下圖所示,將不再存在循環(huán)依賴的情況。

經(jīng)常調(diào)整物理設(shè)計(jì)和物理依賴,可以把原有系統(tǒng)的網(wǎng)狀物理依賴可以調(diào)整為分層的物理依賴,如下所示:

分層的物理依賴主要原則是,每一個(gè)層的編譯單元只物理依賴于它下層的編譯單元。當(dāng)系統(tǒng)的物理設(shè)計(jì)滿足分層架構(gòu)的情況下,不僅非常有利于增量式測試,也有利于代碼復(fù)用和重構(gòu)。如上圖所示,每個(gè)編譯單元都可以與它下層依賴的編譯單元組合起來為一個(gè)可復(fù)用單元,那么我們就可以針對(duì)每個(gè)可復(fù)用單元設(shè)計(jì)包圍測試并進(jìn)行重構(gòu)了。
4.2提升文件封裝性
上面小節(jié)主要談?wù)撐锢硪蕾嚕嗟恼務(wù)撌蔷幾g單元之間的物理依賴,而這里討論的文件封裝性是另外一個(gè)層面,主要是針對(duì)介紹提升文件的封裝性,減少對(duì)外部鏈接域的污染,減少對(duì)全局名字域的污染,同時(shí)提升已有功能模塊的可理解性。
具體措施有如下一些:
- 消滅遺留C系統(tǒng)中的extern 關(guān)鍵字。
- 可以內(nèi)部鏈接的方法都需要static
- 結(jié)構(gòu)體定義盡量的移入到源文件中
舉一個(gè)簡單的例子,代碼重構(gòu)前:
//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);
}
重構(gòu)后
//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語言并不規(guī)定所有的結(jié)構(gòu)體定義必須要放到頭文件中,也不是所有的函數(shù)都需要聲明。為了體現(xiàn)更好的實(shí)現(xiàn)封裝性,我們需要把不需要對(duì)外暴露的結(jié)構(gòu)體放到源文件中,把不需要對(duì)外暴露的接口static到源文件中,同時(shí)消除全局變量。這樣頭文件可以看做對(duì)外暴露較少的外部鏈接接口,只應(yīng)該看到必要的結(jié)構(gòu)體定義和公共函數(shù)接口聲明。
我們?cè)趯?duì)遺留系統(tǒng)中某個(gè)編譯單元或者模塊進(jìn)行重構(gòu)的過程中,首先需要理解原有系統(tǒng)。要理解原有系統(tǒng),第一步必須要清楚系統(tǒng)中的每個(gè)模塊哪些是對(duì)外公共的接口,哪些是內(nèi)部實(shí)現(xiàn)。然后我們才可以針對(duì)外部公共接口構(gòu)造包圍測試,重構(gòu)相應(yīng)的編譯單元或者模塊。
4.3 消滅巨型接口頭文件
在對(duì)遺留系統(tǒng)進(jìn)行重構(gòu)的過程與開發(fā)新的系統(tǒng)有很多的差異,因?yàn)檫z留系統(tǒng)對(duì)重構(gòu)增加了很多內(nèi)在的約束,如下所示:
- 原有子系統(tǒng)的公共接口頭文件通常我們并沒有所有權(quán),那就意味著我們不能做任何修改。
- 重構(gòu)團(tuán)隊(duì)經(jīng)常需要與原有團(tuán)隊(duì)同步前進(jìn),重構(gòu)團(tuán)隊(duì)需要不斷同步適應(yīng)公共接口頭文件的變更。
優(yōu)秀的公共接口頭文件應(yīng)該滿足如下要求:
- 單獨(dú)可以編譯通過(自滿足)。
- 每個(gè)接口頭文件中應(yīng)該只包含單個(gè)結(jié)構(gòu)體定義。
- 接口中的結(jié)構(gòu)體定義支持前置聲明。
- 公共接口頭文件中只包含必要的頭文件。
其他條目很好理解,這里單獨(dú)解釋一下第二條:”每個(gè)接口頭文件應(yīng)該只包含單個(gè)結(jié)構(gòu)體的定義“,這個(gè)應(yīng)該完全是我自己提出的概念,存在少許爭議。我們平時(shí)提到的接口隔離原則,要求針對(duì)不同的客戶定義獨(dú)立的接口,從而不讓客戶看到它不關(guān)心的接口,但并沒有嚴(yán)格要求到每個(gè)接口頭文件只包含單個(gè)結(jié)構(gòu)體定義。我提出這個(gè)規(guī)則是基于這樣的一個(gè)前提,如果重構(gòu)項(xiàng)目中類的職責(zé)很單一,大部分都是很小的類,那么通常情況下只使用某一個(gè)結(jié)構(gòu)體是常態(tài),如果在一些特殊情況下,需要使用多個(gè)結(jié)構(gòu)體,那么包含多個(gè)頭文件也并無大礙。
為了解決遺留巨型接口頭文件對(duì)我們重構(gòu)的制約,最容易想到的方式就是新增加頭文件,然后把結(jié)構(gòu)體按照我們的期望的格式添加到里面,然后在我們重構(gòu)的新代碼中,使用新增加的頭文件即可。這時(shí)我們碰到了三個(gè)新問題,第一個(gè)問題就是,我們破壞了結(jié)構(gòu)體的dry原則,我們新增加的頭文件中的結(jié)構(gòu)體與原來公共頭文件的結(jié)構(gòu)體本質(zhì)上是一個(gè)結(jié)構(gòu)體,但是卻用兩個(gè)定義。第二個(gè)問題,我們的公共接口頭文件中,一共有800多個(gè)結(jié)構(gòu)體定義,如果每個(gè)結(jié)構(gòu)體都拆分一個(gè)頭文件,那就需要拆分800次。我們也經(jīng)常發(fā)現(xiàn),當(dāng)新開發(fā)一個(gè)小類的時(shí)候,拆分頭文件時(shí)間遠(yuǎn)遠(yuǎn)大于開發(fā)新代碼的時(shí)間。第三個(gè)問題,如果我們能夠一次搞定也就罷了,但是我們重構(gòu)版本經(jīng)常需要跟著大版本一起前行,如果大版本中的公共頭文件接口發(fā)生修改,我們?cè)趺幢WC和內(nèi)部新增加的頭文件中的結(jié)構(gòu)體還是一致的,難道需要再把800個(gè)結(jié)構(gòu)體重新拆頭文件一次嗎?
為了解決這里問題,需要借助自動(dòng)化工具,請(qǐng)參考5.2自動(dòng)化頭文件拆分工具。
5物理設(shè)計(jì)相關(guān)工具集
5.1 biicode
biicode 是一個(gè)支持多平臺(tái)的 C/C++ 依賴管理器,可以很方便集成到 Visual Studio 和 Eclipse CDT 中,目前已經(jīng)開源了客戶端的代碼在github上面, 官方稱會(huì)逐漸開源全部代碼.
- 官方博客: blog.biicode.com/biicode-open-source-client/
- 項(xiàng)目主頁: biicode.github.io/biicode/
這個(gè)項(xiàng)目算是彌補(bǔ)了C/C++一直沒有一個(gè)像樣的包管理器的缺陷,當(dāng)你代碼使用第三方庫的過程中,原則上你可能只使用庫中的一部分代碼實(shí)現(xiàn),但是我們通常是把這個(gè)庫文件完整的編譯進(jìn)你的系統(tǒng)中。使用biicode之后,如果你只包含了庫中的某個(gè)頭文件,那么它只會(huì)把庫中和你相關(guān)的實(shí)現(xiàn)編譯到你的系統(tǒng)中。這個(gè)時(shí)候物理設(shè)計(jì)就扮演著非常重要的一個(gè)環(huán)節(jié),如果你系統(tǒng)有良好的物理設(shè)計(jì),那么biicode就會(huì)發(fā)揮比較大的價(jià)值。
未完待續(xù)。
5.2自動(dòng)化頭文件拆分工具
本章節(jié)主要介紹本人開發(fā)的自動(dòng)化頭文件拆分工具的實(shí)現(xiàn),以及如何解決遺留系統(tǒng)中的巨型頭文件問題。
5.2.1消除預(yù)編譯宏
我們想做的第一件事情就是去除頭文件中的預(yù)編譯宏。如下面頭文件,我們重構(gòu)的時(shí)候只關(guān)注某個(gè)單板類型,但是在頭文件里面,我們卻看到了很多我們不關(guān)心的產(chǎn)品的結(jié)構(gòu)體定義;另外一方面結(jié)構(gòu)體中過多的預(yù)編譯宏也著實(shí)讓我們很難受,所以我們首先要消滅它。
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腳本去解析這些預(yù)編譯宏來判斷到底哪些代碼是我們關(guān)注的。但是我很快的發(fā)現(xiàn)頭文件中有很多很復(fù)雜的預(yù)編譯宏,而且有很多包含嵌套,我很認(rèn)真的分析各種嵌套關(guān)系,最后終于在考慮了5層嵌套的情況下搞定了這些預(yù)編譯宏,同時(shí)也針對(duì)新寫腳本增加了測試用例。但是我還是對(duì)這些復(fù)雜的腳本代碼極度的不信任,這時(shí)一位團(tuán)隊(duì)成員給了我一個(gè)很重要的建議,利用編譯器來做這些事情,它們更專業(yè)。
一語中的,那就讓編譯器來幫我來做吧。這里首先給大家介紹一個(gè)概念吧,那就是預(yù)編譯。我們的編譯器在編譯之前首先會(huì)做一次預(yù)編譯,預(yù)編譯工作主要包括頭文件原位置插入和宏替換,同時(shí)也消除了預(yù)編譯宏和注釋。再補(bǔ)充一點(diǎn),我們團(tuán)隊(duì)有一套比較強(qiáng)大的makefile,同時(shí)支持模塊級(jí)別的,子系統(tǒng)模塊集成級(jí)別,以及單板級(jí)別的make.當(dāng)然也支持針對(duì)每個(gè)文件的預(yù)編譯了,那后面的事情就非常簡單了!
實(shí)現(xiàn)上面功能的主要代碼如下,由于編譯器的預(yù)處理只會(huì)對(duì)cpp文件進(jìn)行預(yù)編譯,所以我們還要先對(duì)頭文件進(jìn)行一些預(yù)處理,并轉(zhuǎn)換為cpp文件,構(gòu)造一個(gè)在命令行上的make命令,然后執(zhí)行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
大家可能發(fā)現(xiàn)我們我在頭文件轉(zhuǎn)換為cpp文件時(shí)候,把頭文件中的宏定義,還有包含頭文件的行都刪除了,仔細(xì)想想很容易就能得到答案。刪除#define主要是為了確保我們拆分的結(jié)構(gòu)體中的宏不會(huì)變成魔術(shù)數(shù)字,而刪除#include主要是為了不讓我們重復(fù)對(duì)一個(gè)結(jié)構(gòu)體進(jìn)行重復(fù)的拆分。經(jīng)過預(yù)編譯處理之后的中間文件幫我們消除了預(yù)編譯宏,同時(shí)也消除了那些雜亂無章的注釋,代碼干凈漂亮如下所示,我們就可以基于這樣的代碼進(jìn)行拆分了。
typedef struct {
WORD16 wGid;
UCHAR aucRsv2[12];
} T_PucchRbScheInfo;
5.2.2有效識(shí)別接口文件中的結(jié)構(gòu)體
要做到針對(duì)每個(gè)結(jié)構(gòu)體拆分單獨(dú)的頭文件,那么首先需要準(zhǔn)確的識(shí)別結(jié)構(gòu)體或者枚舉,并生成拆分頭文件的文件名。這里大家可能會(huì)有疑惑,我們?yōu)槭裁礇]有把結(jié)構(gòu)體按照我們的期望的駝峰式進(jìn)行重新命名,道理很簡單因?yàn)檫@些結(jié)構(gòu)體在以前遺留代碼中還在使用,我們并不想因?yàn)檫@點(diǎn)就去就修改遺留代碼。
要做到上面這點(diǎn),我們需要在接口頭文件中識(shí)別一個(gè)結(jié)構(gòu)體的開始定義,結(jié)構(gòu)體的名字,還有結(jié)構(gòu)體定義的末尾。要做到這些也很容易,因?yàn)槊總€(gè)腳本語言都具有非常強(qiáng)大的正則表達(dá)式模式匹配功能,當(dāng)然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
同時(shí)我們還需要根據(jù)結(jié)構(gòu)體的名字,生成拆分的頭文件名字,同樣我也可以利用正則表達(dá)式。
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
當(dāng)生成頭文件名字之后,再根據(jù)頭文件生成頭文件保護(hù)宏就很容易了,這里不做說明了。
5.2.3按照要求生成拆分后的頭文件
為了保證我們的拆分生成的頭文件是自滿足的。通過上面分析,我們期望的每個(gè)結(jié)構(gòu)體的頭文件定義中所需要包含的頭文件如下:
- 包含基本類型定義的頭文件
- 包含結(jié)構(gòu)體中定義需要的宏定義
- 僅包含結(jié)構(gòu)體中嵌套的子結(jié)構(gòu)體的頭文件
由于已經(jīng)提前確定了基本類型的頭文件和宏定義的頭文件,在生成拆分結(jié)構(gòu)體的接口頭文件的時(shí)候只有需要在最開始的時(shí)候包含進(jìn)來就可以了。在處理的過程中,為了達(dá)到一次遍歷遺留系統(tǒng)的巨型頭文件,在掃描的過程中新建文件@test_struct_include_path,然后把結(jié)構(gòu)體中包含的子結(jié)構(gòu)所需要的頭文件先臨時(shí)添加到這個(gè)文件中,新建臨時(shí)文件@test_struct_file_path,把結(jié)構(gòu)體的定義內(nèi)容拷貝到這個(gè)文件內(nèi),最后結(jié)束之后再把兩個(gè)臨時(shí)文件拼接到一起生成最終我們需要的頭文件,下面為核心代碼邏輯樣例。
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
最后我們還需要處理一種特殊情況,就是對(duì)結(jié)構(gòu)體的名字進(jìn)行重定義,代碼如下:
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在原項(xiàng)目中的效果
1)拆分之后的頭文件首先按照原來的不同子系統(tǒng)之間進(jìn)行目錄隔離,如下:

2)然后每個(gè)目錄里面包含了原來公共接口下所有拆分的結(jié)構(gòu)體頭文件,如下:

3)拆分之后每個(gè)頭文件的形式如下:

使用這套ruby的自動(dòng)化腳本之后,只需要在命令行中敲入命令1秒之后,原來子系統(tǒng)之間所有的接口頭文件都已經(jīng)按照自己的要求拆分完成。當(dāng)我開發(fā)完成這套腳本之后,我們團(tuán)隊(duì)也在第一時(shí)間使用了,后續(xù)團(tuán)隊(duì)在開發(fā)和版本同步升級(jí)過程中再也不需要手動(dòng)拆頭文件,很大的節(jié)省團(tuán)隊(duì)這上面的時(shí)間浪費(fèi)。
結(jié)束語
在對(duì)遺留系統(tǒng)進(jìn)行重構(gòu)的過程中,首先需要解決一些糟糕的物理設(shè)計(jì)問題,通過可以經(jīng)過一些初級(jí)的重構(gòu)從而改善遺留系統(tǒng)的物理設(shè)計(jì)問題,然后才可以基于此之上才開始構(gòu)建測試體系,然后再深度重構(gòu)。