# 讓開發自動化: 除掉構建腳本中的氣味
_創建一致、可重復、可維護的構建_
您把多少時間花在維護項目構建腳本上?也許遠遠超出您預期的或者可以忍受的時間。其實大可不必遭受如此痛苦的經歷。在這一期的_讓開發自動化_ 中,開發自動化專家 Paul Duvall 將演示如何改進很多常見的妨礙團隊創建一致的、可重復的、可維護的構建的實踐。
當描述代碼之類的東西時,我不喜歡 “氣味(smell)”這個詞。因為用擬人的手法來談論比特和字節往往令人覺得很怪異。并不是說“氣味”這個詞不能準確地反映出某種表明代碼可能有錯誤的癥狀,只是我覺得這樣聽起來很滑稽。然而,我依然選擇再次用這種令人厭煩的方式來描述軟件構建,坦白說,這是因為這些年我見過的很多構建腳本都_散發著難聞的氣味_。
在創建構建腳本時,即使是偉大的程序員也常常會遇到困難。就好像最近才學會如何編寫_程序性_ 代碼似的 —— 他們還會編寫龐大的單塊構建文件、通過復制-粘貼編寫代碼、對屬性進行硬編碼等等。我總是很想知道為什么會這樣。也許是因為構建腳本沒有被編譯成客戶最終會使用的東西?然而我們都知道,要創建客戶最終使用的代碼,構建腳本是中心,如果那些腳本敗絮其中,那么要想有效地_創建_ 軟件,就需要克服重重挑戰。
幸運的是,您可以輕松地在構建(不管是 Ant、Maven 還是定制的)之上部署一些實踐,它們雖然可以幫助您創建一致的、可重復的、可維護的構建,但其過程會很長。學習如何創建更好的構建腳本的一種有效的方法是搞清楚哪些事情_不要_ 去做,理解其中的道理,然后看看做事的_正確_ 方法。在本文中,我將詳細論述您應該避免的 9 種最常見的構建中的氣味,為什么應該避免它們,以及如何修復它們:
* 惟 IDE 的構建
* 復制-粘貼式的編寫腳本方法
* 冗長的目標
* 龐大的構建文件
* 沒有清理干凈
* 硬編碼的值
* 測試失敗還能構建成功
* 魔力機
* 格式的缺失
## 關于本系列
作為一名開發人員,我們的工作就是為用戶將過程自動化。然而,我們當中有很多人卻忽視了將我們自己的開發過程自動化的機會。為此,我編寫了 _讓開發自動化_ 這個系列的文章,專門探索軟件開發過程自動化的實際應用,并教您_何時_以及_如何_成功地應用自動化。
這里無意給出完整的列表,不過這份列表的確代表了近年來我讀過的和_寫過的_構建腳本中,我遇到的較為常見的一些氣味。有些工具,例如 Maven,是為處理與構建有關的很多管道而設計的,它們可以幫助減輕部分氣味。但是無論使用什么工具,還是有很多問題會發生。
## 避免惟 IDE 的構建
惟 IDE(IDE-only)的構建是指_只能_通過開發人員的 IDE 執行的構建,不幸的是,這似乎在構建中很常見。惟 IDE 的構建的問題是,它助長了 “在我的計算機上能運行”問題,即軟件在開發人員的環境中可以運行,但是在任何其他人的環境中就不能運行。而且,由于惟 IDE 構建自動化程度不是很高,因而為集成到持續集成(Continuous Integration)環境帶來極大的挑戰。實際上,沒有人為的干預,惟 IDE 常常無法自動化。
我們要清楚:使用 IDE 來執行構建并沒有錯,但是 IDE 不應該成為_能_構建軟件的惟一環境。特別是,一個完全用腳本編寫的構建,可以使開發團隊能夠使用多種 IDE,因為只存在從 IDE 到構建的依賴性,而不存在相反方向的依賴性,如圖 1 所示:
##### 圖 1\. IDE 與構建的依賴關系

