Внедряем DTO в Laravel

Внедряем DTO в Laravel

В своей работе я стараюсь придерживаться такого принципа, чтобы код был минимально зависим от фреймворка. Чтобы его можно было при необходимости быстро перенести на другой движок. Чтобы это реализовать, мы должны быть минимально завязаны на таких понятиях, как Model или Request.

В своей работе я стараюсь придерживаться такого принципа, чтобы код был минимально зависим от фреймворка. Чтобы его можно было при необходимости быстро перенести на другой движок. Чтобы это реализовать, мы должны быть минимально завязаны на таких понятиях, как Model или Request.

Об этом же говорят нам принципы проектирования DDD и его реализация на Laravel — Porto.

Если нам приходит запрос от пользователя, Laravel помещает его в объект Request, который затем обрабатывается в контроллере. И ключевой принцип правильного проектирования - сделать так, чтобы контроллер был последним местом, где мы работаем с объектом Request. Все, наверное, слышали, что контроллер должен быть тонким, в нём не должна быть никакая логика. И это верно. Всю логику нужно выносить в другое место - в сервисы, actions, tasks и т.д. Если следовать принципу DDD, контроллер передаёт задачу actions, которые затем распределяют её на таски. И ключевой вопрос заключается в следующем - как нам передать в другие классы то, что нам пришло из контроллера, если Requests нам использовать не рекомендуется?

Аналогичная ситуация возникает и в обратном порядке. Когда мы получили модель и нам требуется обработать в ней данные, как лучше это сделать, абстрагируясь от Laravel?

Здесь есть два варианта. Самый простой из них — преобразовать Request или модель в массив и работать с ним. Но здесь мы сталкиваемся с проблемой подсветки синтаксиса и слабой типизацией. Мы не знаем, какие данные содержатся в массиве и какой тип значений хранится в массивах.

Напомню, что PHP - слабо типизированный язык программирования. С учётом того, что всё что мы получаем в запросе является строкой - это было сделано не зря. И самый простой пример, если у нас есть переменная $a = 'name', у которой тип "строка", то мы можем преобразовать её в целочисленное значение, которое будет $a = 1;

Конечно в такой политике есть свои плюсы и минусы. И  главный минус, на мой взгляд, строго типизированные языки обладают меньшими багами. Если переменная имеет неизменяемый тип, то не может произойти и целый ряд последствий. Гарантия типов даёт дополнительную страховку программисту, оберегая его от неожиданных последствий.

Итак, часто вы наверное видели такой код:

function store(AccountStoreRequest $request, Account $account)

{

$validated = $request->validated();

$account->name = $validated['name'];

$account->phone = $validated['phone'];

// ...

}

Возможно, вы знакомы с проблемой, что мы не знаем, какие данные доступны в массиве validated. Массивы представляют мощную структуру данных только тогда, когда мы используем их для представления списка чего-либо. Но когда мы работаем с большим набором данных, которые нужно преобразовать или обработать, для этого есть другие инструменты.

Второй вариант, наиболее правильный - использовать DTO или Data Tranfer Objects, который по сути является обычным классом, в котором есть нужные нам свойства.

class AccountData

{

public int $id;

public string $name;

}

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

function store(AccountStoreRequest $reequest, Account $account)

{

$validated = AccountData::fromRequest($requet);

$account->name = $validated->name;

}

В таком подходе мы получаем два преимущества - наш IDE подсказывает нам, какие есть свойства у этого класса и они строго типизированы.

Вы, наверное, заметили, что мы используем метод fromRequest для создания DTO. К счастью, у laravel есть отличная библиотека, которая помогает программистам работать c DTO: https://github.com/spatie/data-transfer-object

По ссылке вы можете ознакомиться с её документацией.

Для подключения этой библиотеки в свой проект, запустите команду:

composer require spatie/data-transfer-object

И теперь мы можем создавать свой собственный DTO. Но для начала давайте создадим прослойку, от которой мы будем наследоваться, чтобы следовать принципам Porto. 

