Intermédiaire

Retour d’expérience CQRS

Dans notre lutte contre la complexité des applications, la conception DDD est une arme efficace ; cependant elle n’est pas utile pour la modélisation des données en base. Pour cela CQRS est un bon second couteau. Je vous propose ici un retour d’expérience avec un cas pratique d’implémentation de ce modèle d’architecture.

CQRS

Commande Query Responsability Segregation est un modèle qui prône la séparation des Queries et des Commands. La Query assure la remontée des données à l’utilisateur. La Commande, elle, permet la modification des données sans retourner de résultat.

Le contexte

L’approche classique de gestion des données en base se fait via des opérations CRUD (create, read, update, delete). Les interactions de lecture et d’écriture utilisent les mêmes modèles de données. La lecture et l’écriture en base d’un model se fait via le même repository. Cependant, les contraintes en lecture et en écriture sont souvent différentes.

Une grande partie des requêtes sont des demandes en lecture. On cherche plus souvent à lire ou visualiser des données qu’à enregistrer ou modifier ces dernières. Ces demandes en lecture doivent, le plus souvent, répondre à des contraintes de performance car le demandeur veut avoir accès aux données rapidement. De plus, on touche plusieurs éléments du model lors d’une lecture ; les données sont agrégées depuis plusieurs tables comme par exemple lorsque l’on affiche un écran “Mon compte” sur un site d’e-Commerce : on y retrouve les données du client, mais aussi ses dernières factures, ses listes d’envies et ses préférences.

D’un autre côté, lors de l’écriture on cherche le plus souvent à faire des enregistrements unitaires ou des transactions simples pour ne pas bloquer la base de données, et l’on évite également les modifications en cascade qui sont source d’erreurs. L’écriture des données peut aussi être décalée si elles ne sont pas nécessaires immédiatement.

Afin de solutionner au mieux ces différentes contraintes, CQRS propose de séparer la lecture et l’écriture des données via les Queries et les Commands. Cela apporte des avantages et des inconvénients :

Les avantages

  • La scalabilité : En séparant la lecture de l’écriture des données, il est possible d’optimiser indépendamment ces deux aspects de l’application. On peut par exemple mettre en place une base de données dédiée et optimisée pour la lecture des données.
  • La flexibilité : Chaque opération étant faite dans son propre contexte, elle peut être modifiée sans incidence sur les autres.
  • La sécurité : La gestion des permissions et de la sécurité peut être simplifiée.

Les inconvénients

  • La complexité : Le model CQRS augmente la complexité du code. Comme pour le DDD, CQRS tire donc son avantage dans des applications complexes ou exigeantes.
  • La cohérence : Il faut assurer la cohérence des données entre le Read et le Write Model.

Dans la suite, nous nous concentrerons sur la mise en place d’un cas pratique. Pour un complément d’information, vous trouverez plus de lecture à ce sujet dans les articles suivants :

Cas pratique

Le cas pratique ci-dessous va décrire une vue de gestion de foyer fiscal. Cette vue est une liste affichant le foyer déjà sauvegardé et un bouton permettant de créer un nouveau foyer et de le sauvegarder. Nous verrons ainsi comment mettre en place CQRS avec un exemple simple de commande et de query. La vue contient deux méthodes :
– ChargerListeFoyer() : Qui affiche la liste des foyers en base
– CreerFoyer() : Qui crée un nouveau foyer

Vous trouverez ici le projet complet

Mise en place d’une Query

Commençons par la méthode ChargerListeFoyer(). Elle permet de charger la liste des Foyers présents en base.
En MVVM ou MVC, on aura une méthode ressemblant à celle-ci :


public void ChargerListeFoyer()
{
    //vérifications diverses

    //récupération des foyers
    var foyers = _foyerRepository.GetAll();
            
    //Autres actions

    //Affichage
    foreach (Foyer foyer in foyers)
    {
        this.ListeDeFoyer.Add(foyer);
    }
}

La méthode présentée ici est simple. Mais comme on l’a vu plus haut, CQRS tire son avantage dans des situations complexes. Que se passe-t-il si de nombreuses vérifications doivent être faites avant de récupérer la donnée (validation des droits utilisateur) ? Ou si la donnée vient de plusieurs sources et qu’elle doit être mise en forme ? Toutes ces actions à implémenter risquent d’alourdir et de multiplier les responsabilités du ViewModel. Dans le modèle CQRS, on segmente le code ; le chargement des données se fait via une Query :


public void ChargerListeFoyer()
{
    ChargerListeFoyerQuery chargerListeFoyerQuerry = new ChargerListeFoyerQuery();
    this.NewTask(() =>
        {
            ChargerListeFoyerDTO result =
                _queryProcessor.Process(chargerListeFoyerQuerry);
                    
            return result;
        },
        (result) =>
        {
            this.ListeDeFoyer.Clear();                    
            foreach (var f in result.ListeDeFoyer)
            {
                this.ListeDeFoyer.Add(new FoyerListeModel() {Id = f.Item1, Nom = f.Item2});
            }                                        
        },
        (ex) => { throw ex; }
    );
}

Détaillons le fonctionnement

