honeystone/laravel-dto-tools

Laravel 的 DTO 工具集。

1.1.0 2024-09-14 18:28 UTC

This package is auto-updated.

Last update: 2024-09-14 18:29:37 UTC


README

Static Badge GitHub License Latest Version on Packagist Packagist Dependency Version Packagist Dependency Version Static Badge

DTO 工具包是一个用于为您的原生 PHP 数据传输对象(DTO)提供额外功能和便利的包。此包的主要动机是移除在数据在 DTO 之间移动过程中产生的许多样板代码。例如,将蛇形模型属性转换为驼峰式以供您的表示层消费,或将用户输入的数值字符串(当然是在验证之后)转换为整数。

功能包括属性转换和变异、序列化、补丁数据处理、关系以及模型和集合转换。

支持我们

Support Us

我们致力于提供由 Honeystone 团队维护的高质量开源包。如果您想支持我们的努力,只需使用我们的包,推荐它们并贡献。

如果您在项目中需要任何帮助,或需要任何定制开发,请联系我们

安装

composer require honeystone/laravel-dto-tools

使用以下命令发布配置文件:

php artisan vendor:publish --tag=honeystone-dto-tools-config

用法

此包对现有 DTO 的修改非常少。至少,您的 DTO 需要实现 Transferable 合同并使用 HasTransferableData 特性,如下所示

<?php

declare(strict_types=1);

namespace App\Domains\Articles\Data;

use App\Domains\Articles\Data\Enums\Status;
use Honeystone\DtoTools\Concerns\HasTransferableData;
use Honeystone\DtoTools\Contracts\Transferable;

final readonly class ArticleData implements Transferable
{
    use HasTransferableData;

    public function __construct(
        public string $title,
        public ?string $description,
        public Status $status,
        public string $modified,
    ) {
    }
}

如你所见,这仍然只是一个普通的 readonly PHP 对象。您也可以正常实例化它,但是,如果您想使用属性转换,您需要使用静态的 make() 方法

$data = ArticleData::make(
    title: $source['title'],
    description: $source['description'] === '' ? null : $source['description'],
    status: $source['status'] === 'published' ? Status::PUBLISHED : Status::DRAFT,
    modified: Carbon::make($source['modified'])->toIso8601String(),
);

这真的很乱,所以我们将在下一节中清理它。

属性转换

转换器用于在实例化 DTO 之前拦截和转换/变异值。它们使用 PHP 属性实现。在以下示例中,我们将确保我们的空描述转换为 null,将状态转换为枚举,并将修改时间表示为 iso-8601 字符串

use App\Domains\Articles\Data\Enums\Status;
use Honeystone\DtoTools\Casters\DateTimeCaster;
use Honeystone\DtoTools\Casters\EnumCaster;
use Honeystone\DtoTools\Casters\NullCaster;

final readonly class ArticleData implements Transferable
{
    use HasTransferableData;

    public function __construct(
        public string $title,

        #[NullCaster('')]
        public ?string $description,

        #[EnumCaster(Status::class)]
        public Status $status,

        #[DateTimeCaster]
        public string $modified,
    ) {
    }
}

我们的数据现在可以稍微宽松一些,但我们仍然从 DTO 的类型安全性中受益

$data = ArticleData::make(
    title: $source['title'],
    description: $source['description'],
    status: $source['status'],
    modified: $source['modified'],
);

//or even cleaner
$data = ArticleData::make(Arr::only($source, 'title', 'description', 'status', 'modified'));

以下提供了许多转换器,或者您可以创建自己的

#[DateTimeCaster('Y-m-d')] //takes a format string, or defaults to iso-8601
#[EnumCaster(Status::class, State::class)] //takes one or more enum class strings
#[NullCaster('', '-')] //takes one or more values that should be converted to null
#[ScalarCaster('bool', 'null')] //takes one or more accepted types, if no types match it will cast to the last type

要实现自己的转换器,只需创建一个实现 Honeystone\DtoTools\Casters\Contracts\CastsValuesAttribute

<?php

declare(strict_types=1);

namespace App\Support\Data\Casters;

use Attribute;
use Honeystone\DtoTools\Casters\Contracts\CastsValues;

#[Attribute]
final readonly class MyCaster implements CastsValues
{
    public function cast(mixed $value): mixed
    {
        //...
    }
}

序列化

有两种主要的序列化方法可用,getAttributes()toArray()。区别在于 getAttributes() 将简单地提供一个包含属性值的数组,而 toArray() 将递归地将 TransferableArrayable 属性转换为数组。

还有 getRelationships()toStorableArray(),但我们稍后再谈。

属性转换

有时您需要对进入 DTO 的所有参数进行重大更改。为此,您可以在 DTO 中添加一个静态的 transformIncoming() 方法

