Структура веб-приложений

Одна из постоянных задач, с которой я сталкиваюсь в роли веб-разработчика, это какую архитектуру выбрать для моего приложения. Я считаю, что на эту проблему стоит потратить немало времени. Мне понравились идеи, которые изложил Kris Wallsmith на SymfonyCon. Конечно, я не разделяю его мнения полностью, но его речь вдохновляет, да и всегда интересно заглянуть за кулисы и понять других разработчиков.

Я попробую изложить свое мнение на тему архитектуры веб-приложения. Я не буду изобретать велосипед, а постараюсь собрать вместе лучшие идеи людей, которые намного дальше меня ушли в веб-разработке. Я хотел бы поблагодарить Mathias Varraes, который мне очень помог на форуме DDD в PHP. Я вам советую взглянуть на его блог, там довольно много стоящих статей.

Вступление

Хочу сразу отметить, что я использую Symfony. Но большинство вещей, о которых я буду рассказывать, вполне применимы к любому веб-фреймворку. Так что не пугайтесь, если вы не знакомы с Symfony.

Пакеты

Недавно я завел спор в /r/php о сообществе Laravel, которое продолжает использовать архитектуру hexagonal/command-bus. Как мне кажется, такой подход далек от разумного, ведь по сути это всего лишь исковерканный диспетчер событий. Как же я был неправ. Я хотел бы извиниться перед Shawn McCool, Jeffrey Way и Ross Stuck. Они спокойно терпели мою глупость. С тех пор я стал приверженцем цепочки ответственности в командах.

Если вы хотите полностью контролировать команды вашего приложения, то наилучшим вариантом для достижения этой цели будет использовании одной точки входа в консоль. В своем приложении я использую Simple Bus package от Matthias Noback. Она не зависима от фреймворка, но может работать вместе с Symfony и Doctrine.

Затем я применяю Assert пакет от Benjamin Eberlei. Не смотря на его простоту в применении, его функционал впечатляет. В нем вы найдете просто огромное количество статических методов для проверки данных. Он генерирует исключение в случае провала проверки. Применив этот пакет вы можете быть уверены, что работаете исключительно с верными данными. После установки Assert пакета, я рекомендую создать свой класс AssertionFailedException унаследовав Assertion класс из пакета, и обработовать внем свои виды исключений. Так же в нем я пишу свои статические методы для проверки данных.

Углубляемся

После установки фреймворка и вышеуказанных пакетов я создаю каталог src/Acme/AwesomeProject/Model. Здесь будет храниться сердце приложения. Весь код из этой директории должен быть полностью независим от фреймворка. Также я создаю дополнительный каталог infrastructure и использую его для хранения классов сборки моего приложения.

Вот примерное содержание каталога Model. Мы обсудим каждый из них чуть позднее.

Список директорий

Сущности

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

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

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

<?php
namespace Acme\AwesomeProject\Model\Entity;
use Acme\AwesomeProject\Model\Event\AssetWasEntered;
use Acme\AwesomeProject\Model\Validation\Assert;
use DateTime;
use SimpleBus\Message\Recorder\ContainsRecordedMessages;
use SimpleBus\Message\Recorder\PrivateMessageRecorderCapabilities;
class Asset implements ContainsRecordedMessages
{
   use PrivateMessageRecorderCapabilities;
   // fields
   public function __construct($id, $name, Category $category)
   {
       Assert::id($id, "Invalid asset id.");
       Assert::string($name, "Asset name must be a string.");
       $this->id = $id;
       $this->createdAt = new DateTime();
       $this->name = $name;
       $this->category = $category;
       $this->record(new AssetWasEntered($id));
   }
   // methods
}

Давайте обсудим несколько важных моментов, касающихся кода выше.

Один из основных моментов, почему я использую Simple Bus, пакет для работы с командной строкой, это интеграции Doctrine. Вы можете настроить систему так, что все команды будут окружены транзакцией БД, что очень помогает поддержанию порядка в данных.

Также вы можете использовать другие связующие пакеты, чтобы запускать события, например, при записи новых сущностей. Как вы видите выше я реализую интерфейс Simple Bus и использую трейты для этого. Таким образом я получаю доступ к методу ::record($event). Обработка записи сущности происходит таким образом, что после внесения её в БД срабатывают все события, заложенные в сущность.

Теперь, где бы не обрабатывалась эта сущность, вы можете быть полностью уверены, что все, связанные с ней, события отработают. Вы можете даже создать событие на переименования Asset. В объекте события можно передать asset id, старое имя и новое имя. Возможно, вы захотите отправить email оповещение при наступлении этого события.

Так же я применяю id, который генерирую при помощи uuid пакета от Ben Ramsey. Применения uuid имеет массу преимуществ по сравнению с обычным id, который генерирует ваша СУБД. Вы можете запускать события с uuid до внесения сущности в БД, импорт данных в БД становится проще, а id в url не смогут сказать о размере вашего приложения.

Event и EventListener

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

Когда дела доходит до принятия решения о том, какие данные включить в объект события, я применяю только скалярные величины, ни в коем случае не объекты. В примере выше вы могли заметить, что я включил только asset id в объект события, а не весь объект asset. Такой подход упрощает обработку событий. Конвертации таких событий в json и хранение их в БД становится очень простым занятием. А сделать выборку по id позднее — не такая уж и ресурсоемкая операция. Чаще всего случается так, что объект, который вы запрашиваете уже находится под управлением Doctrine. Поэтому Doctrine не будет выполнять никакой sql запрос, а просто отдаст вам объект из своей памяти.

Важно отметить, что эти события срабатывают после обработки команды, которая окружена ДБ транзакцией. Рассмотрим вариант развития событий: допустим вы применяет сумасшедшее правило которое гласит: “если имя asset начинается в B, то следует обновить еще одно поле этой сущности и несколько полей связанной сущности.” Вместо того, чтобы обрабатывать это события в подписчике, я создам еще одну команду и обработчика для этой команды, который назову как нибудь соответственно. Сам обработчик я буду использовать только в качестве связки. Таким образом я получу участок кода, который смогу использовать в любой части своего приложения. Подписчики очень похожи на контролеры в фреймворке. Они просто передают команды другим частям приложения.

Я столкнулся с хитрой задачей, мне пришлось решить как обработать события удаления. Вы не сможете просто запустить их в методе __destruct(). Вы не сможете точно задать время срабатывания этого метода, за исключением когда вы самостоятельно вызываете его.

По условиям проекта мне необходимо отправлять email каждый раз при удалении задачи в проекте. Мне пришло несколько идей в голову. Первая — запускать событие в обработчике команды после метода $task->remove($task). Так же я подумал, что было бы неплохо создать public метод $task->markForDeletion(), где запускалось бы событие. Но оба эти варианта не подошли. Что произойдет если я удалю задачу, которая владеет сущностью проекта? В таком случае ORM удалит все задачи проекта и я не смогу оповестить пользователей.

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

Command и Handlers

Здесь располагается вся логика приложения. Каждая команда имеет одного обработчика. Команды лишь передают намерения пользователя. События — последствия этих намерений. Сами команды почти полностью идентичны объектам событий. Я не передаю сами объекты в этих событиях, исключительно скалярные данные. Мой http фреймворк отвечает за соответствие команд http запросов, но иногда я самостоятельно перевожу сырые данные в объекты.

<?php
namespace Acme\AwesomeProject\Model\Command;
use Acme\AwesomeProject\Model\ValueObject\PersonName as Name;
use Acme\AwesomeProject\Model\ValueObject\Location;
use SimpleBus\Message\Message;
class TrackPersonCommand implements Message
{
   // fields
   public function __construct(
       $userId,
       $firstName,
       $lastName,
       $address,
       $city,
       $state,
       $zipCode
   ) {
       // assignments
   }
   public function getName()
   {
       return new Name(
           $this->firstName,
           $this->lastName
       );
   }
   public function getLocation()
   {
       return new Location(
           $this->address,
           $this->city,
           $this->state,
           $this->zipCode
       );
   }
}

Помните пакет Assert, о котором я говорил в начале статьи? В конструкторе при обработке полей PersonName и Location я использую этот пакет для проверки данных. После такой проверки работать с данными становится намного проще.

<?php
namespace Acme\AwesomeProject\Model\Handler;
use Acme\AwesomeProject\Model\Command\TrackPersonCommand;
use Acme\AwesomeProject\Model\Repository\PersonRepository;
use SimpleBus\Message\Handler\MessageHandler;
use SimpleBus\Message\Message;
class TrackPersonHandler implements MessageHandler
{
   // fields
   public function __construct(PersonRepository $people)
   {
       // assignment
   }
   /**
    * @param TrackPersonCommand|Message $command
    */
   public function handle(Message $command)
   {
       $person = new Person(
           $command->getPersonId(),
           $command->getName(),
           $command->getLocation()
       );
       $this->people->track($person);
   }
}

Как видите считывать данные становится совсем просто. Вы прекрасно понимаете, что именно происходит в вашем приложении и можете быть полностью уверены в корректности данных. А что касается проверки данных, если необходима связь с БД? Возможно, вы хотите, чтобы у ваших пользователей были уникальные имена? Нам доступно несколько вариантов.

Мы можем создать класс-посредник, которым окружим команду. Он будет проверять является ли команда экземпляром TrackPersonCommand, если так оно и есть, то он попытается найти пользователи в БД по заданному имени ($people->findByNickname($commaind->getNickName())). Если от БД придет ответ о наличии такого пользователя, то будет вызвано исключение.

Или можно просто проверить эти данные в самом обработчике. Рассмотрим этот вариант немного позднее.

Я также довольно часто использую команды в качестве DTO для извлечения сущностей из БД. Например, я создам команду GetPersonCommand и обработчика. С первого взгляда кажется, что куда проще напрямую работать с хранилищем сущностей в контролере. Кажется, что приходится выполнять слишком много работы, но тут стоит подумать о классе-посредники.

Его у вас не будет в случае работы напрямую. Я не стал бы создавать команды для сериализации данных, кеширования или авторизации. Помните, я говорил, что использую только скалярные значения в внутри команд? Я приврал. Я использую сеттеры и геттеры для доступа к сущностям. Таким образом при создании объекта команды в контроллере, я могу передать его цепочке команд для обработки. Если ни одного исключения не было вызвано, то я могу использовать геттер для доступа к сущности. Моим контролерам совсем необязательно знать о хранилищах.

<?php
namespace Acme\AwesomeProject\Infrastructure\AppBundle\Controller;
use Acme\AwesomeProject\Model\Command\GetPersonCommand;
use Symfony\Component\HttpFoundation\Response;
class PersonController extends ApiController
{
   public function getPersonAction($assetId)
   {
       $command = new GetPersonCommand($assetId);
       $this->getCommandBus()->handle($command);
       $person = $command->getPerson();
       return $this
           ->setStatusCode(Response::HTTP_OK)
           ->setData(['person' => $person])
           ->respond()
       ;
   }
}

Exceptions

Я приведу пример исключений, которые использую в своем приложении:

EntityNotFoundException, ValidationException, AccessDeniedException. Все они наследуют класс DomainException. В обработчике исключений приложения вы можете проверить является ли исключение экземпляром класса DomainException. Если так и есть, то вы сможете вывести сообщение исключения и установить его код. Конечно, имеется ввиду, что код вашего исключения должен быть связан с корректным кодом статуса http ответа. Если же исключения оказалось другого типа, то можно просто вернуть код 500 в сообщением ”Внутренняя ошибка сервера”.

<?php
namespace Acme\AwesomeProject\Model\Exception;
class AccessDeniedException extends DomainException
{
   public function __construct($message = 'Access Denied', \Exception $previous = null)
   {
       parent::__construct($message, 403, $previous);
   }
}

Provider

В этом каталоге я храню единственный интерфейс CurrentUserProvider. В нем присутствует только один метод — $provider->getUser(). Реализации этого интерфейса моим фреймворком лежит за пределами каталога Model.

Repository

Здесь я храню интерфейсы своих хранилищ. Никакой реализации логики. Весь код расположен за пределами каталога Model.

<?php
namespace Acme\AwesomeProject\Model\Repository;
interface UserRepository
{
   /**
    * @param string $id
    * @return User
    * @throws UserNotFoundException
    */
   public function find($id);
   /**
    * @param string $email
    * @return User
    * @throws UserNotFoundException
    */
   public function findByEmail($email);
   /**
    * @param string $token
    * @return User
    * @throws UserNotFoundException
    */
   public function findByConfirmationToken($token);
   /**
    * @param User $user
    */
   public function add(User $user);
   /**
    * @param User $user
    */
   public function remove(User $user);
}

Security

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

Так как я использую архитектуру Command Bus и имею одну точку входа в приложение, стоит задуматься о классе-посреднике. Я создал каталог Middleware внутри Security. Тут я и храню весь код связанный с авторизацией.

Я создаю базовый абстрактный класс AuthMiddleware, который будут наследовать все классы-посредники.

<?php
namespace Acme\AwesomeProject\Model\Security\Middleware;
use Acme\AwesomeProject\Model\Entity\User;
use Acme\AwesomeProject\Model\Exception\AccessDeniedException;
use Acme\AwesomeProject\Model\Provider\CurrentUserProvider;
use SimpleBus\Message\Bus\Middleware\MessageBusMiddleware;
use SimpleBus\Message\Message;
abstract class AuthMiddleware implements MessageBusMiddleware
{
   /**
    * @var CurrentUserProvider
    */
   protected $userProvider;
   /**
    * @param CurrentUserProvider $userProvider
    */
   public function __construct(CurrentUserProvider $userProvider = null)
   {
       $this->userProvider = $userProvider;
   }
   /**
    * {@inheritdoc}
    */
   public function handle(Message $command, callable $next)
   {
       if ($this->applies($command)) {
           $this->beforeHandle($command);
           $next($command);
           $this->afterHandle(($command));
       } else {
           $next($command);
       }
   }
   /**
    * Do you auth check before the command is handled.
    *
    * @param Message $command
    * @throws \Exception
    */
   protected function beforeHandle(Message $command)
   {
       // no-op
   }
   /**
    * Do you auth check after the command is handled.
    *
    * @param Message $command
    * @throws \Exception
    */
   protected function afterHandle(Message $command)
   {
       // no-op
   }
   /**
    * @param string $msg
    * @throws AccessDeniedException
    */
   protected function denyAccess($msg = null)
   {
       throw new AccessDeniedException($msg ?: "Access denied.");
   }
   /**
    * @return User
    */
   protected function getUser()
   {
       return $this->userProvider->getUser();
   }
   /**
    * @param Message $command
    * @return bool
    */
   abstract protected function applies(Message $command);
}

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

<?php
namespace Acme\AwesomeProject\Model\Security\Middleware;
use Acme\AwesomeProject\Model\Command\RelocatePersonCommand;
use SimpleBus\Message\Message;
class RelocatePersonCommandMiddleware extends AuthMiddleware
{
   //fields
   public function __construct(CurrentUserProvider $userProvider, PersonRepository $people)
   {
       // assignment
   }
   /**
    * @param RelocatePersonCommand|Message $command
    */
   protected function beforeHandle(Message $command)
   {
       $person = $this->people->find($command->getPersonId());
       if ($person->getCreatedBy() !== $this->getUser()) {
           $this->denyAccess();
       }
   }
   /**
    * {@inheritdoc}
    */
   protected function applies(Message $command)
   {
       return get_class($command) === RelocatePersonCommand::CLASS;
   }
}

Service

Здесь я храню основные и вспомогательные службы, интерфейсы приложения. Например, у меня есть интерфейс UserMailer. За пределами каталога Model я реализую этот интерфейс в классе TwigSwiftMailerUserMailer.

Tests

Этот каталог я использую для unit тестов. Эти тесты не применяют в своей работе соединение с БД, веб-сервер и пр. Это строго unit тесты, не функциональные.

Validation

Здесь расположен подкласс класса Assertion.

ValueObject

В этой директории расположены объекты не требующие id, их я использую только для передачи данных. В качестве примера можно привести классы PersonName, Location и UserCategorySummary. Последний класс заслуживает отдельного внимания. Иногда я использую точки (endpoints) для вывода видов дашборда. Они содержать счетчики некоторых сущностей. namespace Acme\AwesomeProject\Model\ValueObject;

<?php
class CategorizedInvoiceSummary
{
   //fields
   public function __construct(
       $userId,
       $categoryId,
       $totalInvoices,
       $overdueInvoices,
       $paidInvoices
   ) {
       // assignment
   }
   public function getUserId()
   {
       return $this->userId;
   }
   public function getCategoryId()
   {
       return $this->categoryId;
   }
   // etc...
}

Допустим, что я разрабатываю ecommerce платформу. Мне необходимо выводить чеки по категориям. В таком случае я бы создал хранилище StateRepository и метод $stats->getCategorizedInvoiceSummaries($user), который возвращал бы коллекцию вышеуказанных объектов.

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

Заключение

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