Design Pattern: Chain of Responsibility

Design Pattern: Chain of Responsibility

Premier article, d'une longue série je l'espère, sur les design pattern ! Que d'émotions.

Mais qu'est-ce qu'un design pattern ?

Les design patterns nous permettent de structurer notre code au mieux, de le rendre réutilisable, maintenable, mais également d'offrir un langage commun aux développeurs en mettant un nom sur une solution à un problème donné. Mais de quoi s'agit-il véritablement ? En français, on les appelle "Patron de conception". Ils nous permettent de développer des solutions à des problèmes récurrents de conception logicielle, en suivant un schéma adaptable à nos besoins. On dit souvent qu'il n'est pas nécessaire de réinventer la roue, c'est d'autant plus vrai en conception logicielle.

De nombreux développeurs ont consacré du temps à résoudre les problématiques couramment rencontrées dans notre quotidien. Certains d'entre eux se sont même réunis pour écrire un livre qui, aujourd'hui encore, est une référence en matière de design pattern: Design Patterns: Element of Reusable Object-Oriented Software (1994). Ce collectif, connu sous le nom de Gang of Four (GoF pour les intimes) nous a donc pondu un recueil de 23 design pattern pour des problématiques que l'on peut rencontrer dans la conception logicielle et plus globalement en programmation objet.

Gang of Four Design Patterns - Spring Framework Guru

Bien qu'il existe 23 design pattern dans ce recueil, ce ne sont pas les seuls qui existent à ce jour. De nouveaux patterns ont été créés ou découvert depuis 🕵️

Ces patterns sont classés en trois grandes catégories :

  1. Créationnels : Comme son nom l'indique, ces patterns s'attardent sur la création des objets. Ils permettent d'en simplifier le processus et d'augmenter la réutilisabilité du code. On y retrouve par exemple : le Singleton, la Factory ou le Builder.

  2. Structurels : Ceux-là s'intéressent à la composition et l'agencement de nos classes. Ils nous permettent d'assembler nos objets pour former des structures plus grosses, tout en gardant une certaine flexibilité. On peut avoir, par exemple : l'Adapter, le Decorator ou le Proxy.

  3. Comportementaux : Ces derniers sont relatifs aux interactions entre objets et à des algorithmes. Comme par exemple : l'Observer, la Strategy, la Chain of Responsibility (tiens, c'est pas le sujet de ce billet ça ?).

Que vous les utilisiez consciemment ou non, ils sont partout. J'en prends pour exemple notre petit Framework favori qu'est .NET. On retrouve l'implémentation de nombreux design patterns qui facilitent le développement de nos applications. Nous aurons l'occasion dans les futurs articles de faire le parallèle avec les design pattern qui existent dans notre framework. Pour l'heure recentrons nous sur le pattern Chain of Responsibility.

Pipeline de middleware ASP.NET

Revenons au pattern qui nous intéresse dans ce billet : Chain of Responsibility ou chaîne de responsabilité dans la langue de Jul.

Le pipeline de middleware d'ASP.NET est un bel exemple d'implémentation du pattern Chain of Responsibility dans notre framework .NET. Pour rappel, ce pipeline permet de traiter l'ensemble des requests et responses HTTP de notre application.

Traitement séquentiel

Ces flux de requests sont traités séquentiellement par nos middleware au sein du pipeline (qui correspond donc à notre chain of responsibility). Chaque middleware reçoit la request, effectue un traitement (ou non, selon des conditions que vous pouvez définir), passe la request au middleware suivant ou bien interrompt le traitement en n'appelant pas le middleware suivant par exemple.

Découplage

Les middleware sont découplés les uns des autres, se spécialisant chacun sur un aspect spécifique du traitement des requêtes. Autre point important, chaque middleware n'a connaissance que de la request et ne connait aucun middleware dans la chaîne.

Flexibilité

On peut aisément ajouter, supprimer ou changer l'ordre des middleware dans la chaîne, offrant ainsi une flexibilité dans la manière dont les requêtes sont traitées.


On configure en général ce pipeline dans nos Program.cs en faisant bien attention à l'ordre dans lequel on déclare nos middleware, puisqu'il définira sa position dans la chaine et donc l'ordre dans lequel notre request sera traitée:

// Behind this, it is an implementation of chain of responsibility pattern
app.UseErrorHandler(); 
app.UseAuthentication();
app.UseAuthorization();
app.UseCustomMiddleWare();

Dans l'exemple ci-dessus, le middleware ErrorHandler étant déclaré le plus haut, il englobera les appels aux autres middleware. Ainsi, si une erreur survient dans la chaine, elle sera catchée par notre middleware et traitée comme on l'attend.

L'authentification et l'authorization quant à elle, passeront au middleware suivant si l'utilisateur courant est bien authentifié et autorisé à faire une request à notre application. Dans le cas contraire, le traitement sera arrêté et on n'ira pas plus loin dans la chaîne.

