Одна из постоянных задач, с которой я сталкиваюсь в роли веб-разработчика, это какую архитектуру выбрать для моего приложения. Я считаю, что на эту проблему стоит потратить немало времени. Мне понравились идеи, которые изложил 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 в качестве полей сущностей могут выступать другие сущности, в таком случае следует создать соответствующие связи между столбцами таблиц. Таким образом ваш код становится намного чище.
Заключение
Вы можете использовать мою архитектуру без каких-либо изменений. Вам не придется ломать голову над реализацией многих задач.