Fermer

décembre 15, 2022

Application du modèle CQRS dans une application ASP.NET Core

Application du modèle CQRS dans une application ASP.NET Core


CQRS est un modèle architectural bien connu qui peut résoudre de nombreux problèmes rencontrés dans des scénarios complexes. Découvrez dans cet article de blog comment ce modèle fonctionne et comment l’implémenter dans une application ASP.NET Core.

Il existe certaines situations où il est nécessaire de séparer les fonctions de lecture et d’écriture, principalement dans des scénarios complexes ou qui exigent une grande évolutivité des ressources. Imaginez un site de commerce électronique où les données sont lues plusieurs fois par un client pendant qu’il navigue sur le site, mais l’écriture ne commence qu’une fois que l’utilisateur a ajouté un article au panier. Dans ce scénario, la lecture nécessite beaucoup plus de ressources que l’écriture. Dans les modèles traditionnels, il est difficile de gérer cela de manière simple, car les fonctions de lecture et d’écriture sont traitées de la même manière.

Heureusement, il existe des solutions intelligentes qui résolvent ces problèmes et d’autres. L’un des plus connus est le modèle architectural CQRS. Découvrez dans cet article de blog ce qu’est CQRS et comment l’implémenter dans une application ASP.NET Core.

Qu’est-ce que le CQRS ?

CQRS signifie Command and Query Responsibility Segregation, un modèle architectural pour le développement de logiciels.

Dans CQRS, les opérations de lecture de données sont séparées des opérations d’écriture ou de mise à jour des données. Cette séparation se produit dans l’interface ou la classe où les fonctions de lecture et d’écriture sont conservées.

Certains des avantages de l’utilisation de CQRS sont :

  • Des équipes distinctes peuvent mettre en œuvre les opérations.
  • Les opérations d’écriture sont beaucoup moins utilisées que les opérations de lecture (comme ce site de commerce électronique où vous passez des heures à naviguer sur le site et en un instant vous mettez les articles dans le panier), il est donc possible d’adapter les ressources en fonction des besoins.
  • Chaque opération peut avoir sa propre sécurité selon les exigences.

Le terme Séparation des requêtes de commande (CQS), qui a donné naissance à CQRS, a été défini par Bertrand Meyer dans son livre Object-Oriented Software Construction. Dans celui-ci, deux couches bien définies sont séparées l’une de l’autre :

  • Requêtes: Les requêtes renvoient simplement un état et ne le modifient pas.
  • Commandes: Les commandes modifient uniquement l’état.

Alors CQRS (introduit par Greg Young) est basé sur CQS mais est plus détaillé.

Pourquoi utiliser CQRS ?

Il est courant de trouver dans les systèmes modernes et anciens des modèles architecturaux traditionnels qui utilisent le même modèle de données ou DTO pour interroger et conserver/mettre à jour les données. Lorsque le système n’utilise qu’un simple CRUDcela peut être une excellente approche, mais à mesure que le système se développe et devient complexe, cela peut devenir un véritable désastre.

Dans ces scénarios, la lecture et l’écriture présentent des incompatibilités, telles que des propriétés qui doivent être mises à jour mais ne doivent pas être renvoyées dans les requêtes. Cette différence peut entraîner une perte de données et, au mieux, casser la conception architecturale de l’application.

Par conséquent, l’objectif principal de CQRS est de permettre à une application de fonctionner correctement en utilisant différents modèles de données, offrant une flexibilité dans les scénarios qui nécessitent un modèle complexe. Vous avez la possibilité de créer plusieurs DTO sans rompre aucun modèle architectural ni perdre de données au cours du processus.

Application de CQRS dans une application .NET

Ensuite, nous allons implémenter CQRS dans une application .NET. Donc, pour créer le projet, suivez simplement les étapes suivantes.

Création de l’application

Conditions préalables:
– SDK .NET 6
– Fiddler Everywhere (pour tester l’application)

Vous pouvez accéder au référentiel avec le code source utilisé dans les exemples ici : code source. (Remarque : ce message a été écrit avec .NET 6, mais
.NET 7 est maintenant disponible!)

Tout d’abord, créons l’application de base qui sera un API minimalesajoutez les dépendances SQLite et exécutez les commandes Migrations pour créer la base de données.

Ensuite, suivez les étapes ci-dessous :

Dépendances du projet

