Laravel: Использование DTO для сохранения контекста

Источник: «Using DTOs to keep context»
DTO, или Domain Transfer Object, можно использовать для многих целей. С момента выпуска PHP 8 создание этих фантастических классов стало ещё проще.

От экранирования базовой конструкции Array до добавления безопасности типов к тому, что раньше было просто старым массивом. В версиях до PHP 8 всё было возможно; для этого требовалось гораздо больше шаблонного кода, и он никогда не казался стоящим.

С приближением PHP 8.2 наши возможности в экосистеме PHP становятся всё больше и больше. Отличной книгой для чтения было бы Object Design Style Guide Матиаса Нобака (Matthias Noback), которое я рекомендую всем разработчикам прочитать хотя бы раз.

Однако я не называю эти объекты DTO, поскольку я не просто использую их в коде своего домена. Я называю их Объекты Данных, поскольку это именно то, чем они являются.

При создании Объекта Данных мне нравиться делать все свойства доступными только для чтения, поскольку их следует читать, а не записывать — это лишает их смысла. Это даёт неизменяемую структуру, которую можно передать через приложение, чтобы сохранить контекст и безопасность типов, что я называю беспроигрышной ситуацией.

Давайте рассмотрим пример. Я позаимствую идею с Laravel Bootcamp, создадим Chirps (аналог Twitter). У нашего chirp есть две вещи, о которых ему нужно заботиться: это сообщение и пользователь создавший его. Сейчас при создании приложения я использую UUID или ULID, в зависимости от приложения. В этом я буду использовать ULID.

Поэтому проведём рефакторинг кодовой базы Bootcamp, для облегчения управлением в долгосрочной перспективе — веб-интерфейс, API, CLI и т.д. Поэтому мы стремимся перейти от встроенной логики к разделяемым классам. Давайте посмотрим как это выглядит.

$validated = $request->validate([
'message' => 'required|string|max:255',
]);

$request->user()->chirps()->create($validated);

return redirect(route('chirps.index'));

Мы можем провести рефакторинг этого кода для выполнения проверки в Form Request и переместим создание в другое место.

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 McDougall - автор статьи), это final class. Он реализует интерфейс DataObjectContract, происходящий от одного из пакетов Laravel, которые я обычно включаю в проект. Каждое свойство публично и доступно за пределами класса, но они также readonly, поэтому мой контекст не может измениться, как только будет создан. Затем, у меня есть метод 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')),
);
}
}

Это всего лишь простые классы, которые берут массив запроса и превращают его в объект, но по мере увеличения полезной нагрузки запроса они помогают отчистить код вашего контроллера.

Вы нашли интересные способы использования Объектов Данных? Как вы справляетесь с их созданием? Раньше я добавлял статические методы в свои Объекты Данных, но мне казалось, что я смешиваю назначение самого Объекта Данных. Поделитесь своими мыслями в Твиттере

Дополнительные материалы

Предыдущая Статья

Laravel: Ваши контроллеры должны выглядеть так

Следующая Статья

Laravel: Логирование в приложении