重构遗留代码(1):金牌大师
?
http://blog.jobbole.com/78635/
舊代碼,丑陋的代碼,復雜的代碼,意大利面條似的代碼,鬼話廢話……就是四個字:遺留代碼。這是一個系列文章,將有助于你處理并解決它。
在理想的世界中,你只會寫新代碼。你會把代碼寫得既漂亮又完美。你將永不會再看你的代碼,并且你將永遠不會維護一個有十年之久的項目。在理想的世界中…
不幸的是,我們生活在現實的而非理想的世界。我們必須理解修改和增強年代久遠的代碼這件事。我們必須處理遺留代碼。那么你還在等什么?讓我們一頭扎進第一篇教程,拿著代碼,讀懂一點點,并為了我們日后的修改編織一張安全網。
遺留代碼的定義
遺留代碼有如此之多的方式去定義,不可能為其找到一個單一的,普遍被接受的定義。這篇教程開始的一些例子僅是九牛一毛。所以我不會給你們任何官方的定義。相反,我會給大家引用我喜歡的解釋。
對于我來說,遺留代碼就是沒有被測試的簡單代碼。~ 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秒對于這樣一小段代碼來說,仍然是一個大量的時間。
在我們即將完成這個系列的時候,我們的測試程序運行將少于一秒并正確測試所有代碼。所以,敬請期待我們的下一個教程,我們會處理魔術常量,魔幻字符串和復雜的條件句這些問題。感謝你的閱讀。
轉載于:https://www.cnblogs.com/code-style/p/4120155.html
總結
以上是生活随笔為你收集整理的重构遗留代码(1):金牌大师的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 日历,日期类(copy)
- 下一篇: android 从零单排 第一期 按键显