构建你自己的 Laravel 包

共享代码变得前所未有的方便,安装 PHP 包变得更加方便;还没有构建过软件包?在本教程中,我将介绍如何开始以及发布一个新的 Laravel 包。通过设置和工具,您可以使用来确保您的包质量,并且如果您构建和发布某些东西,那么您会做得很好。

那么我们要构建什么?我们可以创建什么包?它非常的简单,以至于您会发现该学习过程很容易,但是仍然有足够的部分来理解它。我们将使用 artisan 命令构建一个包,它允许我们在 Laravel 和 PHP 8.1 中创建数据传输对象,希望在 PHP 8.2 可用时尽快升级到它。除此之外,我们还将有一个用于水合数据传输对象的 Facade,这里称为 DTO。

那么,我们在构建新包时从哪里开始呢?我们的第一步应该是什么?首先,当我要创建一个包时,我喜欢做的是搜索 packageagist,以确保我没有构建一些已经可用或功能丰富的东西,以至于我会浪费我的时间。毕竟我们不想重新创建轮子。

一旦我确定我正在构建一些不存在的有用的东西,我就会考虑我的包需要什么。在我们的例子中,我们的要求相对简单。我们将要创建 3-4 个主要类,仅此而已。决定您的包的结构通常是您必须克服的第一步。您如何创建此代码以以人们习惯的方式与他人共享?幸运的是,Laravel 社区已经为您提供了相关信息。模板库可用于包骨架;您只需要搜索它们。 Spatie 和 Beyond Code 等公司拥有一些功能齐全的最佳软件包框架,可为您节省大量时间。

但是,在本教程中,我不会使用骨架包,因为我觉得在使用工具为您完成工作之前学习如何完成任务是必不可少的。 所以我们将从一张白纸开始。 首先,您需要为您的包裹起一个名字。 我将把我的“Laravel 数据对象工具”称为“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",
      "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 包时,我们首先需要的是 Laravel 支持包,所以使用以下 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,
                ],
            );
        }
    }
}

我通常将我知道不希望扩展的类作为最终类,因为这样做会改变我希望包的操作方式。你不需要这样做。这是你需要为自己做出的判断。所以我们现在注册了一个命令。我们应该考虑创建它。从命名中可以看出,它是一个将为我们生成其他类的命令——与典型的工匠命令略有不同。

我创建了一个名为 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 版本生成文件,因此您可以确保支持您希望支持的每个版本。

最后,我已经为我的 DTO 设置了默认命名空间,所以我知道我希望它们放在哪里。毕竟我不想过度填充根命名空间。

先来快速了解一下这些存根文件,默认的命名空间为 stub:

<?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 类。我们可能不想扩展这个类,所以默认情况下将其设为 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

在我们的包中初始化测试套件的最简单方法是使用 pesPHP 为我们初始化它。运行以下控制台命令:

./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 Dataset 来运行一些选项,有点像 PHPUnit Data Provider。我们遍历每个选项并调用我们的命令,断言文件存在。我们现在知道可以将名称传递给我们的 artisan 命令并创建一个 DTO 供我们在应用程序中使用。

最后,我们想为我们的包构建一个 facade,以允许我们的 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,
        );
    }
}

我们有一个对象映射器传递给构造函数或在构造函数中创建 - 然后我们在填充方法中使用它。然后填充方法使用映射器来水合对象。它使用简单干净,如果我们将来选择使用不同的保湿器,可以轻松复制。但是,使用这种方式,我们希望将水化器绑定到容器中,以允许我们使用依赖注入来解决它。将以下内容添加到 PackageServiceProvider 的顶部:

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

现在我们有了 hydrator,我们需要创建一个 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;
    }
}

所以我们的外观当前返回的是 Hydrator 的事件实现-这意味着我们无法从容器中解决这个问题,所以如果我们切换实现,我们将需要更改 facade。不过,就目前而言,这还不是什么大事。接下来,我们需要将此别名添加到我们的文件中,以便 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 上调用填充方法。传入一个测试类,我们可以创建一组属性来进行水合。然后,当我们在 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] No errors

对于任何包的构建,我喜欢遵循的最后一步是编写我的 README 并添加我希望在包上运行的 GitHub 操作。
我不会在这里添加它们,因为它们很长并且充满了 YAML。
但是,您可以查看 该项目 以了解它们是如何创建的。

你是否构建了 Laravel 或 PHP 软件包 并想让我们了解 ?
你是如何开发你的软件包的 ?
在 Twitter 上告诉我们 !

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://laravel-news.com/building-your-o...

译文地址:https://learnku.com/laravel/t/70422

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!