•   Новости. Советы. Уроки.
Блог / Советы и уроки / Представители (Presenters) в Laravel

Представители (Presenters) в Laravel

David Hemphill
Николенко Виталий
14.09.2016
Перевод статьи Presenters in Laravel

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

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

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

Иногда можно опереться на шаблон проектирования «Декоратор» (Decorator Pattern) которой позволит отрефакторить часть функционала при помощи контекстно-зависимых классов, что конечно разгрузит немного наши модели. Например, можно использовать декораторы для разделения форматирования вывода в PDFs, CSV, или API.

Что такое Декоратор (Decorator)? Что такое Представитель (Presenter)?

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

Представитель – разновидность декоратора, который используется для представления объекта в том или ином виде: например в Blade шаблоне или качестве ответа API.

Форматирование коллекции пользователей для ответа API

Представим, что у нас есть некая коллекция объектов, которую надо вернуть в качестве ответа на запрос к API. В Laravel нам особо ничего делать не надо, просто вернем Collection, которая автоматически трансформируется в строку JSON. Рассмотрим пример, сделаем вызов Eloquent в контроллере, как обычно:

<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;

class UsersController extends Controller
{
    public function index()
    {
        return User::all();
    }
}

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

Примечание: Ну конечно я знаю, что Eloquent поддерживает скрывание атрибутов в json представлении модели путем добавления их в массив $hidden . Не надо паники.

Получается, что этот метод не очень подходит для построения API из-за проблем с безопасностью, как минимум. Например, мы не должны отправлять в ответе хеши паролей, скорее всего не захотим отдавать в неформатированном виде такие проперти как created_at и updated_at. Также атрибуты типа is_active надо будет отдавать не интами а строкой типа true или false. Всего этого можно достичь, если обернуть, или «задекорировать» наши объекты другими объектами.

Встречайте шаблон проектирования «Представитель» (Presenter)

Теперь, когда у нас есть коллекция объектов User , как нам отправить их в представление в декорированной форме? Нам нужен класс, который будет действовать в качестве представителя. В данном примере класс UserPresenter может выглядеть следующим образом. Обратите внимание, что наш представитель делегирует магические вызовы атрибутов first_name, last_name, и created_at исходной модели, потому что этих атрибутов нет в самом представителе:

<?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);
    }
}

Я люблю тупые аналогии, вот одна для данного случая: декоратор – это как костюм Бэтмена для Брюса Уэйна. А, если вы выросли на тех же игрушках, что и я, вы точно знаете, что у Бэтмена целый набор костюмов на все случаи жизни. Как и костюмы, мы можем иметь декораторы для разных ситуаций в нашем приложении.

Давайте теперь применим это знание и переименуем наш класс-представитель по что-то более подходящее по смыслу. Назовем его 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');
    }
}

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

Вы можете также возразить «Я оставлю created_at нетронутым и сделаю несколько мутаторов. Типа friendlyCreatedAt(), pdfCreatedAt(), и createdAtAsYear()»

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

Конечно, мы все когда-то так делали, но огромный плюс в том, чтобы вынести подобный функционал в отдельный класс – наша модель на разрастается, не ширится ее область интересов. Мы доверяем другому классу заботиться о форматировании модели для API.

Давайте добавим еще методы в Presenter.

<?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. Мы также представляем роль пользователя в виде строки. Вернемся к нашему контроллеру, теперь мы можем использовать методы представителя для построения ответа:

<?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);
    }
}

И такой вариант может вполне жить, вы можете так сделать и никто не умрет. Однако, как-то корявенько это выглядит.

Вместо этого я бы сделал решение на основе макроса коллекции, примерно так:

<?php

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

Теперь, если внедрить этот макрос в сервис-провайдер, можно добавить его вызов после User::all() с необходимым представителем в качестве параметра:

<?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);
    }
}

Как по мне, все становится довольно круто. Макрос present становится звеном цепи. Каждая модель в коллекции оборачивается представителем. Затем получившуюсю коллекцию можно передать еще в один декоратор, чтобы скомбинировать ответ из нескольких обработчиков. Это как надеть несколько костюмов Бэтмена!

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

Однако, это еще не все.

Если вы думаете также, как и я, вы точно тоскуете по старым денькам, когда можно было тупо вернуть коллекцию из контроллера и получить отличную JSON строку. Нам придется добавить поддержку этого, мы также наверняка будете скучать по возможности сконвертировать коллекцию в массив, это тоже придется добавить. А что еще было круто? Если можно было просто сделать вызов present у самой модели и на этом все. И было бы нереально круто иметь отличную функцию-помощника, в которую можно было скормить объект любого типа?

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