Skip to content

Déplacer la validation du controleur au service

Dans cet article, je vais vous présenter la manière dont j’ai réussi à implémenter la validation au niveau de mes services, et que ça reste transparent au niveau du front-end.

Le dépôt complet du projet terminé est disponible sur mon github à l’adresse suivante : https://github.com/Sakuto/ValidationService

Besoins

Récemment, je me suis intéressé de près à l’architecture en Onion et notamment à sa découpe en couche. Il va de soi que cette nouvelle architecture m’a fait repenser un grand nombre de mes habitudes de développement.

Un des gros changements encore bloquant sur lequel je me suis penché recemment, est le déplacement de la validation des ViewModels, de ma couche de présentation, vers ma couche service.

Ca a plusieurs avantages tels que :

  • Garder une séparation claire des responsabilités
  • Conserver un couplage faible dans l’application
  • Permettre de changer très facilement le front-end

Cependant, je souhaitais tout de même conserver tous les avantages que m’apporte Razor au niveau de la gestion des erreurs de leur affichage dans un formulaire via les tags helper. Le besoin était donc de récupérer toutes les erreurs de validation transmise par ma couche service, et de les afficher dans mon ModelState comme si c’était Razor qui avait effectué le contrôle.

Solution

Je vais tenter de découper la résolution de ce problème en plusieurs grandes étapes :

Renvoyer les problèmes de validation au niveau du service

La première étape est de trouver un moyen de remonter les erreurs de ma couche service vers ma couche application. Je suis donc passé par une exception que je lance depuis mon service. Cette exception prend en paramètre un dictionnaire contenant :

  • Comme clé,  le nom du champ ayant une erreur
  • Comme valeur, le message d’erreur expliquant le souci.
public class ValidationException : Exception
{
    public IDictionary<string, string> ValidationErrors { get; set; }

    public ValidationException(IDictionary<string, string> validationErrors)
    {
        ValidationErrors = validationErrors;
    }
}
public void CreateArticle(CreateArticle command)
{
    var validationDictionary = new Dictionary<string, string>();

    if (string.IsNullOrEmpty(command.Name))
    {
        validationDictionary.Add(nameof(command.Name), "Le nom doit être rempli");
    }

    if (string.IsNullOrEmpty(command.Description))
    {
        validationDictionary.Add(nameof(command.Name), "La description doit être remplie");
    }

    if (validationDictionary.Count > 0)
    {
        throw new ValidationException(validationDictionary);
    }

    // chemin normal, appel du repository, ...
    // _repository.Add(...)
}
[HttpPost]
public IActionResult Create(Create model)
{
    var command = model.ToCommand();

    try
    {
        _articleService.CreateArticle(command);
    }
    catch (ValidationException e)
    {
        foreach (var validationError in e.ValidationErrors)
        {
            ModelState.AddModelError(validationError.Key, validationError.Value);
        }

        return View(model);
    }

    return RedirectToAction("Index");
}

Pas très élégant n’est-ce pas ? Et si l’on tentait d’améliorer tout ça ?

Traitement automatique de l’exception

On va commencer par s’attaquer à la forte redondance qui va s’installer dès que j’aurai plusieurs méthodes. En effet on peut aisément constater que toute la logique liée à l’ajout des erreurs du service à mon ModelState sera répliquée dans chaque action. Pour ce faire, j’ai décidé de passer par une des « nouveautés » que nous propose ASP.NET Core : les middlewares.

Dans la suite de cet article, je vais utiliser le design pattern « Post-Redirect-Get », en voici une courte définition. Je vous invite cependant à vous renseigner plus amplement dessus si vous ne souhaitez pas être perturbé 🙂

Post-redirect-get ou post/redirect/get (PRG), aussi nommé redirect after post, est un patron de conception très répandu dans la programmation web. Il permet de résoudre une partie des problèmes de soumissions multiples d’un formulaire (en), ainsi que de moins perturber le fonctionnement des marque-pages et de la commande « page précédente » des navigateurs web.

https://fr.wikipedia.org/wiki/Post-redirect-get

Récupération des erreurs de validations

Le seul et unique but de ce middleware est récupèrer les différentes erreurs dans le cas où une exception serait levée lors de la requête. Voici l’implémentation que j’ai choisie :

public class ValidationMiddleware : IMiddleware
{
    private readonly ITempDataProvider _tempDataProvider;

