Как стать автором
Обновить

Решение проблем организации бизнес-логики в PHP или как пойти своим путем

Время на прочтение 12 мин
Количество просмотров 16K
image Привет, Хабр! Не первый раз я пытаюсь написать эту статью, но давно уже есть желание поделиться опытом и люди просят.

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

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

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

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

Немного о том как работать с бизнес логикой в популярных PHP фреймворках



Обычная ситуация


Если мы смотрим на самые популярные фреймворки то за основу в них взят архитектурный паттерн MVC. Инструментов по организации бизнес логики как таковых нет, а вот для создания простых crud все есть.

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

Контроллеры превращаются в что-то среднее между Transaction Script и Service Layer в Domain Model. Они валидируют входные данные, получают из базы модельные сущности и реализуют бизнес логику этих сущностей.

Я не говорю, что это неправильно, в некоторых случаях это оправдывающий себя подход. Когда стоит разрабатывать без какой-то сложной архитектуры для бизнес логики:

  1. При простой бизнес логике
  2. Для создания mvp
  3. При создании прототипов

Если у вас не такая ситуация то стоит задуматься об архитектуре и о месте бизнес логики в этой архитектуре.

Сложная бизнес логика


Если бизнес логика достаточно сложна то есть два стандартных варианта решения:

Transaction Script


Чтобы отделить бизнес логику от фреймворка стоит выделать ее в отдельные сущности. Используя Transaction Script мы можем создать большой набор сценариев, обрабатывающих конкретные запросы пользователей.

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

PhotoUploadScript
class PhotoUploadScript
{
public function run()
{
/*реализация сценария загрузки фото*/
}
}

Подробнее советую почитать об этом подходе в книге «Архитектура корпоративных программных приложений», там описаны все его плюсы и минусы.

Domain Model


При реализации Domain Model все становится значительно сложнее. Надо продумать бизнес логику и выделить сущности с четко разделенной ответственностью. Тут много подводных камней, это и как организовать фасад для предметной области и как избежать создания приложения с клубком связей между бизнес сущностями и т.д.

В книге «Архитектура корпоративных программных приложений» предлагается ввести слой Service Layer который послужит интерфейсом бизнес логики и будет состоять из нескольких сервисов сгруппированных по общему функционалу(например UserService, OrderService и т.д.).

Более подробной этот подход рассмотрен в книгах «Архитектура корпоративных программных приложений» и также ему посвящена целая книга «Предметно-ориентированное проектирование (DDD)», которую я лично очень рекомендую к прочтению.

Наша история


Как все начиналось


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

Выбор пал на PHP, а вот от использования существующих фреймворков было решено отказаться. За основу была взята старая наработка и принято решение дать ей новую жизнь в новом обличии.

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

Архитектура, словарь


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



Что-то бралось из “Большой синей книги” по DDD, что-то из книги «Архитектура корпоративных программных приложений» и все это перерабатывались, если это было необходимо, под наши нужды.

Interface


Interface это в нашем случае слой отвечающий за доступ к предметной области из внешнего мира.
У нас на данный момент есть 2 вида интерфейса:

API — это RESTful api состоящий из View и Controller.

CLI — интерфейс вызова бизнес логики из командной строки(например крон задачи, воркеры для очередей, и т.д.).

View

Данная часть слоя Interface очень проста, так как наши проекты на PHP это исключительно API и не имеют пользовательского интерфейса.

Соответственно не было необходимости включать работу с шаблонизаторами, мы просто отдаем view в виде json.

При необходимости этот функционал можно расширить, добавить поддержку шаблонизаторов, ввести разные форматы отдачи ответа (например xml ), но у нас таких потребностей пока нет. Данное упрощение позволило больше времени уделять более важным частям архитектуры.

Controller

Контроллеры не должны содержать никакой логики предметной области. Контроллер просто получает запрос от роутера и вызывает соответствующий интерфейс модели с параметрами запроса. Он может делать какое-то небольшое преобразование для связи с моделью но никакой бизнес логики в нем нет.

Model


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

Было выделено 3 основных обобщенных элемента модели:

Entity — это сущность с набором характеристик в виде списка параметров и с поведением в виде функций.
Context — это сценарий в рамках которого взаимодействуют сущности.
Repository — это абстракция для хранения Entity.

Storage


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

Подробно теория



View-Controller


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

Router — маршрутизатор http запросов в соответствующий метод контроллера (все стандартно).

View — это по сути преобразователь ответов от модели передаваемых как ValueObjet ( тут мы тоже, не даем view много знаний о бизнес логики) в json. Хотя в классическом MVC view получает обновления от model напрямую, у нас это делается через Controller.

Controller это прослойка скрывающая интерфейсы модели. Controller преобразует http запрос в входные параметры для модели, вызывает сценарий модели, получает результат выполнения и возвращает его View для отображения.

Это часть не содержит никакой бизнес логики и в течении проекта не меняется если только не нужно создать новые API интерфейсы. Мы не раздуваем слой контроллеров, оставляем их компактными и простыми.

Модель


А теперь поговорим о самом главном и ценном элементе, заключающем в себя бизнес логику.
Рассмотрим подробнее основные элементы модели: Entity, Context и Repository.

Entity
Абстрактный класс Entity
abstract class Entity
{
  protected $_privateGetList = [];
  protected $_privateSetList = [ 'ctime', 'utime'];

  protected $id = 0;
  protected $ctime;
  protected $utime;

  public function getId()
  {
    return $this->id;
  }

  public function setId( $id)
  {
    $this->id = $this->id == 0? $id: $this->id;
  }

  public function getCtime()
  {
    return $this->ctime;
  }

  public function getUtime()
  {
    return $this->utime;
  }

  public function __call( $name, $arguments)
  {
    if( strpos( $name, "get" ) === 0)
    {
      $attrName = substr( $name, 3);
      $attrName = preg_replace_callback( "/(^[A-Z])/", create_function( '$matches', 'return strtolower($matches[0]);'), $attrName);
      $attrName = preg_replace_callback( "/([A-Z])/", create_function( '$matches', 'return \'_\'.strtolower($matches[0]);'), $attrName);
      if( !in_array( $attrName, $this->_privateGetList))
        return $this->$attrName;
    }

    if( strpos( $name, "set" ) === 0)
    {
      $attrName = substr( $name, 3);
      $attrName = preg_replace_callback( "/(^[A-Z])/", create_function( '$matches', 'return strtolower($matches[0]);'), $attrName);
      $attrName = preg_replace_callback( "/([A-Z])/", create_function( '$matches', 'return \'_\'.strtolower($matches[0]);'), $attrName);
      if( !in_array( $attrName, $this->_privateSetList))
        $this->$attrName = $arguments[0];
    }
  }

  public function get( $name)
  {
    if( !in_array( $name, $this->_privateGetList))
      return $this->$name;
  }

  public function set( $name, $value)
  {
    if( !in_array( $name, $this->_privateSetList))
      $this->$name = $value;
  }
  static public function name()
  {
    return get_called_class();
  }
} 


Entity это сущности предметной области, например пользователь, заказ, автомобиль и т.д. Все подобные сущности могут иметь параметры: идентификатор, время регистрации, цена, скорость. Работу с параметрами мы обеспечиваем в базовом классе Entity, а поведение уже определяет разработчик в классах наследниках.

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

Идентификатор у Entity в нашей платформе это базовый параметр, он позволяет однозначно идентифицировать разные сущности внутри одно класса объектов.

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

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

Что же можно считать характеристиками которые могут идентифицировать объект в программной системе: атрибуты, класс объекта, поведение. При разработке нашего Entity мы как раз отталкивались от этих характеристик.

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

Context

Абстрактный класс Context

abstract class Context
{
  protected $_property_list = null;

  function __construct( \foci\utils\PropertyList $property_list)
  {
    $this->_property_list = $property_list;
  }

  abstract public function execute();

  static public function name()
  {
    return get_called_class();
  }
}


Context это сценарий в рамках которого взаимодействуют Entity. Например «Регистрация пользователя» у нас отдельный контекст, и работа этого контекста выглядит примерно так:

  1. Запускаем контекст и передаем ему параметры для регистрации. На вход контекст получает список простых параметров (int, string, text и т.д.).
  2. Проходит валидация корректности параметров. Валидация тут именно для предметной области, мы не проверяем тут запросы http.
  3. Создание пользователя.
  4. Сохранение пользователя. Это тоже часть предметной области, главное, что она абстрагирована от того куда и как мы этого пользователя сохраняем. Тут для абстракции сохранения мы используем Репозитории.
  5. Отправка на почту уведомления.
  6. Возврат результатов выполнения контекста. Для возврата результата есть специальный класс ContextResult который содержит признак успешности выполнения контекста и либо данные с результатами, либо список ошибок. (на уровне view-controller модельные ошибки переводятся в ошибки http)

Context практически в чистом виде Transaction Script, но с некоторыми исключениями. Фаулер приводит пример реализации бизнес логики через Transaction Script либо Domain Model. При использовании Domain Model он рекомендует использовать Service Layer в котором сервисы создаются на основе общности функционала (например UserService, MoneyService, и т.д.). В нашем случае Transaction Script может выступать тем же Service Layer если не делать модельные сущности анемичными.

Например набор контекстов связанных с пользователем (UserRegContext, UserGetContext, UserChangePasswordContext, и т.д.) при не аннемичном пользователе практически равноценен UserService (Service Layer). У нас есть контексты которые берут на себя очень много бизнес логики и их можно считать Transaction Script, но есть и контексты которые просто вызывают какой-то функционал Entity а дальше уже вся бизнес логика скрыта от контекста и тут они уже ближе к Service Layer.

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

Repository

Обобщенный класс репозитория
class Repository
{
  function add( \foci\model\Entity &$entity)
  {
    $result =  $this->_mapper->insert( $entity);
    return $result;
  }

  function update( \foci\model\Entity &$entity)
  {
    $result =  $this->_mapper->update( $entity);
    return $result;
  }

  function delete( \foci\model\Entity &$entity)
  {
    $result =  $this->_mapper->deleteById( $entity->getId());
    return $result;
  }
}


Репозитории это абстракиция бизнес логики для организация сохранения/удаления/обновления/поиска сущностей в базе. Конкретные репозитории работают с конкретными сущностями, например UserRepository работает с User.

Хранение данных


Хранение данных сделано с использованием мапперов. Есть обобщенный класс Mapper который содержит базовый функционал для работы с Entity.

Обобщенный класс Mapper
abstract class Mapper
{
protected $_connection;
protected $_table;

function __construct( Connection $connection)
{
$this->_connection = $connection;
}

abstract protected function _createEntityFromRow( $row);

abstract public function insert( \foci\model\Entity &$entity);
abstract public function update( \foci\model\Entity &$entity);
abstract public function delete( \foci\model\Entity &$entity);

public function getTableName()
{
return $this->_table;
}
}

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

Таким образом если у нас в модели есть класс User то ему в соответствие создается класс UserMapper. Логика работы с БД вынесена в отдельны слой и может быть легко заменена при необходимости.

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

Обобщенная схема


Большая картинка
image

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

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

Config и PropertyList на схеме это утилитарные сущности которые используются всеми.

Немного практики (совсем чуть чуть)


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

Далее составим пару сценариев пользователя, возьмем что-то нестандартное, для большей наглядности. Покупка товара — будем считать что покупка для нас это просто факт однократного списания денег. Установка оплаты услуги по календарю — это будет регулярное списания денег с описанием.

Выделим сущности: Money, Budget, User, Product, Service, Calendar.
Выделим 3 контекста по пользовательским сценариям:

Первый контекст это просто покупка товара.

BuyOrderContex


  1. Получаем User из базы по входным данным(например по токену)
  2. Получаем или создаем новый Product
  3. Говорим Budget установить покупку Product за указанную сумму Money

Дальше все сложнее, второй пользовательский сценарий разделился на 2 контекста, в первом мы устанавливаем когда списывать деньги, а во втором делаем само списание.

SetSchedulePayForServiceContext


  1. Получаем User из базы по входным данным(например по токену)
  2. Получаем или создаем новый Service
  3. Устанавливаем в Calendar списание денег на услугу Service по заданной дате

SchedulePayForServiceContext


  1. Смотрим в Calendar есть ли на текущее время списание за Service
  2. Загружаем Service за которой надо списать деньги
  3. Списываем деньги за Service

Уже в этом небольшом примере видны и какие-то плюсы данного подхода и минусы(например дублирование логики в разных контекстах, об этом хорошо написано в книге «Архитектура корпоративных программных приложений»).

Заключение


Наши практики


Разделение функционала


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

Контекст вызывает контекст


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

Крон задачи


Контекст как единица выполнения позволяет вызывать его откуда угодно. На этом основана логика запуска контекстов как крон задач.

SPA


Один из наших проектов это сайт в котором клиентская часть полностью написана на JavaScript и через RESTfull api взаимодействует с серверной часть на PHP. Мы такое даже не планировали когда начинали разработку, но эта возможность строить SPA приложения с нашей платформой в качестве сервера оказалась очень удобной.

Тестирование


Весь код покрывается тестами. Мы используем 2 вида тестов:

  1. Unit тесты, например для таких классов как Config
  2. Acceptnce тесты, для запросов к нашему api.

Планы


  1. Нам очень сильно не хватает более широко распространения нашей разработки. Все написано исключительно для внутреннего использования и это накладывает свои минусы.
  2. Не хватает обкатки в высоконагруженных проектах. У нас уже несколько работающих коммерческих проектов, но высоконагруженными их назвать нельзя.
  3. Логика работы с транзакция пока что у нас сильно не продуманна. В этом месте у нас на данный момент есть небольшое нарушение инкапсуляции модели. В будущем планируется ввести Unit Of Work для абстрагирования от транзакций баз данных
  4. Тестирование главным образом покрывает api, в планах сделать тестирование контекстов. Так появится возможность изолированно тестировать предметную область.

Что мы получили в итоге


  1. Гибкую платформу для создания приложения с четким отделением бизнес логики от инфраструктуры.
  2. Полностью подконтрольную нам разработку и это дает много плюсов в решении технических задач спорных моментов.
  3. Архитектуру основанную на всем известных шаблонах проектирования, позволяющую новым разработчикам быстро включаться в проект.
  4. Огромный опыт применения зарекомендовавших себя подходов в проектировании, про которые часто забывают в новых проектах и не стремятся их использовать в уже существующих.

Выводы


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

PS: Если такой подход к решению проблемы с предметной областью в PHP фреймворках будет интересен сообществу то мы вполне можем поднатужиться и подготовить open source.

PSS: Сразу предвижу фразы на счет того зачем вам велосипед, если все уже есть. Не вижу смысла спорить по этому поводу, это наш подход и он сработал.

PSS: Так же предвижу вопросы, зачем делать кровосмешение Transaction Script и Domain Model, а почему бы не сделать и не получить гибкий инструмент для решения бизнес задач.
Теги:
Хабы:
+7
Комментарии 71
Комментарии Комментарии 71

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн