Composer des API synchrones sur un bus d’événements asynchrone avec Azure Service Bus

Simon Emmanuel RIVAS
Catégorie : Azure / Azure Functions / Service Bus
07/09/2020

La construction d’une API peut impliquer de devoir mettre à disposition des opérations synchrones, c’est-à-dire que l’appelant attend activement une réponse pour pouvoir continuer ses traitements.
Certes, les opérations asynchrones (de type fire-and-forget, ou s’appuyant sur un mécanisme de callback) sont souvent présentées comme la panacée. Mais mettons cela de côté et concentrons-nous sur le cas des opérations synchrones en partant du principe que le besoin de telles opérations est justifié – et il peut parfaitement l’être

 

Découpler, découpler, découpler…

 

En temps que spécialiste de l’intégration, on prône un peu partout le découplage comme bonne pratique fondamentale, sous 3 aspects :

  • Formelen introduisant des schémas pivots,
  • Temporel : en utilisant un bus de messages/événements asynchrone,
  • Fonctionnel : en abstrayant la logique des systèmes que l’on connecte,

Ce qui doit théoriquement nous permettre d’introduire un maximum de flexibilité et d’adaptabilité dans la couche d’intégration et ainsi de réaliser ce petit exercice de passe-passe connu sous le nom de « faire entrer un rond dans un carré ». Ce sont ces principes de découplage que l’on retrouve dans le pattern de Messaging.

 

Synchrone mais asynchrone : fromage ET dessert?

 

Oui mais voilà :

  • D’une part les services de messaging – dont Azure Service Bus – sont par nature asynchrones. C’est-à-dire que des systèmes y publient des messages ou événements, qui sont alors persistés jusqu’à ce que d’autres systèmes les consomment;
  • D’autre part leur fonctionnement se base sur des files d’attentes ou queues. Ainsi, même si l’on sait exactement quel message nous intéresse, il est possible (et même très souvent probable) que d’autres messages le précèdent dans la file d’attente;
  • Enfin ce sont généralement des composants passifs (à quelques exceptions près comme Azure EventGrid), c’est-à-dire qu’ils ne poussent pas les messages vers les consommateurs, mais ce sont les consommateurs qui doivent eux-mêmes venir voir si de nouveaux messages sont disponibles (aka : mécanisme de polling).

De prime abord, ces « contraintes » semblent incompatibles avec les exigences d’une API devant présenter des temps de réponses acceptables. C’est d’ailleurs dans ce genre de scenario que l’on peut être tenté d’être « pragmatique », ou de se dire que « le mieux est l’ennemi du bien », etc. Réflexions qui, bien que nécessaires dans une démarche de doute scientifique, sont trop souvent annonciatrices de l’introduction volontaire de dette technique (lire d’ailleurs à ce sujet l’excellent article de Mark Heath : Top 7 Reasons For Introducing Technical Debt) pour des raisons qui relèvent parfois du simple a priori.

Mais le mieux est-il réellement l’ennemi du bien? La bonne pratique de découplage dont je parlais plus haut est-elle inapplicable/incompatible dans le cas des APIs synchrones? Ou formulé autrement : est-on obligé de choisir entre synchrone et asynchrone?

Pour se fixer les idées, prenons un cas concret : imaginons une API de gestion des clients exposant une opération de création d’un client. Cette opération doit:

  • Cibler des backends différents (entre autres des CRMs) et appliquer des traitements différents en fonction du pays de rattachement du client,
  • Retourner certaines informations, comme l’éventuelle pré-existence du client, son golden ID, etc.

 

Comment appliquer les patterns d’intégration?

 

La théorie

Dans l’écosystème Azure, les scenarii de messaging peuvent être adressés par plusieurs composants, dont les principaux sont :

  • Service Bus
  • EventHub
  • EventGrid

Chacun a ses spécificités et ses cas d’usage de prédilection. La documentation de Microsoft sur le sujet est assez claire, aussi je vous conseille d’y jeter un œil si ce n’est pas déjà fait.

Si l’on reprend les deux responsabilités de l’API décrites plus haut :

  • Le premier point est une application du pattern Message Router: c’est un cas d’usage des Topics d’Azure Service Bus.
  • Le deuxième est une application du pattern Request-Reply: ce pattern peut être adressé grâce aux sessions dans Service Bus.

C’est donc Service Bus que je choisis ici comme colonne vertébrale de mon API. L’architecture technique est très simple :

  • Une Azure Function représentant l’opération de l’API, qui reçoit la requête de l’appelant et lui retourne sa réponse,
  • Un ou des Topics Service Bus, dans lesquels seront publiés les événements de création de client et les réponses,
  • Des functions s’abonnant sur les événements et publiant les réponses.

 