    public ValidationMiddleware(ITempDataProvider tempDataProvider)
    {
        _tempDataProvider = tempDataProvider;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (ValidationException ex)
        {
            _tempDataProvider.SaveTempData(context, new Dictionary<string, object>
            {
                {ValidationConstants.ModelState, ex.ValidationErrors },
            });

            context.Response.Redirect(context.Request.Path);
        }
    }
}

En parcourant ce code, on se rend compte qu’il se contente de :

  • Exécuter la requête
  • Capturer l’exception s’il y en a une
  • Stocker les différentes erreurs dans un TempData
  • Rediriger vers la même action en GET

Ce middleware nous permet de nous absoudre du bloc try catch du contrôleur. Il reste cependant deux problèmes :

  • On ne récupère pas les valeurs entrées par l’utilisateur lors du premier envoi du formulaire, le forcant à tout retaper.
  • On ne transmet jamais ces valeurs à la vue.

On peut donc conclure que ce middleware nous évite la gestion manuelle dans chaque contrôleur mais n’est tout de même pas la solution parfaite à nos problèmes, tentons donc d’aller plus loin.

Récupération des données du formulaire entrée

Commençons par régler le premier problème, pour ce faire nous allons tout simplement récupérer les valeurs du formulaire initial envoyé, et les stocker, eux aussi, en mémoire temporaire.

_tempDataProvider.SaveTempData(context, new Dictionary<string, object>
{
    {ValidationConstants.ModelState, ex.ValidationErrors },
    {ValidationConstants.ViewModel, context.Request.Form.ToDictionary(f => f.Key, f => f.Value.ToString()) }
});

Maintenant, croyez-moi sur parole, on récupère bien les erreurs du service, mais aussi les informations du formulaire. Passons à la prochaine étape, qui est de récupérer tout ça au niveau de la vue.

Récupération des informations au niveau de la vue

Encore une fois, on pourrait se contenter de répéter les mêmes opérations dans chaque méthode contenant formulaire dans ce contrôleur. Cependant, l’essence même du développeur et d’être fainéant, Et comme tout bon développeur je suis extrêmement fainéant. 🙂

Nous allons donc maintenant créer un filtre assignera au ModelState, les valeurs précédemment stockées.

Populer le ModelState de validation

public class ImportModelState : ActionFilterAttribute
{
    public override void OnActionExecuted(ActionExecutedContext context)
    {
        // Vérifie que l'on peut bien récupérer le controller
        if (context.Controller is Controller controller)
        {
            // Récupère le dictionaire d'erreurs si existant. Dans le cas échéant, récupère un dictionnaire vide.  
            var modelStateDictionary = controller.TempData[ValidationConstants.ModelState] as Dictionary<string, string> ?? new Dictionary<string, string>();

            // Pour chaque valeur rentrée par l'utilisateur
            foreach (var modelStateKeyValuePair in modelStateDictionary)
            {
                // Ajoute une erreur dans le ModelState pour ce champs
                context.ModelState.AddModelError(modelStateKeyValuePair.Key, modelStateKeyValuePair.Value);
            }
        }

        base.OnActionExecuted(context);
    }
}

Il suffit maintenant d’appliquer ce filtre sur chaque méthode contenant un formulaire pour que les erreurs de validations soient correctement levées :). 

Populer les valeurs anciennement remplies

Nous allons maintenant rajouter la valeur au niveau du ModelState. Il existe une méthode SetModelValue permettant de définir une valeur pour un élément de celui-ci. C’est cette valeur qui sera affichée dans le formulaire.

Parfait c’est exactement ce que l’on cherche, modifions un peu notre code pour prendre ceci en compte.

public override void OnActionExecuted(ActionExecutedContext context)
{
    // Vérifie que l'on peut bien récupérer le controller
    if (context.Controller is Controller controller)
    {
        // Récupère les dictionaires d'erreurs, et de valeur si existant. Dans le cas échéant, récupère un dictionnaire vide.  
        var modelStateDictionary = controller.TempData[ValidationConstants.ModelState] as Dictionary<string, string> ?? new Dictionary<string, string>();
        var viewModelDictionary = controller.TempData[ValidationConstants.ViewModel] as Dictionary<string, string> ?? new Dictionary<string, string>();

        // Pour chaque valeur rentrée par l'utilisateur
        foreach (var viewModelKeyValuePair in viewModelDictionary)
        {

            // Si une erreur est présente dans le formulaire
            if (modelStateDictionary.TryGetValue(viewModelKeyValuePair.Key, out var errorString))
            {
                // Ajoute l'erreur pour le champs, au formulaire.
                context.ModelState.AddModelError(viewModelKeyValuePair.Key, errorString);
            }

            // Ajoute une valeur pour le champs défini, dans le ModelState
            context.ModelState.SetModelValue(viewModelKeyValuePair.Key, new ValueProviderResult(viewModelKeyValuePair.Value, CultureInfo.InvariantCulture));
        }
    }

    base.OnActionExecuted(context);
}

