旧代码,丑陋的代码,复杂的代码,意大利面条似的代码,鬼话废话……就是四个字:遗留代码。这是一个系列文章,将有助于你处理并解决它。
在理想的世界中,你只会写新代码。你会把代码写得既漂亮又完美。你将永不会再看你的代码,并且你将永远不会维护一个有十年之久的项目。在理想的世界中…
不幸的是,我们生活在现实的而非理想的世界。我们必须理解修改和增强年代久远的代码这件事。我们必须处理遗留代码。那么你还在等什么?让我们一头扎进第一篇教程,拿着代码,读懂一点点,并为了我们日后的修改编织一张安全网。
遗留代码的定义
遗留代码有如此之多的方式去定义,不可能为其找到一个单一的,普遍被接受的定义。这篇教程开始的一些例子仅是九牛一毛。所以我不会给你们任何官方的定义。相反,我会给大家引用我喜欢的解释。
对于我来说,遗留代码就是没有被测试的简单代码。~ 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秒对于这样一小段代码来说,仍然是一个大量的时间。
在我们即将完成这个系列的时候,我们的测试程序运行将少于一秒并正确测试所有代码。所以,敬请期待我们的下一个教程,我们会处理魔术常量,魔幻字符串和复杂的条件句这些问题。感谢你的阅读。