Skip to content

Instantly share code, notes, and snippets.

@greabock
Last active December 13, 2023 14:49
Show Gist options
  • Save greabock/48787baab768b519f21c to your computer and use it in GitHub Desktop.
Save greabock/48787baab768b519f21c to your computer and use it in GitHub Desktop.
Как упороться по модульной структуре и областям ответсвенности в Laravel. А потом стать счастливым =)

#Как упороться по модульной структуре и областям ответственности в Laravel. А потом стать счастливым.

[UPD] после пары вопросов в личку, решил добавить дисклеймер: Я не считаю, что это единственно верный путь. Я просто говорю вам о том, что существует такой подход.

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

Дефолтная структура приложения на laravel выглядит вот так: У вас есть папка Http в которой лежат посредники(раньше это были фильтры) и контроллеры. Так же есть команды, хэндлеры, исключения, модели (последние Тейлор бессовестно бросил просто так - прямо в корне app )... возможно вы сами создаете папки репозиториев, обсерверов... или что-то там еще... потом вы начинаете строить приложение...

Вот вы усердно строчите свой код, прилежно создаете свои классы, аккуратно распихиваете их по папочкам. У вас получается большое приложение которое делает все что нужно, ну прям Code Happy по Daylee Rees. И вот в какой-то момент, вы решаете внедрить новую фичу взамен старой. И что же происходит? Вы как индейцы скачете по своим вьюхам выпиливая переменные, шерстите модели переназначая связи... и задерживаете дыхание в очередной раз обновляя страницу... ну вот - слава богу вы все выпили и перепили... а через неделю на какой-то далекой странице на которую никто не ходит, вдруг оказалось, что что-то не работает... вы просто забыли что там, еще что-то было... ну да и хрен с ней, все равно эта страница никому не нравилась. Или нет? Все верно - это ваш код. И он не работает. Вы послушно получаете пинка от заказчика/начальника и идёте чинить этот геморрой. Но ведь можно было всего этого избежать...

Давайте я покажу вам вот такую структуру приложения: Что, если я скажу вам, что я могу удалить любую из этих папок и мое приложение продолжит работать, как ни в чем не бывало? Вы не поверите? И правильно - еще мне нужно будет удалить провайдер из загрузки =) Как же это стало возможным? А возможным это стало благодаря двум крохотным пакетам: Widget-system и Tentacle Оба пакета работают как на laravel 4, так и на laravel 5. Однако все примеры будут приведены для laravel 5. Но обо всем по порядку. Начнем со структуры приложения...

Все мое приложение поделено на области ответственности. Что это такое? Это такие маленькие участки моего приложения, которые даже можно считать самостоятельными приложениями "вещами в себе" относительно друг друга. Например...
у меня есть область ответственности User. Она включает в себя модели User, Role, контроллеры управления, авторизации и регистраци, обсерверы, репозитории. В общем все как у полноценного приложения. А так же, у меня есть область ответственности Menu, она включает в себя непосредственно Меnu и Items (пункты меню). Все сказанное об области User справедливо и для области Menu. Следите за пространствами имен классов, которые я буду приводить ниже в качестве прмеров, чтобы понять, где мы находимся. И так...

##Widget-system Давайте разберем классическую схему сайта. Как мы обычно решаем эту проблему? Чаще всего мы имеем некоторый шаблон, который предоставляет контент, и этот шаблон расширяет основной лэйаут. То есть в контроллере мы имеем что-то вроде:

return view('some');

а сам шаблон выглядит так:

@extends('layout')
@section('content')
  <div>some content</div>
@stop

а уже в лэйауте:

@include('menu')
@include('left-side-bar')
@yield('content')
@include('right-side-bar')

Как же мы предоставляем переменные, которые должен получать лэйаут? Часто, это бывает что-то в духе View::share(). Парни попродвинутее используют View::creator() или View::composer(), которые привязываются к соответствующим шаблонам. В чем недостаток подобного подхода? В том, что вы жестко привязаны к этой структуре, и вам нужно модифицировать все это, когда вам нужно что-то добавить или убрать.