Voici les dépendances du projet. Vous pouvez les ajouter dans le fichier « ProductCatalog.csproj » ou via NuGet.

<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.7" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.7">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>

Création de l’entité modèle

Créons le modèle d’entité Product. Ajoutez un nouveau dossier appelé « Modèles » et à l’intérieur, ajoutez la classe suivante :

Produit

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ProductCatalog.Models;
public class Product
{
    public int Id { get; set; }

    [StringLength(80, MinimumLength = 4)]
    public string? Name { get; set; }

    [StringLength(80, MinimumLength = 4)]
    public string? Description { get; set; }

    [StringLength(80, MinimumLength = 4)]
    public string? Category { get; set; }

    public bool Active { get; set; } = true;

    [Column(TypeName = "decimal(10,2)")]
    public decimal Price { get; set; }
}

Création du contexte de la base de données

Créons le contexte de la base de données. Ajoutez un nouveau dossier appelé « Data » et à l’intérieur, ajoutez la classe suivante :

ProductDBContextProductDBContext

using Microsoft.EntityFrameworkCore;
using ProductCatalog.Models;

namespace ProductCatalog.Data;
public class ProductDBContext : DbContext
{
    public DbSet<Product> Products { get; set; }
    protected override void OnConfiguring(DbContextOptionsBuilder options) =>
            options.UseSqlite("DataSource=products.db;Cache=Shared");
}

Et enfin, dans l’archive Program.cs, ajoutez le code suivant pour configurer la classe de contexte.

builder.Services.AddDbContext<ProductDBContext>();

Exécution des commandes EF Core

Vous pouvez exécuter les commandes ci-dessous dans un terminal racine du projet.

  • dotnet ef migrations add InitialModel
  • dotnet ef database update

Vous pouvez également exécuter les commandes suivantes à partir de la console du gestionnaire de packages dans Visual Studio :

  • Add-Migration InitialModel
  • Update-Database

Maintenant que l’application de base et la base de données sont prêtes, nous pouvons appliquer le modèle CQRS pour implémenter les méthodes CRUD, en séparant la requête de la persistance.

Mais pour aider à cette implémentation, il existe une fonctionnalité très importante appelée médiateur. Vérifiez ci-dessous ce que fait le médiateur.

Le modèle de médiateur

Le pattern mediator utilise un concept très simple qui remplit parfaitement son rôle : Fournir une classe mediator pour coordonner les interactions entre différents objets et ainsi réduire le couplage et la dépendance entre eux.

En bref, le médiateur établit un pont entre différents objets, ce qui élimine la dépendance entre eux car ils ne communiquent pas directement.

Le schéma ci-dessous montre le fonctionnement du médiateur, reliant indirectement les objets A, B et C.

Diagramme du médiateur indiquant que les objets A, B et C sont tous liés directement au médiateur et non les uns aux autres

Avantages:

  • Indépendance entre différents objets
  • Communication centralisée
  • Entretien facile

Les inconvénients:

  • Une plus grande complexité
  • Cela peut devenir un goulot d’étranglement dans une application s’il y a une grande quantité de données en cours de traitement

Implémentation du pattern Mediator avec MediatR

MédiatR est une bibliothèque créée par Jimmy Bogard (également créateur d’AutoMapper) qui aide à implémenter le modèle Mediator.

Cette bibliothèque fournit des interfaces prêtes à l’emploi qui servent de classe de médiation pour la communication entre les objets. Ainsi, lors de l’utilisation de MediatR, nous n’avons pas besoin d’implémenter l’une de ces classes, utilisez simplement les ressources disponibles dans MediatR.

Ajouter MédiatR au projet, ajoutez simplement le code ci-dessous aux dépendances du projet ou téléchargez-le via le package NuGet de Visual Studio.

   <PackageReference Include="MediatR" Version="10.0.1" />
    <PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />

Créez un nouveau dossier appelé « Ressources » et à l’intérieur, créez deux nouveaux dossiers « Commandes » et « Requêtes ».

Création des requêtes

Ensuite, CQRS est utilisé via l’implémentation du modèle de requête composé de deux objets :

  • Requête – Définit les objets à renvoyer.
  • Gestionnaire de requêtes – Responsable du retour des objets définis par la classe qui implémente le modèle de requête.

Obtenir le produit par identifiant

Dans le dossier Queries, créez la classe suivante :

GetProductByIdQuery

using MediatR;
using ProductCatalog.Models;

