Слоистая архитектура для Yii приложений

2 апреля, 2017
Метки: ,

Толстые контроллеры и модели — неизбежная проблема всех средних и крупных проектов, основанных на MVC-фреймворках таких как Yii и Laravel. Главная причина возникновения толстых контроллеров и моделей это “Active Record” — мощный и важный компонент таких фреймворков.

Проблема: Active Records нарушает принцип единой ответственности (Single Responsibility Principle)

“Active Record” — архитектурный паттерн, подход к проектированию доступа к данным в БД. Он был описан Мартином Фаулером в 2003 году в книге “Patterns of Enterprise Application Architecture” и широко применяется в PHP-фреймворках.

Несмотря на всю мощь Active Record (AR), этот паттерн нарушает принцип единой ответственности (SRP) потому что AR-модель:

  • Работает с запросами и сохранением данных
  • Слишком много знает об остальных моделях, существующих в системе (через отношения)
  • Слишком часто напрямую влияет на бизнес-логику приложения (потому что конкретная имплементация хранения данных тесно связана с бизнес-логикой приложения)

Такое нарушение SRP удобно для быстрой разработки, когда нужно создать прототип приложения как можно скорее, но крайне вредно для разросшихся приложений. Божественные модели и толстые контроллеры сложно тестировать и поддерживать. Использование моделей повсюду в коде ведет к огромному количеству проблем, когда становится нужно что-то изменить в структуре БД (в любом проекте изменения неизбежны).

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

Решение: Слоистая архитектура для PHP MVC Frameworks

Для реализации слоистой архитектуры нам пригодится ‘dependency injection container’ (DIC) — объект который знает как создавать и конфигурировать объекты. В Yii и Laravel всю магию берут на себя фреймворки и нет необходимости самому создавать такой класс контейнера.

class SiteController extends \Illuminate\Routing\Controller
{
   protected $userService;

   public function __construct(UserService $userService)
   {
       $this->userService = $userService;
   }

   public function showUserProfile(Request $request)
   {
       $user = $this->userService->getUser($request->id);
       return view('user.profile', compact('user'));
   }
}

class UserService
{
   protected $userRepository;

   public function __construct(UserRepository $userRepository)
   {
       $this->userRepository = $userRepository;
   }

   public function getUser($id)
   {
       $user = $this->userRepository->getUserById($id);
       $this->userRepository->logSession($user);
       return $user;
   }
}

class UserRepository
{
   protected $userModel, $logModel;

   public function __construct(User $user, Log $log)
   {
       $this->userModel = $user;
       $this->logModel = $log;
   }

   public function getUserById($id)
   {
       return $this->userModel->findOrFail($id);
   }

   public function logSession($user)
   {
       $this->logModel->user = $user->id;
       $this->logModel->save();
   }
}

В этом примере `UserService` внедряется в `SiteController`, `UserRepository` внедряется в `UserService` и AR модели `User` and `Logs` внедряются в `UserRepository` class.

Слой контроллеров

Современные MVC-фреймворки, такие как Laravel и Yii берут на себя такие обязанности контроллеров как обработка входных данных, правила роутинга и HTTP-запросов, тогда как пре-процессинг данных в таких фреймворках вынесен в отдельные компоненты, такие как middleware в Laravel и behavior в Yii. Поэтому программисту остается дописать в контроллер всего пару строчек кода.

Обязанность контроллера заключается в получении запроса от пользователя и отправке пользователю результатов. Контроллер не должен содержать никакой бизнес логики, иначе ее будет невозможно использовать повторно, что приведет к дублированию кода. Также это добавит сложностей при изменении типа ответа контроллера, к примеру для API. Если в контроллере нет ничего лишнего, то поменять формат вывода с отображения view на ответ API не составит никакого труда.

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

  • Бизнес-логика, что приводит к невозможности повторного использования кода
  • Непосредственное изменение состояний модели (любые изменения в БД приводят к огромному количеству изменений повсюду в коде)
  • Использование отношений моделей (к примеру сложные запросы с использованием связей моделей; опять, если что-то нужно поменять в БД, придется менять сотни мест по коду)

Пример контроллера, выполняющего слишком много:

//A bad example of a controller
public function user(Request $request)
{
   $user = User::where('id', '=', $request->id)
   ->leftjoin('posts', function ($join) {
       $join->on('posts.user_id', '=', 'user.id')
           ->where('posts.status', '=', Post::STATUS_APPROVED);
   })
   ->first();
   if (!empty($user)) {
       $user->last_login = date('Y-m-d H:i:s');
   } else {
       $user = new User();
       $user->is_new = true;
       $user->save();
   }
   return view('user.index', compact('user'));
}
    Почему этот контроллер плох:

  • Он содержит бизнес-логику
  • Он напрямую работает с Active Record моделью, поэтому если нужно к примеру переименовать поле last_login в БД, менять придется во всех контроллерах
  • Он знает об отношении моделей, поэтому, если это отношение поменяется, придется менять все контроллеры
  • Этот код невозможно использовать повторно, что приводит к дублированию кода

Контроллер должен быть простой. Все, что он должен делать — это получать запрос и отдавать ответ. Например, такой:

//A good example of a controller
public function user (Request $request)
{
 $user = $this->userService->getUserById($request->id);
 return view('user.index', compact('user'));
}

Куда же деть все остальное? В более низкие слои.

Слой сервисов

Слой сервисов — это слой бизнес логики. Здесь и только здесь должна быть информация о бизнес процессах и взаимосвязях между бизнес моделями. Это абстрактный слой и он будет разным для разных приложений, но в целом общим остается независимость от типа входных данных (это ответственность контроллера) и типа хранения данных (это ответственность более низких слоев).

На этом этапе возникает одна из самых опасных проблем. Часто в контроллер возвращают AR модель. И как результат представление (или контроллер, если речь идет об API) знает об атрибутах и отношении модели. Это приводит к необходимости множественных правок, если нужно поменять поле в БД.

Вот пример использования AR модели в представлении:

<h1>{{$user->first_name}} {{$user->last_name}}</h1>
<ul>
@foreach($user->posts as $post)
<li>{{$post->title}}</li> 
@endforeach
</ul>

Выглядит просто, но если нужно переименовать поле `first_name` менять придется все представления, что чревато ошибками, т.к что-то можно пропустить или забыть. Простое решение — использовать DTO — Data Transfer Objects.

Data Transfer Objects

Данные из сервисного слоя нужно обернуть в простой неизменяемый объект. Он не может быть изменен, после того как создан, поэтому сеттеры в таком классе не нужны. Более того, DTO класс должен быть независимым, он не должен наследоваться от Active Record модели. Часто бизнес модель может не совпадать с AR-моделью.

Представьте себе приложение по доставке товаров. По логике вещей заказ должен включать в себя доставку, но в БД мы храним заказы, прикрепленные к пользователю и адреса доставки, прикрепленные к юзеру. В этом примере бизнес модель заказ содержит в себе несколько AR-моделей, но верхнии слои не должны ничего об этом знать. Тогда если мы меняем AR-модель, относящуюся к данной бизнес модели (к примеру перенесем информацию об адресе доставки в таблице заказов), то нам нужно поменять только наш DTO-объект бизнес модели, а не сотни мест использования AR-модели повсюду.

Использование DTO позволяет исключить возможность использования AR-модели в контроллере или представлении. Также DTO-объект позволяет отделить физическое хранение данных от логического представления бизнес модели. Если что-то меняется на уровне БД, менять придется только DTO-объект, а не все контроллеры и представления.

Пример простого DTO-класса:

//Example of simple DTO class. You can add any logic of conversion from an Active Record object to business model here 
class DTO
{
   private $entity;

   public static function make($model)
   {
       return new self($model);
   }