Как же эту проблему решает Widget-system? А вот так:

{!! Widget::show('menu') !!}
{!! Widget::position('left-side-bar') !!}
@yield('content')
{!! Widget::position('right-side-bar') !!}

Вне зависимости от того, определены ли виджеты, этот код уже работает, и не будет выдавать ошибок. А теперь заглянем в сервис провайдер области ответственности Menu:

<?php namespace App\Menu;

use Widget;
use App\Core\Providers\AbstractProvider;

#..

class Provider extends AbstractProvider{

  #..

	public function boot()
	{
		#..

		Widget::register('App\Menu\Widgets\SimpleMenuWidget', 'menu');

		#..
	}

Это означает следующее: как только будет вызван виджет {!! Widget::show('menu') !!} класс Widget найдет внутри себя соответствующий класс, создаст его объект и выполнит на нем метод render(), результат исполнения этого метода вернется назад и будет выведен в шаблон. Пример класса-виджета:

<?php namespace App\Menu\Widgets;

use Illuminate\Contracts\Support\Renderable;
use App\Menu\Models\Item;

class SimpleMenuWidget implements Renderable {

  public function render()
  {
    $items = Items::all();

    return view('menu::menu.template', compact('items'));
  }

}

Опустим детали того как именно отрисовывается менюшка в шаблоне 'menu.template' - это сейчас не важно. Вместо этого давайте представим, что нам нужно отрисовать менюшку с одними и теми же пунктами как в шапке, так и в футере, данные одинаковые, а шаблоны разные.

Немного изменим класс виджета

<?php namespace App\Menu\Widgets;

use Illuminate\Contracts\Support\Renderable;
use App\Menu\Models\Item;

class SimpleMenuWidget implements Renderable {

  //мы установили шаблон по умолчанию
  protected $defaultView = 'menu::menu.template';

  //метод render() теперь принимает параметр
  public function render($view = null)
  {
    // проверка - если $view определен,
    // то он идет дальше. Иначе устанавливается дефолтный
    $view = $view ? $view : $this->defaultView;

    $items = Items::all();

    return view($view, compact('items'));
  }

}

Тогда в шаблоне мы можем применить такой ход:

{!! Widget::show('menu') !!}
#...
{!! Widget::show('menu', 'menu-bottom.template') !!}

Но вот же косяк... таким образом мы получили два запроса в базу, а ведь переменные одни и те же... Widget-system знает об этой проблеме. Нужно лишь переделать немного класс виджета.

<?php namespace App\Menu\Widgets;

use App\Menu\Models\Item;

class SimpleMenuWidget {

  protected $defaultView = 'menu::menu.template';

  protected $items;

  //вынесли загрузку айтемов в конструктор
  public function __construct()
  {
    $this->items = Items::all();
  }

  public function render($view = null)
  {
    $view = $view ? $view : $this->defaultView;

    return view($view, ['items' => $this->items]);
  }

}

дело в том, что Widget-system хранит объект виджета, и если он был однажды вызван, то повторный его вызов приведет к обращению к тому же объекту. Таким образом, конструктор вызывается лишь однажды, а render() вызывается каждый раз при обращении.

Само собой разумеется, вы можете передать любое количество дополнительных аргументов;

Widget::show('menu', $arg1, $arg2 , $argN)

Кроме того widget-system умеет работать с перегрузкой методов, например:

Widget::menu($arg1, $arg2 , $argN)
// тоже самое что
Widget::show('menu', $arg1, $arg2 , $argN)

Это, в сочетании c любым количеством передаваемых аргументов, открывает огромные возможности для фантазии и творчества =)

Но поговорим немного о другом... в первом примере шаблона я употребил метод Widget::position('left-side-bar'). Что же это значит? Давайте, снова вернемся в сервис-провайдер области ответственности Menu и добавим туда еще кое-что.

public function boot()
{
  #...
  Widget::register('App\Menu\Widgets\SimpleMenuWidget', 'menu');


  // вот это мы добавим
  Widget::register('App\Menu\Widgets\LeftMenuWidget', 'left-menu', 'left-side-bar', 0);
}

Опустим детали и не будем вдаваться в то, как именно рисуется это "левое меню". Обратим лучше внимание на третий и четвертый аргументы. Третий аргумент - это имя позиции в которой будет отображен виджет, а четвертый - приоритет вывода.

Теперь пойдем в сервис-провайдер зоны ответственности Article

public function boot()
{
  Widget::register('App\Article\Widgets\LastArticlesWidget', 'last-articles', 'left-side-bar', 1);
}

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

Стоит так же отметить, что все классы-виджеты вызываются через App::make(); А это значит, что зависимости которые вы укажете в методе-конструкторе виджета будут по возможности разрешены.

Вот поэтому этот крохотный класс widget-system так крут. Надеюсь вам он тоже понравится.

##Tentacles Окей, а как же связи моделей? - спросите вы. Тут к нам на помощь придет другой малютка: класс-трейт - Тентакль.

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

Как мы помним, у нас есть область ответственности Article, и само собой, что у статьи, к примеру, должен быть автор. Но было бы очень странно, если бы у модели User сразу же была связь articles, ведь это совершенно другая область ответственности. Но моя модель User имеет трейт Tentacle - и это прекрасно. Теперь я иду в сервис-провайдер области ответственности Article и добавляю в метод boot() следующий код:

User::addRelation('articles', function(Model $user){
  $user->hasMany(Article::class);
})

И теперь наш класс User может использоваться так:

$user = User::with('articles')->get();

А теперь обратите внимание, что мы ни разу не вторглись в область ответственности User, но при этом привязали к нему статьи. Мы не вторглись в область ответственности Frontend, которая хранит лэйауты для фронта и отвечает за отображение главной страницы. Тем не менее, главная страница упакована необходимыми меню и модулями.

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

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

{!! Widget::position('user-matherials', $user) !!}

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

Общение между областями ответственности должно происходить посредством событий и их слушателей. Реже - адаптеров.

Я надеюсь, что хоть немного пролил свет на модульный подход. Удачи!


P.S. Если вам вдруг тоже приспичит упороться, пользуйтесь этими пакетами аккуратно, они еще сыроваты и скорее всего будут дорабатываться. Доки по пакетам пока что убогие) но основные их возможности уже были изложены в данной статье.

P.P.S. Отдельное спасибо @sleeping-owl. Ну и спасибо всему Cooбществу. Пишите в чатике, курите мануалы

@believer-ufa
Copy link

Обалдеть. Это просто супер, я мечтал о подобной схеме. Большое спасибо за статью и две утилиты. Надо будет попробовать начать её использовать в своём проекте.

@artemtaranov
Copy link

Да, неплохо, обязательно посмотрю.
Спасибо за статью!

@believer-ufa
Copy link

А что насчёт роутов, кстати?) Тоже по папкам всё разбить и инклудить в routes.php?

@greabock
Copy link
Author

@believer-ufa роуты поставляются вместе с областью ответственности. Каждый файл роутов подключается в провайдере своей области ответственности. Я делаю это простым include, хотя найдутся люди, которым это врядли понравится. В свое оправдание могу сказать, что в самой Ларе происходит тоже самое. https://github.com/laravel/laravel/blob/master/app/Providers/RouteServiceProvider.php

@violarium
Copy link

Идея и пакеты очень интересные.

Но про удаление в данном случае вы всё же говорите немного неправду, признайте)
Если вы создали необходимую миграцию для Article и связали её внешним ключём с User, то удалив сервис провайдер, где должны быть прописаны правила удаления статей при удалении пользователя, то вы просто не сможете удалить пользователя из-за ограничений целостности БД.

