如何建立你自己的Laravel包

111 阅读10分钟

那么我们要建立什么呢?我们可以创建什么样的包, 简单到让你觉得很容易学会这个过程, 但又有足够的部分来理解它。我们将建立一个带有artisan命令的包,让我们能够在Laravel和PHP8.1中创建数据传输对象,希望在PHP8.2可用时能尽快升级到它。与此同时, 我们还将有一个Facade用于水化数据传输对象, 在这里被称为DTOs.

那么,在构建一个新的包时,我们应该从哪里开始呢?我们的第一步应该是什么?首先,当我准备创建一个包的时候,我喜欢做的是搜索packagist,以确保我没有建立一些已经可用的或功能足够丰富的东西,以免浪费我的时间。毕竟我们不希望重新创造车轮。

一旦我确定我正在构建一些不存在的有用的东西,我就会考虑我的包需要什么。在我们的案例中,我们的要求相对简单。我们将有3-4个我们想要创建的主要类,仅此而已。决定你的包的结构,通常是你必须克服的第一个步骤之一。你怎样才能创建这些代码,以人们习惯的方式与他人分享呢?幸运的是,Laravel社区在这方面为你提供了保障。模板仓库可以提供包的骨架, 你只需要搜索一下。像Spatie和Beyond Code这样的公司有一些最好的软件包骨架,功能齐全,可以为你节省大量的时间。

然而,在本教程中,我不会使用骨架包,因为我觉得在使用工具为你完成工作之前,学习如何做一项任务是非常重要的。因此,我们将从一张白纸开始。首先,你需要为你的软件包想一个名字。我打算把我的包叫做 "Laravel数据对象工具", 因为最终, 我想建立一个工具集, 以便能够在我的应用程序中更容易地使用DTOs.它告诉人们我的包的目的是什么,并允许我随着时间的推移扩大它的范围。

用你的包名创建一个新的目录,并在你选择的代码编辑器中打开它,这样我们就可以开始设置了。我对任何新包做的第一件事就是把它初始化为一个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",
      "email": "juststevemcd@gmail.com",
      "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/
/.idea
composer.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-ignore
CHANGELOG.md export-ignore
phpunit.xml export-ignore

当创建一个版本时,我们告诉我们的源码控制提供者我们要忽略哪些文件,以及如何处理差异。最后,我们的最后一个支持文件是.editorconfig ,这是一个告诉我们的代码编辑器如何处理我们正在编写的文件的文件。

root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml,json}]
indent_size = 2

现在我们有了版本控制和编辑器的支持文件,我们可以开始考虑我们的软件包在依赖性方面的需求。我们的包将依赖哪些依赖,我们使用的是哪些版本?让我们开始吧。

由于我们正在建立一个Laravel包, 我们首先需要的是Laravels支持包, 所以使用下面的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,
                ],
            );
        }
    }
}

我通常会把我知道的不希望被扩展的类变成最终类, 因为这样做会改变我希望包的操作方式.你不需要这样做.这是一个你需要为自己做出的判断。所以我们现在有一个注册的命令。我们应该考虑创建这个。正如你从命名中可以看出的那样,这是一个将为我们生成其他类的命令--与你典型的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版本生成文件,因此可以确保对你希望支持的每个版本的支持。

最后,我已经为我的DTOs设置了默认的命名空间,所以我知道我想让这些东西住在哪里。我毕竟不想让根命名空间过度填充。

让我们先来看看这些存根文件,首先是默认存根。

<?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类是最终的。我们很可能不想扩展这个类,所以默认为final是一个明智的做法。现在让我们看看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 我设置为false
cacheResults

我这样做是因为如果一个测试失败了,我想立即知道它。早期回报和失败是帮助我们建立更有信心的东西。缓存结果可以加速你的包的测试。然而,我喜欢确保每次都从头开始运行我的测试套件,以确保它能按照我的期望工作。

现在让我们把注意力集中在一个默认的测试案例上,我们需要我们的包测试在此基础上运行。在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 Dataset来运行一些选项,有点像PHPUnit的数据提供者。我们循环浏览每个选项并调用我们的命令,断言文件存在。我们现在知道,我们可以给我们的artisan命令传递一个名字,并创建一个DTO供我们在应用中使用。

最后,我们要为我们的包建立一个facade,以使我们的DTO易于水化。拥有一个DTO往往只是成功的一半,是的,我们可以为我们的DTO本身添加一个方法来静态调用--但我们可以将这个过程简化很多。我们将通过使用Frank de Jonge在他的Eventsauce包中的一个非常有用的包,叫做 "object hydrator "来促进这个过程。要安装这个包,请运行以下 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,
        );
    }
}

我们有一个传入构造函数的对象映射器或在构造函数中创建的映射器--然后我们在填充方法中使用它。然后,填充方法使用映射器来填充一个对象。这种方法使用起来简单明了,而且如果我们将来选择使用不同的水化器,可以很容易地复制。然而,使用这个方法,我们要将水合剂绑定到容器中,以便我们使用依赖注入来解决它。在你的PackageServiceProvider 的顶部添加以下内容。

public array $bindings = [
    HydratorContract::class => Hydrate::class,
];

现在我们有了我们的氢化器,我们需要创建一个facade,这样我们就可以在我们的应用程序中很好地调用它。现在让我们在下面创建它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;
    }
}

因此,我们的Facade目前正在返回hydrator的事件酱实现--这意味着我们不能从容器中解决这个问题,所以如果我们切换实现,我们将需要改变facade。不过这对现在来说并不是什么大问题。接下来, 我们需要将这个别名添加到我们的composer.json 文件中, 以便Laravel在安装软件包时知道它.

"extra": {
  "laravel": {
    "providers": [
      "JustSteveKing\\DataObjects\\Providers\\PackageServiceProvider"
    ],
    "aliases": [
      "JustSteveKing\\DataObjects\\Facades\\Hydrator"
    ]
  }
},

现在我们已经注册了我们的Facade, 我们需要测试它是否能按预期工作。让我们来看看我们如何测试它.在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的新数据集,它返回一个随机字符串数组供我们使用。我们把这个传入我们的测试,并尝试调用我们的facade上的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。