舊程式碼,醜陋的程式碼,複雜的程式碼,義大利麵條似的程式碼,鬼話廢話……就是四個字:遺留程式碼。這是一個系列文章,將有助於你處理並解決它。
在理想的世界中,你只會寫新程式碼。你會把程式碼寫得既漂亮又完美。你將永不會再看你的程式碼,並且你將永遠不會維護一個有十年之久的專案。在理想的世界中…
不幸的是,我們生活在現實的而非理想的世界。我們必須理解修改和增強年代久遠的程式碼這件事。我們必須處理遺留程式碼。那麼你還在等什麼?讓我們一頭扎進第一篇教程,拿著程式碼,讀懂一點點,併為了我們日後的修改編織一張安全網。
遺留程式碼的定義
遺留程式碼有如此之多的方式去定義,不可能為其找到一個單一的,普遍被接受的定義。這篇教程開始的一些例子僅是九牛一毛。所以我不會給你們任何官方的定義。相反,我會給大家引用我喜歡的解釋。
對於我來說,遺留程式碼就是沒有被測試的簡單程式碼。~ Michael Feathers
好吧,這是第一個對遺留程式碼正式的定義,由 Michael Feathers 在他的書《修改程式碼的藝術》(Working Effectively with Legacy Code)中給出。當然,業界很久以來都使用這個表述,主要針對任何很難修改的程式碼。但是這個定義給出了一些不同的方面。它把問題解釋得很清晰,以至於解決方法變得很明顯。“很難修改”是如此得模糊。我們應該做什麼來使得它容易修改?我們不知道!另一方面“未測試的程式碼”是具體的。對於我們之前的一個問題就簡單了,讓程式碼可以測試並且測試它。那麼讓我們開始吧。
得到遺留程式碼
這個系列將基於J.B. Rainsberger為遺留程式碼撤退事件所寫的特殊益智問答遊戲而來。它被開發得像是真的遺留程式碼,並在一個相當困難的等級上,提供了各種各樣重構的機會。
檢出原始碼
益智問答遊戲放在GitHub上,並且遵循GPLv3許可,所以你可以自由使用。我們將從檢出官方資料庫開始我們的系列教程。我們將要做出修改的程式碼也會附在本教程中,所以如果你仍有疑惑,你可以對最後的結果來個先睹為快。
1 2 3 4 5 6 7 8 |
$ git clone https://github.com/jbrains/trivia.git Cloning into 'trivia'... remote: Counting objects: 429, done. remote: Compressing objects: 100% (262/262), done. remote: Total 429 (delta 100), reused 419 (delta 93) Receiving objects: 100% (429/429), 848.33 KiB | 305.00 KiB/s, done. Resolving deltas: 100% (100/100), done. Checking connectivity... done. |
當你開啟Trivia的目錄,你會發現我們的程式碼有幾種編碼語言。我們將用PHP來演示,當然你可以選擇你最喜歡的一個語言,並且適用於這裡介紹的技巧。
理解程式碼
根據定義,遺留程式碼很難理解,特別是當我們不知道它能做什麼的時候。所以第一步是執行程式碼,並且做出某些推理它是關於什麼的。
在目錄中我們有兩個檔案。
1 2 3 4 5 6 7 |
$ cd php/ $ ls -al total 20 drwxr-xr-x 2 csaba csaba 4096 Mar 10 21:05 . drwxr-xr-x 26 csaba csaba 4096 Mar 10 21:05 .. -rw-r--r-- 1 csaba csaba 5568 Mar 10 21:05 Game.php -rw-r--r-- 1 csaba csaba 410 Mar 10 21:05 GameRunner.php |
對我們執行程式碼,GameRunner.php似乎是個不錯的選擇。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
$ php ./GameRunner.php Chet was added They are player number 1 Pat was added They are player number 2 Sue was added They are player number 3 Chet is the current player They have rolled a 4 Chet's new location is 4 The category is Pop Pop Question 0 Answer was corrent!!!! Chet now has 1 Gold Coins. Pat is the current player They have rolled a 2 Pat's new location is 2 The category is Sports Sports Question 0 Answer was corrent!!!! Pat now has 1 Gold Coins. Sue is the current player They have rolled a 1 Sue's new location is 1 The category is Science Science Question 0 Answer was corrent!!!! Sue now has 1 Gold Coins. Chet is the current player They have rolled a 4 ## Some lines removed to keep ## the tutorial at a reasonable size Answer was corrent!!!! Sue now has 5 Gold Coins. Chet is the current player They have rolled a 3 Chet is getting out of the penalty box Chet's new location is 11 The category is Rock Rock Question 5 Answer was correct!!!! Chet now has 5 Gold Coins. Pat is the current player They have rolled a 1 Pat's new location is 10 The category is Sports Sports Question 1 Answer was corrent!!!! Pat now has 6 Gold Coins. |
好的,我們的猜測是正確的。我們的程式碼跑起來並且有了一些輸出。分析這些輸出,有助於我們推斷一些程式碼做了什麼的基本概念。
- 1.我們知道它是一個益智問答遊戲。當我們檢出原始碼的時候我們知道的。
- 2.我們的例子有三個玩家:Chet、Pat和Sue。
- 3.有擲骰子或者相似的概念。
- 4.一個玩家有一個當前位置。可能在某種告示牌上?
- 5.對於被問及的問題有各種分類。
- 6.使用者回答問題。
- 7.答案正確,會給予玩家金幣。
- 8.錯誤的答案會把玩家送入禁區。
- 9.玩家可以從禁區出來,基於一些不明確的邏輯。
- 10.似乎第一個拿到6枚金幣的使用者就獲勝了。
這已經知道很多了。我們可以僅通過輸出就弄清楚該應用的基本行為。在真實的應用當中,輸出未必顯示在螢幕上,但它可能是一個網頁,一個錯誤日誌,一個資料庫,一個網路連線,一個轉儲檔案等等。在其他的情況下,你需要修改的模組是不能單獨執行的。如果這樣,你將需要通過更大的應用程式中的其他模組來執行它。僅僅嘗試新增最小的模組組合,從你的遺留程式碼中得到一些合理的輸出。
掃描程式碼
現在我們對於程式碼輸出有了一些認識,我們可以開始看程式碼了。我們將從執行器(runner)程式碼開始。
Game Runner
用 IDE 格式化所有程式碼後,我喜歡這樣來執行程式碼。通過以我習慣的方式,能極大提高程式碼可讀性,所以這段程式碼:
…將變成這樣:
…這樣比較好一些。對於這樣少量的程式碼來說,可能不是很大的變化,但它將用在我們後面的檔案中。
檢視GameRunner.php檔案,我們很容易認出一些之前我們看到的輸出中的關鍵點。我們可以看到增加使用者的行(9-11),roll()方法被呼叫了並且勝出者也選出了。當然,離這個邏輯遊戲的內在祕密還有很遠,但至少我們開始認出關鍵方法,這將幫助我們探索剩下的程式碼。
遊戲檔案
我們也要對Game.php檔案進行同樣的格式化。
這個檔案很大;大約200行程式碼。大部分方法都是大小適中,但其中一些卻很大並且在格式化之後,我們可以看到在兩個地方程式碼的縮排已經超過四個層次了。高層次的縮排通常意味著很多更復雜的抉擇,所以目前,我們假定程式碼中的這些點將更復雜並且對修改更敏感。
金牌大師
改變的想法促使我們認識到缺少測試。我們在Game.php中看到的程式碼相當複雜。如果你不理解它們那麼別擔心。此時,它們對於我來說也是個迷。遺留程式碼是個我們需要解決和理解的謎題。我們第一步去理解它,現在是時候進行我們的第二步了。
那麼什麼是金牌大師?
當面對遺留程式碼時,幾乎不可能理解它並且寫出完全執行程式碼所有路徑的測試程式碼。對於這種測試,我們需要理解程式碼,但我們還沒能這麼做。所以我們需要採取另一個方法。
替代試圖弄清楚去測試什麼,我們可以測試所有東西許多遍,以便我們有大量的輸出來結束,這樣我們幾乎可以認為這些輸出是執行了遺留程式碼的所有部分產生的。建議是執行程式碼至少10000次。我們將寫一個測試程式執行它兩次並儲存輸出。
寫金牌大師生成器
我們可以提前考慮並開始建立一個生成器和一個測試程式作為將來測試的兩個檔案,但有必要嗎?我們還不能肯定。那麼為什麼不從一個基本的測試檔案開始,執行我們的程式碼一次並且從那裡構建我們的邏輯。
你將發現附件程式碼存檔,在source資料夾裡面但在trivia資料夾外面有我們的Test?資料夾。在這個資料夾裡,我們建立了一個檔案:GoldenMasterTest.php。
1 2 3 4 5 6 7 8 9 10 11 12 |
class GoldenMasterTest extends PHPUnit_Framework_TestCase { function testGenerateOutput() { ob_start(); require_once __DIR__ . '/../trivia/php/GameRunner.php'; $output = ob_get_contents(); ob_end_clean(); var_dump($output); } } |
我們可以用很多種方式做這個。舉個例子,我們可以從控制檯執行我們的程式碼並將它輸出到檔案。然而,我們不應該忽視這樣一個優勢,建立測試檔案並在我們的IDE中是很容易執行的。
程式碼很簡單,它緩衝了輸出,並且將其放入$output這個變數。在包含的檔案內,方法require_once()也會執行所有程式碼。在我們的變數區我們將看到一些已經熟悉的輸出。
但在第二次執行時,我們看到一些奇怪的東西:
…輸出不一樣了。即使我們執行了同樣的程式碼,輸出卻不一樣了。滾動的數字不一樣,玩家的位置不一樣。
為隨機數生成器播種
1 2 3 4 5 6 7 8 9 10 11 |
do { $aGame->roll(rand(0, 5) + 1); if (rand(0, 9) == 7) { $notAWinner = $aGame->wrongAnswer(); } else { $notAWinner = $aGame->wasCorrectlyAnswered(); } } while ($notAWinner); |
通過分析執行器的基本程式碼,我們看到它使用rand()這個方法來生成隨機數。我們接下來做的是通過官方的PHP文件來研究rand()這個方法。
隨機數生成器是自動播種的。
文件告訴我們播種是自動發生的。現在我們有了另一個任務。我們需要找到一種方式去控制種子。srand()方法可以幫助做到。這裡是它從文件來的定義。
為隨機數發生器播種或者沒提供種子時生成隨機值。
它告訴我們,如果我們在任何對rand()的呼叫前執行它,我們應該總會以相同結果結束執行。
1 2 3 4 5 6 7 8 9 |
function testGenerateOutput() { ob_start(); srand(1); require_once __DIR__ . '/../trivia/php/GameRunner.php'; $output = ob_get_contents(); ob_end_clean(); var_dump($output); } |
我們在require_once()之前放上srand(1)。現在輸出總是一樣的了。
將輸出放入檔案
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class GoldenMasterTest extends PHPUnit_Framework_TestCase { function testGenerateOutput() { file_put_contents('/tmp/gm.txt', $this->generateOutput()); $file_content = file_get_contents('/tmp/gm.txt'); $this->assertEquals($file_content, $this->generateOutput()); } private function generateOutput() { ob_start(); srand(1); require_once __DIR__ . '/../trivia/php/GameRunner.php'; $output = ob_get_contents(); ob_end_clean(); return $output; } } |
這個修改看起來很合理。對嗎?我們提取程式碼生成一個方法,執行兩次,並期待輸出相同結果。但是它們不同。
原因是require_once()兩次沒有請求相同的檔案。第二次呼叫generateOutput()方法將產生一個空的字串。所以,我們能做什麼呢?我們單單呼叫require()怎麼樣?那樣應該就可以每次執行到了。
好吧,這又導致了另一個問題:”Cannot redeclare echoln()”。但它從哪裡來?恰恰是在Game.php檔案的開始處。這個錯誤發生的原因是因為GameRunner.php 中我們有 include __DIR__ . ‘/Game.php’;,每次當我們呼叫generateOutput()方法的時候它會試圖引入Game檔案兩次。
1 |
include_once __DIR__ . '/Game.php'; |
使用GameRunner.php中的include_once將解決我們的問題。是的,到目前為止我們需要修改GameRunner.php使得沒有針對它的測試。然而,我們可以99%得確定我們的修改不會破壞程式碼本身。這是一個小而簡單的修改並不會讓我們很害怕。最重要的是,它會使測試通過。
執行許多次
現在我們有了可以執行多次的程式碼,是時候生成一些輸出了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function testGenerateOutput() { $this->generateMany(20, '/tmp/gm.txt'); $this->generateMany(20, '/tmp/gm2.txt'); $file_content_gm = file_get_contents('/tmp/gm.txt'); $file_content_gm2 = file_get_contents('/tmp/gm2.txt'); $this->assertEquals($file_content_gm, $file_content_gm2); } private function generateMany($times, $fileName) { $first = true; while ($times) { if ($first) { file_put_contents($fileName, $this->generateOutput()); $first = false; } else { file_put_contents($fileName, $this->generateOutput(), FILE_APPEND); } $times--; } } |
這裡我們抽出了另一個方法:generateMany()。它有兩個引數。一個是我們想要執行生成器的次數,另一個是目標檔案。它將把生成的輸出放到檔案當中去。第一次執行時,它清空檔案,剩下的迭代,它會附加資料。你可以檢視檔案,看看執行20次生成的輸出。
但等等!同一個玩家每次都贏?這可能嗎?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
at /tmp/gm.txt | grep "has 6 Gold Coins." Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. |
是的,這是可能的!它不單單是可能的。而是肯定的事。對於隨機功能我們提供了相同的種子。我們一遍遍得玩同一個遊戲。
每次以不同的方式執行程式
我們需要玩個不一樣的遊戲,否則幾乎可以肯定我們的遺留程式碼僅有一小部分在真正地一遍遍執行。金牌大師的範圍是執行儘可能多的程式碼。我們需要每次都給隨機數生成器以種子,但通過控制的方式。一種選擇是使用計數器作為種子值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
private function generateMany($times, $fileName) { $first = true; while ($times) { if ($first) { file_put_contents($fileName, $this->generateOutput($times)); $first = false; } else { file_put_contents($fileName, $this->generateOutput($times), FILE_APPEND); } $times--; } } private function generateOutput($seed) { ob_start(); srand($seed); require __DIR__ . '/../trivia/php/GameRunner.php'; $output = ob_get_contents(); ob_end_clean(); return $output; } |
這仍然能使我們的測試程式執行,所以我們確信,當輸出每次迭代都執行一個不同的遊戲時,其都生成了相同的完整輸出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
cat /tmp/gm.txt | grep "has 6 Gold Coins." Sue now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Pat now has 6 Gold Coins. Pat now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Sue now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Sue now has 6 Gold Coins. Chet now has 6 Gold Coins. Sue now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. Pat now has 6 Gold Coins. Chet now has 6 Gold Coins. Chet now has 6 Gold Coins. |
在隨機方式下游戲有了多個勝出者。看起來不錯。
執行20000次
你要嘗試的第一件事是讓我們程式碼迭代20000次遊戲過程。
1 2 3 4 5 6 7 8 |
function testGenerateOutput() { $times = 20000; $this->generateMany($times, '/tmp/gm.txt'); $this->generateMany($times, '/tmp/gm2.txt'); $file_content_gm = file_get_contents('/tmp/gm.txt'); $file_content_gm2 = file_get_contents('/tmp/gm2.txt'); $this->assertEquals($file_content_gm, $file_content_gm2); } |
這幾乎就執行了。將生成兩個55M的檔案。
1 2 3 |
ls -alh /tmp/gm* -rw-r--r-- 1 csaba csaba 55M Mar 14 20:38 /tmp/gm2.txt -rw-r--r-- 1 csaba csaba 55M Mar 14 20:38 /tmp/gm.txt |
另一方面,測試會因為記憶體不足的錯誤而失敗。和你的機器有多少記憶體無關,測試將會失敗。我有8G多的記憶體並有4G的交換區,它仍然失敗了。兩個字串只是太大了而不能在斷言中比較。
換句話說,我們生成了正常的檔案,但是PHPUnit不能比較他們。我們需要一個解決方法。
1 |
$this->assertFileEquals('/tmp/gm.txt', '/tmp/gm2.txt'); |
看上去這是一個好的選擇,但它仍然失敗了。真可惜。我們需要進一步研究現狀。
1 |
$this->assertTrue($file_content_gm == $file_content_gm2); |
然而這個可以執行。
這可以比較兩個字串而當它們不同時就會失敗。然而它有些小代價。當字串不同時,它不會準確地告知哪裡錯了。而僅僅會告知“Failed asserting that false is true.”。但我們將在後面的教程處理這個問題。
最後的思考
這篇教程結束了。在第一課我們學到了很多並且對於將來的工作有了一個好的開始。我們看了程式碼,以不同的方式分析它並且主要了解了它的基本邏輯。然後我們建立了一套測試程式來保證儘可能多得執行它。是的,測試執行非常慢。在我的Core i7 CPU的配置中它花了24秒才生成兩次輸出檔案。幸運的是,在我們將來的開發中,我們將保留gm.txt檔案不變,並且每次執行只生成另一個檔案一次。但12秒對於這樣一小段程式碼來說,仍然是一個大量的時間。
在我們即將完成這個系列的時候,我們的測試程式執行將少於一秒並正確測試所有程式碼。所以,敬請期待我們的下一個教程,我們會處理魔術常量,魔幻字串和複雜的條件句這些問題。感謝你的閱讀。