La pratique

Ci-dessous un schéma de principe :

 

Schéma de principe du pattern Request-Reply

 

Le fonctionnement est le suivant :

  • Une fonction représentant l’opération de l’API, reçoit la requête de l’appelant puis :
    • Elle publie dans un topic l’événement de création de client et:
      • Spécifie l’ID de session qu’elle va écouter pour la réponse dans la propriété ReplyToSessionId, et éventuellement l’entité Service bus dans laquelle elle s’attend à recevoir la réponse dans la propriété ReplyTo,
      • Indique le pays de rattachement du client dans une propriété custom du message;

Ci-dessous un exemple de code :

 

Message requestMessage = new Message(System.Text.Encoding.UTF8.GetBytes(requestBody));
requestMessage.ReplyTo = responseEntity;
requestMessage.ReplyToSessionId = requestID;
requestMessage.SessionId = requestID;
requestMessage.UserProperties.Add("EventType", "CreationRequested");
requestMessage.UserProperties.Add("CountryCode", "FRA");

 

    • Elle se met en écoute sur le topic de réponse (qui peut être le même) en s’abonnant sur la souscription correspondant aux réponses et en écoutant une session en particulier, grâce à l’instruction :

 

IMessageSession session = await rcvClient.AcceptMessageSessionAsync(requestID);
Message response = await session.ReceiveAsync(TimeSpan.FromSeconds(responseTimeout));

 

  • En fonction du code pays, l’événement est routé vers la bonne souscription;
  • Une autre fonction est abonnée à cette souscription et consomme donc l’événement;
  • Une fois ses traitements terminés, elle publie dans un topic la réponse en spécifiant en SessionID la valeur de la propriété ReplyToSessionId du message de création;

 

outputMessage.SessionId = inputMessage.ReplyToSessionId;

 

  • La première fonction consomme alors la réponse, qui peut alors éventuellement la retraiter avant de l’envoyer à l’appelant.

Et c’est tout! On peut bien évidemment enrichir le scenario en imaginant un chaînage des événements et des traitements, mais le principe fondamental reste celui décrit ci-dessus.

 

Le prix à payer

 

C’est bien joli sur le papier, mais quid des performances? Pour une API, le temps de réponse est l’un des nerfs de la guerre; la latence induite par le découplage au travers de Service Bus doit donc rester acceptable et maîtrisée : c’est-à-dire qu’elle peut varier, aussi bien dans le temps qu’en fonction de la charge.

J’ai effectué un certain nombre de tests de performance en me basant sur un simple namespace Service Bus Standard. Les besoins de mon API ne devant pas dépasser 50 requêtes/s, j’ai choisi de prendre quelques précautions : j’ai donc multiplié cette charge par 10 en testant cette mécanique sous 500 requêtes/s. Ci-dessous les résultats :

 

Distribution des temps de réponse

 

Les résultats montrent donc une forte cohérence des performances avec une distribution assez étroite, et une latence très bien contenue, n’excédant pas 200ms. Les tests que j’ai pu effectuer sur du Premium ne montrent pas de gain sur ces aspects, mais dans mon cas ces performances sont de toute manière tout à fait suffisantes.

ATTENTION : on pourrait se dire que Premium n’apportant pas vraiment d’avantage de performances, on peut se contenter d’un namespace Standard. Gardez en tête que le Standard n’est pas taillé pour la performance. Il a pour simple vocation de fournir des fonctionnalités Messaging as a Service. En effet il s’appuie sur des infrastructures partagées et le risque de phénomène de « voisin bruyant » est réel – même si Microsoft travaille actuellement à mettre en place des mécanismes de lissage des performances (qui passeront certainement par une forme de throttling). Autrement dit : aucune garantie que les performances décrites plus haut soient cohérentes dans la durée! Service Bus Premium, lui, est taillé pour la performance en s’appuyant notamment sur une infrastructure radicalement différente.

 

Conclusion

 

Mon propos n’est pas ici d’énoncer une règle absolue. Il convient à chacun de s’approprier ces éléments et d’étudier leur adéquation avec ses besoins. Néanmoins, je retire personnellement ces enseignements :

  • Service Bus permet de mettre en œuvre efficacement du découplage, même dans des scénario synchrones à faible latence;
  • Il est toujours vital de tester et valider ses hypothèses;
  • Les patterns de messaging restent plus que jamais valables.

Sur ce, je vous souhaite un bon messaging! 🙂