В данном случае не освещён вопрос редактирования статей.
Предполагаются контроллеры в самом сервисе статей?
А что делать, если, к примеру, необходима возможность одновременно редактировать и информацию о пользователе, и сами его статьи в рамках одной формы? Как в таком случае вгрызаться в уже существующий контроллер, валидаторы и прочее?

@greabock
Copy link
Author

@violarium, что касается миграций - у меня их просто нет. В каждом модуле у меня есть инсталяторы. Этот путь, к сожалению не очень подходит для "разработки в процессе". Да, я немного не договорил. После простого удаления папки, в базе останется мусор. Но он абсолютно не помешает работе приложения. Тем не менее, перед удалением модуля я использую команду app:article down, которая удаляет все таблицы и завязки из базы данных. Но подчеркну: я не говорил неправду - оно действительно продолжит работать, даже с мусором )

редактировать и информацию о пользователе, и сами его статьи в рамках одной формы

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

Спасибо Вам за отзыв и указание на проблемы!

@maximw
Copy link

maximw commented Mar 5, 2015

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

@greabock
Copy link
Author

greabock commented Mar 5, 2015

@maximw пока серьезных проблем не возникало. Поначалу, приходится костыльки подставлять, но потом приходит понимание "как сделать правильно", и проблемы отпадают. Но было время - шишек набил.

@xbagir
Copy link

xbagir commented May 10, 2015

@greabock
Спасибо за труд, особенно на нашем родном языке, приятно читать !)

Если предложение упороться по DDD еще сильнее, по самым запущенным случаям, а именно:

1. Бизнес логика у вас в контроллерах ? В списке каталогов не увидел services. Сервисы как раз и должны инкапсулировать логику, а контроллер только передавать им данные и получать результат.

2. У вас Repository возвращают объекты Eloquent ? Тоже неверно, пример:

$user = $userRepository->create(arrray());
$user->name = 'user1'
$user->save() //fail

Сущность не должна знать где и как она хранится и т.д. Т.е. нужно уходить от моделей Laravel к голым Entity и паттерну DataMapper + UnitOfWork. Иначе нормально не получится разрулить ситуацию, когда меняется хранилище с mysql на mongo для userRepository, и и нужно как-то мапать на те же модели данные с нового хранилища, чтобы не переписывать пол проекта.

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

SELECT (список полей)
FROM Posts
  LEFT JOIN Cars      on Cars.id  = Posts.carId
  LEFT JOIN Cities    on Cities.id = Posts.cityId
  LEFT JOIN Regions   on Regions.id = Cities.regionId
  LEFT JOIN Countries on Countries.id = Regions.countryId
  LEFT JOIN Models    on Models.id = Cars.modelId
  LEFT JOIN Brands    on Brands.id = Models.brandId

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

На все описанные траблы, в рамках Laravel не нашел толковых решений. Doctrine 2 , мне совсем не понравилась своей громоздкостью.

Вы с подобными проблемами как боритесь ?

@greabock
Copy link
Author

@xbagir
1. что касается сервисов, это просто пример такой, вообще, они конечно же есть, хоть их и не так много (основная логика по атомарным операция в командах)
2. Да, у меня репозитории возвращают или коллекции eloquent или модели eloquent, в зависимости от контекста. Снаружи (вне контекста репозитория) из интерфейса моделей используется только доступ к атрибутам. Так что, я не вижу проблемы в подмене хранилища.
И да, кстати вот это

