Accélérez vos applications avec RabbitMQ


Technique

La stabilité et la vitesse d’une application ou d’un site web font certainement partie des facteurs les plus importants pour les utilisateurs. Qui n’a jamais quitté un site avant même d’en voir la première page tellement celle-ci s’est faite attendre ? Nous allons voir aujourd’hui comment accélérer le temps de chargement de vos pages en exécutant les traitements les plus lourds de façon asynchrone, le tout géré très simplement grâce à RabbitMQ.

RabbitMQ: Qu’est ce que c’est ?

RabbitMQ est un système permettant de gérer des files de messages afin de permettre à différents clients de communiquer très simplement. Pour que chaque client puisse communiquer avec RabbitMQ, celui-ci s’appuie sur le protocole AMQP. Ce protocole définit précisément la façon dont vont communiquer les différents clients avec RabbitMQ. AMQP n’étant qu’un protocole et non une implémentation, chaque client est libre d’implémenter le protocole comme il le souhaite, ou de s’appuyer sur une bibliothèque. Des bibliothèques existent pour énormément de langages de programmation différents, ce qui permet de faire communiquer facilement des applicatifs utilisant des technologies très différentes.

 

Nous allons donc pouvoir faire en sorte que notre application envoie des messages vers RabbitMQ, qui va ensuite les transmettre à d’autres clients qui vont pouvoir agir en conséquence. Par exemple, sur un site e-commerce, une fois qu’un client passe une commande et paie, plutôt que de générer une facture en PDF et envoyer un mail pendant qu’il attend la page de confirmation, on va plutôt envoyer un message vers RabbitMQ disant “Le client X vient de passer la commande Y” et afficher la page de confirmation à l’utilisateur directement. Un autre client écoutera les messages que RabbitMQ lui transmettra, et en recevant ce message il pourra générer la facture PDF et envoyer le mail de confirmation.

Avant de commencer : les concepts

Établissement de la connexion

Afin de pouvoir transmettre des messages à RabbitMQ, les clients doivent établir une connexion. A travers cette connexion, il peuvent ouvrir un ou plusieurs “channels”. Ces channels sont des canaux de communication indépendants permettant de faire passer différentes communications en parallèle au sein de la même connexion TCP. Si ce concept est un peu compliqué, ignorez le pour le moment car il n’est pas important pour la suite de cette introduction.

Production d’un message

Les clients qui produisent un message sont appelés des “producers”. Une fois leur message produit, les producers déposent ce message dans un “exchange”.

L’exchange va servir à trouver dans quelle(s) “queue(s)” le message doit être entreposé. Pour cela, il se sert de “bindings”.

Les bindings sont en quelque sorte des règles qui vont permettre à l’exchange de déterminer dans quelle(s) queue(s) il doit déposer le message. Pour cette introduction nous nous limiterons à un exchange qui possède un binding simple qui permettra de “router” les messages vers une unique file. Mais AMQP vous permet de gérer des cas bien plus complexes, comme envoyer le même message vers plusieurs files différentes, ou encore inspecter la “routing_key” d’un message (clé de routage) afin de décider dans quelle file le mettre.

Les queue(s) sont les endroits finaux où sont entreposés les messages. Une fois dans une queue, un message est prêt à être consommé.

Enfin, sachez qu’un message peut être tout et n’importe quoi : une chaîne de caractères, du XML, du JSON, etc.

Consommation d’un message

Une fois les messages entreposés dans les queues, ils sont prêts à être consommés. Les clients qui consomment les messages sont appelés des “consumers”. Ces consumers s’abonnent à des files de messages, et RabbitMQ leur transmettra un message dès que celui-ci arrivera dans la queue. Vous pouvez avoir plusieurs consumers abonnés à la même file de message. C’est ici que cela devient réellement intéressant car vous pouvez démultiplier le nombre de consumers pour traiter plus de messages en parallèle sans devoir changer le code de vos différents clients.

Un peu de pratique pour bien comprendre

Installation de RabbitMQ

La première chose à faire est donc bien évidemment de commencer par installer RabbitMQ. Vous trouverez toutes les instructions ici. Les utilisateurs de Homebrew sur Mac pourront se contenter d’un brew install rabbitmq.

Création du projet d’introduction

Pour cette introduction, nous proposons un exemple vraiment très basique avec un producer qui envoie des messages, et deux consumers pour les traiter. On utilisera pour ce faire le langage PHP en ligne de commande directement et la bibliothèque php-amqplib pour communiquer avec RabbitMQ.

L’installation de cette bibliothèque se fait très simplement avec Composer en une seule commande:

composer require videlalvaro/php-amqplib=~2.2

Le producer

On va se contenter pour cette introduction d’un producer envoyant simplement un message qui va contenir du JSON, contenant le titre de la tâche à effectuer et le temps que celle-ci doit prendre. Cela nous permettra de simuler des exécutions de tâches un peu longues pour introduire les concepts suivants. Voilà à quoi pourrait ressembler notre producer :

use PhpAmqpLib\Connection\AMQPConnection;
use PhpAmqpLib\Message\AMQPMessage;
require(__DIR__.'/vendor/autoload.php');
$connection = new AMQPConnection('localhost', 5672, 'guest', 'guest');
$channel = $connection->channel();
$queue   = $channel->queue_declare(
'tasks', // name
false,   // passive=false : can be used to check if a queue exists without actually creating it
false,   // durable=false : the queue will not survive server restart
false,   // exclusive=false : the queue is not exclusive to this connection
false // auto_delete=false : the queue will not be auto deleted
);
$message = json_encode(array('title' => 'Traitement du message', 'duration' => rand(1, 5)));
$amqpMessage = new AMQPMessage($message);

$channel->basic_publish($amqpMessage, '', 'tasks');
$channel->close();
$connection->close();

Une fois l’autoloader de composer chargé, on initialise une connection vers RabbitMQ, et on créé un nouveau channel. On déclare ensuite une queue. En effet, pour avoir un message qui arrive dans une queue, il faut que celle-ci existe. La méthode queue_declare() permet donc de créer une queue si elle n’existe pas. Si la queue existe déjà, elle ne sera pas écrasée donc pas de problème à faire exécuter cette ligne de code tout le temps.

Nous avons commenté les différents paramètres acceptés par la méthode queue_declare() qui permettent d’influencer la durée de vie de la queue que l’on créée. Ici, on souhaite qu’elle s’appelle tasks, quelle ne soit pas “durable” (si le serveur redémarre, cette queue n’existera plus, ni les messages qu’elle contenait à ce moment là. RabbitMQ permet d’avoir des queues et des messages persistants pour ne pas risquer de les perdre en cas de coupure du serveur), qu’elle ne soit pas exclusive à cette connexion (une queue exclusive n’est accessible qu’à travers la connexion avec laquelle elle a été déclarée et est supprimée une fois la connexion interrompue), et on ne souhaite pas qu’elle soit automatiquement supprimée une fois vide et lorsque plus aucun consumer n’y est abonné.

Pour finir, on créée notre message et on le publie. Le 2ème paramètre de la méthode basic_publish() est normalement le nom de l’exchange à utiliser mais RabbitMQ a quelques exchanges préconfigurés pour les cas les plus simples et nous n’avons donc pas besoin ni de déclarer d’exchange, ni de binding. La chaîne vide permet d’utiliser l’exchange par défaut qui permet d’envoyer un message directement dans la file dont le nom est transmis en tant que 3ème paramètre.

 

Il ne nous reste plus qu’à développer notre consumer :

 

use PhpAmqpLib\Connection\AMQPConnection;
use PhpAmqpLib\Message\AMQPMessage;

require(__DIR__.'/vendor/autoload.php');

$connection = new AMQPConnection('localhost', 5672, 'guest', 'guest');
$channel = $connection->channel();
$queue   = $channel->queue_declare(
'tasks', // name
false,   // passive=false : can be used to check if a queue exists without actually creating it
false,   // durable=false : the queue will not survive server restart
false,   // exclusive=false : the queue is not exclusive to this connection
false // auto_delete=false : the queue will not be auto deleted
);

echo "En attente de messages ... Utilisez CTRL+C pour quitter.\n";

$channel->basic_consume(
'tasks',
'',
false,
true,   // no_ack=true : do not wait for ack
false,
false,
function ($message) {
$data = json_decode($message->body);
echo $data->title, "\n";
sleep($data->duration);
}
);

while (count($channel->callbacks)) {
$channel->wait();
}

Le début est similaire au producer : il s’agit de l’établissement de la connexion et de la déclaration de la queue. Une fois ceci fait, le consumer s’abonne à la queue. La méthode anonyme fournie en tant que dernier paramètre de basic_consume() est la méthode qui sera exécutée pour chaque message transmis à notre consumer. Ici, elle affichera simplement le titre fourni dans le message et attendra un temps défini dans le message. Lorsqu’il n’a aucun message à traiter, le consumer attend jusqu’à ce qu’un message lui parvienne.