Parfait ! Nous avons maintenant notre système de validation complet et avec un couplage faible.

Il reste cependant une toute dernière étape pour rendre ceci encore un petit peu plus light. En effet, si l’on regarde le code de notre service, on peut voir qu’une grosse partie est dédiée à la validation.

J’ai donc décidé, dans mon projet, d’utiliser FluentValidation pour déporter cette logique dans une autre classe.

Validation avancée avec FluentValidation

Qu’on se le dise, je ne suis pas forcément un grand fan de ce Framework, cependant le seul que je connais ! Donc en attendant on se contentera de ça :D.

Définir les règles de validation

Si je reprends les règles que j’ai définies au début dans mon service, ça nous donnerait un code de ce genre :

public class CreateArticleValidator : AbstractValidator<CreateArticle>
{
    public CreateArticleValidator()
    {
        RuleFor(c => c.Name)
            .NotEmpty()
            .WithMessage("Le nom doit être rempli");

        RuleFor(c => c.Description)
            .NotEmpty()
            .WithMessage("La description doit être remplie");
    }
}

Et au niveau de notre service :

public void CreateArticle(CreateArticle command)
{
    var validator = new CreateArticleValidator();
    var validationResult = validator.Validate(command);

    if (!validationResult.IsValid)
    {
        var validationDictionary = validationResult.Errors.ToDictionary(v => v.PropertyName, v => v.ErrorMessage);
        throw new ValidationException(validationDictionary);
    }

    // chemin normal, appel du repository, ...
    // _repository.Add(...)
}

On y voit tout de suite plus clair non ? Mais…

DRY : Un principe de base

Si je reprends toujours ce principe du DRY, je vais me rendre compte que, encore une fois, cette logique sera répétée dans chaque méthode. Tentons d’aller encore un cran plus loin et de créer une méthode permettant de ne pas ré-écrire ceci.

public static class Validator
{
    /// <summary>
    /// Valide une entité et lance une exception dans le cas où la validation échouerait.
    /// </summary>
    /// <typeparam name="TValidator">Classe Validator contenant les règles</typeparam>
    /// <typeparam name="TEntity">Type de l'entité à valider</typeparam>
    /// <param name="entity">Entité à valider</param>
    public static void Validate<TValidator, TEntity>(TEntity entity) where TValidator : AbstractValidator<TEntity>, new()
    {
        var validator = new TValidator();
        var validationResult = validator.Validate(entity);

        if (!validationResult.IsValid)
        {
            var validationDictionary = validationResult.Errors.ToDictionary(v => v.PropertyName, v => v.ErrorMessage);
            throw new ValidationException(validationDictionary);
        }
    }
}

Ce qui nous donne du coup, au niveau du service :

public void CreateArticle(CreateArticle command)
{
    Validator.Validate<CreateArticleValidator, CreateArticle>(command);


    // chemin normal, appel du repository, ...
    // _repository.Add(...)
}

Et là, on est bien !

Conclusion

On est maintenant arrivé au terme de cet article, je reste persuadé qu’il existe bien meilleure solution pour ce faire et que la mienne pourrait être optimisée. Cependant à l’heure actuelle elle est fonctionnelle et me permet de simplifier grandement toutes les tâches de validation et de rendu utilisateur pour mon application.

Je vous remercie de votre lecture et je vous invite à m’indiquer en commentaire les pistes d’amélioration ou les éventuelles questions que vous auriez. Je vous invite aussi à faire des pull request Sur le dépôt de cet article si vous pensez pour améliorer le code. Je mettrai alors à jour l’article pour qu’il continue à coller avec le code.

Sources :

Published inArchitectureASP.NET CoreC#

Be First to Comment

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

1 Partages
Partagez1
Tweetez
Partagez