惟 IDE 的構建有礙自動化,清除的惟一方法就是創建可編寫腳本的構建。有足夠的文檔和太多的書籍可以為您提供指導(見 [參考資料](#resources)),而像 Maven 之類的項目也為從頭開始定義構建提供了極大的方便。不管采用何種方法,都是選擇一種構建平臺,然后盡快地讓項目成為可編寫腳本的。
* * *
## 復制-粘貼就像廉價的香水
復制代碼是軟件項目當中一個常見的問題。實際上,甚至很多流行的開放源碼項目都存在 20% 到 30% 的復制代碼。代碼復制令軟件程序更難于維護,同理,構建腳本中的復制代碼也存在這樣的問題。例如,想象一下,假設您需要通過 Ant 的 `fileset` 類型引用特定的文件,如清單 1 所示:
##### 清單 1\. 復制-粘貼 Ant 腳本
```
<fileset dir="./brewery/src" >
<include name="**/*.java"/>
<exclude name="**/*.groovy"/>
</fileset>
```
如果需要在其他地方引用這組文件,例如為了編譯、檢查或生成文檔,那么最終您可能會在多個地方使用相同的 `fileset`。如果在將來某個時候,您需要對那個 `fileset` 做出修改(比如說排除 `.groovy` 文件),那么最終可能需要在多個地方做更改。顯然,這不是可維護的解決方案。然而,要除掉這股氣味其實很簡單。
如清單 2 所示,通過 Ant 的 `patternset` 類型可以引用一個邏輯名稱,以表示所需要的文件。那么,當需要向 `fileset` 添加(或排除)文件時,只需更改_一次_。
##### 清單 2\. 復制-粘貼 Ant 腳本
```
<patternset id="sources.pattern">
<include name="**/*.java"/>
<exclude name="**/*.groovy"/>
</patternset>
...
<fileset dir="./brewery/src">
<patternset refid="sources.pattern"/>
</fileset>
```
對于精通面向對象編程的人來說,這種修復方法看上去很熟悉:既定的慣例不是在不同的類中一次又一次地定義相同的邏輯,而是將那個邏輯放在一個方法中,在不同地方都可以調用這個方法。于是,這個方法成為惟一的維護點,從而可以限制錯誤級聯并可以鼓勵重用。
* * *
## 不要摻入冗長目標的氣味
Martin Fowler 在他撰寫的 _Refactoring_ 這本書中,對代碼中存在_冗長方法_的氣味這個問題做了精妙的描述 —— 過程越長,越難理解。實際上,冗長方法最終會擔負太多的責任。當談到構建時, _冗長目標_這種構建氣味是指更難于理解和維護的腳本。清單 3 就展示了一個相當冗長的目標:
##### 清單 3\. 冗長目標
```
<target name="run-tests">
<mkdir dir="${classes.dir}"/>
<javac destdir="${classes.dir}" debug="true">
<src path="${src.dir}" />
<classpath refid="project.class.path"/>
</javac>
<javac destdir="${classes.dir}" debug="true">
<src path="${test.unit.dir}"/>
<classpath refid="test.class.path"/>
</javac>
<mkdir dir="${logs.junit.dir}" />
<junit fork="yes" haltonfailure="true" dir="${basedir}" printsummary="yes">
<classpath refid="test.class.path" />
<classpath refid="project.class.path"/>
<formatter type="plain" usefile="true" />
<formatter type="xml" usefile="true" />
<batchtest fork="yes" todir="${logs.junit.dir}">
<fileset dir="${test.unit.dir}">
<patternset refid="test.sources.pattern"/>
</fileset>
</batchtest>
</junit>
<mkdir dir="${reports.junit.dir}" />
<junitreport todir="${reports.junit.dir}">
<fileset dir="${logs.junit.dir}">
<include name="TEST-*.xml" />
<include name="TEST-*.txt" />
</fileset>
<report format="frames" todir="${reports.junit.dir}" />
</junitreport>
</target>
```
這個冗長的目標(相信我,我還見過_冗長_得多的目標)要執行四個不同的過程:編譯源代碼、編譯測試、運行 JUnit 測試和創建一個 JUnitReport。要擔負的責任已經夠多了,更不用說將所有 XML 放在一個地方所增加的相關的復雜性。實際上,這個目標可以拆分成四個不同的、邏輯上的目標,如清單 4 所示:
##### 清單 4\. 提取目標
```
<target name="compile-src">
<mkdir dir="${classes.dir}"/>
<javac destdir="${classes.dir}" debug="true">
<src path="${src.dir}" />
<classpath refid="project.class.path"/>
</javac>
</target>
<target name="compile-tests">
<mkdir dir="${classes.dir}"/>
<javac destdir="${classes.dir}" debug="true">
<src path="${test.unit.dir}"/>
<classpath refid="test.class.path"/>
</javac>
</target>
<target name="run-tests" depends="compile-src,compile-tests">
<mkdir dir="${logs.junit.dir}" />
<junit fork="yes" haltonfailure="true" dir="${basedir}" printsummary="yes">
<classpath refid="test.class.path" />
<classpath refid="project.class.path"/>
<formatter type="plain" usefile="true" />
<formatter type="xml" usefile="true" />
<batchtest fork="yes" todir="${logs.junit.dir}">
<fileset dir="${test.unit.dir}">
<patternset refid="test.sources.pattern"/>
</fileset>
</batchtest>
</junit>
</target>
<target name="run-test-report" depends="compile-src,compile-tests,run-tests">
<mkdir dir="${reports.junit.dir}" />
<junitreport todir="${reports.junit.dir}">
<fileset dir="${logs.junit.dir}">
<include name="TEST-*.xml" />
<include name="TEST-*.txt" />
</fileset>
<report format="frames" todir="${reports.junit.dir}" />
</junitreport>
</target>
```
可以看到,由于每個目標只擔負一種責任,清單 4 中的代碼理解起來要容易得多。根據用途分離目標,不但可以減少復雜性,還為在不同上下文中使用目標創造了條件,必要時還可以重用。
* * *
## 龐大的構建文件也有一種很重的氣味
Fowler 還將 _龐大的類_也看作一種代碼氣味。就構建腳本而言,有這種類似氣味的就是龐大的構建文件,它相當難以讀懂。很難知道哪個目標是做什么的,目標的依賴關系是什么。這同樣會給維護帶來問題。而且,龐大的構建文件通常有相當多的剪切-粘貼的痕跡。
為了縮小構建文件,可以從腳本中找出邏輯上相關的部分,將它們提取到更小的構建文件中,由主構建文件來執行這些較小的構建文件(例如,在 Ant 中,可以使用 `ant` 任務調用其他構建文件)。
通常,我喜歡根據核心功能拆分構建腳本,確保它們可以作為獨立腳本來執行(想想構建組件化)。例如,在我的 Ant 構建中,我喜歡定義四種類型的開發者測試:單元、組件、系統和功能。而且,我還喜歡運行四種類型的自動檢查工具:編碼標準、依賴性分析、代碼覆蓋范圍和代碼復雜度。我不是將這些測試和檢查工具的執行放在一個龐大的構建腳本中(還加上編譯、數據庫集成和部署),而是將測試和檢查工具的執行目標提取到兩個不同的構建文件中,如圖 2 所示:
##### 圖 2\. 提取構建文件

更小、更簡潔的構建文件維護和理解起來要容易得多。實際上,這種模式對于代碼而言同樣適用。我們似乎在這里看到了模式的概念,不是嗎?
* * *
## 沒有清理
沒有嚴格減少所有底層假設的構建無疑是一顆定時炸彈。例如,如果構建沒有避免一些簡單的假設,例如會去掉用陳舊的數據生成的二進制文件,那么前一次構建遺留下來的文件就會引起錯誤。或者,正是_由于_前一次構建留下的文件,構建竟然得以"成功",這種情況更糟糕。
幸運的是,這個問題的解決辦法很直觀:只需刪除任何之前的構建留下的所有目錄和文件,就可以很容易地消除假設。這個簡單的動作就可以減少假設,保證構建的成功或失敗都是正確的。清單 5 演示了通過使用 `delete` Ant 任務刪除之前的構建所使用的所有文件或目錄,從而清理構建環境的一個例子:
##### 清單 5\. 事先清理
```
<target name="clean">
<delete dir="${logs.dir}" quiet="true" failonerror="false"/>
<delete dir="${build.dir}" quiet="true" failonerror="false"/>
<delete dir="${reports.dir}" quiet="true" failonerror="false"/>
<delete file="cobertura.ser" quiet="true" failonerror="false"/>
</target>
```
眾所周知,舊的構建遺留下來的文件會導致很多不必要的麻煩。為了自己的方便,在運行一個構建之前,務必先刪除構建所創建的任何工件。
* * *
## 硬編碼的臭味
復制-粘貼式的編程有礙重用,將值進行硬編碼又何嘗不是呢。當構建腳本包含硬編碼的值時,如果某個方面需要修改,那么就需要在多個地方修改那個值。更糟糕的是,很可能會忽略了某個地方而沒有改那個值,從而引起與不匹配的值相關的錯誤,這種錯誤是很隱蔽的。而且,如果相信我的建議,選擇使用多個構建腳本,那么硬編碼的值將可能會成為構建維護中最終的挑戰。在這一點上也請相信我!
例如,在清單 6 中,`run-simian` 任務有很多硬編碼的路徑和值,即 `_reports` 目錄:
##### 清單 6\. 硬編碼的值
```
<target name="run-simian">
<taskdef resource="simiantask.properties"
classpath="simian.classpath" classpathref="simian.classpath" />
<delete dir="./_reports" quiet="true" />
<mkdir dir="./_reports" />
<simian threshold="2" language="java"
ignoreCurlyBraces="true" ignoreIdentifierCase="true" ignoreStrings="true"
ignoreStringCase="true" ignoreNumbers="true" ignoreCharacters="true">
<fileset dir="${src.dir}"/>
<formatter type="xml" toFile="./_reports/simian-log.xml" />
</simian>
<xslt taskname="simian"
in="./_reports/simian-log.xml"
out="./_reports/Simian-Report.html"
style="./_config/simian.xsl" />
</target>
```
如果硬編碼 `_reports` 目錄,那么當我決定將 Simian 報告放到另一個目錄時,就會很麻煩。而且,如果其他工具在腳本的其他地方使用這個目錄,那么很可能會有人輸錯目錄名稱,導致報告顯示在不同的目錄中。這時可以定義一個屬性值,由這個屬性值指向這個目錄。然后,在整個腳本中都可以引用這個屬性,這意味著當需要更改的時候,只需光顧一個地方,即屬性的定義。清單 7 展示了重構之后的 `run-simian` 任務:
##### 清單 7\. 使用屬性
```
<target name="run-simian">
<taskdef resource="simiantask.properties"
classpath="simian.classpath" classpathref="simian.classpath" />
<delete dir="${reports.simian.dir}" quiet="true" />
<mkdir dir="${reports.simian.dir}" />
<simian threshold="${simian.threshold}" language="${language.type}"
ignoreCurlyBraces="true" ignoreIdentifierCase="true" ignoreStrings="true"
ignoreStringCase="true" ignoreNumbers="true" ignoreCharacters="true">
<fileset dir="${src.dir}"/>
<formatter type="xml" toFile="${reports.simian.dir}/${simian.log.file}" />
</simian>
<xslt taskname="simian"
in="${reports.simian.dir}/${simian.log.file}"
out="${reports.simian.dir}/${simian.report.file}"
style="${config.dir}/${simian.xsl.file}" />
</target>
```
硬編碼的值不僅沒有提高靈活性,反而擬制了靈活性。就像在源代碼中很容易硬編碼數據庫連接 `String` 一樣,在構建腳本中也應該避免將路徑之類的東西硬編碼。
* * *
## 測試失敗時,構建卻能成功
構建遠遠不止于單純的源代碼編譯,它還可能包括自動化開發者測試的執行,如果想讓軟件一直正常運行,那么決不能允許構建中有任何失敗的測試。別忘了,如果測試都得不到信任,那么還要測試干什么呢?
清單 8 是這種構建氣味的一個例子。注意 `junit` Ant 任務的 `haltonfailure` 屬性被設置為 `false`(它的缺省值)。這意味著即使任何 JUnit 測試是失敗的,構建也_不會_失敗。
##### 清單 8\. 氣味:測試失敗,構建卻成功
```
<junit fork="yes" haltonfailure="false" dir="${basedir}" printsummary="yes">
<classpath refid="test.class.path" />
<classpath refid="project.class.path"/>
<formatter type="plain" usefile="true" />
<formatter type="xml" usefile="true" />
<batchtest fork="yes" todir="${logs.junit.dir}">
<fileset dir="${test.unit.dir}">
<patternset refid="test.sources.pattern"/>
</fileset>
</batchtest>
</junit>
```
有兩種方法防止構建中的這種氣味。第一種方法是將 `haltonfailure` 屬性設置為 `true`。這樣就可以防止測試失敗構建卻成功的情況發生。
對于這種方法,我惟一不喜歡的地方是,我想看看有多大百分比的測試遭到了失敗,以便弄清楚失敗的模式。因此第二種方法就是,每當有測試失敗,就設置一個屬性。然后,我對 Ant 進行配置,使得當執行了所有的測試之后,構建最終失敗。這兩種方法都行之有效。清單 9 演示了使用 `tests.failed` 屬性的第二種方法:
##### 清單 9\. 測試令構建失敗
```
<junit dir="${basedir}" haltonfailure="false" printsummary="yes"
errorProperty="tests.failed" failureproperty="tests.failed">
<classpath>
<pathelement location="${classes.dir}" />
</classpath>
<batchtest fork="yes" todir="${logs.junit.dir}" unless="testcase">
<fileset dir="${src.dir}">
<include name="**/*Test*.java" />
</fileset>
</batchtest>
<formatter type="plain" usefile="true" />
<formatter type="xml" usefile="true" />
</junit>
<fail if="tests.failed" message="Test(s) failed." />
```
如果測試失敗時構建還能通過,就會提供關于安全性的一種錯感。如果測試失敗,那么讓構建也失敗:早一點從容地處理問題,總比以后問題半夜三更把您從夢中喚醒要好。
* * *
## 魔力機的氣味
在本文談到的所有氣味當中,這一種也許是最難聞的,因為_魔力機(magic machine)_是那種剛好_惟一_能夠構建一個公司的軟件應用程序的硬件。這種情況看上去難以相信,實則不然。在我的職業生涯中,就多次碰到過它。當依賴性丟失,或者當不斷累積的問題爆發時,這些機器就獲得了所謂的魔力。
我們很容易看出,公司基礎設施中的一臺正常的機器是如何獲得魔力的:隨著時間的推移,開發者無意間在機器的腳本中添加了硬性的依賴性,包含了對目錄路徑的全限定引用,甚至安裝了只有一臺機器上有的工具,久而久之,構建在任何其他機器上再也不能運行了。圖 3 就展示了一個例子:
##### 圖 3\. 魔力機

對一臺機器的硬編碼引用,包括特定驅動器(例如 C:)的路徑,以及機器上特有的工具,都是令一臺機器著魔的罪魁禍首。_每當_看到對 C: 盤的引用,或者看到對特定工具(例如 `grep`)的調用時,應該馬上更改腳本。如果發現自己聲稱 "`C:\Program Files\` 目錄在_每_臺機器上都有" 的時候,也要三思。
* * *
## 不良格式也有氣味
和主流語言中的編程格式一樣,在管理構建腳本的時候,也有類似的考慮。當為構建腳本考慮編程格式的時候,需要考慮以下幾個方面:
* 屬性名稱
* 目標名稱
* 目錄名稱
* 環境變量名稱
* 縮進
* 代碼行長度
就個人而言,對于格式上的約定,我喜歡盡可能利用他人的規則。幸運的是,有人已經提供了那樣的參考,即 _The Elements of Ant Style_ (見 [參考資料](#resources))。在這本書中,作者描述了各種規則,例如用小寫字母加上用于分隔單詞的連字符來命名目標,以及代碼行長度和縮進等。不管選擇哪一種方法,始終如一地應用有關格式的規則有助于構建文件的長期維護。
* * *
## 構建從來沒有如此好聞
我尚能忍受廉價香水的氣味。但是,如果說有一樣東西我無法忍受的話,那一定是難于維護的構建腳本所散發出的氣味。差勁的代碼顯然會浪費您寶貴的時間,設計不良的構建也不例外。如果構建中還飄散著不一致的、不可重復的和不可維護的氣味,那么現在就花時間重構這些至關重要的資源吧。您的開發環境定會香如玫瑰。
- 追求代碼質量
- 追求代碼質量: 對 Ajax 應用程序進行單元測試
- 追求代碼質量: 使用 TestNG-Abbot 實現自動化 GUI 測試
- 追求代碼質量: 用 AOP 進行防御性編程
- 追求代碼質量: 探究 XMLUnit
- 追求代碼質量: 用 JUnitPerf 進行性能測試
- 追求代碼質量: 通過測試分類實現敏捷構建
- 追求代碼質量: 可重復的系統測試
- 追求代碼質量: JUnit 4 與 TestNG 的對比
- 追求代碼質量: 馴服復雜的冗長代碼
- 追求代碼質量: 用代碼度量進行重構
- 追求代碼質量: 軟件架構的代碼質量
- 讓開發自動化: 除掉構建腳本中的氣味
- 追逐代碼質量: 決心采用 FIT
- 追求代碼質量: 不要被覆蓋報告所迷惑