Доброго времени, уважаемые господа.
Не так давно столкнулся с явлением дублирующегося и повторяющегося кода при код ревью одного проекта на 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 не обязательно передавать класс, реализующий ваш тип. Метод 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)
L0NGMAN
13.05.2019 01:19А почему не хотите создавать классы? Ведь они различные сущности, различные ендпоинты, на что вы экономите?
И да, если год работать на Вордпрессе, то наверное и «архитектура» Вордпресса понравится :)Nathan_Foxy Автор
13.05.2019 06:27Они не разные сущности, а одна, но возвращающая разные сущности. Мы говорим о контроллерах, а не моделях.
Если бы такой подход нигде, кроме магенты, не применялся, но подход виртуальных типов я последнее время встречаю все чаще.
lnkvisitor
13.05.2019 06:26А кто мешает использовать базовый контроллер, но передавать параметры в роуте?
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')
Nathan_Foxy Автор
13.05.2019 06:26Это далеко не верный подход, т.к мы занимаемся логикой в роутах, чего там быть не должно.
lnkvisitor
13.05.2019 13:27А указание
Backend\Crud\OrdersResourceController@index
в роутах — это уже не логика?
При этом найти OrdersResourceController невозможно потому что он не существует. Думай потом откуда ноги растут.
yu-hritsaiy
13.05.2019 07:14После такой «архитектуры», не дай Бог попасть работать с таким кодом. Вы хотите с ларавель сделать мадженту. Должен быть эндпоинт зарегистрированный в роутинге, должен быть класс контроллера который вернёт вид, это пример правильного подхода, чем меньше магии — тем лучше
Nathan_Foxy Автор
13.05.2019 08:20Причем тут виды?
О «подходе» ларавель можно годами рассуждать. Один AR чего стоит.
ellrion
14.05.2019 10:19"чем меньше магии — тем лучше"
да… это точно про Laravel)
Простите, просто глаз зацепился. Безотносительно основной части сообщения.
Bluz
13.05.2019 13:19Идея не писать дублирующий код — хороша. Но вот с реализацией через json-конфиг хочется поспорить.
Не прозрачно получилось. Когда придет новый разработчик, надо ему объяснить, что для реализации стандартных КРУД'ов надо в json'чик добавить несколько строк конфига. А если забыть это сделать, то большинство без зазрений совести сделают artisan make:controller, и в общем-то будут правы.
И да, если в вашем случае потребуется некий специфический метод для одной сущности, то всё равно придется добавлять новый контроллер.
На мой взгляд, реализовать некий КРУД-сервис, который будет реализовывать нужные методы, а в основных контроллерах по сущностям инжектить этот сервис, и в методах контроллера вызывать 1 метод сервиса. Такое решение хотя бы у новых людей не будет вызывать оторопь.Nathan_Foxy Автор
13.05.2019 13:21Я не говорю, что такой подход является единственно-верным. Но первое, что надо делать с новичком — это вводить его в курс дела. Мол, можешь делать и контроллеры, но за это тебе по жопе надаем, вот мы придумали так — пиши так. И все. Я все таки думаю, что если на проекте 1-2 мидла/сениора решили, что так будет лучше, то джунам нужно так делать. :)
Таки этот подход я тоже не с потолка взял.
sayber
13.05.2019 17:50cmd+пкм уже не сработает, cmd+n подавно.
Для команды разработчиков и крупного проекта, виртуализация — та еще головная боль.
Через месяц не найдешь концов.
Для одиночек и фанатов мадженты, ок.
Andreyika
Простите, может быть очень бегло прочитал и не понял какой-то особой мысли, но у вас в коде дальше
routes.php
?
тогда почему нельзя вместо странных биндигов напрямую прописать в роутах этот самый BaseController сразу?
Nathan_Foxy Автор
Route::get(“something”, ...)
Потому что таких запросов ресурсов могут быть тысячи (пользователи, заказы...). Зачем мне каждый раз для этого писать контроллер? И как я буду использовать BaseController, когда туда четко не прописаны зависимости.
И с чего это биндинги вдруг странными стали?
Andreyika
В основном биндинги не странные, кроме ваших.
Вот зачем они тут? Для того, чтобы в роутах написать несуществующий контроллер, а потом придумать его через алиас? Чтобы люди, которые примерно понимают как работает ларавель, но сразу сообразили, где этот чудо-контроллер находится?