Шаблон Presenter в Laravel

Если вы используете Laravel в своем проекте достаточно долго, ваши модели, скорее всего, стали довольно большими. Со временем их становится все труднее поддерживать, т.к. они обрастают новым функционалом. Когда вы пишете код для каждого случая, где используются ваши модели, возникает соблазн «откормить» наши модели до тех пор, пока они не разжиреют.

image

В таких ситуациях мы можем воспользоваться паттерном Декоратор, который позволит нам выделить код, специфичный для каждого случая в отдельный класс. Например, мы можем использовать декораторы для того, чтобы разделить формирование представления для PDF-документа, CSV или ответа API.

 

Что такое Декоратор и что такое Презентер?

 

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

 

Презентер — это разновидность Декоратора, используемая для приведения объекта к нужному виду (например для Blade-шаблона или ответа API).

 

Приведение коллекции пользователей к ответу API

 

Предположим, у нас есть коллекция пользователей, которую мы должны вернуться в ответе API. В Laravel мы можем легко это реализовать, просто вернув саму коллекцию, которая затем будет преобразована в JSON. Давайте получим модели наших пользователей в контроллере:

 

<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
class UsersController extends Controller
{
    public function index()
    {
        return User::all();
    }
}

 

Метод all возвращает всех пользователей из базы данных. Модель User содержит в себе все поля таблицы. Пароли и другая важная информация так же находятся там. Кроме того, Laravel при выводе автоматически преобразует результат метода all в JSON.

 

Однако это не самый лучший вариант решения задачи. К примеру, на не нужно отправлять хеши паролей пользователей в ответе.

 

Также нам может не понравится, как выглядят даты created_at и updated_at. Или если наше поле is_active имеет тип tinyint, мы возможно захотим преобразовать ее в строку, либо в логическое значение.

 

Примечание: да-да, я знаю, что Eloquent позволяет скрыть поля модели при ее преобразовании в JSON, используя свойство $hidden у модели. Просто подыграйте мне.

 

Воспользуемся паттерном Presenter.

 

Сейчас, когда у нас есть коллекция моделей User, нам нужно понять, как передать их представлению, обернув их в декоратор. Нам понадобится класс, который будет выполнять роль Презентера. Наш класс UserPresenter в таком случае будет выглядеть следующим образом:

 

<?php
namespace App\Users;
class UserPresenter
{
    protected $model;
    public function __construct(Model $model)
    {
        $this->model = $model;
    }
    public function __call($method, $args)
    {
        return call_user_func_array([$this->model, $method], $args);
    }
    public function __get($name)
    {
        return $this->model->{$name};
    }
    public function fullName()
    {
        return trim($this->model->first_name . ' ' . $this->model->last_name);
    }
}

 

Заметьте, что наш презентер получает свойства first_name, last_name, и created_at у модели, потому что этих свойств нет у презентера.

 

Я люблю тупые аналогии и это одна из них: декоратор это что-то вроде костюма Бэтмена на Брюсе Уейне. И у Бэтмена есть куча разных костюмов для разных ситуаций. Как и костюмы Бэтмена, мы можем использовать различные декораторы для разных ситуаций, где нам нужна модель User. Давайте переименуем наш декоратор во что-то более подходящее, например, ApiPresenter, а затем поместим его в папку Presenters. Так же мы выделим код, который можно переиспользовать, в отдельный класс Presenter:

 

<?php
namespace App\Presenter;
abstract class Presenter
{
    protected $model;
    public function __construct(Model $model)
    {
        $this->model = $model;
    }
    public function __call($method, $args)
    {
        return call_user_func_array([$this->model, $method], $args);
    }
    public function __get($name)
    {
        return $this->model->{$name};
    }
}

 

Давайте добавим новый метод к ApiPresenter:

 

<?php
namespace App\Users\Presenters;
use App\Presenter\Presenter;
class ApiPresenter extends Presenter
{
    public function fullName()
    {
        return trim($this->model->first_name . ' ' . $this->model->last_name);
    }
    public function createdAt()
    {
        return $this->model->created_at->format('n/j/Y');
    }
}

 

Вы могли бы подумать, что можно использовать мутаторы Laravel для преобразования дат в нужный нам формат и избежать всей это возни с презентерами. Это возможно, если нам нужен только один вариант отображения.

 