Comme autre exemple, on peut aussi citer le pipeline behavior de MediatR (une implémentation du pattern Mediator qu'on aura l'occasion de découvrir dans un futur billet). Ce pipeline behavior permet d'ajouter une chaîne autour de notre handler mediator. En général, on utilise ce pipeline behavior pour intércepter la commande, ajouter un LoggingBehavior, qui permet de logguer avant et après l'exécution de notre commande ou le DataValidationBehavior pour vérifier que nos requests d'entrée sont conformes à ce qui est attendu.

On peut également citer les systèmes de log. Lorsqu'on log, on veut pouvoir gérer la criticité de notre message de log et éventuellement agir de manière différente en fonction du type de message (info, warning, error). En utilisant la Chain of Responsibility, chaque handler peut traiter un certain niveau de log et, si nécessaire, passer le message au handler suivant.

On verra dans un exemple plus bas un cas de validation de données également.

Un pattern pour les amener tous

La Chain of Responsibility a des caractéristiques uniques qui la distinguent des autres design patterns, mais elle peut aussi être combinée efficacement avec eux. Et bien oui, personne ne vous interdit de combiner les patterns, pour tirer parti des bénéfices de chacun. 🙃

Vous pourriez par exemple:

  • La combiner avec la Strategy. Un handler de votre chaîne peut très bien implémenter ce dernier pour determiner l'algorithme à utiliser pour un traitement particulier.

  • Ou avec l'Observer, pour notifier automatiquement vos observateurs d'un évènement en particulier au cours de votre chaîne de traitement.

  • Le pattern Command peut permettre d'effectuer une opération différente sur la request.

Et il est possible d'en utiliser bien d'autres encore. On peut aisément exploiter la capacité de la Chain of Responsibility pour passer d'un handler à un autre, couplée avec d'autre pattern pour répondre à nos besoins spécifique.

Implémentation en c dièze

Passons maintenant à l'implémentation de ce pattern en C#. Comme évoqué précédemment, nous créerons un scénario de validation de données où chaque étape est gérée par un handler distinct. Notez cependant qu'il s'agit ici d'un moyen d'implémenter ce pattern et non pas LA seule solution pour l'implémenter. Les nom des classes, interfaces ou méthode peut très bien être différent de ce que vous verrez dans cet article, ce qui importe c'est l'interaction entre nos handlers.

On peut également ajouter une classe processor par exemple qui sera en charge de créer et ordonner nos handlers. Dans le cas présent, nous irons au plus simple et on fera tout cela directement dans le Program.cs.

💡
Imaginons un scénario où nous avons besoin de valider des données complexes, passant par différentes étapes de vérification. Chaque étape peut potentiellement rejeter les données ou nécessiter une modification avant de passer à l'étape suivante.

Structure de notre pipeline

Voici les étapes que l'on souhaite avoir pour traiter notre donnée d'entrée:

  1. Validation Initiale : On vérifie ici la conformité basique des données, à savoir que notre contexte n'est pas nul et que la Data est bien présente.

  2. Validation de Sécurité : Pour des raisons de sécurité, notre Data doit respecter certaine règle, notamment avoir une longueur strictement supérieur à 10 caractères.

  3. Validation Métier : Notre request doit être valide d'un point de vue métier.

  4. Enrichissement des Données : Enfin, lorsque l'on passe l'ensemble de nos validations, on doit attribuer un code signifiant que notre request a bien passé le process.

Créons donc un objet de type ValidationContext qui sera notre request que l'on souhaite traiter au travers de notre chaîne. Ici rien de compliquer, pour les besoins de l'article. Nous partons simplement sur une donnée Data de type string et une variable IsValid de type bool. Rien de bien transcendant, mais rien ne vous empêche de manipuler des request plus complexe et plus riche.

public record ValidationContext(string Data, bool IsValid);

Nous définissons ensuite une interface pour nos handlers. Le code est ici très simple.

  • Une méthode SetNext qui permet de définir le prochain handler dans la chaîne et nous le renvoie. Cela nous permettra de chaîner plus facilement nos handlers dans l'ordre qui nous intéresse.

  • Et une méthode Handle qui permet de traiter notre request de type ValidationContext.

public interface IHandler
{
    IHandler SetNext(IHandler nextHandler);
    void Handle(ValidationContext context);
}

Nous ajoutons également une classe de base abstraite qui implémente l'interface IValidationHandler et qui permettra de mutualiser le code redondant.

Ici, la méthode SetNext sera la même pour l'ensemble des handlers, aucune valeur ajoutée à la dupliquer dans l'ensemble des handlers. La méthode Handle sera elle abstract pour qu'elle puisse être override par les classes filles et implémenter le traitement que l'on souhaite dans chaque handler.

Notre propriété NextHandler est naturellement nullable car nous ne souhaitons pas nécessairement avoir de successeur pour nos handlers. Il nous faudra donc vérifier qu'un successeur est défini avant de faire appel à sa méthode Handle.