namespace ProductCatalog.Resources.Queries;
public class GetProductByIdQuery : IRequest<Product>
{
    public int Id { get; set; }
}

Ici, nous définissons une classe qui renvoie un objet Product. Par son intermédiaire, nous envoyons une requête au médiateur qui exécutera la requête.

La classe suivante exécutera la requête et renverra le produit, donc à l’intérieur du dossier Requêtes ajoute la classe ci-dessous :

GetProductByIdQueryHandler

using MediatR;
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
using ProductCatalog.Models;

namespace ProductCatalog.Resources.Queries;

public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, Product>
{
    private readonly ProductDBContext _context;
    public GetProductByIdQueryHandler(ProductDBContext context)
    {
        _context = context;
    }

    public async Task<Product> Handle(GetProductByIdQuery request, CancellationToken cancellationToken) =>
        await _context.Products.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
}

Obtenir tous les produits

Dans le dossier Queries, créez la classe ci-dessous :

GetAllProductsQuery

using MediatR;
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
using ProductCatalog.Models;

namespace ProductCatalog.Resources.Queries;
public class GetAllProductsQueryHandler : IRequestHandler<GetAllProductsQuery, IEnumerable<Product>>
{
    private readonly ProductDBContext _context;
    public GetAllProductsQueryHandler(ProductDBContext context)
    {
        _context = context;
    }

    public async Task<IEnumerable<Product>> Handle(GetAllProductsQuery request, CancellationToken cancellationToken) =>
        await _context.Products.ToListAsync();
}

Création des commandes

Ensuite, CQRS est utilisé via la mise en œuvre du modèle de commande composé de deux objets :

  • Commande – Définit quelles méthodes doivent être exécutées.
  • Gestionnaire de commandes – Responsable de l’exécution des méthodes définies par les classes Command.

Les commandes exécuteront les méthodes de persistance Créer/Mettre à jour/Supprimer.

Toutes les classes de commandes implémentent le IRequest<T> interface, où le type de données à retourner est spécifié. De cette façon, MediatR sait quels ver objet est invoqué lors d’une requête.

Ainsi, dans le dossier « Commands », créez un nouveau dossier appelé « Create » et à l’intérieur, créez les classes ci-dessous :

CréerCommandeProduit

using MediatR;
using ProductCatalog.Models;

namespace ProductCatalog.Resources.Commands.Create;
public class CreateProductCommand : IRequest<Product>
{
    public string? Name { get; set; }
    public string? Description { get; set; }
    public string? Category { get; set; }
    public bool Active { get; set; } = true;
    public decimal Price { get; set; }
}

CreateProductCommandHandler

using MediatR;
using ProductCatalog.Data;
using ProductCatalog.Models;

namespace ProductCatalog.Resources.Commands.Create;
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Product>
{
    private readonly ProductDBContext _dbContext;

    public CreateProductCommandHandler(ProductDBContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Product> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        var product = new Product
        {
            Name = request.Name,
            Description = request.Description,
            Category = request.Category,
            Price = request.Price,
        };

        _dbContext.Products.Add(product);
        await _dbContext.SaveChangesAsync();
        return product;
    }
}

Ensuite, créez un nouveau dossier appelé « Mise à jour » et à l’intérieur, créez les classes ci-dessous :

UpdateProductCommand

using MediatR;
using ProductCatalog.Models;

namespace ProductCatalog.Resources.Commands.Update;
public class UpdateProductCommand : IRequest<Product>
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public string? Description { get; set; }
    public string? Category { get; set; }
    public bool Active { get; set; } = true;
    public decimal Price { get; set; }
}

UpdateProductCommandHandlerUpdateProductCommandHandler

using MediatR;
using ProductCatalog.Data;
using ProductCatalog.Models;

namespace ProductCatalog.Resources.Commands.Update
{
    public class UpdateProductCommandHandler : IRequestHandler<UpdateProductCommand, Product>
    {
        private readonly ProductDBContext _dbContext;

        public UpdateProductCommandHandler(ProductDBContext dbContext)
        {
            _dbContext = dbContext;
        }

        public async Task<Product> Handle(UpdateProductCommand request, CancellationToken cancellationToken)
        {
            var product = _dbContext.Products.FirstOrDefault(p => p.Id == request.Id);

            if (product is null)
                return default;

            product.Name = request.Name;
            product.Description = request.Description;
            product.Category = request.Category;
            product.Price = request.Price;

            await _dbContext.SaveChangesAsync();
            return product;
        }
    }
}