$userRepositoryCreate([#...]);

делать нельзя. Согласись - это странно когда бабушка-гардеробщица еще и пошивом шуб занимается. Так что, вот так

$user;  // = тут твой сервис или команда, или как тебе еще вкуснее сущности создавать
$userRepository->save($user);

будет правильнее.

3.Если я тебя правильно понял, то именно поэтому, я использую связи а не join'ы. Вообще выборка - это отдельная дискуссия. Подробнее в личку или чатик.

@matchish
Copy link

Есть ли проект на котором вживую можно посмотреть код с такой структурой?

@greabock
Copy link
Author

@husband Из публичных - KodiCMS следует подобной идеологии, но со своим взглядом на некоторые вещи =)

@nnnikolay
Copy link

да простят меня фанаты @greabock но это к DDD никаким боком не относится.
DDD это об Ubiquitous language, (Root) Aggregate Entities, Value Objects, Bounded Context, Events, Persistance Ignorance etc
А все что изложено выше, еще один из способов (и совсем не плохой надо сказать) выжить в каше-PHP-Ларавел.

@greabock
Copy link
Author

greabock commented Jan 8, 2016

@nnnikolay, ну вообще да - "DDD" в заголовке скорее для "желтухи". А вообще, в тексте присутствует ремарка, по этому поводу

Конечно же это еще не DDD. Это лишь одно из условий его обеспечивающих - модульность (слабая связанноть). Сам по себе DDD намного больше, и это скорее философия, чем реальный паттерн.

@nikorgl
Copy link

nikorgl commented Sep 15, 2016

А есть какой-нибудь бойлерплейт такой модульной реализации Laravel?

@yazux
Copy link

yazux commented Oct 17, 2016

Очень полезная статья, спасибо. Искал пример именно такого подхода.

@bigperson
Copy link

https://github.com/arrilot/laravel-widgets а этот пакет для системы виджетов не пойдет?

@grscl
Copy link

grscl commented Mar 16, 2017

Тентакль бестолковая прослойка, для связей можно было обойтись трейтом без сервис провайдера ;)

@Piterden
Copy link

Посмотрите на PyroCMS. Это - альтернативная реализация подхода DDD. Я б даже сказал, это NoDDD )))

@shov
Copy link

shov commented Feb 16, 2018

Огонь! Пока не дочитал до тентакля)

На самом деле проблемы будут если удалить допустим директорию User,
хотябы в Article есть в провайдере вот это

User::addRelation('articles', function(Model $user){
  $user->hasMany(Article::class);
})

и это уже все развалит, зависимость от класса User и его присутствия в системе в том namespace откуда он use очевидна

Было бы куда веселее что-тов роде

$tentakle->tryAddRelation('App\\User\\Models\\User', 'articles', function( ....

и если такой модели нет, то.. просто ничего не делать

@4n70w4
Copy link

4n70w4 commented Jan 14, 2020

На данный момент Tentacle не совместим с Laravel 6. Есть какие-либо альтернативы?

@greabock
Copy link
Author

@4n70w4 создать пулл-реквест )
можно еще в сторону Macroable посмотреть

@4n70w4
Copy link

4n70w4 commented Jan 29, 2020

Посмотрел в сторону Macroable, требовалось внести фикс, чтобы он работал в том числе и с моделями.
laravel/framework#31286

@salimonenko
Copy link

"в базе останется мусор. Но он абсолютно не помешает работе приложения"
Ну, всё, дальше можно не обсуждать. Лично я буду делать чтобы как уж нибудь без мусора. Раздражает, знаете ли, мусор.

Если откровенно, мой скромный и исключительно субъективный опыт использования Laravel говорит следующее: сие годится, разве что, чтобы по-быстрому сляпать что-то типа "работающего сайта" и впарить заказчику. В итоге, ежели возникнут проблемы (в том числе. оттого, что разработчик фреймворка выпустит очередную несовместимую версию), потом программист потратит ГОРАЗДО больше времени на выявление тех самых таинств модульной структуры и их согласование, чтобы, мол, "заработало".
Нет уж. Лично для себя и для тех, кого не хочу подводить - буду делать так, как делал раньше: вручную. И кроссбраузерно.
А вот всем остальным - по желанию.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment