
В данной статье будет рассмотрен 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(\Magento\Module\Api\Data\DataInterface $entityData); public function get($entityId); public function delete(\Magento\Module\Api\Data\DataInterface $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 \Magento\InventoryApi\Api\Data\SourceInterface $source * @return int * @throws \Magento\Framework\Exception\CouldNotSaveException */ 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 \Magento\InventoryApi\Api\Data\SourceInterface * @throws \Magento\Framework\Exception\NoSuchEntityException */ public function get($sourceId); /** * Load Source data collection by given search criteria * * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria * @return \Magento\InventoryApi\Api\Data\SourceSearchResultsInterface */ 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 \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria * @return \Magento\InventoryApi\Api\Data\SourceItemSearchResultsInterface */ public function getList(SearchCriteriaInterface $searchCriteria); /** * Delete Source Item data * * @param SourceItemInterface $sourceItem * @return void * @throws \Magento\Framework\Exception\NoSuchEntityException * @throws \Magento\Framework\Exception\CouldNotDeleteException */ public function delete(SourceItemInterface $sourceItem); }
Так как основные сценарии использования операции сохранения происходят с набором SourceItems, а не с одной сущностью, как это предполагает стандартный контракт save в репозитории.
Для множественного сохранения, которое может происходить во время операций импорта или синхронизации стоков с внешними ERP или PIM системами — вводится отдельный контракт SourceItemSaveInterface, который предоставляет возможность атомарного сохранения множества SourceItem-ов в рамках одного вызова сервиса. Такой контракт позволяет обработать операцию вставки используя один запрос в базу данных, что значительно ускорит обработку. Базовая операция сохранения, принимающая одиночную сущность, не добавлена в контракт репозитория для того, чтобы не добавлять несколько точек для расширения, так как по факту в этом случае стороннему разработчику прийдется плагинизировать обе save операции (единичную и множественную). Поэтому кастомизация одной точки расширения всегда выглядет предпочтительней.
Контракт команды множественного сохранения выглядит как Magento\InventoryApi\Api\SourceItemSaveInterface
/** * 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 \Magento\InventoryApi\Api\Data\SourceItemInterface[] $sourceItems * @return void * @throws \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\CouldNotSaveException */ public function execute(array $sourceItems); }
А ее реализация SourceItemSave делегирует сохранение ресурс модели SaveMultiple.
Также в SourceItemRepository отсутствует метод get(), так как SourceItem — это сущность-связка и она определяется составным идентификатором (SKU и SourceId).
Репозиторий для Stock (виртуальных агрегаций Source сущностей) выглядит стандартно:
interface StockRepositoryInterface { /** * Save Stock data * * @param \Magento\InventoryApi\Api\Data\StockInterface $stock * @return int * @throws \Magento\Framework\Exception\CouldNotSaveException */ 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 \Magento\InventoryApi\Api\Data\StockInterface * @throws \Magento\Framework\Exception\NoSuchEntityException */ public function get($stockId); /** * Find Stocks by given SearchCriteria * * @param \Magento\Framework\Api\SearchCriteriaInterface|null $searchCriteria * @return \Magento\InventoryApi\Api\Data\StockSearchResultsInterface */ 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 \Magento\Framework\Exception\CouldNotDeleteException */ 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 \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\CouldNotSaveException */ public function execute(array $sourceIds, $stockId); } interface GetAssignedSourcesForStockInterface { /** * Get Sources assigned to Stock * * @param int $stockId * @return \Magento\InventoryApi\Api\Data\SourceInterface[] * @throws \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\LocalizedException */ public function execute($stockId); } interface UnassignSourceFromStockInterface { /** * Unassign source from stock * * @param int $sourceId * @param int $stockId * @return void * @throws \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\CouldNotDeleteException */ public function execute($sourceId, $stockId); }
API vs SPI
В рамках данного проекта было решено явно разделять API (Application Programming Interface) от SPI (Service Provider Interfaces) для того, чтобы улучшить возможности расширения и уменьшить связность компонентов.
- Репозитории могут быть расценены как API, соответственно предполагается, что методы интерфейса репозитория вызываются в PHP коде бизнес логики.
- Отдельные классы-команды на которые класс-реализация репозитория проксирует методы (такие как: Get, Save, GetList, Delete) могут быть расценены как SPI — интерфейсы, для которых может быть предложена своя реализация сторонним разработчиком, чтобы расширить или заменить текущее поведение системы.
Таким образом, например, реализация репозитория Magento\Inventory\Model\StockRepository выглядит следующим образом:
/** * @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 \Magento\InventoryApi\Api\StockRepositoryInterface * @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 \Magento\InventoryApi\Api\StockRepositoryInterface * @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 \Magento\InventoryApi\Api\StockRepositoryInterface * @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 \Magento\InventoryApi\Api\StockRepositoryInterface * @api */ interface GetListInterface { /** * Find Stocks by given SearchCriteria * * @param SearchCriteriaInterface|null $searchCriteria * @return StockSearchResultsInterface */ public function execute(SearchCriteriaInterface $searchCriteria = null); }
Эти команды представляют SPI интерфейсы модуля и находятся под неймспейсом
Magento\Inventory\Model\Stock\Command\*
Реализации команд выглядят следующим образом (Magento\Inventory\Model\Stock\Command\*). Например, команда сохранения 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. */ const STATUS_OPEN = 1; const STATUS_CLOSED = 2; /**#@-*/ /** * 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 \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\CouldNotSaveException */ 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 — OPEN
Обработав этот заказ — создаем другое резервирование
ReservationID — 2, StockId — 1, SKU — SKU-1, Qty — (+30), Status — CLOSED
Суммарно эти два резерва (-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 (Англ.)
