Архитектура Enterprise на Yii2. Абстракция, инверсия зависимости, инкапсуляция бизнес-логики и управление изменчивостью

в 17:55, , рубрики: DiC, enterprise, ioc, php, yii, yii2, бекенд, ооп, Программирование, фреймворки

Архитектура Enterprise на Yii2. Абстракция, инверсия зависимости, инкапсуляция бизнес-логики и управление изменчивостью - 1 Большинство сайтов в вебе работают исключительно с простой информацией: страница, статья, категория статей. При генерации HTML, на стороне сервера происходят некоторые простые процессы: подключение к базе, получение статьи по ID, привязка к статье комментариев и т.д.

Однако, с развитием Интернета и бизнеса в нем, на сайте нередко начинают происходить сложные бизнес-процессы, для которых никакие CMS не предназначаны.

Пример бизнес-процессов:

  • Применить промокод
  • Отменить заказ
  • Рассчитать размер вознаграждения продавцу

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

Как быть? Разрабатывать E-commerce сайты в стиле Enterprise: делить все на слои, хранить бизнес-логику в отдельном слое приложения, инкапсулировать изменчивость. И вообще, следовать принципам SOLID при написании кода.

На PHP в 2017 тоже можно писать качественный Enterprise, это уже не просто шаблонизатор. В статье рассказывается про некоторые вещи, которые обязательно применять при разработке Enterprise на примере PHP и Yii2 фреймворка.

1. Модульная слоистая архитектура

В настоящий момент мейнстримом является слоистая архитектура в ООП стиле. Программист видит информационную систему в виде слоев (сверху вниз):

  • Доменный и сервисный слой (слой с бизнес-логикой);
  • Слой моделей;
  • Слой базы данных;
  • Слой контроллеров c роутингом;
  • Слой Request.

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

Наиболее гибкими можно назвать те системы, которые состоят из наибольшего количества отдельных, изолированных друг от друга компонентов (модулей). Очень здорово, когда в случае поломки не нужно пытаться склеить разбитый монолит, а достаточно заменить один маленький осколок. Еще более здорово, когда однажды написанный код прекрасно встает в любые другие системы. Но есть одна проблема — как правильно связать все эти маленькие компоненты, чтобы приложение выглядело как единое целое, а не как куча костылей и велосипедов? В борьбе за ответ на этот вопрос родился принцип IoC (Inversion of control или инверсия управления). Принцип гласит о том, что любой ваш класс или компонент не должен жестко зависеть от других, ваш код должен работать с тем, что ему передало приложение. Код должен быть слабосвязанным и каждая его структурная частичка (классметодкомпонент) должна выполнять лишь одну обязанность. Продолжим про инверсию зависимости в пункте 4, а пока отвлечемся на бизнес-логику и абстракцию.

2. Инкапсуляция бизнес-логики

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

Если мы отделим бизнес-логику, то получим:

  • Тонкий слой контроллеров и модулей;
  • Возможность тестировать в изоляции важнейшие бизнес-процессы;
  • Переносимость кода.

Итак, живой пример: мы хотим написать универсальный модуль «заказ», который должен свободно встать в любое приложение. Начнем с того, что выделим в предметной области «заказ» бизнес-логику и определимся, от чего она здесь зависит. Бизнес-логику будем описывать очень простым способом: терминами, название которых совпадает с профессиональным языком общения (профессионалами в данном вопросе являются клиенты супермаркетов и продавцы).

  • Отменить заказ;
  • Добавить элементы в заказ;
  • Отменить элемент заказа;
  • Присвоить клиента заказу ;
  • Присвоить продавца заказу;
  • Изменить статус;
  • Подсчитать сумму заказа;
  • Подсчитать общее количество элементов в заказе.

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

В логике работы с моделями благодаря ActiveRecord имеются похожие штуки:

  • save;
  • delete;
  • load;
  • link и т.д.

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

$model = new Order; //Это слой моделей
$db = Order::find(); //А это уже слой БД
$db = $db->where(['id' => '1'])->one(); //Снова слой моделей

Чтобы закрепить, давай проведем соответствия «название метода» — «слой»:

  • getFullName — выносим в модель ActiveRecord, слой моделей
  • getUserByName — в ActiveQuery, слой работы с БД
  • showFields — в виджет или вью-файл, слой отображения
  • cancelCashboxTransaction — бизнес логика, сервисный или доменный слой

Вернемся к бизнес-слою. В Yii2 для подобных классов (как LoadElements) в модуле можно создать отдельную папку logic. Но есть одно «но»… В этой статье мы говорим про Enterprise. И будет глупо использовать данные рекомендации для обычного блога или новостного сайта. Для более простых решений бизнес-логику удобно хранить прямо в модели, а еще лучше — в сервисе.

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

$order = Order::fineOne(1);
yii::$app->order->cancel($order);

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

Компонент Order.php:

Order.php

<?php
namespace pistol88order;
//...
use pistol88orderlogicLoadElements;
use pistol88orderlogicOrderCancel;
use pistol88orderlogicOrderRecovery;
//...

class Order extends Component
{
    //...
    public function loadElements(OrderInterface $order)
    {
        return yii::createObject(['class' => LoadElements::class, 'order' => $order])->execute();
    }
    
    public function cancel(OrderInterface $order)
    {
        return yii::createObject(['class' => OrderCancel::class, 'order' => $order])->execute();
    }
    
    public function recovery(OrderInterface $order)
    {
        return yii::createObject(['class' => OrderRecovery::class, 'order' => $order])->execute();
    }
//...

Его можно добавить в секцию components конфига и использовать глобально откуда угодно.

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

3. Абстракция и интерфейсы для нее

Теперь об абстракции. Для описания бизнес-логики мы используем методологию ООП, важнейшим принципом которой является абстракция. Бизнес-логика обязательно должна работать не с каким-то конкретным Order и Element, а с абстрактным (в качестве вакуума выступает Yii). Это сильно упростит всем разработчикам понимание, что делает эта самая логика. Только приложение знает, какой конкретно заказ (с какими полями) создается в системе и что является элементом заказа. Где-то элементом будет продукт питания, а где-то целый автомобиль. Наш заказ будет работать в любом случае, в этом суть полиморфизма. Для бизнес-логики предметной области «заказ» важны лишь пара свойств, которые следует описать в интерфейсе. Создаем папку interfaces и кладем туда интерфейс корзины, элемента и заказа.

Рассмотрим абстракцию на примере элемента корзины. Модуль «заказ» должен работать с любой корзиной, которая коллекционирует элементы, подходящие под интерфейс CartElement. Он содержит лишь несколько геттеров и сеттеров. Это значит, что бизнес-логика «заказа» работает лишь с ними, они создают примитивную абстракцию. По умолчанию вместе с модулем поставляется и реализация данного интерфейса в виде AR модели Element. Но модуль ни в коем случае не навязывает именно ее, конечный пользователь может «подсунуть» другую модель через Di-container.

А вот как эта абстракция используется в бизнес-логике LoadElements:

LoadElements.php

<?php
namespace pistol88orderlogic;
use pistol88orderinterfacesCart;
use pistol88orderinterfacesOrderElement;
use yii;

class LoadElements
{
    public $order;
    
    protected $cart;
    protected $element;
    
    public function __construct(Cart $cart, OrderElement $element, $config = [])
    {
        $this->cart = $cart;
        $this->element = $element;
    }
    
    public function execute()
    {
        foreach($this->cart->elements as $element) {
            $elementModel = $this->element;
            $elementModel = new $elementModel;
            
            $elementModel->setOrderId($this->order->id);
            $elementModel->setAssigment($this->order->is_assigment);
            $elementModel->setModelName($element->getModelName());
            ///Еще куча вызовов set
        }
        
        //...
    }
}

Бизнес-логика заявляет в конструкторе, что ждет на вход тип Cart и OrderElement. Магия Yii2 в своем внутреннем реестре ищет подходящий тип, имплементирующий интерфейс типа и передает его на вход. Сам LoadElements работает с абстракцией.

Обратите внимание, проектируя бизнес-логику, я беру эти интерфейсы из реальной (не компьютерной) жизни. Я понятия не имею, как будет развиваться система, в которой установлен данный модуль, какая там будет база данных, что там будут заказывать. Но я имею представление о реальной жизни и точно знаю, что модуль не должен выходить за ее рамки, иначе код начнет бродить в байтах и битах. В бизнес-логике не может появиться никаких таблиц БД и работы с байтами. Вы видели в реальной жизни при создании заказов в магазине какие-то там таблицы? Их там нет. Все эти таблицы БД есть в предметной области «веб-приложение» на уровень ниже, предметная область «заказ» оперирует совершенно другими сущностями, находящимися на самом верху.

4. Инверсия зависимости

Самая популярная на сегодня реализация принципа IoC (Inversion of Control, Инверсия зависимости) — Dependency Injection (внедрение зависимостей). Здесь все очень просто. Допустим, есть модули:

  • Корзина;
  • Заказ, зависящий от корзины.

Каждый модуль написал независимый разработчик. При этом они могут прекрасно работать вместе или порознь. Лишь только приложение, в котором они установлены, знает что от чего зависит (конкретно, а не абстрактно). Это приложение должно как-то связать эти модули, при этом связь должна осуществляться не в где-то там в удобном сейчас месте, а в особом, специально выделенном под это контейнере. Этот контейнер так и называется: «IoC-контейнер». Он содержит данные о связях, приложение «инжектит» эти связи в отдельные классы модулей.

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

Вернемся к LoadElements, который зависит от некоего типа Cart (класс реализации обязательно должен содержать методы getElements и truncate). Ключевое слово тут — «некий», а не «конкретный». Мы видим четкую зависимость конкретного «заказа» от некой «корзины». Только приложение знает, каким образом устроена в нем корзина — может, это отдельный модуль, а может быть нативная реализация. В любом случае, приложение должно каким-то образом «заинжектить» эту корзину в заказ, нужно связать интерфейс Cart с реализацией.

Допустим, мы работаем с приложением — онлайн пиццерией. Значит, внедряя зависимости, мы будем работать на стыке трех предметных областей:

  • Магазин;
  • Заказ;
  • Корзина.

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

yii::$container->set('pistol88orderinterfacesCart', 'appobjectsCart');

yii::$container — тот самый контейнер, который существует в любом приложении Yii2 из коробки. Все очень просто, первым параметров передается интерфейс, вторым — реализация. Осталось только разобраться, в каком месте вставлять эту строку. Вариантов несколько:

  • В index.php (точка входа) приложения
  • В config.php перед return. Я рекомендую именно этот вариант.

В качестве реализаций для интерфейсов мы передаем объект Cart, который не делает ничего:

<?php
namespace appobjects;

class Cart extends pistol88cartCart implements pistol88orderinterfacesCart
{
    
}

Это пустой класс (так повезло), который просто указывает, что в качестве Cart в данной системе выступает pistol88cartCart, который четко имплементирует нужный «заказу» интерфейс. Изначальный Cart даже не знает, что он имплементирует что-то там в системе. Если бы нам не повезло и у Cart не было бы getElements, пришлось бы его реализовать в appobjectsCart. После того, как мы заинжектим зависимость, данный Cart магическим образом передается на конструтор LoadElements.

Еще раз обратим внимание на факт: модули yii2-cart и yii2-order никак не связаны друг с другом, никак не зависят друг от друга. Это значит, что модуль следует расшифровке одной из букв аббревиатуры SOLID (D, Принцип инверсии зависимостей), и это здорово. Значит, модуль достаточно универсален и может быть использован в любом инстансе Yii2. Было бы совсем круто, если бы модуль не зависел даже от фреймворка, но тогда придется отказаться от готовой админки в его составе, поставлять эту админку и все виджеты отдельным модулем, что красиво, но неудобною.

Очень важное замечание: такой способ внедрения зависимости подойдет только для очень крупных E-comerce проектов. Есть более простой и менее магический способ внедрения зависимости. При подключении модулякомпонента можно просто передать ему нужный класс в качестве свойства. Сложности с yii::$container нужны, по сути, только для того, чтобы сделать как можно более тонкими и простыми все слои (сервисный, моделей) за счет выделения еще одного слоя с зависимостями.

5. Управление изменчивостью

Под влиянием WordPress, я очень полюбил делать хуки вообще везде. Я считаю, именно благодаря хукам WordPress завоевал мир, хуки закрыли большинство возражений «а что если...», «а можно ли...». Да, можно всё, если в месте, о котором идет речь, есть хук. Это просто идеальный способ быстро изменить поведение стороннего модуля или отдельного участка системы, не трогая код ядра.

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

В Yii2 хуки удобнее всего делать через события и поведения. Давайте на примере модуля pistol88/yii2-cart посмотрим, как можно реализовать управление изменчивостью.

Имеем:

  • Десять разных ИМ, продающих автомобильные шины
  • Все 10 ИМ используют один скелетон и одни модули

Проблема:

  • У 5 из 10 ИМ используется нетипичное округление суммы заказа: где-то до 5 рублей в большую, где-то до 10 в меньшую

Решение проблемы заложено в сервисе, где расположена вся бизнес-логика. В момент вызова getCost вызываются триггеры:

//..
use pistol88carteventsCart as CartEvent;
//..
    public function getCost($withTriggers = true)
    {
        //...
        $cartEvent = new CartEvent(['cart' => $this->cart, 'cost' => $cost]);
        if($withTriggers) {
            $this->trigger(self::EVENT_CART_COST, $cartEvent);
            $this->trigger(self::EVENT_CART_ROUNDING, $cartEvent);
        }
        //..

А в конфиге приложения события триггера self::EVENT_CART_ROUNDING («cart_rounding») прослушиваются, происходит прием DataProvider и внесение изменчивости в эти данные:

        'cart' => [
            'class' => 'pistol88cartCart',
            //..
            'on cart_models_rounding' => function($event) {
                $event->cost = ceil($event->cost/10)*10;
            }
            //'as RoundBehavior' => ... //Как альтернатива "on" с переносимым поведением
        ],

В getCost принимается уже измененный $cost из датапровайдера, обработанного коллбек функцией конфига:

        $cartEvent = new CartEvent(['cart' => $this->cart, 'cost' => $cost]);
        if($withTriggers) {
            $this->trigger(self::EVENT_CART_COST, $cartEvent);
            $this->trigger(self::EVENT_CART_ROUNDING, $cartEvent);
        }
        $cost = $cartEvent->cost;
        $this->cost = $cost;
        return $this->cost;

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

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

6. СОЛИД

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

Попробую немного упростить этот набор принципов. Проект имеет шансы прожить достаточно долго, только если в нем соблюдены такие принципы:

  • Отсутствие дублирования кода. Важно писать код так, чтобы его куски не приходилось каждый раз переносить вручную и адаптировать под «здесь и сейчас», все должно быть по щелчку пальцев, одной настройкой и в одном месте.
  • Легкая переносимость кода. Любой единожды написанный функционал должен быть инкапсулирован от системы. Тогда этот функционал можно взять и просто перенести куда угодно, не нужно тратить кучу времени на интеграцию и обрубание лишнего.
  • Слабая связанность кода. Функциональные части системы должны быть слабо связаны между собой. «Мохнатые уши» не должны зависеть от конкретной кошки и ее головы. Если мы однажды описали уши, то они должны быть полиморфны, то есть применимы к любому животному, которое соответствует типу Animal.

Только следуя принципам SOLID, IT миру можно обрести гармонию с миром бизнеса.

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

Автор: pistol

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js