final readonly class SomeData implements Transferable
{
    use HasTransferableData;

    public function __construct(
        public string $foo,
        public string $bar,
    ) {
    }

    protected static function transformIncoming(array $parameters): array
    {
        return array_map(static fn (string $value): string => "🔥 $value 🔥", $parameters);
    }
}
echo SomeData::make(['foo', 'bar'])->getAttributes(); //['foo' => '🔥 foo 🔥', 'bar' => '🔥 bar 🔥']

对于 DTO 序列化,还可以实现 transformOutgoing() 方法

final readonly class SomeData implements Transferable
{
    use HasTransferableData

    public function __construct(
        public string $foo,
        public string $bar,
    ) {
    }

    protected static function transformOutgoing(array $parameters): array
    {
        return array_map(static fn (string $value): string => "🔥 $value 🔥", $parameters);
    }
}
echo SomeData::make(['foo', 'bar'])->getAttributes(); //['foo' => 'foo',       'bar' => 'bar']
echo SomeData::make(['foo', 'bar'])->toArray();       //['foo' => '🔥 foo 🔥', 'bar' => '🔥 bar 🔥']

在 Laravel 中,一个非常常见的用例是将蛇形属性转换为驼峰式。因此,提供了 CreatableFromSnakeSerializesToSnake 特性

use Honeystone\DtoTools\Concerns\CreatableFromSnake;
use Honeystone\DtoTools\Concerns\SerializesToSnake;

final readonly class SomeData implements Transferable
{
    use HasTransferableData, CreatableFromSnake, SerializesToSnake;

    public function __construct(
        public string $someProperty,
    ) {
    }
}
echo SomeData::make(some_property: 'value')->getAttributes(); //['someProperty'  => 'value']
echo SomeData::make(some_property: 'value')->toArray();       //['some_property' => 'value']

可存储数据

我们已经了解了 Transferable 数据,这对于在服务层传递完整数据非常好,但如果您需要处理部分数据(即补丁)怎么办?这时就出现了 Storable

<?php

declare(strict_types=1);

namespace App\Domains\Articles\Data;

use App\Domains\Articles\Data\Enums\Status;
use Honeystone\DtoTools\Concerns\HasStorableData;
use Honeystone\DtoTools\Contracts\Storable;

final class ArticlePatchData implements Storable
{
    use HasStorableData;

    public function __construct(
        public readonly string $title,
        public readonly ?string $description,
        public readonly Status $status,
        public readonly string $modified,
    ) {
    }
}

基本的 StorableTransferable 非常相似,除了类本身不能是 readonlyStorable 需要一点状态才能运行,因此我们用 readonly 标记我们的属性。

默认情况下,可存储对象不在修补模式。为此,我们需要添加 Patch 类属性

<?php
use Honeystone\DtoTools\Attributes\Patch;

#[Patch]
final class ArticlePatchData implements Storable
{
    use HasStorableData;

    public function __construct(
        //...
    ) {
    }
}

我们可以使用 isPatching() 方法检查可存储对象是否处于修补模式。

当可存储对象处于修补模式时,使用 toStorableArray() 方法将自动排除 null 值进行序列化

echo SomeData::make(foo: 'value', bar: null)->toStorableArray(); //['foo' => 'value']
echo SomeData::make(foo: 'value', bar: null)->toArray();         //['foo' => 'value', 'bar => null]

您也可以使用 isStorable() 方法检查单个属性是否可以存储

$data = echo SomeData::make(foo: 'value', bar: null);

$data->isStorable('foo'); //true
$data->isStorable('bar'); //false

有时,null 是一个有效的值并且应该被存储。在这些情况下,您可以使用 force() 方法将这些属性标记为可存储

$data = echo SomeData::make(foo: 'value', bar: null);

$data->force('bar');

$data->isStorable('bar'); //true

如果您需要强制属性列表,请使用 getForced()

可存储关系

偶尔,当将数据传输到您的服务层时,您需要表示关系结构的更改。您可以通过在您的 DTO 上设置一个简单的属性来实现这一点,例如

<?php
#[Patch]
final class ArticlePatchData implements Storable
{
    use HasStorableData;

    /**
    * @param array<int>|null $tags
    */
    public function __construct(
        //...
        public ?array $tags = null,
    ) {
    }
}

尽管这种方法有几个问题:没有真正的类型安全,您不能随意添加或删除标签,您必须提供所有标签,并且不能有任何元数据(例如顺序)。也许您可以将它升级为 DTO 数组,但有一个更简单的方法

<?php
use Honeystone\DtoTools\Attributes\ToMany;

#[Patch]
#[ToMany(['tags' => 'int|empty'])]
final class ArticlePatchData implements Storable
{
    use HasStorableData;

    public function __construct(
        //...
    ) {
    }
}

使用 ToMany 类属性,我们可以声明一个名为 'tags' 的多对多关系,该关系可以为空,或为整数。

我们现在可以添加、删除和替换相关的标签

$data = ArticlePatchData::make(...);

$data->relationships()->addToManyRelation('foo', 123, ['priority' => 5]);

