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, Adobe Commerce and Magento Open Source provide 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:
Copied to your clipboard1interface DeferredInterface2{3 /**4 * @return mixed Value.5 * @throws \Throwable6 */7 public function get();89 public function isDone(): bool;10}
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
:
Copied to your clipboard1interface CancelableDeferredInterface extends DeferredInterface2{3 /**4 * @param bool $force Cancel operation even if it's already started.5 * @return void6 * @throws CancelingDeferredException When failed to cancel.7 */8 public function cancel(bool $force = false): void;910 /**11 * @return bool12 */13 public function isCancelled(): bool;14}
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:
Copied to your clipboard1public function aMethod() {2 //Started executing 1st operation3 $operationA = $serviceA->executeOp();45 //Executing 2nd operations at the same time6 $operationB = $serviceB->executeOp2();78 //We need to wait for 1st operation to start operation #39 $serviceC->executeOp3($operationA->get());1011 //We don't have to wait for operation #2, let client code wait for it if it needs the result12 //Operation number #3 is being executed simultaneously with operation #213 return $operationB;14}
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: employ the autogenerated factory YourClassName\ProxyDeferredFactory
. With its help, you can return values that seem like regular objects
but are in fact deferred results.
For example:
Copied to your clipboard1public function __construct(CallResult\ProxyDeferredFactory $callResultFactory)2{3 $this->proxyDeferredFactory = $callResultFactory;4}56....78public function doARemoteCall(string $uniqueValue): CallResult9{10 //Async HTTP request, get() will return a CallResult instance.11 //Call is in progress.12 $deferredResult = $this->client->call($uniqueValue);1314 //Returns CallResult instance that will call $deferredResult->get() when any of the object's methods is used.15 return $this->proxyDeferredFactory->create(['deferred' => $deferredResult]);16}1718public function doCallsAndProcess(): Result19{20 //Both calls running simultaneously21 $call1 = $this->doARemoteCall('call1');22 $call2 = $this->doARemoteCall('call2');2324 //Only when CallResult::getStuff() is called the $deferredResult->get() is called.25 return new Result([26 'call1' => $call1->getStuff(),27 'call2' => $call2->getStuff()28 ]);29}
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:
Copied to your clipboard1class DeferredShipment implements DeferredInterface2{3 private $request;45 private $done = false;67 private $trackingNumber;89 public function __construct(AsyncRequest $request)10 {11 $this->request = $request;12 }1314 public function isDone() : bool15 {16 return $this->done;17 }1819 public function get()20 {21 if (!$this->trackingNumber) {22 $this->request->wait();23 $this->trackingNumber = json_decode($this->request->getBody(), true)['tracking'];2425 $this->done = true;26 }2728 return $this->trackingNumber;29 }30}3132class Shipping33{34 ....3536 public function ship(array $products): array37 {38 $shipments = [];39 //Shipping simultaneously40 foreach ($products as $product) {41 $shipments[] = new DeferredShipment(42 $this->client->sendAsync(['id' => $product->getId()])43 );44 }4546 return $shipments;47 }48}4950class ShipController51{52 ....5354 public function execute(Request $request): Response55 {56 $shipments = $this->shipping->ship($this->products->find($request->getParam('ids')));57 $trackingsNumbers = [];58 foreach ($shipments as $shipment) {59 $trackingsNumbers[] = $shipment->get();60 }6162 return new Response(['trackings' => $trackingNumbers]);63 }64}
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.
Copied to your clipboard1class EntityRepository2{3 private $requestedEntityIds = [];45 private $identityMap = [];67 ...89 /**10 * @return Entity[]11 */12 public function findMultiple(array $ids): array13 {14 .....1516 //Adding found entities to the identity map be able to find them by ID.17 foreach ($found as $entity) {18 $this->identityMap[$entity->getId()] = $entity;19 }2021 ....22 }2324 public function find(string $id): Entity25 {26 //Adding this ID to the list of previously requested IDs.27 $this->requestedEntityIds[] = $id;2829 //Returning deferred that will find all requested entities30 //and return the one with $id31 return $this->proxyDeferredFactory->createFor(32 Entity::class,33 new CallbackDeferred(34 function () use ($id) {35 if (empty($this->identityMap[$id])) {36 $this->findMultiple($this->requestedEntityIds);37 $this->requestedEntityIds = [];38 }3940 return $this->identityMap[$id];41 }42 )43 );44 }4546 ....47}4849class EntitiesController50{51 ....5253 public function execute(): Response54 {55 //No actual DB query issued56 $criteria1Id = $this->entityService->getEntityIdWithCriteria1();57 $criteria2Id = $this->entityService->getEntityIdWithCriteria2();58 $criteria1Entity = $this->entityRepo->find($criteria1Id);59 $criteria2Entity = $this->entityRepo->find($criteria2Id);6061 //Querying the DB for both entities only when getStringValue() is called the 1st time.62 return new Response(63 [64 'criteria1' => $criteria1Entity->getStringValue(),65 'criteria2' => $criteria2Entity->getStringValue()66 ]67 );68 }69}
Examples#
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.