Vos deux programmes sont maintenant terminés. Vous pouvez lancer le consumer à l’aide de la commande “php consumer.php” afin que celui-ci se mette en attente de message. Vous pouvez également en lancer deux dans deux terminaux différents afin de constater comment ils se comportent lorsqu’ils sont plusieurs. Ensuite, à l’aide un autre terminal, vous pouvez exécuter le producer plusieurs fois de suite et jeter un oeil du côté des consumers. La commande à utiliser est “php producer.php”.

Quelques concepts avancés qu’il est intéressant de connaître

Gérer les erreurs

Il est à noter que dans la configuration que nous avons utilisée jusqu’à maintenant, RabbitMQ envoie les messages vers le consumer dès qu’il les reçoit de la part du producer, et ce même si le consumer est déjà en train de traiter un message. De plus, il supprime immédiatement ce message de sa mémoire. Cela signifie que si votre consumer plante, vous perdez tous les messages qui lui ont été transmis mais qui n’ont pas été traités. Pour palier à ce problème, il est possible d’activer ce qu’on appelle l”acknowledgment” des messages, souvent abrégé en ack. Le consumer devra alors pour chaque message répondre à RabbitMQ qu’il a bien terminé de traiter le message.

Si le consumer plante pendant le traitement d’un message et que RabbitMQ n’a pas reçu d’ack pour les messages qui lui ont été distribués, alors ils pourront être redistribués vers d’autres consumers.

Pour activer les ack, il suffit de passer le 4ème paramètre de la méthode basic_consume() à false, et de procéder à l’envoie de l’ack à la fin de la callback de traitement.

 

$channel->basic_consume(
'tasks',
'',
false,
false,   // no_ack=false : wait for ack
false,
false,
function ($message) {
$data = json_decode($message->body);
echo $data->title, "\n";
sleep($data->duration);
$message->delivery_info['channel']->basic_ack($message->delivery_info['delivery_tag']);
}
);

Maintenant, RabbitMQ attendra d’avoir la confirmation du consumer pour supprimer le message de sa mémoire. En cas de plantage, les messages seront alors redistribués à un autre consumer.

 

Et si RabbitMQ plante ? Pour ce genre de situations, sachez que vous pouvez rendre des queues et des messages “durables” : ils seront persistants même en cas de reboot de RabbitMQ. Vous pourrez en apprendre plus au chapitre “Message Durability” de la documentation officielle.

Distribution des messages à plusieurs consumers

Lorsque plusieurs consumers sont abonnés à la même file de messages, RabbitMQ va leur transmettre les messages de façon séquentielle. Le consumer A aura le 1er message, le consumer B aura le 2ème, le A le 3ème, le B le 4ème, et ainsi de suite, peu importe le temps qu’ils mettent à traiter leurs messages. Ils recevront donc en moyenne le même nombre de messages. Si les messages 1 et 3 ont un temps de traitement de 10 secondes alors que les messages 2 et 4 n’ont qu’une seconde de traitement, le consumer A sera finalement bien plus occupé que le B et le mode de fonctionnement par défaut ne conviendra pas forcément pour une vitesse de traitement optimal. Il est donc possible de modifier la distribution des messages afin que RabbitMQ n’envoit pas plus de X messages non traités vers un consumer. Si ce seuil est atteint, alors RabbitMQ ne lui enverra pas d’autres messages et répartira plutôt les messages sur les autres consumers dont les seuils ne sont pas atteints. En passant ce seuil à 1, il est possible donc d’envoyer les messages vers les consumers uniquement lorsque l’un de ceux-ci est disponible. Pour activer ce comportement, il suffit d’utiliser la méthode basic_qos() sur l’objet AMQPChannel de la façon suivante:

 

// consumer.php
// Autoloading, création de la connexion, etc.
// [...]
$channel = $connection->channel();
$channel->basic_qos(null, 1, null); // Pas plus de 1 message à la fois

 

Avec cette configuration, le consumer A aura le 1er message, le B aura les messages 2 et 3, et le A aura le 4ème message. La durée de traitement totale pour les 4 messages sera d’environ 11 secondes, contre environ 20 secondes auparavant.

De nombreuses possibilités

Vous avez maintenant toutes les cartes en main pour exécuter de façon asynchrone les traitements les plus lourds de vos applications web, que ce soit des envois de mails, des générations de pdfs, de l’invalidation de caches, etc. Les possibilités sont nombreuses, à vous de bien analyser vos pages et tous les traitements qui sont faits avant que celles-ci ne s’affichent afin de trouver les tâches qui n’ont pas besoin d’être exécutée de façon synchrone.

 

Amusez-vous bien !

Laisser un commentaire