On a créé un objet ChargerListeFoyerQuery. L’objet Query définit un contexte d’exécution de la Query. Il est ici instancié via un constructeur vide car nous n’avons pas de paramètres (notre cas est simple). Néanmoins on pourrait par exemple lui transmettre l’id de l’utilisateur pour filtrer les résultats par utilisateur.
NewTask est une méthode permettant d’exécuter une tâche asynchrone et d’appeler un callback lorsqu’elle est terminée. Grâce à cette méthode, on va pouvoir lancer l’exécution de la Query sans bloquer l’utilisateur, puis charger sa réponse dans l’interface.
ChargerListeFoterDTO est le résultat de la query. Les données y sont préformatées pour l’affichage. Dans l’exemple on remplit la liste des foyers à afficher grâce aux données du résultat de la Query. Ici on crée directement les objets UI. On peut aussi mettre en place un automapper pour des modèles plus complexes.
C’est le QueryProcessor qui va lancer le travail via sa méthode Process :


public TResult Process <TQuery, TResult>(TQuery query) where TQuery : IQuery<TResult>
{
    IQueryHandler<TQuery, TResult> handler = null;            

    // Recherche de l'handler associé à la query.                       
    handler = DependencyFactory.Resolve<IQueryHandler<TQuery, TResult>>();

    // On lève une erreur si aucun handler a été trouvé.                           
    if (handler == null)
    {
        throw new Exception("Handler non trouvé");
    }

    // On appele le handler et on retourne le résultat de la commande.
    return handler.Handle(query);
}

Pour chaque Query qui implémente l’interface IQuery, le QueryProcessor va trouver le QueryHandler qui lui est associé et lancer sa méthode Handle. C’est cette dernière qui va centraliser le travail et retourner un résultat. La classe DependancyFactory gère un conteneur IOC qui enregistre tous les QueryHandler. Chaque QueryHandler implémente l’interface IQueryHandler avec le type de Query auquel il répond. Dans notre cas :


public class ChargerListeFoyerQueryHandler : IQueryHandler<ChargerListeFoyerQuery, ChargerListeFoyerDTO>
{
    #region Propriete
    private IFoyerReadRepository _foyerRepository;
    #endregion

    #region Constructeur
    public ChargerListeFoyerQueryHandler(IFoyerReadRepository foyerRepository)
    {
        _foyerRepository = foyerRepository;
    }
    #endregion

    public ChargerListeFoyerDTO Handle(ChargerListeFoyerQuery query)
    {
        ChargerListeFoyerDTO result = new ChargerListeFoyerDTO();
        var foyers = _foyerRepository.GetAll()
            .Select(f => new Tuple<Guid, string>(f.Id, f.Nom))
            .ToList();
        result.ListeDeFoyer = foyers;            
        return result;
    }
}

Le QueryHandler est une classe dédiée à la récupération et au formatage des données de la Query. Il retourne un QueryResult ou un DTO qui n’est pas forcément le reflet de la structure des données présente en base dans notre cas.

Mise en place d’une commande

Intéressons-nous maintenant à la méthode CreerFoyer(). Cette méthode va modifier les données en base en ajoutant un nouveau Foyer fiscal. Voici un exemple d’implémentation.


        public void CreerFoyer()
        {
            if (!String.IsNullOrWhiteSpace(this.NomNouveaufoyer))
            {
                Foyer foyer = new Foyer(this.NomNouveaufoyer);
                _foyerRepository.Add(foyer);
                this.ListeDeFoyer.Add(foyer);
            }
        }

Cette méthode est pour le moment dans le ViewModel. On constate alors que toute la logique métier est dans le ViewModel, ce qui n’est pas sa vocation. Pour répondre à cette problématique, nous allons mettre en place une Command.


public void CreerFoyer()
{
    if (!String.IsNullOrWhiteSpace(this.NomNouveaufoyer))
    {
        CreerFoyerCommand creerfoyerCommand = new CreerFoyerCommand() {NomFoyer = this.NomNouveaufoyer};
        this.NewTask(() =>
        {
            _commandProcessor.Process<CreerFoyerCommand>(creerfoyerCommand);                    
        },
            () =>
            {
                ChargerListeFoyer();
            },
            (ex) => { throw ex; });
    }
}        

De la même manière que pour la Query, chaque Command implémente l’interface ICommand, et est gérée par un CommandProcessor qui va faire appel à un CommandHandler gérant la commande.


public class CreerFoyerCommandHandler : ICommandHandler<CreerFoyerCommand>
{
    private IFoyerWriteRepository _foyerWriteRepository;

    public CreerFoyerCommandHandler(IFoyerWriteRepository foyerWriteRepository)
    {
        _foyerWriteRepository = foyerWriteRepository;
    }

    public void Handle(CreerFoyerCommand command)
    {
        //Traitement du code métier ( vérification de droit ... )            
        Foyer foyer = new Foyer(command.NomFoyer);
        _foyerWriteRepository.Add(foyer);            
        //Lancement d'event...
    }
}

On utilise cette fois un WriteRepository qui est distinct du repository “Read” de la Query. C’est grâce à cela que l’on fait la séparation des modèles “Read” et “Write”. On voit également que la commande ne retourne pas de résultat. Cependant, elle peut lancer un ou plusieurs DomainEvent à la fin de son traitement. À charge ensuite d’assurer la cohérence entre ses 2 modèles.
À la fin de l’exécution Command, on recharge l’interface en appelant la méthode ChargerListeFoyer() vue précédemment.

Conclusion

CQRS est un modèle assez simple au premier abord. Sa mise en place n’est pas excessivement complexe et permet une bonne séparation des couches de l’application. Il n’est pourtant pas toujours facile de le faire accepter par des équipes de développement qui n’en voient pas tout de suite l’intérêt. La plus grande difficulté de ce modèle vient de l’organisation que l’on met en place ensuite (gestion de la cohérence des données). Il demande une certaine rigueur en continu, notamment sur le nommage des Query et Command qui doit être explicite.

Nombre de vue : 556

AJOUTER UN COMMENTAIRE