关系使用相关 ID 存储。这可以是整数或字符串。您还可以提供一个 TransferableStorable,库将使用其 getKey() 方法来确定 ID。还可以作为数组或 Transferable 提供附加元数据

$data = ArticlePatchData::make(...);

$data->relationships()->addToManyRelation('foo', TagData::make(...), TagMetaData::make(...));

以下关系类 Attributes 受支持

#[ToOne(['foo' => 'int|string|null'])]
#[ToMany(['bar' => 'int|string|empty'])]

以下关系方法可用

$data->relationships()->hasToOne('foo');
$data->relationships()->getOneRelated('foo');
$data->relationships()->setOneRelated('foo', 123);
$data->relationships()->unsetOneRelated('foo');

$data->relationships()->hasToMany('bar');
$data->relationships()->getManyRelated('bar'); //plus any additions, minus any removals
$data->relationships()->getManyAdditions('bar');
$data->relationships()->getManyRemovals('bar');
$data->relationships()->addToManyRelation('bar', 123, []); //param 3 optional
$data->relationships()->removeToManyRelation('bar', 123);
$data->relationships()->replaceToMany('bar', 123, []); //param 3 optional, clears addition and removals
$data->relationships()->resetToMany('bar'); //clears addition and removals
$data->relationships()->getMetaData('bar');
$data->relationships()->setMetaData('bar', []);

序列化关系包含在 toStorableArray() 中,或者您可以使用 getRelationships() 仅获取关系。

模型转换

Model 转换为 DTO 并不罕见。但它们将有所不同。DTO 的数据应更具体和情境化。这可能导致大量样板代码来处理转换。本包包含一个 abstract ModelTransformer 以帮助清理。

以下是最基本的示例

<?php

declare(strict_types=1);

namespace App\Domains\Articles\Data\Transformers;

use App\Domains\Articles\Data\ArticleData;
use Honeystone\DtoTools\Transformers\ModelTransformer;

final class ArticleTransformer extends ModelTransformer
{
    protected string $dataClass = ArticleData::class;
}

您现在可以调用转换器的 transform()transformCollection() 方法,将您的 Model 转换为 Transferable

$transformer = app(ArticleTransformer::class);

$transformer->transform(Article::first());
$transformer->transformCollection(Article::all());

内部此示例将使用模型的自定义 toArray() 方法。

我们可以使用 $only 属性具体指定要包含在转换中的字段

final class ArticleTransformer extends ModelTransformer
{
    protected string $dataClass = ArticleData::class;

    protected array $only = [
        'title',
        'description',
        'status',
        'modified',
    ];
}

如果需要执行更复杂的操作,我们可以创建一个 map() 方法

final class ArticleTransformer extends ModelTransformer
{
    protected string $dataClass = ArticleData::class;

    protected function map(Model $model): array
    {
        return [
            'title' => Str::title($model->title),
            'status' => in_array($model->status, ['published', 'active']) ? 'published' : 'draft',
            ...$model->only('description', 'modified'),
        ];
    }
}

我们还可以将附加参数传递给 map 方法

$transformer->transform(Article::first(), foo: 'bar');
$transformer->transformCollection(Article::all(), foo: 'bar', bar: 'baz');

override()exclude() 方法可以链接起来,以便在转换时进行即时更改

$transformer
    ->exclude('foo', 'bar')
    ->override(['status' => 'preview'])
    ->transform(Article::first()); //transform() or transformCollection()

那很好,但关系怎么办?

final class ArticleTransformer extends ModelTransformer
{
    protected string $dataClass = ArticleData::class;

    protected function map(Model $model): array
    {
        return [
            'title' => Str::title($model->title),
            'status' => in_array($model->status, ['published', 'active']) ? 'published' : 'draft',
            ...$model->only('description', 'modified'),
            'tags' => $this->includeRelated('tags', $model->tags),
            'category' => $this->requireRelated('category', $model->category),
        ];
    }
}

includeRelated()requireRelated() 方法将使用本包配置文件中的转换映射,根据 ModelTransformer 转换 Model

requireRelated() 方法如果关系尚未加载,将抛出 Honeystone\DtoTools\Exceptions\RequiredRelationNotLoadedException

您可以传递附加参数给这些方法,这些参数将传递给相应的 ModelTransformermap() 方法。

任何排除或覆盖也可以作为附加参数包含

final class ArticleTransformer extends ModelTransformer
{
    protected string $dataClass = ArticleData::class;

    protected function map(Model $model): array
    {
        return [
            //...
            'category' => $this->requireRelated(
                'category',
                $model->category,
                exclude: ['foo', 'bar'],
                override: ['status' => 'preview'],
                bar: 'baz', //passed onto map() method
            ),
        ];
    }
}

就是这样!如果您发现此包有用,我们非常乐意听到您的意见。

变更日志

更改列表可在 CHANGELOG.md 文件中找到。

许可证

MIT © Honeystone Consulting Ltd