编程

使用 DTO 保持 Context 上下文

1078 2023-03-02 17:12:43

DTO(数据转换对象Data Transfer Objects)可用于多个方面。PHP 8 发布后,创建这样的类变得十分容易。

从逃避数组的基本构造到为过去只是普通的旧数组添加类型安全。在 PHP 8 之前,这些也都是可能的;不过它花费了更多的样板代码,而且从未觉得有价值。

随着 PHP 8.2 出现,我们的选择在PHP生态系统中越来越开放。马蒂亚斯·诺巴克(Matthias Noback)的《对象设计风格指南》(Object Design Style Guide)是一本很好的书,我建议所有开发人员至少阅读一次。

不过,我没有把它们称为 DTO,因为我不只是在我的域代码中使用它们。相反,我称这些为数据对象(Data Object),因为这就是它们本来的样子。在本教程的其余部分中,我将它们称为数据对象。

在创建数据对象时,我喜欢将所有的属性设置为只读的,因为它们只应该被读取,而不应该被写入——这违背了它们的意义。这给了我一个不可变的结构,我可以通过应用程序来保持上下文和类型安全——我称之为双赢。

让我们看一个例子。我会借用 Laravel Bootcamp 的想法,创造出 Chirp。我们的 chirp 需要关注两件事,即它的消息和创建它的用户。如今在构建应用程序时,我要么使用 UUID,要么使用 ULID,这取决于应用程序。在本例中,我将使用 ULID。

因此,我们希望重构 Bootcamp 代码库,以使其更易于长期管理——web 接口、API、CLI 等。因此,我们期望从应用程序中的内联逻辑转移到共享类。让我们看看这是什么样子。

$validated = $request->validate([
    'message' => 'required|string|max:255',
]);
 
$request->user()->chirps()->create($validated);
 
return redirect(route('chirps.index'));

我们可以对其进行重构,以便在表单请求中进行验证,并将创建移到其他位置。

public function __invoke(StoreRequest $request): Response
{
    return new JsonResponse(
        data: $this->command->handle(
            chirp: $request->validated(),
        ),
        status: Http::CREATED->value,
    );
}

在这里,我们将返回并一次性处理所有内容-这可能会使阅读变得有点困难,所以让我们将其分开。

$chirp = $this->command->handle(
    chirp: $request->validated(),
);

这很好,你不是一定要比这更进一步。然而,如果您想做更多的工作并开始添加上下文,那么您可以开始添加数据对象,在我看来,这些数据对象很好用。

我们的 chirp 应该是什么样子?什么对我们有帮助?让我们看看我使用了什么,并通过决策过程进行讨论。

final class ChirpObject implements DataObjectContract
{
    public function __construct(
        public readonly string $user,
        public readonly string $message,
    ) {}
 
    public function toArray(): array
    {
        return [
            'message' => $this->message,
            'user_id' => $this->user,
        ];
    }
}

所以,以典型的 Steve 风格,这是 final 类。它实现了一个名为 DataObjectContract 的接口,它来自我通常在项目中使用的 Laravel 包之一。每个属性都是公共的,并且可以在类外部访问,但是它们也是只读的,这样我的上下文就不会在创建对象后更改。然后,我有一个名为 toArray 的方法,它由接口强制执行,这是我实现如何将该对象发送给 Eloquent 的一种方法。

使用这种方法,我可以使用上下文对象,并为应用程序添加额外的类型安全性。这意味着在应用中传递数据时,我可以很轻松。我们的控制器现在看起来怎么样?

public function __invoke(StoreRequest $request): Response
{
    return new JsonResponse(
        data: $this->command->handle(
            chirp: new ChirpObject(
                user: strval(auth()->id()),
                message: strval($request->get('message')),
            ),
        ),
        status: Http::CREATED->value,
    );
}

对我来说,这段代码是理想的。我们可能希望将代码包装在一个 try-catch 块中,以捕捉任何潜在的问题,但这并不是我现在试图解决的问题。

到目前为止,我发现的最大问题是创建数据对象有时会有点麻烦,尤其是当数据对象越来越大时。如果我在一个更大的应用中工作,其中数据对象更大,我将使用稍微不同的方法。在本例中,我不会使用它。但是,为了向您展示如何使用它,我现在将向您展示:

final class StoreController
{
    public function __construct(
        private readonly ChirpFactoryContract $factory,
        private readonly CreateNewChirpContract $command,
    ) {}
 
    public function __invoke(StoreRequest $request): Response
    {
        return new JsonResponse(
            data: $this->command->handle(
                chirp: $this->factory(
                    data: [
                        ...$request->validated(),
                        'user' => strval(auth()->id()),
                    ]
                ),
            ),
            status: Http::CREATED->value,
        );
    }
}

创建数据对象工厂将允许我们控制数据对象的创建方式,并允许我们将传入的请求转换为更接近我们希望在应用程序中工作的方式。让我们看看数据对象工厂的样子。

final class ChirpFactory implements ChirpFactoryContract
{
    public function make(array $data): DataObjectContract
    {
        return new ChirpObject(
            user: strval(data_get($data, 'user')),
            message: strval(data_get($data, 'message')),
        );
    }
}

它们只是将请求数组转换为对象的简单类,但随着请求负载变大,这些类有助于清理控制器代码。

您是否找到了令人兴奋的使用数据对象的方法?你如何处理他们的创建?我曾经向我的数据对象添加静态创建方法,但感觉我混合了数据对象本身的用途。

欢迎评论发布您的看法!