Вы также можете сказать: «Я мог бы оставить поле created_at как есть и использовать несколько мутаторов для разных ситуаций. Например, friendlyCreatedAt(), pdfCreatedAt() и createdAtAsYear()«. Главным аргументом против такого подхода является то, что ваша модель постепенно станет огромной и будет приносить нам много беспокойства. Мы можем переложить эту ответственность на отдельный класс, который будет приводить нашу модель к нужному виду.

 

Давайте добавим еще несколько методов к нашему презентеру:

 

<?php
namespace App\Users\Presenters;
class ApiPresenter
{
    public function fullName()
    {
        return trim($this->model->full_name . ' ' . $this->model->last_name);
    }
    public function createdAt()
    {
        return $this->model->created_at->format('n/j/Y');
    }
    public function isActive()
    {
        return (bool) $this->model->is_active;
    }
    public function role()
    {
        if ($this->model->is_admin) {
            return 'Admin';
        }
        return 'User';
    }
}

 

Здесь мы приводим поле is_active нашей модели к логическому типу вместо tinyint. Также мы предоставляем API строковое представление роли пользователя.

 

Вернемся к нашему контроллеру. Теперь мы можем использовать презентер для построения ответа:

 

<?php
namespace App\Http\Controllers;
use App\Users\Presenters\ApiPresenter;
use App\Http\Controllers\Controller;
class UsersController extends Controller
{
    public function show($id)
    {
        $user = new ApiPresenter(User::findOrFail($id));
        return response()->json([
            'name' => $user->fullName(),
            'role' => $user->role(),
            'created_at' => $user->createdAt(),
            'is_active' => $user->isActive(),
        ]);
    }
}

 

Это замечательно! Теперь API возвращает только нужную информацию и код стал выглядеть чище. Но еще лучше то, что если мы захотим использовать значение, которого нет в ApiPresenter, но есть в модели User мы можем просто вернуть его динамически из модели, как мы привыкли:

 

<?php
return response()->json([
    'first_name' => $user->first_name,
    'last_name' => $user->last_name,
    'name' => $user->fullName(),
    'role' => $user->role(),
    'created_at' => $user->createdAt(),
    'is_active' => $user->isActive(),
]);

 

Декорирование коллекции пользователей

 

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

 

<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Users\Presenters\ApiPresenter;
class UsersController extends Controller
{
    public function index()
    {
        $users = User::all();
        $apiUsers = [];
        foreach ($users as $user) {
            $apiUser = new ApiPresenter($user);
            $apiUsers[] = [
                'first_name' => $apiUser->model->first_name,
                'last_name' => $apiUser->model->last_name,
                'name' => $apiUser->fullName(),
                'role' => $apiUser->role(),
                'created_at' => $apiUser->createdAt(),
                'is_active' => $apiUser->isActive(),
            ];
        }
        return response()->json($apiUsers);
    }
}

 

Все прекрасно, но выглядит это не очень красиво. Вместо этого я хочу воспользоваться макросами, которые позволяет создавать класс Collection:

 

<?php
Collection::macro('present', function ($class) {
    return $this->map(function ($model) use ($class) {
        return new $class($model);
    });
});

 

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

 

<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Users\Presenters\ApiPresenter;
class UsersController extends Controller
{
    public function index()
    {
        $users = User::all()
            ->present(ApiPresenter::class)
            ->map(function ($user) {
                return [
                    'first_name' => $user->first_name,
                    'last_name' => $user->last_name,
                    'name' => $user->fullName(),
                    'role' => $user->role(),
                    'created_at' => $user->createdAt(),
                    'is_active' => $user->isActive(),
                ];
            });
        return response()->json($users);
    }
}

 

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

 

Таким образом декораторы и презентеры являются мощным инструментом, которым мы располагаем. Их легко писать и они легко тестируются. Используйте их, когда это имеет смысл. Они могут помочь вам при рефакторинге.

 

Но это еще не все. Было бы круто, если бы могли вызвать метод present для отдельной модели. И если бы у нас был хелпер, который позволил бы нам обернуть модель в презентер.

 

Что же, позвольте мне представить вам пакет Hemp/Presenter. Он делает все то, о чем мы говорили, плюс ко всему он реализует те пожелания, о которых я говорил. И все это протестировано. Попробуйте и расскажите мне, что вы думаете о нем. Наслаждайтесь!