diff --git a/_data/toc/php-developer-guide.yml b/_data/toc/php-developer-guide.yml index 3794ce08d80..2b89bc370c3 100644 --- a/_data/toc/php-developer-guide.yml +++ b/_data/toc/php-developer-guide.yml @@ -123,6 +123,10 @@ pages: url: /extension-dev-guide/module-development.html children: + - label: Asynchronous/deferred operations + include_versions: ["2.3"] + url: /extension-dev-guide/async-operations.html + - label: Service contracts url: /extension-dev-guide/service-contracts/service-contracts.html diff --git a/guides/v2.3/extension-dev-guide/async-operations.md b/guides/v2.3/extension-dev-guide/async-operations.md new file mode 100644 index 00000000000..ec624ca7bef --- /dev/null +++ b/guides/v2.3/extension-dev-guide/async-operations.md @@ -0,0 +1,277 @@ +--- +group: php-developer-guide +title: Asynchronous and deferred operations +--- + +Asynchronous operations are not native to PHP but it is still possible to execute heavy +operations simultaneously, or delay them until they absolutely have to be finished. + +To make writing asynchronous code easier, Magento provides the `DeferredInterface` to use with asynchronous operations. +This allows client code to work with asynchronous operations just as it would with standard operations. + +## DeferredInterface + +`_Magento\Framework\Async\DeferredInterface_` is quite simple: + +```php +interface DeferredInterface +{ + /** + * @return mixed Value. + * @throws \Throwable + */ + public function get(); + + public function isDone(): bool; +} +``` + +When the client code needs the result, the `_get()_` method will be called to retrieve the result. +`_isDone()_` can be used to see whether the code has completed. + +There are 2 types of asynchronous operations where `_DeferredInterface_` can be used to describe the result: + +* With asynchronous operations in progress, calling `_get()_` would wait for them to finish and return their result. +* With deferred operations, `_get()_` would actually start the operation, wait for it to finish, and then return the result. + +Sometimes developers require more control over long asynchronous operations. +That is why there is an extended deferred variant - `Magento\Framework\Async\CancelableDeferredInterface`: + +```php +interface CancelableDeferredInterface extends DeferredInterface +{ + /** + * @param bool $force Cancel operation even if it's already started. + * @return void + * @throws CancelingDeferredException When failed to cancel. + */ + public function cancel(bool $force = false): void; + + /** + * @return bool + */ + public function isCancelled(): bool; +} +``` + +This interface is for operations that may take too long and can be canceled. + +### Client code + +Assuming that `_serviceA`, `serviceB` and `serviceC_` all execute asynchronous operations, such as HTTP requests, the client code would look like: + +```php +public function aMethod() { + //Started executing 1st operation + $operationA = $serviceA->executeOp(); + + //Executing 2nd operations at the same time + $operationB = $serviceB->executeOp2(); + + //We need to wait for 1st operation to start operation #3 + $serviceC->executeOp3($operationA->get()); + + //We don't have to wait for operation #2, let client code wait for it if it needs the result + //Operation number #3 is being executed simultaneously with operation #2 + return $operationB; +} +``` + +And not a callback in sight! + +With the deferred client, the code can start multiple operations at the same time, wait for operations required to finish and pass the promise of a result to another method. + +## ProxyDeferredFactory + +When writing a module or an extension, you may not want to burden other developers with having to know that your method is performing an asynchronous operation. +There is a way to hide it: `_Magento\Framework\Async\ProxyDeferredFactory_`. With its help, you can return values that seem like regular objects +but are in fact deferred results. + +For example: + +```php +public function doARemoteCall(string $uniqueValue): CallResult +{ + //Async HTTP request, get() will return a CallResult instance. + //Call is in progress. + $deferredResult = $this->client->call($uniqueValue); + + //Returns CallResult instance that will call $deferredResult->get() when any of the object's methods is used. + return $this->proxyDeferredFactory->createFor(CallResult::class, $deferredResult); +} + +public function doCallsAndProcess(): Result +{ + //Both calls running simultaneously + $call1 = $this->doARemoteCall('call1'); + $call2 = $this->doARemoteCall('call2'); + + //Only when CallResult::getStuff() is called the $deferredResult->get() is called. + return new Result([ + 'call1' => $call1->getStuff(), + 'call2' => $call2->getStuff() + ]); +} +``` + +## Using DeferredInterface for background operations + +As mentioned above, the first type of asynchronous operations are operations executing in a background. +`DeferredInterface` can be used to give client code a promise of a not-yet-received result and wait for it by calling the `_get()_` method. + +Take a look at an example: creating shipments for multiple products: + +```php +class DeferredShipment implements DeferredInterface +{ + private $request; + + private $done = false; + + private $trackingNumber; + + public function __construct(AsyncRequest $request) + { + $this->request = $request; + } + + public function isDone() : bool + { + return $this->done; + } + + public function get() + { + if (!$this->trackingNumber) { + $this->request->wait(); + $this->trackingNumber = json_decode($this->request->getBody(), true)['tracking']; + + $this->done = true; + } + + return $this->trackingNumber; + } +} + +class Shipping +{ + .... + + public function ship(array $products): array + { + $shipments = []; + //Shipping simultaneously + foreach ($products as $product) { + $shipments[] = new DeferredShipment( + $this->client->sendAsync(['id' => $product->getId()]) + ); + } + + return $shipments; + } +} + +class ShipController +{ + .... + + public function execute(Request $request): Response + { + $shipments = $this->shipping->ship($this->products->find($request->getParam('ids'))); + $trackingsNumbers = []; + foreach ($shipments as $shipment) { + $trackingsNumbers[] = $shipment->get(); + } + + return new Response(['trackings' => $trackingNumbers]); + } +} +``` + +Here, multiple shipment requests are being sent at the same time with their results gathered later. +If you do not want to write your own `_DeferredInterface_` implementation, you can use `_CallbackDeferred_` to provide callbacks that will be used when `_get()_` is called. + +## Using DeferredInterface for deferred operations + +The second type of asynchronous operations are operations that are being postponed and executed only when a result is absolutely needed. + +An example: + +Assume you are creating a repository for an entity and you have a method that returns a singular entity by ID. +You want to make a performance optimization for cases when multiple entities are requested during the same request-response process, so you would not load them separately. + +```php +class EntityRepository +{ + private $requestedEntityIds = []; + + private $identityMap = []; + + ... + + /** + * @return Entity[] + */ + public function findMultiple(array $ids): array + { + ..... + + //Adding found entities to the identity map be able to find them by ID. + foreach ($found as $entity) { + $this->identityMap[$entity->getId()] = $entity; + } + + .... + } + + public function find(string $id): Entity + { + //Adding this ID to the list of previously requested IDs. + $this->requestedEntityIds[] = $id; + + //Returning deferred that will find all requested entities + //and return the one with $id + return $this->proxyDefferedFactory->createFor( + Entity::class, + new CallbackDeferred( + function () use ($id) { + if (empty($this->identityMap[$id])) { + $this->findMultiple($this->requestedEntityIds); + $this->requestedEntityIds = []; + } + + return $this->identityMap[$id]; + } + ) + ); + } + + .... +} + +class EntitiesController +{ + .... + + public function execute(): Response + { + //No actual DB query issued + $criteria1Id = $this->entityService->getEntityIdWithCriteria1(); + $criteria2Id = $this->entityService->getEntityIdWithCriteria2(); + $criteria1Entity = $this->entityRepo->find($criteria1Id); + $criteria2Entity = $this->entityRepo->find($criteria2Id); + + //Querying the DB for both entities only when getStringValue() is called the 1st time. + return new Response( + [ + 'criteria1' => $criteria1Entity->getStringValue(), + 'criteria2' => $criteria2Entity->getStringValue() + ] + ); + } +} +``` + +## Examples in Magento + +Please see our asynchronous HTTP client `_Magento\Framework\HTTP\AsyncClientInterface_` and `_Magento\Shipping\Model\Shipping_` with various `_Magento\Shipping\Model\Carrier\AbstractCarrierOnline_` implementations to see how `DeferredInterface` can be used to work with asynchronous code.