Система управления складом с использованием CQRS и Event Sourcing. Service Layer

в 21:03, , рубрики: catalog, catalog inventory, command, cqrs, event sourcing, inventory, Magento, magento 2, MSI, php, service contracts, service layer, Проектирование и рефакторинг, Разработка под e-commerce, Совершенный код

Система управления складом с использованием CQRS и Event Sourcing. Service Layer - 1

В данной статье будет рассмотрен Service Layer в Magento 2 и сервисы (API интерфейсы) для управления сущностями, которые были описаны в предыдущей статье, посвященной проектированию и выделению доменных сущностей для системы управления складом (Inventory).

Service Layer

Так как систему управления складом мы пишем на платформе Magento 2, соответсвенно и сервисы, которые мы вводим будут описаны с учетом особенностей этой платформы.
В Magento 2 для реализации принципа слабой связности на уровне модулей (в пределах Bounded Context-ов) был введен Service Layer, который определяет набор доступных операций для каждого модуля с точки зрения взаимодействия клиентов и других модулей системы.

Service Layer (или Service Contracts) в Magento это набор PHP интерфейсов, которые определены для модуля и находятся в папке Api этого модуля. Сервис контракты состоят из Data Interfaces — DTO интерфейсы, представляющие данные сущностей доменной области; и Service Interfaces — интерфейсы, которые предоставляют доступ к бизнес логике, которая может быть вызвана клиентом (контроллером, web сервисом REST/SOAP, PHP кодом других модулей).

Так как предполагается, что все внешние клиенты модуля будут работать с ним по контрактам, описанным Service Layer, то Service Layer фактически можно представить как Facade, который за собой скрывает детали реализации и сложность бизнес логики.
Для клиентов зависимость на четко определенные API позволяет легче проводить апгрейды на следующие версии системы, так как модули подчиняются семантическому версионированию (Semantic Versioning).

Для лучшей модулярности и отделения (decoupling) сервис контрактов от реализации иногда сервис контракты выделяют в отдельный модуль. Например, в случае Inventory мы имеем два модуля: один декларирует набор сервис интерфейсов InventoryAPI, второй — предоставляет реализацию для этих интерфейсов — Inventory. Таким образом, сторонний разработчик, который захочет подменить базовую реализацию, больше не привязан к этой реализации в коде. Все что ему нужно — интерфейсы, так как именно на интерфейсы зависят другие модули в системе.
Система управления складом с использованием CQRS и Event Sourcing. Service Layer - 2

Интерфейсы Репозиториев — Repository

Репозитории представляют собой интерфейсы предоставляющие набор CRUD операций для сущностей.
Типичный интерфейс репозитория состоит из набора следующих методов:

 public function save(MagentoModuleApiDataDataInterface $entityData);
 public function get($entityId);
 public function delete(MagentoModuleApiDataDataInterface $entityData);
 public function deleteById($entityId);
 public function getList(SearchCriteriaInterface $searchCriteria);

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

Репозитории можно воспринимать как Фасады (Facade), которые объединяют наборы методов по управлению сущностями.

В контексте модуля Inventory появляются репозитории для сущностей Source (сущность ответственная за представление любого физического склада где может находиться товар) и SourceItem ( сущность-связка, представляет собой количество определенного продукта (SKU) на конкретном физическом хранилище).


/**
 * This is Facade for basic operations with Source
 * There is no delete method, as Source can't be deleted from the system because we want to keep Order information for all orders placed. Sources can be disabled instead.
 *
 * Used fully qualified namespaces in annotations for proper work of WebApi request parser
 *
 * @api
 */
interface SourceRepositoryInterface
{
    /**
     * Save Source data
     *
     * @param MagentoInventoryApiApiDataSourceInterface $source
     * @return int
     * @throws MagentoFrameworkExceptionCouldNotSaveException
     */
    public function save(SourceInterface $source);

    /**
     * Get Source data by given sourceId. If you want to create plugin on get method, also you need to create separate
     * plugin on getList method, because entity loading way is different for these methods
     *
     * @param int $sourceId
     * @return MagentoInventoryApiApiDataSourceInterface
     * @throws MagentoFrameworkExceptionNoSuchEntityException
     */
    public function get($sourceId);

    /**
     * Load Source data collection by given search criteria
     *
     * @param MagentoFrameworkApiSearchCriteriaInterface $searchCriteria
     * @return MagentoInventoryApiApiDataSourceSearchResultsInterface
     */
    public function getList(SearchCriteriaInterface $searchCriteria = null);
}

В случае SourceRepository у нас отсутствует метод delete, потому что такой бизнес операции над сущностями Source не существует, так как необходимо всегда сохранять всю информацию, связанную с размещенными заказами (включая откуда товар был доставлен). Соответсвенно нужно предотвратить возможную потерю таких данных в будущем (удаляя Source из которого выполнялась доставка). Вместо этого используется операция — пометить Source как неактивный (disabled).


/**
 * This is Facade for basic operations with SourceItem
 *
 * The method save is absent, due to different semantic (save multiple)
 * @see SourceItemSaveInterface
 *
 * There is no get method because SourceItem identifies by compound identifier (sku and source_id),
 * thus, it's needed to use getList() method
 *
 * Used fully qualified namespaces in annotations for proper work of WebApi request parser
 *
 * @api
 */
interface SourceItemRepositoryInterface
{
    /**
     * Load Source Item data collection by given search criteria
     *
     * We need to have this method for direct work with Source Items, as Source Item contains
     * additional data like  qty, status (can be searchable by additional field)
     *
     * @param MagentoFrameworkApiSearchCriteriaInterface $searchCriteria
     * @return MagentoInventoryApiApiDataSourceItemSearchResultsInterface
     */
    public function getList(SearchCriteriaInterface $searchCriteria);

    /**
     * Delete Source Item data
     *
     * @param SourceItemInterface $sourceItem
     * @return void
     * @throws MagentoFrameworkExceptionNoSuchEntityException
     * @throws MagentoFrameworkExceptionCouldNotDeleteException
     */
    public function delete(SourceItemInterface $sourceItem);
}

Так как основные сценарии использования операции сохранения происходят с набором SourceItems, а не с одной сущностью, как это предполагает стандартный контракт save в репозитории.
Для множественного сохранения, которое может происходить во время операций импорта или синхронизации стоков с внешними ERP или PIM системами — вводится отдельный контракт SourceItemSaveInterface, который предоставляет возможность атомарного сохранения множества SourceItem-ов в рамках одного вызова сервиса. Такой контракт позволяет обработать операцию вставки используя один запрос в базу данных, что значительно ускорит обработку. Базовая операция сохранения, принимающая одиночную сущность, не добавлена в контракт репозитория для того, чтобы не добавлять несколько точек для расширения, так как по факту в этом случае стороннему разработчику прийдется плагинизировать обе save операции (единичную и множественную). Поэтому кастомизация одной точки расширения всегда выглядет предпочтительней.

Контракт команды множественного сохранения выглядит как MagentoInventoryApiApiSourceItemSaveInterface


/**
 * Service method for source items save multiple
 * Performance efficient API, used for stock synchronization
 *
 * Used fully qualified namespaces in annotations for proper work of WebApi request parser
 *
 * @api
 */
interface SourceItemSaveInterface
{
    /**
     * Save Multiple Source item data
     *
     * @param MagentoInventoryApiApiDataSourceItemInterface[] $sourceItems
     * @return void
     * @throws MagentoFrameworkExceptionInputException
     * @throws MagentoFrameworkExceptionCouldNotSaveException
     */
    public function execute(array $sourceItems);
}

А ее реализация SourceItemSave делегирует сохранение ресурс модели SaveMultiple.

Также в SourceItemRepository отсутствует метод get(), так как SourceItem — это сущность-связка и она определяется составным идентификатором (SKU и SourceId).

Репозиторий для Stock (виртуальных агрегаций Source сущностей) выглядит стандартно:


