Sei uno sviluppatore o una sviluppatrice web/server? Lavori in PHP, magari su Symfony? Ti trovi in difficoltà nello sviluppo di una o più applicazioni perché ci sono alcune richieste che devono svolgere operazioni molto pesanti e finiscono per avere tempi di risposta lunghissimi? Sei in crisi perché non sai come consegnare i quattro progetti che hai in sospeso entro il prossimo venerdì?
Sull’ultimo problema purtroppo non posso aiutarti (e nemmeno sul secondo, a dirla tutta), ma per quanto riguarda il terzo potrei avere qualcosa da suggerirti.
In particolare nello sviluppo di API, non è raro ritrovarsi in una situazione in cui una certa chiamata deve scatenare l’esecuzione di un task molto lungo e pesante, ma allo stesso tempo non ci si può permettere di far aspettare troppo i client in attesa di una risposta. Spesso, però, questo task può anche non essere fatto durante la chiamata. Pensiamo a un’API per l’invio di notifiche push: il client manda la notifica da inviare e l’indicazione dei dispositivi a cui inviarla, ma non è necessario aspettare di averla spedita a tutti i riceventi prima di rispondergli. Si può dire lo stesso per la schedulazione o l’invio di email, o ancora per elaborazioni di dati molto pesanti che non richiedono interazioni da parte del client.
Se ti sei trovato ad affrontare problemi simili, potresti averli risolti mediante la classica schedulazione di task: salvi i dettagli dell’operazione da effettuare, concludi la chiamata e poi un comando pianificato si occupa del lavoro sporco al posto dell’API. Ma se ti dicessi che, con Symfony, c’è un metodo migliore che non richiede di creare nessuna configurazione separata rispetto alla tua applicazione?
Avrai probabilmente già sentito parlare degli eventi Symfony e di come utilizzarli. Non mi ci dilungherò in questa sede, ma si tratta di eventi lanciati dal framework nel corso dell’elaborazione di una richiesta HTTP (e non solo), utili per controllarne e modificarne il flusso. Uno in particolare può fornire una semplice ed elegante soluzione al nostro problema: il kernel.terminate. Questo evento viene lanciato dopo l’invio della risposta al chiamante. Ma in che modo può aiutarci con la lentezza della nostra API per fare felici i nostri client?
Vediamo subito un esempio.
La nostra applicazioncina di prova sarà sviluppata in Symfony 4.4, ovvero la corrente versione LTS. Su altre versioni da Symfony 3 in poi, per cui invito a consultare la giusta documentazione, il funzionamento di quel che vediamo è comunque molto simile.
Abbiamo un’API quindi, con un endpoint /perform-heavy-task che si occupa di una qualche operazione molto pesante, con dei log per tenerla d’occhio.
namespace AppController; use PsrLogLoggerInterface; use SymfonyBundleFrameworkBundleControllerAbstractController; use SymfonyComponentRoutingAnnotationRoute; class HeavyTaskController extends AbstractController { /** * @Route("/perform-heavy-task", name="perform_heavy_task", defaults={"_format": "json"}) */ public function performHeavyTask(LoggerInterface $logger) { $logger->info("Starting heavy task..."); // Complesso task assolutamente cruciale per la buona riuscita del nostro progetto. sleep(10); $logger->info("Sending response..."); return $this->json(['message' => '*puff puff* Heavy task completed!']); } } |
Come possiamo vedere dal log, in questo semplice assetto l’applicazione fa esattamente quel che immaginiamo, ovvero la chiamata arriva al controller, passa l’intero tempo di completamento del task e la risposta viene mandata al client. Non proprio il massimo, insomma.
[2020-03-09 19:27:20] request.INFO: Matched route "perform_heavy_task"... [2020-03-09 19:27:20] app.INFO: Starting heavy task... [] [] [2020-03-09 19:27:30] app.INFO: Sending response... [] [] |
Ma, se il nostro task può essere rimandato, ecco che kernel.terminate interviene in nostro soccorso.
Creiamo un event listener (o un subscriber, se preferiamo) che sia pronto ad accettare un oggetto TerminateEvent, ovvero il nostro evento, e spostiamo lì il nostro importantissimo task. Avremo bisogno della RouterInterface per assicurarci che il task parta effettivamente dopo la performHeavyTask: kernel.terminate viene lanciato dopo tutte le richieste!
namespace AppEventListener; use PsrLogLoggerInterface; use SymfonyComponentHttpKernelEventTerminateEvent; use SymfonyComponentRoutingRouterInterface; class HeavyTaskListener { /** * @var RouterInterface */ private $router; /** * @var LoggerInterface */ private $logger; public function __construct(RouterInterface $router, LoggerInterface $logger) { $this->router = $router; $this->logger = $logger; } public function onKernelTerminate(TerminateEvent $event) { // Ci facciamo dire qual è la route dell'attuale richiesta. $currentRoute = $this->router->match($event->getRequest()->getPathInfo()); if ('perform_heavy_task' === $currentRoute['_route']) { // Siamo nella route interessata: via con il task. $this->logger->info("Starting heavy task..."); // Complesso task assolutamente cruciale per la buona riuscita del nostro progetto. sleep(10); $this->logger->info("*puff puff* Heavy task completed!"); } } } |
Configuriamo il listener in services.yaml…
AppEventListenerHeavyTaskListener:
arguments:
- '@router'
- '@logger'
tags:
- { name: kernel.event_listener, event: kernel.terminate } |
…con la action di prima che modifichiamo così.
public function performHeavyTask(LoggerInterface $logger) { $logger->info("Sending response..."); return $this->json(['message' => 'About to perform the heavy task!']); } |
Proviamo il tutto, e vediamo subito la differenza: questa volta la risposta viene mandata immediatamente, e soltanto in seguito il task viene lanciato e completato.
[2020-03-09 19:50:44] request.INFO: Matched route "perform_heavy_task"... [2020-03-09 19:50:44] app.INFO: Sending response... [] [] [2020-03-09 19:50:44] app.INFO: Starting heavy task... [] [] [2020-03-09 19:50:54] app.INFO: *puff puff* Heavy task completed! [] [] |
Molto meglio!
In chiusura, qualche nota su questo evento.
Come detto, gli eventi del componente HttpKernel, come appunto il kernel.terminate, vengono lanciati durante l’elaborazione di tutte le richieste ricevute dall’applicazione, per cui bisogna implementare un sistema per “riconoscere” la richiesta nel listener. Nell’esempio abbiamo usato il riconoscimento della route, ma ovviamente non è l’unica tecnica possibile: un’altra possibilità è usare un servizio, iniettato sia nel controller che nel listener, che tenga traccia dello stato della richiesta. Questo è un ottimo modo, fra l’altro, per passare dati fra il controller e il listener.
Dato che la risposta viene spedita prima dell’avvio del listener, si capisce che il client nessuna informazione riguardo un eventuale fallimento del task svolto nel listener stesso. Se è importante notificare gli errori al chiamante, dovremo ingegnarci per farglieli avere in un altro modo, ad esempio con una email.
Al momento in cui scrivo, soltanto i server PHP-FPM sono in grado di continuare l’elaborazione della richiesta dopo l’invio della risposta: se il nostro server non utilizza questa tecnologia, il listener girerà, ma la risposta arriverà al client solo alla fine dell’elaborazione, rendendo di fatto inutile l’evento.
Al di là di queste precisazioni, abbiamo visto come con kernel.terminate possiamo velocizzare le chiamate lato client senza per questo doverci affidare ad altri servizi o tecnologie.
Quindi facciamo la gioia dei nostri client! Affidiamo i nostri task troppo pesanti ai listener! E non dimentichiamoci il motto che ogni sviluppatore che si rispetti dovrebbe avere: se c’è del lavoro che può essere rimandato, rimandalo.
Andrea Cioni