Создадим в папке Parents новый файл:

 

<?php

 

 

namespace Parents\DataTransferObjects;

 

 

use Carbon\Carbon;

 

class ObjectData extends \Spatie\DataTransferObject\DataTransferObject

{

    public static function generateCarbonObject(?string $date): ?Carbon

    {

        if (!$date) {

            return null;

        }

        return \Illuminate\Support\Carbon::createFromFormat(config('panel.date_format') . ' ' . config('panel.time_format'), $date);

    }

}

DTO также позволяет работать с коллекциями, нам потребуется и другой родительский класс:

 

<?php

 

 

namespace Parents\DataTransferObjects;

 

 

class ObjectDataCollection extends \Spatie\DataTransferObject\DataTransferObjectCollection

{

 

}

Теперь у нас есть всё необходимое, чтобы создать свой первый DTO. Давайте сделаем это на примере класса User:

 

<?php

declare(strict_types=1);

 

namespace Domains\Users\DataTransferObjects;

 

use Carbon\Carbon;

use Domains\Currencies\Models\Currency;

use Domains\Teams\Models\Team;

use Domains\Users\Models\User;

use Parents\Requests\Request;

 

 

final class UserData extends \Parents\DataTransferObjects\ObjectData

{

    public int $id;

 

    public string $name;

 

    public string $email;

 

    public ?Carbon $email_verified_at;

 

    public bool $approved;

 

    public bool $verified;

 

    public ?Carbon $verified_at;

 

    public Carbon $created_at;

 

    public string $login;

 

    public int $operations_number = 10;

 

    public int $budget_day_start = 1;

 

    public Currency $primary_currency;

 

    public ?string $timezone;

 

    public ?string $phone;

 

    public ?Team $team;

 

    public string $language;

 

    public bool $google_sync;

 

    public static function fromRequest(Request $request): self

    {

        return new self([

            'name' => $request->get('name'),

            'email' => $request->get('email'),

            'login' => $request->get('login'),

            'operations_number' => (int) $request->get('operations_number'),

            'budget_day_start' => (int) $request->get('budget_day_start')

        ]);

    }

 

    public static function fromModel(User $user): self

    {

        return new static([

            'id' => $user->id,

            'name' => $user->name,

            'email' => $user->email,

            'approved' => (bool) $user->approved,

            'verified' => (bool) $user->verified,

            'verified_at' => self::generateCarbonObject($user->verified_at),

            'created_at' => $user->created_at,

            'login' => $user->login,

            'operations_number' => $user->operations_number,

            'budget_day_start' => $user->budget_day_start,

            'primary_currency' => $user->currency,

            'timezone' => $user->timezone,

            'phone' => $user->phone,

            'team' => $user->team,

            'language' => $user->language,

            'google_sync' => (bool) $user->google_sync,

        ]);

    }

 

}

Как видите, у нас есть два метода, которые позволяют сгенерировать объект из модели или из request.

Более того, в PHP 8 можно код переписать примерно следующим образом:

 

    public function fromRequest(

        Request $request

    ): UserData {

        return new UserData(

            name: $request->get('name'),

            email: $request->get('email'),

            login: $request->get('login'),

        );

    }

 

И далее в контроллере или любом другом методе мы можем создать нужный нам объект следующим способом:

 

$user = User::first();

$userData = UserData::fromModel($user);

 

И мы получаем объект, который не является типом request и не является моделью. Т.е. независим от фреймворка и удовлетворяет принципам проектирования.

 

Теперь давайте пойдём дальше и посмотрим как нам возвращать ответ по API. Как мы делали раньше в случае с моделями:

 

public function store(StoreUserRequest $request): \Illuminate\Http\JsonResponse

    {

        $user = User::create($request->all());

        $user->roles()->sync($request->input('roles', []));

 

        return (new UserResource($user))

            ->response()

            ->setStatusCode(Response::HTTP_CREATED);

    }

 

