Are you a web/server developer? Do you work in PHP, maybe on Symfony? Do you find it difficult to develop one or more applications because there are some requests that have to perform very heavy operations and end up having very long response times? Are you in a crisis because you don’t know how to finish the four projects you’re stuck on by next Friday?
Unfortunately, I can’t help you with that last problem (and not even with the second one, to be honest), but when it comes to the third problem I might have a suggestion.
Particularly in API development, it’s not unusual to find yourself fin a situation where a certain call has to trigger the execution of a longer and heavier task, but at the same time you can’t afford to make the clients wait too long waiting for a reply. Often, though, this task can also be performed during the call. Let’s think of an API for sending push notifications: the client sends a notification to be delivered and the address of the devices to send it to, but you don’t have to wait for it to be sent to every recipient before replying to it. You can also say that about the scheduling or sending emails, or even for the elaboration of heavy data that doesn’t need client interaction.
If you have found yourself facing similar problems, you may have solved them using the classic task scheduling: save the details of the operation to be performed, end the call and then a scheduled command takes care of the dirty work instead of the API. But what if I told you that, with Symfony, there is a better way that doesn’t require creating a configuration separate from your application?
You probably already heard about the Symfony events and how to use them. I will not dwell on this too much, but they’re events launched by the framework during the elaboration of an HTTP request (and not only that), useful to control and modify their flow. One in particular can provide a simple and elegant solution to our problem: the kernel.terminate. This event is launched after sending the reply to the caller. But how can it help us with the slowness of our API to make our clients happy?
Let’s see an example.
Our test application will be delivered in Symfony 4.4, the current LTS version. On other versions of Symfony, from the 3rd onwards, on which I recommend you to read about, the functionality is still very similar.
We then have an API with an endpoint /perform-heavy-task that takes care of some heavy operation, with logs to keep an eye on it.
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!']); } } |
As we can see in the log, in this simple asset the application does exactly what we imagined: the call reaches the controller, the entire task completion time passes and the response is sent to the client. So, not the best.
[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... [] [] |
But, if our task can be postponed, our kernel.terminate comes to our rescue.
We create an event listener (or a subscriber, if we prefer) ready to accept a TerminateEvent object, aka our event, and move our important task there. We will need a RouterInterface to be sure that the task will actually start after the performHeavyTask: kernel.terminate will be launched after all the requests!
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!"); } } } |
Let’s configure the listener in services.yaml…
AppEventListenerHeavyTaskListener:
arguments:
- '@router'
- '@logger'
tags:
- { name: kernel.event_listener, event: kernel.terminate } |
…with the previous action which we will modify like this.
public function performHeavyTask(LoggerInterface $logger) { $logger->info("Sending response..."); return $this->json(['message' => 'About to perform the heavy task!']); } |
We try it and instantly see the difference: this time the response is sent immediately, and only after that the task is launched and completed.
[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! [] [] |
Much better!
In closing, a few notes on this event.
As I have said, the events of the HttpKernel component, like the kernel.terminate, are launched during the elaboration of all the requests received by the application, which is why you need to implement a system to “recognize” the request in the listener. In the example we have used route recognition, but it’s of course not the only possible technique: another possibility is to use a service, injected in both the controller and the listener, which will track the status of the request. This is also an excellent way to share data between the controller and the listener.
Since the response is sent before the listener starts, the client has no information about a possible failure of the task performed in the listener itself. If it is important to notify the caller about errors, we will have to deliver them in another way, for example with an email.
At the moment of writing, only PHP-FPM servers can continue elaborating requests after sending the response: if our server can’t use this technology, the listener will run, but the response will reach the client only after the elaboration, rendering the event useless.
Beyond these clarifications, we have seen how with kernel.terminate we can speed up client-side calls without having to rely on other services or technologies.
So, let’s make our clients happy! Let’s entrust our heavy tasks to the listeners! And let’s not forget the motto that every self-respecting developer should have: if some work can be postponed, postpone it.
Andrea Cioni