   public function __construct($model)
   {
       $this->entity = (object) $model->toArray();
   }

   public function __get($name)
   {
       return $this->entity->{$name};
   }

}

//usage example
public function user (Request $request)
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 return view('user.index', compact('user'));
}

Декораторы представлений

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

Так выглядит представление без декоратора:

<div class="status">
   @if($user->status == \App\Models\User::STATUS_ONLINE)
       <label class="text-primary">Online</label>
   @else
       <label class="text-danger">Offline</label>
   @endif   
</div>
<div class="info"> {{date('F j, Y', strtotime($user->lastOnline))}} </div>        

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

class UserProfileDecorator
{
   private $entity;

   public static function decorate($model)
   {
       return new self($model);
   }

   public function __construct($model)
   {
       $this->entity = $model;
   }

   public function __get($name)
   {
       $methodName = 'get' . $name;
       if (method_exists(self::class, $methodName)) {
           return $this->$methodName();
       } else {
           return $this->entity->{$name};
       }
   }

   public function __call($name, $arguments)
   {
       return $this->entity->$name($arguments);
   }

   public function getStatus()
   {
       if($this->entity->status == \App\Models\User::STATUS_ONLINE) {
           return '<label class="text-primary">Online</label>';
       } else {
           return '<label class="text-danger">Offline</label>';
       }
   }

   public function getLastOnline()
   {
       return  date('F j, Y', strtotime($this->entity->lastOnline));
   }
}

public function user (Request $request)
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 $user = UserProfileDecorator::decorate($user);
 return view('user.index', compact('user'));
}

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

<div class="status"> {{$user->status}} </div>    
<div class="info"> {{$user->lastOnline}} </div>

Декораторы также можно комбинировать друг с другом:

public function user (Request $request)
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 $user = UserDecorator::decorate($user);
 $user = UserProfileDecorator::decorate($user);
 return view('user.index', compact('user'));
}

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

Слой репозиториев

Репозиторий работает с конкретным способом хранения данных. Лучше всего внедрять репозиторий через интерфейс, тогда его легко будет подменить в случае смены типа БД. При изменении хранилища данных достаточно создать новый класс репозитория, реализующий такой интерфейс и не придется менять весь код.

Репозиторий получает данные из БД и управляет работой нескольких AR-моделей. AR-модель в данном контексте выполняет роль Entity. Entities — это примитивный объект системы, который хранит в себе информацию, но не знает ничего о том, как он появился (только создан или получен из БД) или как сохранить или обновить свое состояние. Сохранение и обновление AR моделей — это ответственность репозитория. Такой подход помогает разделить ответственность — управление AR-моделями лежит на репозитории, а сами AR-модели — примитивные хранилища данных.

Пример метода репозитория:

public function getUsers()
{
return User::leftjoin('posts', function ($join) {
    $join->on('posts.user_id', '=', 'user.id')
        ->where('posts.status', '=', Post::STATUS_APPROVED);
   })
   ->leftjoin('orders', 'orders.user_id', '=', 'user.id')
   ->where('user.status', '=', User::STATUS_ACTIVE)
   ->where('orders.price', '>', 100)
   ->orderBy('orders.date')
   ->with('info')
   ->get();
}

Слои, соблюдающие правила единой ответственности

В только что созданном Yii или Laravel приложении есть только папки сontrollers, models, и views. Ни Yii, ни Laravel не добавляют дополнительные слои в пример приложения. Простая и интуитивно понятная даже для новичков MVC структура упрощает работу с фреймворком, но важно понимать, что такая архитектура — это только пример, а не стандарт или стиль, насаживаемый фреймворком.

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

  • Entities — примитивные модели данных
  • Репозиторий работает с БД
  • Слой сервиса с бизнес логикой
  • Контроллер, общающийся со сторонними сервисами и конечными пользователями.

Если вы только начинаете проект, который имеет шанс разрастись в будущем, подумайте заранее о разделении ответственности.