У нас был объект UserResource, который возвращал нужный объект в формате Json. В случае с DTO Laravel не знает, как его обрабатывать. Для этого нам нужно ему об этом сказать. Чтобы этого достичь, нам нужно имплементировать интерфейс Responsable. Поэтому давайте создадим аналог класса UserResource, который будет возвращать данные в json.

 

<?php

 

 

namespace Parents\DataTransferObjects;

 

 

use Illuminate\Contracts\Support\Responsable;

 

final class ResponseData extends ObjectData implements Responsable

{

 

    public int $status = 200;

 

    public ObjectData|ObjectDataCollection $data;

 

    public function toResponse($request): \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response

    {

        return response()->json(

            [

                'data' => $this->data->toArray(),

            ],

            $this->status

        );

    }

}

 

Здесь у нас есть два аттрибута - status (по умолчанию мы возвращаем 200) и data, который принимает DTO.

Вы, наверное, заметили знакомую конструкцию toArray()? Как и в случае с моделями, у пакета spatie есть имплементация данного метода.

Теперь в контроллере мы можем сделать нечто вроде такого:

 

$user = User::whereId(2)->forstOrFail();

 

return new ResponseData([

    'data' => UserData::fromModel($user),

]);

 

В ответ мы получаем следующее:

{

  "data": {

    "id": 2,

    "name": "user",

    "email": "user@user.com"

  }

}

 

А что если мы хотим работать с коллекциями и возвращать результат в виде пагинации?

Для начала у нас должен быть класс, который управляет коллекциями DTO. Напишем его:

 

<?php

 

 

namespace Domains\Users\DataTransferObjects;

 

 

use Domains\Users\Models\User;

 

class UserDataCollection extends \Parents\DataTransferObjects\ObjectDataCollection

{

    public function current(): UserData

    {

        return parent::current();

    }

 

    /**

     * @param  User[]  $data

     * @return UserDataCollection

     */

    public static function fromArray(array $data): UserDataCollection

    {

        return new static(

            array_map(fn(User $item) => UserData::fromModel($item), $data)

        );

    }

}

 

Теперь нам нужно создать класс, который будет формировать response в виде пагинации.

 

<?php

 

 

namespace Parents\DataTransferObjects;

 

 

use Illuminate\Contracts\Support\Responsable;

use Illuminate\Contracts\Pagination\LengthAwarePaginator;

 

class ResponsePaginationData extends ObjectData implements Responsable

{

    public LengthAwarePaginator $paginator;

 

    public ObjectDataCollection $collection;

 

    public int $status = 200;

 

    public function toResponse($request): \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response

    {

        return response()->json(

            [

                'data' => $this->collection->toArray(),

                'meta' => [

                    'currentPage' => $this->paginator->currentPage(),

                    'lastPage' => $this->paginator->lastPage(),

                    'path' => $this->paginator->path(),

                    'perPage' => $this->paginator->perPage(),

                    'total' => $this->paginator->total(),

                ],

            ],

            $this->status

        );

    }

}

 

Сейчас у нас есть три аттрибута: статус, paginator, collection. В контроллере мы можем использовать его следующим образом:

 

$users = User::paginate(100);

 

return new ResponsePaginationData([

    'paginator' => $users,

    'collection' => UserCollection::fromArray($users->items()),

]);

 

И получим такой ответ:

{

  "data": [

    {

      "id": 1,

      "name": "admin",

      "email": "admin@admin.com"

    },

    {

      "id": 2,

      "name": "User",

      "email": "user@user.com"

    }

  ],

  "meta": {

    "currentPage": 1,

    "lastPage": 1,

    "path": "http://localhost/api/users",

    "perPage": 30,

    "total": 2

  }

}

 

Поскольку работа с данными лежит в основе практически любого проекта, особенно если дело касается DDD, то DTO является важным элементом инфраструктуры, т.к. они предлагают способ работы с данными структурированным, типизированным, предсказуемым и удобным способом.