Пишем меньше дублирующего кода, используя биндинг в Laravel

  • Tutorial
image

Доброго времени, уважаемые господа.

Не так давно столкнулся с явлением дублирующегося и повторяющегося кода при код ревью одного проекта на Laravel.

Суть в следующем: у системы существует некоторая структура внутреннего API для AJAX запросов, по сути возвращающая коллекцию чего-либо из базы (заказы, пользователи, квоты, etc...). Вся суть данной структуры — вернуть JSON с результатами, не более. При код-ревью я насчитал 5 или 6 классов, использующие один и тот же код, разница была лишь в инжекте зависимостей ResourceCollection, JsonResource и непосредственно модели. Такой подход мне показался в корне неверным, и я решил внести свои, как я считаю, правильные изменения в данный код, воспользовавшись мощным DI, который предоставляет нам Laravel Framework.

Итак, как же я пришел к тому, о чем расскажу дальше.

У меня уже примерно полтора года опыта разработки под Magento 2, и впервые столкнувшись с этой CMS, я был в шоке о ее DI. Для тех, кто не знает: в Magento 2 не малая часть системы построена на так называемых «виртуальных типах». То есть, обращаясь к определенному классу, мы не всегда обращаемся к «реальному» классу. Мы обращаемся к виртуальному типу, который был «собран» на основе определенного «реального» класса (пример — Collection для админского грида, собираемый через DI). То есть, мы можем фактически собрать любой класс для использования с нашими зависимостями, просто прописав в DI нечто подобное:

<virtualType name="Vendor\Module\Model\ResourceModel\MyData\Grid\Collection"
                 type="Magento\Framework\View\Element\UiComponent\DataProvider\SearchResult">
        <arguments>
            <argument name="mainTable" xsi:type="string">vendor_table</argument>
            <argument name="resourceModel" xsi:type="string">Vendor\Module\Model\ResourceModel\MyData
            </argument>
        </arguments>
</virtualType>

Теперь, запросив класс Vendor\Module\Model\ResourceModel\MyData\Grid\Collection, мы получим экземпляр Magento\Framework\View\Element\UiComponent\DataProvider\SearchResult, но с подставленным через DI зависимостями mainTable — «vendor_table» и resourceModel — «Vendor\Module\Model\ResourceModel\MyData».

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

Возвращаемся к Laravel.

DI Laravel построен на «сервис-контейнере» — сущности, которая управляет биндингами и зависимостями в системе. Таким образом мы можем, например, указать интерфейсу DummyDataProviderInterface вполне себе реализацию этого интерфейса DummyDataProvider.

app()->bind(DummyDataProviderInterface::class, DummyDataProvider::class);

Затем, когда мы запросим DummyDataProviderInterface в сервис-контейнере (например, через конструктор класса), мы получим экземпляр класса DummyDataProvider.

Многие (по каким-то причинам) на этом заканчивают познания в сервис-контейнере Laravel и идут заниматься своими, куда более интересными делами, а зря.

Laravel может «биндить» не только реальные сущности, как например данный интерфейс, но и создавать так называемые «виртуальные типы» (а.к.а алиасы). И, даже в этом случае, Laravel не обязательно передавать класс, реализующий ваш тип. Метод bind() вторым аргументом может принимать анонимную функцию, с передаваемым туда параметром $app — экземпляр класса приложения. Вообще, сейчас мы больше уходим в контекстный биндинг, где от текущей ситуации зависит то, что мы передадим в реализующий «виртуальный тип» класс.

Предупреждаю, что с таким подходом к построению архитектуры приложений согласны не все, поэтому если вы любитель сотни одинаковых классов — пропустите этот материал.

Итак, для начала определимся, что будет выступать в качестве «реального» класса. На примере проекта, попавшего мне на код-ревью, возьмем ту же ситуацию с запросами ресурсов (по сути CRUD, но немного урезанный).

Посмотрим на реализацию общего Crud-контроллера:


<?php

namespace Wolf\Http\Controllers\Backend\Crud;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Wolf\Http\Controllers\Controller;

class BaseController extends Controller
{
    /**
     * @var Model
     */
    protected $model;

    /**
     * @var \Illuminate\Http\Resources\Json\ResourceCollection|null
     */
    protected $resourceCollection;

    /**
     * @var \Illuminate\Http\Resources\Json\JsonResource|null
     */
    protected $jsonResource;

    /**
     * BaseController constructor.
     * @param Model $model
     * @param \Illuminate\Http\Resources\Json\ResourceCollection|null $resourceCollection
     * @param \Illuminate\Http\Resources\Json\JsonResource|null $jsonResource
     */
    public function __construct(
        $model,
        $resourceCollection = null,
        $jsonResource = null
    ) {
        $this->model = $model;
        $this->resourceCollection = $resourceCollection;
        $this->jsonResource = $jsonResource;
    }

