重构遗留代码(7):识别表示层

旧代码,丑陋的代码,复杂的代码,意大利面条似的代码,鬼话废话……就是四个字:遗留代码。这是一个系列文章,将有助于你处理并解决它。

在我们重构教程的第七章,我们将做些不同类型的重构。我们注意到在过去的教程中有个表示层相关的代码遍布在遗留代码中。我们将尽我们所能试着识别所有的表示层相关代码并且采取必要步骤将其从业务逻辑中分离出来。

驱动力

无论何时我们重构修改代码,都是根据一些指导原则这么做的。这些原则和规则帮助我们识别问题,并且许多情况下,它们在为了使代码更好的正确方向上指引着我们。

单一职责原则(The Single Responsibility PrincipleSRP)

SRP是我们非常详细地在之前教程中谈到的一个坚实的原则之一:SOLID: Part 1 – The Single Responsibility Principle。如果你想要了解细节,我建议你读读这篇文章,否则就往下继续阅读来看看下面单一职责原则的概述。

SRP基本上说的是,任何模块,类或者方法只应有单一的职责。这样的职责定义为修改的核心。修改的核心是修改的方向,理由。所以,SRP意味着我们类的修改应该有单一的原因。

既然这听起来很简单,那么你怎么定义“修改的原因”?我们必须从代码的用户的角度来考虑这个问题,无论是普通的终端用户还是各个软件部门。这些用户可以代表演员。当一位演员想要我们修改代码,那就是一个决定修改核心的修改理由。这样的一个要求如果可能的话应该只影响我们的模块,类或者甚至是方法中的一个。

一个非常显著的例子可能是,如果我们的UI设计团队要求我们以某种方式提供需要被呈现的所有信息,以便我们的应用程序能够将其传递到HTML网页,来替代我们目前的命令行界面。

正如现在我们的代码所表示的,我们可以只将所有的文本发送给一些能将其传递给HTML的额外的智能对象上。但这可能起效仅仅因为HTML主要是基于文本的。那如果我们的UI团队想要以有窗口,按钮和许多表格的桌面UI来呈现我们的益智游戏会怎么样?

如果我们的用户想要以代表城市街道的虚拟游戏板,玩家作为在街上走来走去的人们这样来玩游戏会怎么样?

我们可以定义这些人为UI演员。并且我们必须意识到现在我们代码所代表的,可能我们需要修改trivia类和几乎它的所有方法。如果我想要修正屏幕上文本中一个错字去修改Game类的wasCorrectlyAnswered()方法或者如果我想要以虚拟游戏板来展现我们的trivia软件,这听起来合乎逻辑吗?不。答案绝对是不。

 

干净的架构

干净的架构是Robert C.Martin主要推广的一个概念。主要说的是我们的业务逻辑应该很好地定义并且与对系统核心功能不想关的其他模块清晰得分隔开。这将带来解耦和高可测试的代码。

你可能在我的教程和课程中已经见过这张图。我认为它如此重要以至于在我不考虑它的时候从不写代码或者谈论代码。它完全地改变了我们在Syneto写代码的方式和我们项目的样子。之前我们所有的代码都在MVC框架下,业务逻辑放在Models中。这样既难理解又难测试。而且业务逻辑与特定的MVC框架完全耦合。而这么做可能对小宠物项目来说可以起作用,但当涉及到一个公司将来可以赖以依靠,包括在某种程度上所有员工的一个大项目时,你就必须停止再用MVC框架并且必须开始思考如何组织你的代码。一旦你这么做了并且也做对了,你将永远不再想回到你曾经架构项目的方式上去。

 

察所属

在之前的一些教程中我们已经开始从表示层分离业务逻辑了。我们有时注意一些打印功能并将它们抽出到同一个Game类的私有方法中。这是我们的潜意识告诉我们在方法层面将表示层从业务逻辑中分离。

现在是时候分析和观察了。

这是Game.php文件中所有的变量,方法和函数的列表。标记橘色“f”的是变量。红色“m”的是方法。如果跟随着一个绿色的锁,它就是公开的。如果是跟着红色的锁,它就是私有的。从上面的列表中,所有我们所感兴趣的是下面的部分。

所有选中的方法有些共同的东西。它们所有的名字都以“display”…开头。它们是所有和打印到屏幕相关的方法。它们是所有在之前教程中被我们识别并无缝抽取的方法,一次一个。现在我们必须看到它们是属于一个整体的一组方法。做一件具体的事情,满足一个单一的职责,在屏幕上显示信息的一组方法。

 

提取重构