public abstract class BaseHandler : IHandler
{
    protected IHandler? NextHandler;

    public IHandler SetNext(IHandler nextHandler)
    {
        NextHandler = nextHandler;

        return NextHandler;
    }

    public abstract void Handle(ValidationContext context);
}

Concernant les implémentations détaillées de nos handlers, je vais en présenter une seule dans cet article. Cependant, elles suivront toutes une structure similaire. Pour ceux qui sont intéressés par une exploration plus approfondie, l'intégralité du code sera accessible sur GitHub en suivant ce lien: Chain of Responsibility.

public class InitialValidationHandler : BaseHandler
{
    public void Handle(ValidationContext context)
    {
        // Initial validation rules
        if (context is null)
            throw new ArgumentNullException(nameof(context), "Context is null");

        if (context.Data is null)
            throw new ArgumentException("Data is null");

        Console.WriteLine("1 - Initial validation passed");

        // Ensure that NextHandler is not null before calling Handle
        NextHandler?.Handle(context);

        Console.WriteLine("1 - Initial validation finished");
    }
}

Pour les besoins de l'exemple, j'ai ajouté dans les logs l'ordre de chaque handler pour comprendre un peu mieux quand ils sont appelés lors de l'exécution.

Dernière étape, configurer notre Program.cs pour initialiser notre context et lancer notre Chain of Responsibility:

// Create our handlers
var initialValidator = new InitialValidationHandler();
var securityValidator = new SecurityValidationHandler();
var businessValidator = new BusinessValidationHandler();
var enricher = new DataEnrichmentHandler();

// This determine the execution order of our handlers
initialValidator
    .SetNext(securityValidator)
    .SetNext(businessValidator)
    .SetNext(enricher);

// This is the data we want to validate
var validData = new ValidationContext("Some data", IsValid: true);
var invalidData = new ValidationContext("Some data", IsValid: false);

// This will execute the chain of handlers
try
{
    Console.WriteLine("Process valid data");
    initialValidator.Handle(validData);

    Console.WriteLine("Process invalid data");
    initialValidator.Handle(invalidData);
}
catch(Exception e)
{
    Console.WriteLine(e.Message);
}

Lorsqu'on exécute notre programme et que l'on observe la console, nous obtenons les logs suivantes pour le traitement de notre validData:

Process valid data
1 - Initial validation passed
2 - Security validation passed
3 - Business validation passed
4 - Data enrichment passed
4 - Data enrichment finished
3 - Business validation finished
2 - Security validation finished
1 - Initial validation finished

L'observation des logs nous permet de comprendre l'importance de l'ordre dans lequel nos handlers sont déclarés.

Dans le cas d'invalidData, où nous avons une erreur lors d'un traitement dans la chaîne, nous obtenons la sortie suivante:

Process invalid data
1 - Initial validation passed
2 - Security validation passed
Business validation failed, context is not valid

Voilà, rien de plus pour implémenter ce pattern. Comme vous le voyez, il n'y a rien de bien compliqué et les possibilités sont presques infinies. N'hésitez pas à partager en commentaire vos use case d'utilisation de ce pattern, je serai ravi d'échanger à ce sujet.

Pour aller plus loin

Pour aller plus loin, je vous recommande de mettre la main sur le livre du GoF qui reste, aujourd'hui encore, d'actualité et une excellente source d'information. Il regroupe les 23 design pattern avec une série d'exemple et d'explication sur chacun de ces patterns.

Vous pouvez aussi vous rendre sur Refactoring Guru, un super aide mémoire pour les design pattern avec des exemples dans plusieurs langages de programmation et dans plusieurs langue également (FR disponible pour les allergiques à l'anglais).

Et pour ceux qui préférent plutôt les supports vidéos, n'hésitez pas à faire un tour sur la chaîne YouTube de Christophe Mommer qui propose beaucoup de contenu .NET de qualité en français et notamment une série sur les Design Pattern en C#.

Conclusion

Pour conclure ce premier article de notre série sur les design patterns, nous avons vu comment le pattern Chain of Responsibility est utilisé dans .NET, permettant un traitement flexible et découplé des requêtes HTTP. Nous avons également passé en revue, au travers d'un exemple volontairement simplifié, UNE implémentation de ce pattern from scratch.

Ce pattern, ainsi que les autres patterns du Gang of Four, sont donc des outils puissants pour nous aider dans la conception de nos solutions. Ils permettant de résoudre des problèmes complexes de manière élégante et efficace.

Dans les prochains articles, nous continuerons d'explorer d'autres patterns, en mettant en lumière leur utilité et leur application dans le monde réel.

En attendant, n'hésitez pas à partager en commentaire ou sur LinkedIn le prochain pattern ou sujet que vous voulez voir abordé dans cette série.

Did you find this article valuable?

Support Yassine FERNANE by becoming a sponsor. Any amount is appreciated!