    /**
     * Display a listing of the resource.
     *
     * @param Request $request
     * @return \Illuminate\Http\Resources\Json\ResourceCollection
     */
    public function index(Request $request)
    {
        return $this->resourceCollection::make($this->model->get());
    }

    /**
     * Display the specified resource.
     *
     * @param  int $id
     * @return \Illuminate\Http\Resources\Json\JsonResource
     */
    public function show($id)
    {
        return $this->jsonResource::make($this->model->find($id));
    }
}

Я не сильно заморачивался с реализацией, т.к проект находится на стадии, по сути, планирования.

У нас есть два метода, которые должны нам что-либо возвращать: index, возвращающий коллекцию сущностей из базы, и show, возвращающий json-ресурс определенной сущности.

Если бы использовали реальные классы — мы бы каждый раз создавали класс, содержащий в себе 1-2 сеттера, которые задавали бы классы для моделей, ресурсов и коллекций. Представьте себе, десятки файлов, из которых по истине сложная реализация находится только в 1-2. Избежать таких «клонов» мы можем, используя DI Laravel.

Итак, архитектура данной системы будет проста, но надежна как швейцарские часы.
Существует json-файл, который содержит массив «виртуальных типов» с непосредественным указанием на классы, которые будут использованы в качестве коллекций, моделей, ресурсов, etc…

Например, такой:

{
    "Wolf\\Http\\Controllers\\Backend\\Crud\\OrdersResourceController": {
        "model": "Wolf\\Model\\Backend\\Order",
        "resourceCollection": "Wolf\\Http\\Resources\\OrdersCollection",
        "jsonResource": "Wolf\\Http\\Resources\\OrderResource"
    }
}

Далее, используя биндинг Laravel, мы будем задавать для нашего виртуального типа Wolf\Http\Controllers\Backend\Crud\OrdersResourceController в качестве реализующего класса наш базовый круд-контроллер Wolf\Http\Controllers\Backend\Crud\BaseController (обратите внимание, что класс не должен быть абстрактным, т.к при запросе Wolf\Http\Controllers\Backend\Crud\OrdersResourceController мы должны получить экземпляр Wolf\Http\Controllers\Backend\Crud\BaseController, а не абстрактный класс).

В CrudServiceProvider в метод boot() поместим следующий код:


$path = app_path('etc/crud.json');
if ($this->filesystem->isFile($path)) {
    $virtualTypes = json_decode($this->filesystem->get($path), true);
    foreach ($virtualTypes as $virtualType => $data) {
        $this->app->bind($virtualType, function ($app) use ($data) {
            /** @var Application $app */
            $bindingData = [
                'model' => $app->make($data['model']),
                'resourceCollection' => $data['resourceCollection'],
                'jsonResource' => $data['jsonResource']
            ];
            return $app->makeWith(self::BASE_CRUD_CONTROLLER, $bindingData);
        });
    }
}

Константа BASE_CRUD_CONTROLLER содержит имя класса, реализующего логику CRUD-контроллера.

Далеко не идеал, но зато работает :)

Здесь мы проходим по массиву с виртуальными типами и задаем биндинги. Заметьте, что из сервис-контейнера мы получаем только экземпляр модели, а ResourceCollection и JsonResource остаются всего лишь именами классов. Почему так? Модель не обязательно должна принимать в себя атрибуты для заполнения, она вполне может обойтись и без них. А вот коллекции должны принимать в себя какой-либо ресурс, из которого они будут доставать данные и сущности. Поэтому, в BaseController мы используем статические методы collection() и make() соответственно (в принципе, можем добавить динамические геттеры, которые будут класть что либо в ресурс и возвращать нам экземпляр, но это я оставлю вам), которые будут возвращать нам экземпляры этих же коллекций, но с переданными в них данными.

По сути, вы можете в принципе весь биндинг Laravel довести до такого состояния.

Итого, запросив Wolf\Http\Controllers\Backend\Crud\OrdersResourceController мы получим экземпляр контроллера Wolf\Http\Controllers\Backend\Crud\BaseController, но с встроенными зависимостями нашей модели, ресурса и коллекции. Осталось только создать ResourceCollection и JsonResource и можно управлять возвращаемыми данными.
Поделиться публикацией

