构建您自己的 Laravel 包
最后更新于 作者: Steve McDougall
共享代码从未如此便捷,安装 PHP 包也变得很方便;但构建包呢?在本教程中,我将逐步介绍如何启动和发布新的 Laravel 包。我们将介绍设置和工具,您可以使用这些工具来确保您的包质量,并确保在构建和发布时,您都能做到最好。
那么,我们将构建什么呢?我们可以创建什么包,它足够简单,方便您学习流程,但又拥有足够的组件来理解它。我们将构建一个包含 Artisan 命令的包,该命令将允许我们在 Laravel 和 PHP 8.1 中创建数据传输对象,并希望在 PHP 8.2 可用后立即升级。除此之外,我们还将有一个用于填充数据传输对象的 Facade,在此称为 DTO。
那么,构建新包时从哪里开始?第一步应该是什么?首先,我喜欢在创建包之前搜索 Packagist,以确保我没有构建已经存在的包,或者功能足够丰富,以至于我会浪费时间。毕竟,我们不想重新发明轮子。
一旦确定我正在构建一些有用的且不存在的东西,我会考虑我的包需要什么。就我们而言,我们的需求相对简单。我们将有 3-4 个主要类要创建,仅此而已。决定包的结构通常是您必须克服的第一步。如何创建此代码以便以用户习惯的方式与他人共享?幸运的是,Laravel 社区已经为您做好了准备。模板存储库可用于包骨架;您只需要搜索它们。像 Spatie 和 Beyond Code 这样的公司提供了一些最好的包骨架,这些骨架功能齐全,可以为您节省大量时间。
但是,在本教程中,我不会使用骨架包,因为我认为在使用工具来完成工作之前,学习如何完成任务至关重要。因此,我们将从空白状态开始。首先,您需要为您的包想一个名称。我将我的包命名为“Laravel 数据对象工具”,因为最终我希望构建一个工具集,以便能够更轻松地在应用程序中使用 DTO。它告诉人们我的包的目的是什么,并允许我在时间推移过程中扩展它。
使用您的包名称创建一个新目录,并在您选择的代码编辑器中打开它,这样我们就可以开始设置。我在任何新包中做的第一件事就是将其初始化为一个 Git 存储库,因此运行以下 Git 命令
git init
现在我们有了可以使用的存储库,我们知道我们可以将东西提交到源代码管理,并在到时允许我们对包进行版本控制。创建 PHP 包需要立即做一件事,即一个 `composer.json` 文件,它将告诉 Packagist 此包是什么以及运行它需要什么。您可以使用命令行 Composer 工具或手动创建 Composer 文件。我通常使用命令行 `composer init`,因为它是一种交互式设置方法;但是,我将显示我的 Composer 文件开头的输出,以便您可以看到结果
{ "name": "juststeveking/laravel-data-object-tools", "description": "A set of tools to make working with Data Transfer Objects easier in Laravel", "type": "library", "license": "MIT", "authors": [ { "role": "Developer", "name": "Steve McDougall", "homepage": "https://www.juststeveking.uk/" } ], "autoload": { "psr-4": { "JustSteveKing\\DataObjects\\": "src/" } }, "autoload-dev": { "psr-4": { "JustSteveKing\\DataObjects\\Tests\\": "tests/" } }, "require": { "php": "^8.1" }, "require-dev": {}, "minimum-stability": "dev", "prefer-stable": true, "config": { "sort-packages": true, "preferred-install": "dist", "optimize-autoloader": true }}
这是我大多数包的基础,无论是 Laravel 包还是纯 PHP 包,它都以我熟悉的方式为我设置,我确信自己会保持一致性。我们需要向我们的包添加一些支持文件才能开始。首先,我们需要添加我们的 .gitignore 文件,以便我们可以告诉版本控制哪些文件和目录我们不想提交
/vendor//.ideacomposer.lock
这是我们想要忽略的文件的开头。我正在使用 PHPStorm,它将添加一个名为 `.idea` 的元目录,其中将包含我的 IDE 需要理解我的项目的所有信息——我不想将其提交到版本控制。接下来,我们需要添加一些 Git 属性,以便版本控制知道如何处理我们的存储库。这被称为 `.gitattributes`
* text=auto *.md diff=markdown*.php diff=php /.github export-ignore/tests export-ignore.editorconfig export-ignore.gitattributes export-ignore.gitignore export-ignoreCHANGELOG.md export-ignorephpunit.xml export-ignore
创建版本时,我们告诉我们的源代码管理提供商要忽略哪些文件以及如何处理差异。最后,我们最后一个支持文件将是我们的 `.editorconfig`,它是一个告诉我们的代码编辑器如何处理我们编写的文件的文件
root = true [*]charset = utf-8end_of_line = lfinsert_final_newline = trueindent_style = spaceindent_size = 4trim_trailing_whitespace = true [*.md]trim_trailing_whitespace = false [*.{yml,yaml,json}]indent_size = 2
现在我们有了版本控制和编辑器的支持文件,我们可以开始考虑我们的包在依赖项方面需要什么。我们的包将依赖哪些依赖项,以及我们使用哪些版本?让我们开始吧。
因为我们正在构建一个 Laravel 包,所以我们需要的第一个东西是 Laravel 的 Support 包,因此使用以下 Composer 命令安装它
composer require illuminate/support
现在我们有了一些可以开始的东西,让我们看一下我们的包将需要的第一个重要代码部分;服务提供者。服务提供者是任何 Laravel 包中必不可少的一部分,因为它告诉 Laravel 如何加载包以及哪些可用。首先,我们希望让 Laravel 知道我们有一个可以在安装后使用的控制台命令。我将我的服务提供者称为 `PackageServiceProvider`,因为我没有想象力,而且命名很困难。如果您愿意,可以随意更改您自己的命名。我在 `src/Providers` 下添加我的服务提供者,因为它与 Laravel 应用程序非常熟悉。
declare(strict_types=1); namespace JustSteveKing\DataObjects\Providers; use Illuminate\Support\ServiceProvider;use JustSteveKing\DataObjects\Console\Commands\DataTransferObjectMakeCommand; final class PackageServiceProvider extends ServiceProvider{ public function boot(): void { if ($this->app->runningInConsole()) { $this->commands( commands: [ DataTransferObjectMakeCommand::class, ], ); } }}
我通常会将我知道不希望扩展的类设置为 final,因为这样做会改变我想要包运行的方式。您无需执行此操作。这是一个您需要自己做出的判断。因此,我们现在注册了一个命令。我们应该考虑创建它。顾名思义,它是一个生成其他类的命令——这与典型的 Artisan 命令略有不同。
我创建了一个名为 `DataTransferObjectMakeCommand` 的类,它非常冗长,但解释了它在 `src/Console/Commands` 中的作用。如您所见,在创建这些类时,我试图反映 Laravel 开发人员熟悉的目录结构。这样做使使用包变得容易得多。让我们看一下此命令的代码
declare(strict_types=1); namespace JustSteveKing\DataObjects\Console\Commands; use Illuminate\Console\GeneratorCommand;use Illuminate\Support\Str; final class DataTransferObjectMakeCommand extends GeneratorCommand{ protected $signature = "make:dto {name : The DTO Name}"; protected $description = "Create a new DTO"; protected $type = 'Data Transfer Object'; protected function getStub(): string { $readonly = Str::contains( haystack: PHP_VERSION, needles: '8.2', ); $file = $readonly ? 'dto-82.stub' : 'dto.stub'; return __DIR__ . "/../../../stubs/{$file}"; } protected function getDefaultNamespace($rootNamespace): string { return "{$rootNamespace}\\DataObjects"; }}
让我们看一下此命令,以了解我们正在创建什么。我们的命令希望扩展 `GeneratorCommand`,因为我们希望生成一个新文件。这对于理解很有用,因为关于如何执行此操作的文档很少。此命令唯一需要的是一个名为 `getStub` 的方法——它需要知道如何加载存根文件的路径,以帮助生成文件。我在包的根目录下创建了一个名为 `stubs` 的目录,这是一个 Laravel 应用程序熟悉的地方。您将在这里看到,我正在检查已安装的 PHP 版本,以查看我们是否在 PHP 8.2 上,如果我们在 PHP 8.2 上,我们希望加载正确的存根版本,以利用只读类。目前,这种情况发生的可能性很低——但我们并不遥远。这种方法有助于为特定 PHP 版本生成文件,因此您可以确保支持您想要支持的每个版本。
最后,我为我的 DTO 设置了默认命名空间,因此我知道我希望这些 DTO 位于哪里。毕竟,我不想过度填充根命名空间。
让我们快速看一下这些存根文件,首先是默认存根
<?php declare(strict_types=1); namespace {{ namespace }}; use JustSteveKing\DataObjects\Contracts\DataObjectContract; final class {{ class }} implements DataObjectContract{ public function __construct( // ) {} public function toArray(): array { return []; }}
我们的 DTO 将实现一个合约以保证一致性 - 这是我喜欢对尽可能多的类进行的操作。另外,我们的 DTO 类是最终的。我们不太可能想要扩展此类,因此默认情况下将其设为最终是一个明智的做法。现在让我们看看 PHP 8.2 版本。
<?php declare(strict_types=1); namespace {{ namespace }}; use JustSteveKing\DataObjects\Contracts\DataObjectContract; readonly class {{ class }} implements DataObjectContract{ public function __construct( // ) {} public function toArray(): array { return []; }}
这里唯一的区别是我们正在使我们的 DTO 类只读,以利用该语言的较新功能。
我们如何测试它?首先,我们想安装一个测试包,以便我们可以确保可以编写测试来运行此命令 - 我将使用 pestPHP 来执行此操作,但使用 PHPUnit 的方式非常相似。
composer require pestphp/pest --dev --with-all-dependencies
此命令将要求您允许 pest 使用 composer 插件,因此如果您需要针对测试使用 pest 插件(例如并行测试),请确保对此说“是”。接下来,我们需要一个包,使我们能够在测试中使用 Laravel,以确保我们的包有效地工作。这个包叫做 Testbench,当我构建 Laravel 包时,我发誓要使用它。
composer require --dev orchestra/testbench
在我们的包中初始化测试套件的最简单方法是使用 pestPHP 为我们初始化它。运行以下控制台命令
./vendor/bin/pest --init
这将生成 phpunit.xml
文件和 tests/Pest.php
文件,我们使用它们来控制和扩展 pest 本身。首先,我喜欢对 pest 将使用的 PHPUnit 配置文件进行一些更改。我喜欢添加以下选项,以使我的测试更容易
stopOnFailure
我设置为 true cacheResults
我设置为 false
我这样做是因为如果测试失败,我想立即知道。提前返回和失败是有助于我们构建我们更有信心的事物的东西。缓存结果可以加快您的包的测试速度。但是,我喜欢确保每次都从头开始运行我的测试套件,以确保它按预期工作。
现在让我们将注意力转移到我们的包测试需要运行的默认测试用例上。在 tests/PackageTestCase.php
下创建一个新文件,以便我们可以更轻松地控制我们的测试。
declare(strict_types=1); namespace JustSteveKing\DataObjects\Tests; use JustSteveKing\DataObjects\Providers\PackageServiceProvider;use Orchestra\Testbench\TestCase; class PackageTestCase extends TestCase{ protected function getPackageProviders($app): array { return [ PackageServiceProvider::class, ]; }}
我们的 PackageTestCase
扩展了测试台 TestCase
,因此我们可以从包中借用行为来构建我们的测试套件。然后,我们注册我们的包服务提供者,以确保我们的包已加载到测试应用程序中。
现在让我们看看我们如何测试它。在编写测试之前,我们要确保我们的测试涵盖了包的当前行为。到目前为止,我们所有的测试都只是提供了一个可以运行的命令来创建一个新文件。我们的测试目录结构将镜像我们的包结构,因此让我们在 tests/Console/Commands/DataTransferObjectMakeCommandTest.php
下创建第一个测试文件,并开始我们的第一个测试。
在编写第一个测试之前,我们需要编辑 tests/Pest.php
文件,以确保我们的测试套件正确使用我们的 PackageTestCase
。
declare(strict_types=1); use JustSteveKing\DataObjects\Tests\PackageTestCase; uses(PackageTestCase::class)->in(__DIR__);
首先,我们要确保可以运行我们的命令,并且它能够成功运行。因此,请添加以下测试
declare(strict_types=1); use JustSteveKing\DataObjects\Console\Commands\DataTransferObjectMakeCommand; use function PHPUnit\Framework\assertTrue; it('can run the command successfully', function () { $this ->artisan(DataTransferObjectMakeCommand::class, ['name' => 'Test']) ->assertSuccessful();});
我们正在测试当我们调用此命令时,它是否能无错误地运行。如果你问我,这是最关键的测试之一,如果它出错,就意味着出了问题。
现在我们知道我们的测试可以运行了,我们还要确保类已创建。所以让我们接下来写这个测试
declare(strict_types=1); use Illuminate\Support\Facades\File;use JustSteveKing\DataObjects\Console\Commands\DataTransferObjectMakeCommand; use function PHPUnit\Framework\assertTrue; it('create the data transfer object when called', function (string $class) { $this->artisan( DataTransferObjectMakeCommand::class, ['name' => $class], )->assertSuccessful(); assertTrue( File::exists( path: app_path("DataObjects/$class.php"), ), );})->with('classes');
这里我们使用 Pest 数据集来运行一些选项,有点像 PHPUnit 数据提供者。我们遍历每个选项并调用我们的命令,断言文件存在。现在我们知道我们可以将一个名称传递给我们的 artisan 命令,并为我们在应用程序中使用创建一个 DTO。
最后,我们要为我们的包构建一个外观,以便可以轻松地对我们的 DTO 进行水合。拥有一个 DTO 通常只是战斗的一半,是的,我们可以在我们的 DTO 本身上添加一个方法来静态调用它 - 但我们可以简化这个过程很多。我们将通过使用 Frank de Jonge 在他的 Eventsauce 包 中的一个非常有用的包来促进这一点,叫做“对象水合器”。要安装它,请运行以下 composer 命令
composer require eventsauce/object-hydrator
是时候围绕这个包构建一个包装器,这样我们就可以很好地使用它了,所以让我们在 src/Hydrator/Hydrate.php
下创建一个新类,如果我们想在任何时候替换实现,我们还会创建一个与之相关的契约。这将是 src/Contracts/HydratorContract.php
。让我们从契约开始,以了解我们希望它做什么。
declare(strict_types=1); namespace JustSteveKing\DataObjects\Contracts; interface HydratorContract{ /** * @param class-string<DataObjectContract> $class * @param array $properties * @return DataObjectContract */ public function fill(string $class, array $properties): DataObjectContract;}
我们只需要一种方法来对对象进行水合,所以我们获取对象的类名和一个属性数组,以返回一个数据对象。现在让我们看看实现
declare(strict_types=1); namespace JustSteveKing\DataObjects\Hydrator; use EventSauce\ObjectHydrator\ObjectMapperUsingReflection;use JustSteveKing\DataObjects\Contracts\DataObjectContract;use JustSteveKing\DataObjects\Contracts\HydratorContract; class Hydrate implements HydratorContract{ public function __construct( private readonly ObjectMapperUsingReflection $mapper = new ObjectMapperUsingReflection(), ) {} public function fill(string $class, array $properties): DataObjectContract { return $this->mapper->hydrateObject( className: $class, payload: $properties, ); }}
我们在构造函数中传入了一个对象映射器,或者在构造函数中创建了它 - 然后我们在 fill 方法中使用它。fill 方法然后使用映射器来对对象进行水合。它使用起来简单干净,如果我们选择在将来使用不同的水合器,可以轻松地复制它。但是,使用它,我们要将水合器绑定到容器中,以便我们可以使用依赖注入来解析它。将以下内容添加到您的 PackageServiceProvider
的顶部
public array $bindings = [ HydratorContract::class => Hydrate::class,];
现在我们有了水合器,我们需要创建一个外观,这样我们就可以在应用程序中很好地调用它。现在让我们在 src/Facades/Hydrator.php
下创建它
declare(strict_types=1); namespace JustSteveKing\DataObjects\Facades; use Illuminate\Support\Facades\Facade;use JustSteveKing\DataObjects\Contracts\DataObjectContract;use JustSteveKing\DataObjects\Hydrator\Hydrate; /** * @method static DataObjectContract fill(string $class, array $properties) * * @see \JustSteveKing\DataObjects\Hydrator\Hydrate; */final class Hydrator extends Facade{ /** * @return class-string */ protected static function getFacadeAccessor(): string { return Hydrate::class; }}
所以我们的外观目前正在返回事件源实现的水合器 - 这意味着我们无法从容器中解析它,因此如果我们切换实现,我们将需要更改外观。不过,现在还不是什么大问题。接下来,我们需要将这个别名添加到我们的 composer.json
文件中,以便 Laravel 在我们安装包时知道它。
"extra": { "laravel": { "providers": [ "JustSteveKing\\DataObjects\\Providers\\PackageServiceProvider" ], "aliases": [ "JustSteveKing\\DataObjects\\Facades\\Hydrator" ] }},
现在我们已经注册了外观,我们需要测试它是否按预期工作。让我们逐步了解如何测试它。在 tests/Facades/HydratorTest.php
下创建一个新的测试文件,并开始
declare(strict_types=1); use JustSteveKing\DataObjects\Facades\Hydrator;use JustSteveKing\DataObjects\Tests\Stubs\Test; it('can create a data transfer object', function (string $string) { expect( Hydrator::fill( class: Test::class, properties: ['name' => $string], ), )->toBeInstanceOf(Test::class)->toArray()->toEqual(['name' => $string]);})->with('strings');
我们创建了一个名为 strings 的新数据集,它返回一个随机字符串数组供我们使用。我们将它传递到我们的测试中,并尝试在我们的外观上调用 fill 方法。传入一个测试类,我们可以创建一个属性数组来进行水合。然后,我们测试实例是否已创建,以及当我们在 DTO 上调用 toArray
方法时它是否与我们的预期相符。我们可以使用反射 API 来确保我们的 DTO 按照预期为我们最终测试创建。
it('creates our data transfer object as we would expect', function (string $string) { $test = Hydrator::fill( class: Test::class, properties: ['name' => $string], ); $reflection = new ReflectionClass( objectOrClass: $test, ); expect( $reflection->getProperty( name: 'name', )->isReadOnly() )->toBeTrue()->and( $reflection->getProperty( name: 'name', )->isPrivate(), )->toBeTrue()->and( $reflection->getMethod( name: 'toArray', )->hasReturnType(), )->toBeTrue();})->with('strings');
现在我们可以确定我们的包按预期工作。我们最后需要做的是关注代码的质量。在我的大多数包中,我喜欢确保代码风格和静态分析都在运行,这样我就可以有一个可靠的包,我可以信任它。让我们从代码风格开始。为此,我们将安装一个名为 Laravel Pint 的包,它相对较新
composer require --dev laravel/pint
我喜欢对我的代码风格使用 PSR-12,所以让我们在包的根目录下创建一个 pint.json
,以确保我们将 pint 配置为运行我们想要运行的标准
{ "preset": "psr12"}
现在运行 pint 命令来修复任何不符合 PSR-12 的代码风格问题
./vendor/bin/pint
最后,我们可以安装 PHPStan,这样我们就可以检查代码库的静态分析,以确保我们尽可能地严格和一致地使用类型
composer require --dev phpstan/phpstan
要配置 PHPStan,我们需要在包的根目录下创建一个 phpstan.neon
,以了解正在使用的配置。
parameters: level: 9 paths: - src
最后,我们可以运行 PHPStan 来确保我们从类型角度来看还不错。
./vendor/bin/phpstan analyse
如果一切顺利,我们现在应该看到一条消息,显示“[OK] 无错误”。
对于任何包构建,我喜欢遵循的最后步骤是编写我的 README 并添加我可能想要在包上运行的任何特定 GitHub 操作。我不会在这里添加它们,因为它们很长,而且充满了 YAML。但是,您可以查看 存储库 本身,以了解这些操作是如何创建的。
您是否构建了任何 Laravel 或 PHP 包,您想让我们知道?您如何处理您的包开发?请在 Twitter 上告诉我们!