В данной статье будет рассмотрен 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. Таким образом, сторонний разработчик, который захочет подменить базовую реализацию, больше не привязан к этой реализации в коде. Все что ему нужно — интерфейсы, так как именно на интерфейсы зависят другие модули в системе.
Интерфейсы Репозиториев — 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.
Открытый проект, где ведется разработка, и куда привлекаются инженеры из сообщества, а также где можно ознакомиться с текущим состоянием проекта и документацией, доступен по ссылке.
Более подробная документация по
- MSI Service Contracts (Англ.)
- Reservations (Англ.)
Автор: Игорь Миняйло