重构的乐趣
发布时间 作者: 史蒂夫·麦克杜格尔
重构不是一个贬义词,恰恰相反。重构是你在升级或普遍改进后会做的事情。如果你没有改进,重构是不可行的,当然,除非你第一次没有尝试过!
本教程将讨论重构,我们可以做些什么,以及我们如何处理一些现实世界的例子。我们将使用一个在 GitHub 上开源的实际项目,并逐步进行重构练习。我们将使用的项目是 Laravel Shift Blueprint,这是一个流行的引导 Laravel 应用程序的方法。
让我们深入研究。我已经创建了一个新的存储库分支来隔离地构建东西 - 在我的帐户中运行 GitHub 操作,这样核心库就不会被淹没。现在我已经做到了,我可以克隆我的分支并开始在本地工作。
当我打开项目时,我注意到 composer 需要设置一个所需的 PHP 级别。这不是什么大问题 - 然而,对我来说,我会把它设置为最低支持的 PHP 版本。PHP 8.0 是唯一一个有足够支持让我安心写代码的版本。让我们做第一个改变。
"require": { "doctrine/dbal": "^3.3", "illuminate/console": "^9.0", "illuminate/filesystem": "^9.0", "illuminate/support": "^9.0", "laravel-shift/faker-registry": "^0.2.0", "symfony/yaml": "^6.0"},
更新依赖项并添加语言约束后,现在看起来像这样
"require": { "php": "^8.0", "doctrine/dbal": "^3.5", "illuminate/console": "^9.39", "illuminate/filesystem": "^9.39", "illuminate/support": "^v9.39", "laravel-shift/faker-registry": "^0.2.0", "symfony/yaml": "^6.1"},
我们需要在每次更改前后运行测试,以确保最新的更改没有破坏任何东西。如果它坏了,我们需要回滚或重构损坏的部分。这是重构过程中的一个关键步骤,因为你总是希望确保你是在为了项目的改进而进行重构 - 而不是仅仅因为你有不同的意见。
OK (430 tests, 2056 assertions)
到目前为止,一切都很好!现在让我们继续看看代码本身。我注意到在打开代码库中的随机文件时,它没有利用类型提示或返回类型。此外,由于我们现在已经设置了最低 PHP 版本,我们可以更新我们使用 PHP 语言的方式。让我们看一个简单的例子
class Tree{ private $tree; public function __construct(array $tree) { $this->tree = $tree; $this->registerModels(); } private function registerModels() { $this->models = array_merge($this->tree['cache'] ?? [], $this->tree['models'] ?? []); } // More code was there, but let's simplify}
查看这段代码,我们知道我们可以进行一些快速的重构改进 - 只是为了达到我们现在希望设置为最低的语言级别。首先,让我们修复构造函数并删除属性。
class Tree{ public function __construct( private array $tree, ) { $this->registerModels(); } // ...}
转而使用构造函数属性提升,使我们能够简化代码。让我们运行我们的测试。
OK (430 tests, 2056 assertions)
仍然很好。我们对这段代码进行了一个简单的重构,可以继续进行下一部分。让我们看看在构造函数中调用的方法。我们在类上有一个动态属性 - 这是在未来的 PHP 支持版本中会被删除的。虽然现在可能不是问题 - 但我们知道将来会出现问题。因此,我们可以提前进行重构。
class Tree{ public function __construct( private array $tree, private array $models = [], ) { $this->registerModels(); } private function registerModels(): void { $this->models = [ ...$this->tree['cache'] ?? [], ...$this->tree['models'] ?? [] ]; } // ...}
我们现在在构造函数中为类设置了属性 - 并重构了数组合并以使用数组解构。这使我们的代码更容易阅读。让我们重新运行这些测试!
OK (430 tests, 2056 assertions)
让我们看看下面的方法:一个从 Tree 类中获取控制器的 getter。我们目前没有返回类型,需要知道这可能包含什么。使用“查找使用情况”快速浏览代码库,我发现别的地方我们正在动态地将它设置为 Controller
的实例。让我们看看这个方法
public function controllers(){ return $this->tree['controllers'];}
首先,我们想要添加返回类型,它将是一个数组。
public function controllers(): array{ return $this->tree['controllers'];}
到目前为止,一切都很好。所有测试都还在通过。但这够了吗?当我们可以在一个地方让我们的项目知道这一点时,我们应该在项目中添加一个动态类型设置吗?让我们更新它。
/** * @return array<int,Controller> */public function controllers(): array{ return $this->tree['controllers'];}
我们知道这将返回一个 Controller
,所以为什么不在我们的 docblock 中将其设置为通用类型,这样我们就可以充分利用现代 PHP?现在重新运行我们的测试。
OK (430 tests, 2056 assertions)
到目前为止,我们的重构都很成功。让我们继续另一个例子。
接下来,让我们看看这个包的 ServiceProvider
。像另一个例子一样,这里没有返回类型 - 所以我将快速添加它们,在这里不担心这些例子。让我们看看如何改进 register 方法。
public function register(): void{ $this->mergeConfigFrom( __DIR__ . '/../config/blueprint.php', 'blueprint' ); File::mixin(new FileMixins()); $this->app->bind('command.blueprint.build', fn ($app) => new BuildCommand($app['files'], app(Builder::class))); $this->app->bind('command.blueprint.erase', fn ($app) => new EraseCommand($app['files'])); $this->app->bind('command.blueprint.trace', fn ($app) => new TraceCommand($app['files'], app(Tracer::class))); $this->app->bind('command.blueprint.new', fn ($app) => new NewCommand($app['files'])); $this->app->bind('command.blueprint.init', fn ($app) => new InitCommand()); $this->app->bind('command.blueprint.stubs', fn ($app) => new PublishStubsCommand()); $this->app->singleton(Blueprint::class, function ($app) { $blueprint = new Blueprint(); $blueprint->registerLexer(new \Blueprint\Lexers\ConfigLexer($app)); $blueprint->registerLexer(new \Blueprint\Lexers\ModelLexer()); $blueprint->registerLexer(new \Blueprint\Lexers\SeederLexer()); $blueprint->registerLexer(new \Blueprint\Lexers\ControllerLexer(new \Blueprint\Lexers\StatementLexer())); foreach (config('blueprint.generators') as $generator) { $blueprint->registerGenerator(new $generator($app['files'])); } return $blueprint; }); $this->app->make('events')->listen(CommandFinished::class, function ($event) { if ($event->command == 'stub:publish') { $this->app->make(Kernel::class)->queue('blueprint:stubs'); } }); $this->commands([ 'command.blueprint.build', 'command.blueprint.erase', 'command.blueprint.trace', 'command.blueprint.new', 'command.blueprint.init', 'command.blueprint.stubs', ]);}
首先,我们在容器中绑定了很多东西,并使用字符串常量,这是可以的 - 但是我们可以快速改进它。
$this->app->bind(BuildCommand::class, fn ($app) => new BuildCommand($app['files'], app(Builder::class)));$this->app->bind(EraseCommand::class, fn ($app) => new EraseCommand($app['files']));$this->app->bind(TraceCommand::class, fn ($app) => new TraceCommand($app['files'], app(Tracer::class)));$this->app->bind(NewCommand::class, fn ($app) => new NewCommand($app['files']));$this->app->bind(InitCommand::class, fn ($app) => new InitCommand());$this->app->bind(PublishStubsCommand::class, fn ($app) => new PublishStubsCommand());
这是第一步;接下来,让我们添加命名参数,使它看起来更干净。
$this->app->bind( abstract: BuildCommand::class, concrete: fn (Application $app): BuildCommand => new BuildCommand( filesystem: $app['files'], builder: $app->make( abstract: Builder::class, ), ),);$this->app->bind( abstract: EraseCommand::class, concrete: fn (Application $app): EraseCommand => new EraseCommand( filesystem: $app['files'], ),);$this->app->bind( abstract: TraceCommand::class, concrete: fn (Application $app): TraceCommand => new TraceCommand( filesystem: $app['files'], tracer: $app->make( abstract: Tracer::class, ), ),);$this->app->bind( abstract: NewCommand::class, concrete: fn (Application $app): NewCommand => new NewCommand( filesystem: $app['files'], ),);$this->app->bind( abstract: InitCommand::class, concrete: fn (): InitCommand => new InitCommand(),);$this->app->bind( abstract: PublishStubsCommand::class, concrete: fn () => new PublishStubsCommand(),);
至少对我来说,现在看起来干净多了。我们添加了更多的类型提示,并为清晰起见添加了返回类型。并不是每个人都会同意命名参数,但这使代码更易于阅读。
重构的目的是使代码更易于管理、更高效,或者两者兼而有之。我们在上面所做的展示了我们如何使代码更类型安全,更易于管理。Blueprint 是一个相当复杂的项目。它有很多活动部件,以确保它的正常运行。如果不花很长时间深入了解项目的具体细节及其未来目标,很难看出可以做出哪些改进。
我重构的典型方法是首先解决那些快速获胜的问题,即支持的语言级别和软件包版本,然后是类型提示和返回类型,然后继续现代化代码的各个部分。在逐步进行的过程中,务必确保测试仍在运行,这一点至关重要。