Комментарии 15

    +1

    Простите, может быть очень бегло прочитал и не понял какой-то особой мысли, но у вас в коде дальше
    routes.php


    Route::get('Backend\Crud\OrdersResourceController@index');

    ?
    тогда почему нельзя вместо странных биндигов напрямую прописать в роутах этот самый BaseController сразу?

      0
      Route::get(“something”, ...)

      Потому что таких запросов ресурсов могут быть тысячи (пользователи, заказы...). Зачем мне каждый раз для этого писать контроллер? И как я буду использовать BaseController, когда туда четко не прописаны зависимости.

      И с чего это биндинги вдруг странными стали?
        +2

        В основном биндинги не странные, кроме ваших.


        Вот зачем они тут? Для того, чтобы в роутах написать несуществующий контроллер, а потом придумать его через алиас? Чтобы люди, которые примерно понимают как работает ларавель, но сразу сообразили, где этот чудо-контроллер находится?


        Зачем мне каждый раз для этого писать контроллер?
        Вопрос дискуссионный, но допустим общий контроллер лучше. Тогда почему бы не расположить эту логику в том самом BaseController, добавив 3 функции вида

           protected function getModel(string $controller): object
            {
                $class = ['OrdersResourceController' => 'Wolf\\Model\\Backend\\Order'][$controller] ?? null;
                if ($class) {
                    return new $class();
                }
            }
        
        /// или через конфиг
            protected function getResourceCollection(string $class): object
            {
                $path = app_path('etc/crud.json');
                if ($this->filesystem->isFile($path)) {
                    $virtualTypes = json_decode($this->filesystem->get($path), true);
                    if ($virtualTypes[$class] ?? null) {
                        return new $virtualTypes[$class]['resourceCollection']();
                    }
                }
            }
      0
      А почему не хотите создавать классы? Ведь они различные сущности, различные ендпоинты, на что вы экономите?
      И да, если год работать на Вордпрессе, то наверное и «архитектура» Вордпресса понравится :)
        0
        Они не разные сущности, а одна, но возвращающая разные сущности. Мы говорим о контроллерах, а не моделях.

        Если бы такой подход нигде, кроме магенты, не применялся, но подход виртуальных типов я последнее время встречаю все чаще.
        +1

        А кто мешает использовать базовый контроллер, но передавать параметры в роуте?


        Route::get('orders', [
            'uses' => 'Backend\Crud\BaseController@index', 
            'model' => 'Wolf\Model\Backend\Order',
            'resourceCollection' => 'Wolf\Http\Resources\OrdersCollection',
            'jsonResource' => 'Wolf\Http\Resources\OrderResource'
        ]);
        //В контроллере
        $request->route->getAction('model')
          –1
          Это далеко не верный подход, т.к мы занимаемся логикой в роутах, чего там быть не должно.
            0

            А указание Backend\Crud\OrdersResourceController@index в роутах — это уже не логика?
            При этом найти OrdersResourceController невозможно потому что он не существует. Думай потом откуда ноги растут.

          +1
          После такой «архитектуры», не дай Бог попасть работать с таким кодом. Вы хотите с ларавель сделать мадженту. Должен быть эндпоинт зарегистрированный в роутинге, должен быть класс контроллера который вернёт вид, это пример правильного подхода, чем меньше магии — тем лучше
            0
            Причем тут виды?

            О «подходе» ларавель можно годами рассуждать. Один AR чего стоит.
              0
              А причем тут eloquent к архитектуре лары?
              0
              "чем меньше магии — тем лучше"

              да… это точно про Laravel)


              Простите, просто глаз зацепился. Безотносительно основной части сообщения.

              +1
              Идея не писать дублирующий код — хороша. Но вот с реализацией через json-конфиг хочется поспорить.

              Не прозрачно получилось. Когда придет новый разработчик, надо ему объяснить, что для реализации стандартных КРУД'ов надо в json'чик добавить несколько строк конфига. А если забыть это сделать, то большинство без зазрений совести сделают artisan make:controller, и в общем-то будут правы.

              И да, если в вашем случае потребуется некий специфический метод для одной сущности, то всё равно придется добавлять новый контроллер.

              На мой взгляд, реализовать некий КРУД-сервис, который будет реализовывать нужные методы, а в основных контроллерах по сущностям инжектить этот сервис, и в методах контроллера вызывать 1 метод сервиса. Такое решение хотя бы у новых людей не будет вызывать оторопь.
                0
                Я не говорю, что такой подход является единственно-верным. Но первое, что надо делать с новичком — это вводить его в курс дела. Мол, можешь делать и контроллеры, но за это тебе по жопе надаем, вот мы придумали так — пиши так. И все. Я все таки думаю, что если на проекте 1-2 мидла/сениора решили, что так будет лучше, то джунам нужно так делать. :)

                Таки этот подход я тоже не с потолка взял.
                0
                cmd+пкм уже не сработает, cmd+n подавно.
                Для команды разработчиков и крупного проекта, виртуализация — та еще головная боль.
                Через месяц не найдешь концов.

                Для одиночек и фанатов мадженты, ок.

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

                Самое читаемое