interface StockRepositoryInterface
{
    /**
     * Save Stock data
     *
     * @param MagentoInventoryApiApiDataStockInterface $stock
     * @return int
     * @throws MagentoFrameworkExceptionCouldNotSaveException
     */
    public function save(StockInterface $stock);
    /**
     * Get Stock data by given stockId. If you want to create plugin on get method, also you need to create separate
     * plugin on getList method, because entity loading way is different for these methods
     *
     * @param int $stockId
     * @return MagentoInventoryApiApiDataStockInterface
     * @throws MagentoFrameworkExceptionNoSuchEntityException
     */
    public function get($stockId);
    /**
     * Find Stocks by given SearchCriteria
     *
     * @param MagentoFrameworkApiSearchCriteriaInterface|null $searchCriteria
     * @return MagentoInventoryApiApiDataStockSearchResultsInterface
     */
    public function getList(SearchCriteriaInterface $searchCriteria = null);
    /**
     * Delete the Stock data by stockId. If stock is not found do nothing
     *
     * @param int $stockId
     * @return void
     * @throws MagentoFrameworkExceptionCouldNotDeleteException
     */
    public function deleteById($stockId);
}

Сервисы для маппинга Source и Stock

Руководствуясь правилом "don't make your client do anything you can do for them" чтобы уменьшить количество boilerplate кода в клиенте API (в коде бизнес логики) мы не вводим Data interface SourceStockLinkInterface. Вместо этого мы вводим набор доменных сервисов-команд для связывания (assignment) Source на Stock.

В итоге получаем три команды:


interface AssignSourcesToStockInterface
{
    /**
     * Assign list of source ids to stock
     *
     * @param int $stockId
     * @param int[] $sourceIds
     * @return void
     * @throws MagentoFrameworkExceptionInputException
     * @throws MagentoFrameworkExceptionCouldNotSaveException
     */
    public function execute(array $sourceIds, $stockId);
}

interface GetAssignedSourcesForStockInterface
{
    /**
     * Get Sources assigned to Stock
     *
     * @param int $stockId
     * @return MagentoInventoryApiApiDataSourceInterface[]
     * @throws MagentoFrameworkExceptionInputException
     * @throws MagentoFrameworkExceptionLocalizedException
     */
    public function execute($stockId);
}

interface UnassignSourceFromStockInterface
{
    /**
     * Unassign source from stock
     *
     * @param int $sourceId
     * @param int $stockId
     * @return void
     * @throws MagentoFrameworkExceptionInputException
     * @throws MagentoFrameworkExceptionCouldNotDeleteException
     */
    public function execute($sourceId, $stockId);
}

API vs SPI

В рамках данного проекта было решено явно разделять API (Application Programming Interface) от SPI (Service Provider Interfaces) для того, чтобы улучшить возможности расширения и уменьшить связность компонентов.

  • Репозитории могут быть расценены как API, соответственно предполагается, что методы интерфейса репозитория вызываются в PHP коде бизнес логики.
  • Отдельные классы-команды на которые класс-реализация репозитория проксирует методы (такие как: Get, Save, GetList, Delete) могут быть расценены как SPI — интерфейсы, для которых может быть предложена своя реализация сторонним разработчиком, чтобы расширить или заменить текущее поведение системы.

Таким образом, например, реализация репозитория MagentoInventoryModelStockRepository выглядит следующим образом:


/**
 * @inheritdoc
 */
class StockRepository implements StockRepositoryInterface
{
    /**
     * @var SaveInterface
     */
    private $commandSave;

    /**
     * @var GetInterface
     */
    private $commandGet;

    /**
     * @var DeleteByIdInterface
     */
    private $commandDeleteById;

    /**
     * @var GetListInterface
     */
    private $commandGetList;

    /**
     * @param SaveInterface $commandSave
     * @param GetInterface $commandGet
     * @param DeleteByIdInterface $commandDeleteById
     * @param GetListInterface $commandGetList
     */
    public function __construct(
        SaveInterface $commandSave,
        GetInterface $commandGet,
        DeleteByIdInterface $commandDeleteById,
        GetListInterface $commandGetList
    ) {
        $this->commandSave = $commandSave;
        $this->commandGet = $commandGet;
        $this->commandDeleteById = $commandDeleteById;
        $this->commandGetList = $commandGetList;
    }

    /**
     * @inheritdoc
     */
    public function save(StockInterface $stock)
    {
        $this->commandSave->execute($stock);
    }

    /**
     * @inheritdoc
     */
    public function get($stockId)
    {
        return $this->commandGet->execute($stockId);
    }

    /**
     * @inheritdoc
     */
    public function deleteById($stockId)
    {
        $this->commandDeleteById->execute($stockId);
    }

    /**
     * @inheritdoc
     */
    public function getList(SearchCriteriaInterface $searchCriteria = null)
    {
        return $this->commandGetList->execute($searchCriteria);
    }
}

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