Martin Fowler在重构-改善既有代码的设计中对于提取类重构最好的例子和解释是,在你意识到你的类要做的工作应该由两个类来完成之后,你应该去构筑两个类。对着来说有特定的机制,正如下文中从上述的书中引用的解释:

  • 确定如何分割类的职责
  •  创建一个新类来表述分隔开的职责。

○   如果旧类的职责不在与其类名匹配,就重命名旧类。

  • 从旧类到新类做个连接。

○   你可能需要一个双向连接。直到你发现你需要它为止,不要创建反向连接。

  • 对每个你想移动的字段使用移动字段。
  • 在每次移动之后编译并测试。
  • 使用移动方法将旧类的方法移动到新类中。从低等级方法(被调用而不是调用)开始并向高等级构筑。
  • 在每次移动之后编译并测试。
  • 复审并削减每个类的接口。

○   如果你确实有双向连接,检查看看是否可以改成单向的。

  • 确定是否暴露新类。如果你暴露了新类,确定是否将其作为引用对象或者不可变的值对象来暴露。

 

应用提取类

不幸的是,在写这篇文章的时候,在PHP上没有一个IDE可以只通过选择一组方法并从菜单选择一个选项来完成提取类。

因为了解处理代码的流程机制永远不会有坏处,所以我们将采取上面的步骤,一个一个将方法重构到我们的代码中。

 

确定如何分割职责

我们已经知道这个了。我们想要将表示层从业务逻辑分离出来。我们想要将输出,显示功能和其他的代码移动到其他地方去。

 

创建一个新类

我们第一步是创建一个新的,空的类。

是的,这就是目前能做的。并且为其寻找一个合适的名字合适相当容易的。Display是代表我们开始感兴趣的所有方法的词。这是它们名字的共同点。这是关于它们共同行为一个相当强大的建议,根据行为我们定义新的类。

如果你愿意并且你的编程语言支持,PHP是支持的,你可以在旧类的相同文件中创建一个新的类。或者,你可以从一开始就为其创建一个新的文件。我个人发现没有明确的理由按照哪种方式或者禁止任何一种方式。所以这取决于你。只要决定了就继续吧。

创建从旧类到新类的连接

这一步可能听起来不是很熟悉。它的含义是,在旧类中声明一个类变量,并将其作为新类的实例。

简单。不是吗?在Game的构造函数中,我们只初始化了一个和新类同样名字的私有类变量,display。我们也需要将Display.php引入我们的Game.php文件。我们还没有自动装载器。也许未来的教程中如果需要的话我们可能会引入一个。

和往常一样,别忘了运行你的测试程序。单元测试在这个阶段足够了,只要保证在新加的代码中没有错字。

 

移动域和编译/测试

让我们一次做这两步。从Game到Display我们能识别什么域?

只通过观察列表…

…我们找不到任何变量/字段必须属于Display的。也许过段时间一些变量会浮现出来。所以这步什么都不做。至于测试程序,刚才我们已经运行过了。继续吧。

 

移动方法到新的类

这本身,是另一个重构。你可以以好几种方式来做,也将在我们之前谈论的相同书中发现精妙的定义。

正如上面提及的,我们应该从最低等级的方法开始。就是那些没有调用其他方法的,而被调用的方法。

displayPlayersNewLocation()似乎是个不错的候选方法。让我们分析下它做了什么。

我们可以看到在Game类中它并没有调用其他方法。取而代之的,它使用了三个域:players,currentPlayer和places。这些可以变成2或3个参数。到目前为止相当不错。但我们方法中唯一的函数调用echoln()怎么办?echoln()是从哪儿来的呢?

它在Game.php文件的顶部,在Game类本身的外面。

它确实如其表达的那么实行了。重复一个字符串并以一个新行结束。并且这是纯表示。他应该放入Display类中。那么让我们将其拷贝到那里。

再次运行我们的测试程序。直到我们完成提取所有的表示层代码到新的Display类中之前,我们可以使金牌大师测序保持禁用。任何时候,如果你感到输出可能已被修改,那么也要再次运行金牌大师测试程序。从这点来说,就通过拷贝函数到新的地方来说,测试程序将证明我们没有引入错字或者重复方法宣言,或者任何其它错误。

现在,去删除Game.php文件中的echoln()吧,运行我们的测试程序,并期望他们失败。

干得漂亮!我们的测试程序在这儿帮了大忙。它运行得很快并且告诉了我们问题的准确位置。我们去看55行。

 

看啊!那儿有个echoln()调用。测试程序从不撒谎。让我们通过调用$this->display->echoln()来修正它吧。

