软件开发似乎是关于变化的:业务发生了变化,我们需要反映这些变化,所以需求或规格发生了变化,框架和库发生了变化,所以我们必须改变与它们的集成,等等。相应地改变代码库往往是相当痛苦的,因为我们在许多方面使它抵制变化。
抵制变化的代码
我发现,并不是每个开发者都能注意到变化的 "痛苦程度"。举个例子,如果我不能重命名一个类,或者改变它的命名空间,我认为这是非常痛苦的。一个原因可能是有些类不是用Composer自动加载的,而是仍然用require 语句手动加载。另一个原因可能是,框架希望类有一个特定的名字,在一个特定的命名空间,等等。这可能是你个人不认为痛苦的事情,因为你可以通过简单地不考虑重命名或移动类来避免痛苦。
但是,最终你知道,像这样抵制变化的代码库会被认为是 "严重的遗留代码的情况"。这是因为在软件开发的世界里,你无法抗拒改变,最终会到了做出改变的时候,然后你就可以体验到被你推迟了很久的痛苦。
软件可以在很多方面抵制变化,这里只是我想到的几个例子。
- 类必须在某个目录/命名空间中,方法必须有特定的名称,才能被框架所接受
- 有另一个应用程序使用同一个数据库,它不能处理它不知道的额外列
- 只有当你先调用
session_start(),类的自动加载才能发挥作用 - 等等...。如果你有另一个很酷的例子,请把它作为评论添加到下面!
社会上形成的对变化的厌恶
厌恶改变也可以在社会上建立。举个例子,团队可能会使用一个规则,说 "如果你创建了一个类,你也必须为它创建一个单元测试"。这是非常糟糕的,因为你可以在一个测试中使用多个类,并且仍然称它为单元测试,所以每一个类都是一个单元的假设是完全错误的。更重要的是,你不可能对所有类型的类进行单元测试;有些类需要综合测试。总之,我们不要得意忘形;)我的观点是,如果你有这样的规则,你会让开发者更难添加一个新的类,因为他们害怕为它创建一个测试的额外(通常是无意义的)工作。在某种意义上,开发人员开始抵制变化。代码库本身也会抵制变化,因为单元测试往往离实现太近,使得设计的改变非常困难。
单元测试是我最喜欢的例子,但还有其他一些社会公认的做法也会阻碍变革的进行。比如,"不要改变这段代码,因为5年前Leo碰过它,我们不得不工作到半夜来修复生产"。或者 "我们向经理申请了一些时间来解决这个问题,但我们没有得到"。
让改变事情变得容易
从这些以及更多的--事实上是痛苦的经历中,我得出了一个结论:判断代码和设计质量的一个非常有力的方法就是回答这个问题:这容易改变吗?这种改变可以是关于一个函数名,一个文件的位置,安装一个Composer依赖,注入一个额外的构造器依赖,等等。
然而,有时自己进行这种评估真的很难,因为作为一个长期的开发者,你可能已经习惯了相当多的 "痛苦"。你可能会为了做一个改变而跳过重重障碍,甚至没有意识到这是愚蠢的,应该是更容易的。这就是结对编程或暴徒/集合编程真正有用的地方:在同一台电脑上一起工作会暴露出所有你避免的变化。
-
"嘿,我们给那个类重新命名吧!"
-
"好吧,我不确定我们是否可以,让我们把这个留到下一次吧。
-
"现在让我们把那个新的服务作为构造函数参数注入。"
-
"对不起,我们不能在代码库的这一部分使用依赖注入。"
这就是为什么我通常会全力以赴地进行集合编程,所以我们可以对团队避免的所有变化有一个清晰的看法。我们看着怪物的眼睛。
补遗:当变化破坏东西时
开发者厌恶变化的部分原因是变化可能会破坏其他东西的风险。如果你重命名一个方法,你也应该重命名所有相关的方法调用。幸运的是,现在我们有了静态反射,它可以告诉你任何你错过的调用站点。当然,在大多数情况下,IDE可以安全地帮你进行修改。不幸的是,情况并不总是这样的。这个世界上的很多代码都有以下问题:如果你重命名一个文件/类/方法/等等,你将无法找到所有你必须更新的地方。比如说。
- 类的名字可能被用来派生出一个字符串的名字(例如,一个
EmailValidator,可以通过动态调用'email'验证器来使用)。重命名该类会破坏验证。 - 控制器动作必须被称为
public function [name]Action(),否则就不能被调用。重命名类使得整个路由或端点无法到达。 - 一个模型类必须在
src/App/Entity,否则它将不会被添加到数据库模式中。 - 以此类推!
危险的是,除非你运行完整的应用程序(或其测试,如果你有完整的覆盖率),否则你无法看到你破坏了什么。这是很不方便的。这就是为什么一般的规则是 "好的代码容易改变",而 "容易改变 "的一部分是,一个没有在系统中完全传播的改变会提前 "爆发 "出来。除了早期警告,最好是静态错误(相对于运行时错误),如果我们在破坏一些东西后得到的错误信息是清晰的,并帮助我们找到问题(我们刚做的改变),那就更好了。在现实世界的项目中,这种情况并不总是存在。
建立一个安全网来防止破坏性的改变
让你的项目更安全地进行修改的一个方法是寻找这些错误。当你做了一个改变,而你知道(太晚了)它导致的一些问题时,要确保它永远不会再发生。举个例子,最近我在做一个Symfony控制台的命令。我想使用QuestionHelper ,它可以通过调用$this->getHelper('question') 来获取。作为我自己的一个服务的依赖,我不想当场得到这个帮助器,但我想在构造函数中正确地设置为一个依赖,与其他服务依赖一起。不幸的是,'question' 帮助器在构造器中是不可用的,你只有在终端运行命令时才知道这一点。为了防止这个问题再次发生,我添加了一个集成测试,验证你可以运行这个命令,而且它至少会做一些事情(通过构造器)。这样一来,当我不小心破坏了这个命令时,构建会让我知道。
添加一个测试是防止在运行时出现与变化有关的问题的一种方法。也许更好的办法是在分析时防止它,例如写一个PHPStan规则,只要你在一个命令的构造函数中调用$this->getHelper() ,就会触发一个错误。
/**
* @implements Rule<MethodCall>
*/
final class GetHelperRule implements Rule
{
public function getNodeType(): string
{
return MethodCall::class;
}
/**
* @param MethodCall $node
*/
public function processNode(Node $node, Scope $scope): array
{
// ...
if ($node->name->name !== 'getHelper') {
// This is not a call to getHelper()
return [];
}
// ...
if (! $scope->getFunction()->getDeclaringClass()
->isSubclassOf(Command::class)) {
// This is not a command class
return [];
}
if ($scope->getFunctionName() !== '__construct') {
// The call happens outside the constructor
return [];
}
return [
RuleErrorBuilder::message('getHelper() should not be called ...')
->build()
];
}
}
现在只要有人犯了在构造函数中调用getHelper() 的错误,他们就会得到这个漂亮而有用的错误。
15/15 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
------ -------------------------------------------------------------
Line src/PhpAstInspector/Console/InspectCommand.php
------ -------------------------------------------------------------
38 getHelper() should not be called in the constructor because
helpers have not been registered at that point
------ -------------------------------------------------------------
结论
简而言之,请注意以下情况。
- 你想做一个改变,但你不能,因为(遗留的)原因X。不要忽视这一点,或绕过它,做点什么
- 你做了一个改变,但这个改变以一种令人惊讶的方式破坏了某些东西。确保你不会再犯这样的错误(增加一个测试,或一个静态分析规则)。