Интерфейсы SPI команд выглядят следующим образом:


/**
 * Save Stock data command (Service Provider Interface - SPI)
 *
 * Separate command interface to which Repository proxies initial Save call, could be considered as SPI - Interfaces
 * so that you should extend and implement to customize current behaviour, but NOT expected to be used (called) in the code
 * of business logic directly
 *
 * @see MagentoInventoryApiApiStockRepositoryInterface
 * @api
 */
interface SaveInterface
{
    /**
     * Save Stock data
     *
     * @param StockInterface $stock
     * @return int
     * @throws CouldNotSaveException
     */
    public function execute(StockInterface $stock);
}

/**
 * Get Stock by stockId command (Service Provider Interface - SPI)
 *
 * Separate command interface to which Repository proxies initial Get call, could be considered as SPI - Interfaces
 * that you should extend and implement to customize current behavior, but NOT expected to be used (called) in the code
 * of business logic directly
 *
 * @see MagentoInventoryApiApiStockRepositoryInterface
 * @api
 */
interface GetInterface
{
    /**
     * Get Stock data by given stockId
     *
     * @param int $stockId
     * @return StockInterface
     * @throws NoSuchEntityException
     */
    public function execute($stockId);
}

/**
 * Delete Stock by stockId command (Service Provider Interface - SPI)
 *
 * Separate command interface to which Repository proxies initial Delete call, could be considered as SPI - Interfaces
 * that you should extend and implement to customize current behaviour, but NOT expected to be used (called) in the code
 * of business logic directly
 *
 * @see MagentoInventoryApiApiStockRepositoryInterface
 * @api
 */
interface DeleteByIdInterface
{
    /**
     * Delete the Stock data by stockId. If stock is not found do nothing
     *
     * @param int $stockId
     * @return void
     * @throws CouldNotDeleteException
     */
    public function execute($stockId);
}

/**
 * Find Stocks by SearchCriteria command (Service Provider Interface - SPI)
 *
 * Separate command interface to which Repository proxies initial GetList call, could be considered as SPI - Interfaces
 * that you should extend and implement to customize current behaviour, but NOT expected to be used (called) in the code
 * of business logic directly
 *
 * @see MagentoInventoryApiApiStockRepositoryInterface
 * @api
 */
interface GetListInterface
{
    /**
     * Find Stocks by given SearchCriteria
     *
     * @param SearchCriteriaInterface|null $searchCriteria
     * @return StockSearchResultsInterface
     */
    public function execute(SearchCriteriaInterface $searchCriteria = null);
}

Эти команды представляют SPI интерфейсы модуля и находятся под неймспейсом

MagentoInventoryModelStockCommand*

Реализации команд выглядят следующим образом (MagentoInventoryModelStockCommand*). Например, команда сохранения Stock:


/**
 * @inheritdoc
 */
class Save implements SaveInterface
{
    /**
     * @var StockResourceModel
     */
    private $stockResource;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @param StockResourceModel $stockResource
     * @param LoggerInterface $logger
     */
    public function __construct(
        StockResourceModel $stockResource,
        LoggerInterface $logger
    ) {
        $this->stockResource = $stockResource;
        $this->logger = $logger;
    }

    /**
     * @inheritdoc
     */
    public function execute(StockInterface $stock)
    {
        try {
            $this->stockResource->save($stock);
            return $stock->getStockId();
        } catch (Exception $e) {
            $this->logger->error($e->getMessage());
            throw new CouldNotSaveException(__('Could not save Stock'), $e);
        }
    }
}

Механизм Резерваций

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

Мы вводим Data Interface резервации


/**
 * The entity responsible for reservations, created to keep inventory amount (product quantity) up-to-date.
 * It is created to have a state between order creation and inventory deduction (deduction of specific SourceItems)
 *
 * @api
 */
interface ReservationInterface extends ExtensibleDataInterface
{
    /**
     * Constants for keys of data array. Identical to the name of the getter in snake case
     */
    const RESERVATION_ID = 'reservation_id';
    const STOCK_ID = 'stock_id';
    const SKU = 'sku';
    const QUANTITY = 'quantity';
    const STATUS = 'status';