这使得测试程序通过55行但在56行失败了。

解决方案很明显。这是一个繁琐的过程,但至少它是容易的。

这真的使最初的三个测试程序通过了,也告诉我们我们下一个要修改的调用的地方。

那是在wrongAnswer()中。

修正这两个调用,将错误向下推到了228行。

一个display方法!也许这是我们最先应该移动的方法。我们试着在这儿做点测试驱动开发(TDD)。当测试失败时,我们不允许写任何对让测试通过来说不是完全必须的产品代码。而所需的仅仅是修改echoln()调用直到我们的单元测试通过。

你可以通过使用IDE或者编辑器的查找和替换功能来加速这一过程。只要在你完成了这个替换后,运行所有的包括金牌大师在内的测试程序。我们的单元测试没有覆盖所有的代码,和所有的echoln()调用。

我们可以处理最初的候选方法,displayCurrentPlayer()。将它复制到Display中并运行你的测试程序。

然后,在Display类中将其设为public,在Game类的displayCurrentPlayer()中用调用$this->display->displayCuttentPlayer()来替代直接用echoln()。最后,运行你的测试程序。

它们将失败。但通过这种方式的修改,我们确定只改了一样可能失败的代码。所有其他的方法仍然在调用Game类的displayCurrentPlayer()。并且这是对Display的委托。

我们的方法使用类字段。这些需要作为函数的参数。如果你跟踪测试程序的错误,你应该看到Game中以下面这样结束的代码。

在Display中。

用Display中的本地方法来替换对Game类的调用。也不要忘了将参数提高一个层级。

最后,从Game类中移除没有用到的方法。并且运行测试程序保证一切都没问题。

这是一个繁琐的过程。通过一次采取多种方法和使用无论你的IDE能做的来帮助在类之间移动和替换代码,你可以提点速。剩下的方法将留作你的练习,或者你可以阅读本章更多高亮显示的过程。本文所附的完成代码将包含完整的Display类。

啊,别忘了Game类没有被提取到“display”方法中的代码。你可能直接移动那些echoln()调用到display中。我们的目标是完全不调用Game类中的echoln(),并在Display类中将其设为private。

在做了短短半小时这样的工作之后,Display开始变得精妙起来。

 

所有来自Game类的显示方法都放到Display类中了。现在我们也可以看看仍然留在Game类中的所有echoln调用并且移动它们了。当然,测试是通过的。

但我们一面对askQuestion()方法,我们就意识到它也只是表示层代码。这意味着各个问题数组也应该放到Display类中。

这看起来是恰当的。问题只是字符串,我们展示它们并且它们在这儿也适应得更好。当我们做这种类型的重构时,对于新移动的代码来说也是个重构的好机会。我们定义声明字段的初始值,将它们设为私有,并创建一个需要被执行的方法以便它不只是徘徊在构造函数中。取而代之的,它被藏在类的底部,并不碍事。

在抽取了如下两个方法之后,我们意识到在Display类中,不以“display”开头来命名它们更好。

随着我们测试程序通过并且做得很好,现在可以重构并重命名方法了。PHPStorm可以很好地处理重命名重构。它会根据Game类重命名函数调用。下面就是这段代码。

仔细看看选中的119行。那看起来像是我们不久前在Display类中抽取的方法。

但如果我们调用它而不是代码,测试将失败。是的!那有个错字。也不是!不该修正它。我们在重构。我们必须保证功能不变,即使有个缺陷。

方法剩下的部分显示出没有特殊的挑战。

 

复审并削减接口

现在所有的表示层功能都在Display类中了,我们必须复审方法并保持它们在Game类中调用的方法为公开的。这一步骤也是出于我们过去教程中谈到的接口隔离原则

在我们的例子中,识别什么方法需要为公开或者私有最简单的方式是一次将每一个方法都设为私有的,运行测试程序,如果它们失败了就将其转为公开的。

因为金牌大师测试运行得慢,我们也可以依赖IDE来加速这一过程。PHPStorm足够智能而能分清方法是否没有用到。如果我们设一个方法私有的,而它突然变得未使用,很明显它是在Display类外使用的,需要将其改为公开。

最后,我们可以重新组织Display类以便私有方法放在类的最后。

 

最后的思考

现在,提取类重构原则的最后一步在我们的例子中是不相关的。因此,总结下教程,但还不是这个系列的总结。请继续关注下一片文章,我们将进一步得到一个干净的架构,并且反转依赖。

收藏 评论

关于作者:EluQ

南京土著,程序员。(新浪微博:@EluQ) 个人主页 · 我的文章 · 11

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部