Ensuite, créez un nouveau dossier appelé « Delete » et, à l’intérieur de celui-ci, créez les classes ci-dessous :

Supprimer la commande de produit

using MediatR;
using ProductCatalog.Models;

namespace ProductCatalog.Resources.Commands.Delete;
public class DeleteProductCommand : IRequest<Product>
{
    public int Id { get; set; }
}

SupprimerProductCommandHandler

using MediatR;
using ProductCatalog.Data;
using ProductCatalog.Models;

namespace ProductCatalog.Resources.Commands.Delete;
public class DeleteProductCommandHandler : IRequestHandler<DeleteProductCommand, Product>
{
    private readonly ProductDBContext _dbContext;

    public DeleteProductCommandHandler(ProductDBContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Product> Handle(DeleteProductCommand request, CancellationToken cancellationToken)
    {
        var product = _dbContext.Products.FirstOrDefault(p => p.Id == request.Id);

        if (product is null)
            return default;

        _dbContext.Remove(product);
        await _dbContext.SaveChangesAsync();
        return product;
    }
}

Configuration de la classe de programme

Dans l’archive Program.cs, ajoutez la ligne de code suivante :

builder.Services.AddMediatR(Assembly.GetExecutingAssembly());

Ajout des points de terminaison

Toujours dans l’archive Program.cs, ajoutez le code suivant pour configurer les points de terminaison de l’API :


app.MapGet("product/get-all", async (IMediator _mediator) =>
{
    try
    {
        var command = new GetAllProductsQuery();
        var response = await _mediator.Send(command);
        return response is not null ? Results.Ok(response) : Results.NotFound();
    }
    catch (Exception ex)
    {
        return Results.BadRequest(ex.Message);
    }
});

app.MapGet("product/get-by-id", async (IMediator _mediator, int id) =>
{
    try
    {
        var command = new GetProductByIdQuery() { Id = id };
        var response = await _mediator.Send(command);
        return response is not null ? Results.Ok(response) : Results.NotFound();
    }
    catch (Exception ex)
    {
        return Results.BadRequest(ex.Message);
    }
});

app.MapPost("product/create", async (IMediator _mediator, Product product) =>
{
    try
    {
        var command = new CreateProductCommand()
        {
            Name = product.Name,
            Description = product.Description,
            Category = product.Category,
            Price = product.Price,
            Active = product.Active,
        };
        var response = await _mediator.Send(command);
        return response is not null ? Results.Ok(response) : Results.NotFound();
    }
    catch (Exception ex)
    {
        return Results.BadRequest(ex.Message);
    }
});


app.MapPut("product/update", async (IMediator _mediator, Product product) =>
{
    try
    {
        var command = new UpdateProductCommand()
        {
            Id = product.Id,
            Name = product.Name,
            Description = product.Description,
            Category = product.Category,
            Price = product.Price,
            Active = product.Active,
        };
        var response = await _mediator.Send(command);
        return response is not null ? Results.Ok(response) : Results.NotFound();
    }
    catch (Exception ex)
    {
        return Results.BadRequest(ex.Message);
    }
});

app.MapDelete("product/delete", async (IMediator _mediator, int id) =>
{
    try
    {
        var command = new DeleteProductCommand() { Id = id };
        var response = await _mediator.Send(command);
        return response is not null ? Results.Ok(response) : Results.NotFound();
    }
    catch (Exception ex)
    {
        return Results.BadRequest(ex.Message);
    }
});

Exécution du projet avec Fiddler Everywhere

Le GIF ci-dessous montre l’exécution du projet via Violoniste partout, un proxy de débogage Web sécurisé pour n’importe quelle plate-forme. Vous pouvez voir certaines fonctions CRUD s’exécuter parfaitement comme prévu.

L'exécution du projet

Conclusion

CQRS est une norme de développement qui apporte de nombreux avantages, tels que la possibilité pour des équipes distinctes de travailler sur la couche de lecture et de persistance et également de pouvoir faire évoluer les ressources de la base de données selon les besoins.

Comprendre comment fonctionne CQRS et comment l’implémenter dans une application vous permet de très bien faire lorsque le besoin se fait sentir de l’utiliser dans un projet.




Source link

décembre 15, 2022