    /**#@+
     * Reservation possible statuses. Maybe make sense to intorduce extension point for Reservation Open-Close satuses
     */
    const STATUS_ORDER_CREATED = 1;    // For Order Placed
    const STATUS_RETURN_CREATED = 2;   // For RMA Placed

    const STATUS_OREDER_COMPLETE = 101; // For Order Complete
    const STATUS_OREDER_CANCELED = 102; // For Order Canceled
    const STATUS_RMA_COMPLATE = 103;    // For RMA Canceled
    /**#@-*/

    /**
     * Get Reservation id
     *
     * @return int|null
     */
    public function getReservationId();

    /**
     * Get stock id
     *
     * @return int
     */
    public function getStockId();

    /**
     * Get Product SKU
     *
     * @return string
     */
    public function getSku();

    /**
     * Get Product Qty
     *
     * @return float
     */
    public function getQuantity();

    /**
     * Get Reservation Status
     *
     * @return int
     */
    public function getStatus();
}

Так как мы воспринимаем резервацию как Append-Only неизменяемую сущность, то нам не нужны модификаторы (setter методы) в ReservationInterface. Соответсвенно нам нужен ReservationBuilderInterface для того, чтобы создавать объекты-резервации.


$reservationBuilder->setStockId(1);
$reservationBuilder->setSku('sku');
$reservationBuilder->setQty(10);
$newReservation = $reservationBuilder->build();
//now we could save Reservation entity 
$reservationAppend->execute([$newReservation]);

Сервисы Резерваций

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


/**
 * Command which appends reservations when order placed or canceled
 *
 * @api
 */
interface ReservationAppend
{
    /**
     * Append reservations when Order Placed (or Cancelled)
     *
     * @param Reservation[] $reservations
     * @return void
     * @throws MagentoFrameworkExceptionInputException
     * @throws MagentoFrameworkExceptionCouldNotSaveException
     */
    public function execute(array $reservations);
}

Следующий сервис используется для того, чтобы подсчитать точное число (Quantity) товара доступное для продажи, так как Quantity StockItem-a обновляется с задержкой (latency) вызванной природой Event Sourcing, так как во время размещения заказа система работает со StockItem сущностью (виртуальной агригацией) и не знает из каких физических складов (Source) произойдет списание. Таким образом между операцией размещения заказа и обработки — может пройти опредленное время.


/**
 * Command which returns Reservation Quantity by Product SKU and Stock
 *
 * @api
 */
interface GetReservationQuantityForProduct
{
    /**
     * Get Reservation Quantity for given SKU in a given Stock
     *
     * @param string $sku
     * @param int $stockId
     * @return float
     */
    public function execute($sku, $stockId);
}

Каждая резервация может представлять собой открытое или закрытое состояние.
Так как резервации — immutable объект, который не может изменяться. Вместо того, чтобы изменить состояние резервации — мы просто создаем вторую резервацию, которая «гасит» списание первой.
Например,
размещая заказ на 30 единиц товара создаем резервацию:
ReservationID — 1, StockId — 1, SKU — SKU-1, Qty — (-30), Status — CREATED
Обработав этот заказ — создаем другую резервацию
ReservationID — 2, StockId — 1, SKU — SKU-1, Qty — (+30), Status — CANCELLED

Суммарно эти две резервации (-30) + (+30) = 0 не повлияют на Quantity, которое хранится в StockItem.
Здесь важно заметить две вещи: мы не вводим связь (binding) между резервацией и заказом (Order), так как резервация может быть привязана и к другим бизнес операциям. И с точки зрения склада (Inventory) нам не важен номер заказа, в рамках которого нужно отгрузить товар и уменьшить сток.
Использование отрицательных и положительных резерваций поможет нам упростить подсчет общего числа, которое мы должны отнять от Quantity сохраненным в StockItem.
Например, с помощью такого запроса:

select 
   SUM(r.qty) as total_reservation_qty
from 
   Reservations as r
where 
  stockId = {%id%} and sku  = {%sku%}

Magento MSI (Multi Source Inventory)

Данная статья является третьей статьей в цикле «Система управления складом с использованием CQRS и Event Sourcing» в рамках которого будет рассмотрен сбор требований, проектирование и разработка системы управления складом на примере Magento 2.

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

Более подробная документация по

Автор: Игорь Миняйло

Источник

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


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