Оригинал статьи на английском тут.


Метки: ,

Оставить комментарий

29 комментариев »

    Insolita

    К сожалению совсем не упомянуто про выделение валидации, примеры из Laravel — там валидация на Request с возможностью FormRequest завязана, в YII классы для валидации можно создавать наследуясь от \yii\base\Model и стоит обязательно отметить что в репозитории данные необходимо передавать уже валидными, в них никаких проверок быть не должно.

    Ну и на самом деле такие статьи без примеров для новичков — дорога в ад.

      Developer

      Да, валидацию упустила из вида. Все правильно, валидация должна быть на Request, контроллер не должен пропускать в более низкие слои невалидные данные.


    Insolita

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

      Developer

      Я хочу переписать base app для yii в таком стиле, как будет свободное время


    Arman

    А «слои» могут работать с ответом приложения (response) или только контроллер может? Тогда как слои должны сказать контроллеру что что-то пошло не так? Вернуть false, а проблемы в getErros() или кидать исключения? Дальше появляется какой-то франкенштейн, который содержит и логику и данные и верстку. =)

      Developer

      Нет, работать с response может только контроллер. Представьте что каждый слой у вас физически находится на разных машинах (этакая распределенная система). Как бы вы организовали обработку ошибок?
      Во-первых можно возвращать заранее определенные коды ошибок (все слои знают о соглашении и кодах). Так же сообщение об ошибки можно передавать в одном из полей ответа.
      Во-вторых если слой не уверен в своем подчиненном слое — можно сделать try catch до вызова сервиса/репозитория и обработать ошибку, подготовив правильный формат для более выского слоя.


    Михаил

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

      Developer

      Нет, не встречала. Такая архитектура — необходимость в крупных проектах, а такие проекты в паблик не выкладываются. Для мелких проектов такая архитектура требует много времени и сил, а так как мелкий модуль не будет особо разрастаться, большого смысла в этом нет.


    Vladimir K

    >> Да, валидацию упустила из вида. Все правильно, валидация должна быть на Request, контроллер не должен пропускать в более низкие слои невалидные данные.

    В первом Yii (со вторым не работал) была возможность валидацию сделать в AR и это уменьшало дублирование кода. Одна и та же модель валидировалась всегда одинаково в разных контроллерах. Имхо, такой подход тоже имеет право на жизнь.

      Developer

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


    fun4life
      Developer

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


    prohp

    У вас UserRepository не наследует никакого интерфейса и жестко зависит от ORM. Получается ваш репозиторий железно зависит от реализации доступа к данным, в нашем случае — БД

      Developer

      Да, в статье говорится, что хорошо бы делать интерфейс, но я лично считаю что интерфейсы для всего на свете (как это принято в Java) — излищняя кодовая база, особенно для PHP проекта. Вероятность того, что у вас будет более 1 реализации этого интерфейса, как и вероятность того что вам придется менять тип БД/хранилища — ничтожна мала. А вот струткрута БД меняется по моему опыту достаточно часто, поэтому всю завязку к таблицам и структуре БД лучше где-нибудь инкапсулировать, в моем примере это репозиторий.


    Антон

    Подобную архитектуру я реализовал на базе advanced (https://github.com/phantom-d/yii2-app-services), правда давненько не обновлял.
    Данная архитектура, только в более строгом варианте, применяется в достаточно крупном проекте.


    Максим

    Просьба не по теме, но все не смог найти как с вами связаться. Не могли бы вы добавить RSS в свой блог, чтобы другие пользователи могли читать вас не заходя периодично на ваш сайт и проверяя наличии новых записей?

      Developer

      Спасибо, добавила RSS, ссылку можно найти в футере, рядом с ссылками на профили в github и linkedin 😉


    Alex

    В случае DTO не вполне понятна выгода. У вас это такой Proxy который читает из AR и никак не решает вопрос изменения полей.

    То есть view -> dto -> ar. При изменении поля в AR, можно сделать геттер в DTO, но это уже будет условно legacy и все равно во всех местах где используется DTO вместо AR надо будет менять обращение к свойству.

      Developer

      View -> AR — в случаи изменения поля в AR нужно менять все вызовы во View
      View -> DTO -> AR — в случаи изменения поля в AR нужно менять геттер в DTO, менять во View ничего не нужно.
      Насчет легаси не согласна. DTO в моем понимании — это представление бизнес модели, которое инкапсулирует в себе то, как именно эта бизнес модель распределена по AR и хранилищам. Хранение может (и будет по моему опыту) меняться, так же как может меняться и требование к бизнес модели. Но в случаи с AR -> DTO есть четкое разделение мух от котлет и понятно что именно менять в зависимости о того, меняется ли суть бизнес модели или только внутренняя кухня как эта модель хранится в БД.


    Artem

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

      Developer

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


    евгений

    А если я хочу вывести список всех пользователей с декораторами, это значит, что надо в контроллере пройтись циклом по всей найденым моделям пользователей, и к каждому применить $user = UserProfileDecorator::decorate($user) прежде чем передать их во view?

      Developer

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

      public function renderAsLabel($model)
      {
      if ($model->status == 1) return '<span class="my class">Active</span>';
      else return '<span class="another class">Disabled</span>';
      }
      

      Зачем? Во view не должо быть if-ов, т.к любой if — это уже логика. Декоратор знает отчего зависит вид кусочка view. Если поменяется зависимость — поменяется декоратор, а не все view.


    евгений

    А как вам такой вариант обращения к декоратору, на примере «последнего поcещения»: $user->decorator->lastVisit?
    Тут decorator — это функция getDecorator() модели $user, в которой возвращается UserDecorator с переданным им моделью $user.

    ///// в User
    public function getDecorator() {
    if ($this->_decorator === nulll) {
    $this->_decorator = new Decorator::($this);
    }
    return $this->_decorator;
    }
    
    ///// 
    class UserDecorator {
    public $user;
    public function __construct ($user) {
    $this->user = $user;
    }
    public function getLastVisit() {
    return ''.$this->user->last_visit.'';
    }
    }
    
      Developer

      Нормальное решение, главное удержаться от соблазна и не использовать user без декоратора, когда он вот уже тут бери и пользуйся 😉


    Павел

    Добрый день! Скажите, а как по-вашему лучше быть с отношениями? Например у нас есть связь между пользователями и задачами? Я хочу вывести все задачи пользователя, при этом перед выводом в шаблон я должен воспользоваться переводом данных в json-формат. Для этого нужно воспользоваться DTO? То есть именно в DTO определить метод tasks и вывести оттуда уже отформатированный вид?

      Developer

      Да, все верно. Представьте что в какой-то момент вы поменяете отношения. Например задачи станут общими для компании, в которой работает юзер (user->company->task). Если отношения у вас размазаны по всему коду, менять придется очень многое. Идеально: из метода в DTO user возвращать DTO task. Тогда DTO task не зависит от того, как именно он получил данные. И если что-то нужно будет менять — то поменяется только тот метод, который создает DTO task.


    Виктор

    Добрый день! Не понял, почему у вас метод репозитория getUsers() возвращает AR объекты, а не DTO? И какой тогда класс будет заниматься формированием DTO на основе полученых из репозитория AR объектов?

      Developer

      Репозиторий — это уровень работы с БД, поэтому на этом уровне работа идет с AR, а в DTO данные преобразует контроллер, прежде чем передать их во view. Можно сделать класс UserDTO в котором будет зашита логика преобразования AR -> DTO для класса User. Если вы размажете эту логику по всему репозиторию, то в случаи изменения структуры БД будет сложно сохранить не изменным DTO объект и придется менять код на всех уровнях.


Оставить комментарий:









Копирование материалов разрешено при наличии активной ссылки на источник