Comment binder différentes implémentation d'un modèle avec ASP.Net MVC 5 ?

Comment binder différentes implémentation d'un modèle avec ASP.Net MVC 5 ?



L'un des avantages dans l'utilisation du pattern MVC fourni par le framework ASP.NET, c'est son système de route via les contrôleurs et actions. Cependant il existe certains cas que le framework ne sait pas gérer de manière native. Dans le cas qui nous intéresse ici, il s'agit de l'héritage des modèles.

Si mon action Process prend en paramètre un objet complexe MyVo qui existe sous plusieurs versions (MyDerivedVo par exemple), le binder natif à MVC ne pourra que bind sur l'objet de base MyVo même si l'objet envoyé est un MyDerivedVo.

Pour contourner ce soucis, le framework permet d'implémenter des objets permettant de gérer nous même le binding. Il s'agit des ModelBinders.

C'est quoi un ModelBinder ?

Tout d'abord, le binding des modèles c'est quoi? C'est une action entreprise par le framework au moment du routage et qui permet de mapper les paramètres (POST ou GET) en un ou plusieurs objets (qui peuvent être simples comme un int ou bien plus complexe).

cf : la documentation Microsoft sur les Modèles

Dans l'utilisation basique de MVC, généralement le model binding par défaut est suffisant. Cependant il peut arriver d'avoir besoin d'utiliser une version custom de cette mécanique. Par exemple pour pouvoir binder un objet DateTime à partir d'un format de string particulier.


Les différents ModelBinders

Il existe deux types de ModelBinders. Un binder actif au travers de toute l'application ou défini de manière plus ciblée.

ModelBinder Global

Je vais rapidement expliquer comment les déclarer et utiliser un ModelBinder global en prenant un exemple très simple. Dans cet exemple, je veux modifier le binder par défaut d'un decimal pour pouvoir binder un decimal sans distinction du caractère de séparation (comprendre "je veux binder un decimal avec une virgule ou un point sans que ça plante"). 

Première étape, je crée mon binder. Cela correspond à créer une classe qui hérite de DefaultModelBinder et de surcharger la méthode BindModel.

public class MyDecimalModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        //récupère l'objet que MVC va tenter de binder
        ValueProviderResult valueResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);

        ModelState modelState = new ModelState { Value = valueResult };

        object actualValue = null;

        try
        {
            //si la valeur contient une virgule, je parse en decimal en spécifiant le séparateur
            if (valueResult.AttemptedValue.Contains(","))
                actualValue = decimal.Parse(valueResult.AttemptedValue, new NumberFormatInfo() { NumberDecimalSeparator = "," });

            //sinon j'utilise le convertisseur en spécifiant InvariantCulture
            else
                actualValue = Convert.ToDecimal(valueResult.AttemptedValue, CultureInfo.InvariantCulture);
        }
        catch (FormatException e)
        {
            //si il n'arrive pas à binder, j'ajoute une erreur dans le ModelState
            modelState.Errors.Add(e);
        }

        //je rajoute mon binding au ModelState et je la retourne
        bindingContext.ModelState.Add(bindingContext.ModelName, modelState);
        return actualValue;
    }
}

Grâce à ce ModelBinder, les champs de type "decimal" vont être tester avant leur binding pour pouvoir utiliser le séparateur traditionnel '.' ou le séparateur français ','.
Pour pouvoir utiliser le binder, il faut le déclarer. Pour cela, il suffit de rajouter la ligne suivante dans le Global.asax.cs, au niveau de l'Application_Start().

ModelBinders.Binders.Add(typeof(DateTime), new MyDateTimeModelBinder());

Le binder sera maintenant utilisé pour chaque binding d'un decimal dans l'application, qu'il soit directement en entrée de l'action ou bien qu'il soit contenu dans un objet complexe.

ModelBinderAttribute

Ce type de ModelBinder est un peu plus compliqué à gérer. Il ne se déclare pas au sein d'une application entière mais directement au niveau du paramètre de l'action. Cela permet de n'utiliser le binder que dans certaines actions et pour certains paramètres uniquement.

Pour nous, il s'agit une action de paiement. Nous mettons à disposition des internautes des moyens de paiements différents (carte bancaire, virement bancaire, chèque vacances pour n'en citer que quelques uns). Chaque moyen de paiement est géré par un view model parent : "MeanOfPaymentVo" qui définie les propriétés génériques d'un moyen de paiement. Il est ensuite hérité pour chaque moyen de paiement pour récupérer les champs spécifiques demandés à l'internaute. Mais c'est toujours la même action qui va réaliser le paiement.

Un exemple de view model représentant le paiement

public class MeanOfPaymentVo
{
   public MeanOfPaymentVo() { }

   public MeanOfPaymentVo(MeanOfPaymentVo previousCopy, string derivedType = "")
   {
      this.Amount = previousCopy.Amount;
      this.DerivedType = String.IsNullOrEmpty(derivedType) ? typeof(MeanOfPaymentVo).Name : derivedType;
      this.Currency = previousCopy.Currency;
   }

   public decimal Amount { get; set; }
        
   public string DerivedType { get; set; }
        
   public string Currency { get; set; }   
}

[Binder(typeof(CreditCarVoBinder))]
public class CreditCardVo : MeanOfPaymentVo
{
   public CreditCardVo(MeanOfPaymentVo mother) : base(mother, typeof(CreditCardVo).Name) { }

   public int Number { get; set; }

   public DateTime ExpirationDate { get; set; }

   public int Cryptogram { get; set; }
}

L'objet CreditCardVo possède des propriétés en plus par rapport a sa classe parente


On voit que la classe CreditCardVo possède un attribut Binder. On y reviendra plus tard.

Utiliser un CustomModelBinder pour binder une classe et ses héritages

Le soucis avec les model binders c'est que ca ne gère pas les objets hérités. Si je passe en paramètre d'une action un objet complexe contenant un autre objet complexe (défini dans la classe par son type de base, mais pouvant être instancié sur une de ses dérivées).

La première étape consiste à définir quels paramètres de quelles actions seront impliqués dans ce binding custom.

public ActionResult Process([FromDerivedVo]MeanOfPaymentVo payment)
{
   return PartialView("_MyView", _loader.Pay(payment));
}

public class FromDerivedVoAttribute : CustomModelBinderAttribute
{
   public override IModelBinder GetBinder()
   {
      return new CustomVoBinder();
   }
}

Dans cet exemple, l'action Process va nous permettre d'envoyer un paiement. Pour cela, on indique au framework que pour paramètre payment on va trouver son binder via un objet FromDerivedVoAttribute qui hérite de CustomModelBinderAttribute. Cette classe doit implémenter la méthode GetBinder() permettant de retourner l'instance du binder à utiliser. Ici il s'agit d'un CustomVoBinder que l'on va voir en détails plus bas.

Maintenant, on va créer la classe instanciée par la méthode GetBinder() à savoir le CustomVoBinder. Cette classe implémente l'interface IModelBinder qui définit la méthode BindModel qui sera appelée par le framework.

public class CustomVoBinder : IModelBinder
{
   public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
   {
      //je récupère mon binder par défaut
      var baseBinder = new DefaultModelBinder();
      //je fait le binding par défaut de mon modèle et je récupere le type
      var model = baseBinder.BindModel(controllerContext, bindingContext) as MeanOfPaymentVo;      
      var typeBase = typeof(MeanOfPaymentVo);


      //Le modèle de base contient le nom du modèle dérivé pour pouvoir le binder
      //Je récupère le type de mon modèle dérivé via un système de réflexion    
      //model.Payment est la propriété que je dois mapper
Type DerivedType = Type.GetType(typeBase.FullName.Replace(typeBase.Name, model.DerivedType)); //Je crée une instance issu du type dérivé, en envoyant le type de base au constructeur var monInstanceAEnrichir = Activator.CreateInstance(DerivedType, model); //on retourne un moyen de paiement enrichi par sa classe dérivée. model = monInstanceAEnrichir as MeanOfPaymentVo; return model; } }

Mon binder va donc instancier le binder du framework et l'utiliser pour remplir le modèle.
Ensuite il va récupérer le type de ce modèle (MeanOfPaymentVo) et va remplacer le type par la propriété DerivedType (renvoyée dans la requête http) de la classe de base, récupérer son type et l'instancier. C'est une technique qui va permettre d'instancier une classe a partir de son nom en string.
Pour finir, j'affecte ma nouvelle instance créé dans le bon type à mon modèle.


Pour l'instant, je suis donc capable d'instancier le paramètre de mon choix de l'action de mon choix sur un de ses types hérités. Cependant, je ne peux pas encore renseigner ses propres propriétés. Pour cela on va utiliser l'attribut Binder qu'on avait mis sur la classe CreditCardVo au dessus. Cette classe va hériter de la classe du framework Attribute.

[AttributeUsage(AttributeTargets.Class)]
public class BinderAttribute : Attribute
{
   private readonly Type _binderType;
   
   //Quand je déclare l'attribute en tete de ma classe, je lui donne le nom de son binder 
   public BinderAttribute(Type binderType)
   {
      this._binderType = binderType;
   }


   //Mon binder possède une méthode GetBinder qui permet de créer une instance en fonction du BinderType passé au constructeur
   public ICustomModelBinder GetBinder(object instance, NameValueCollection nvc)
   {
      return (Activator.CreateInstance(_binderType, instance, nvc) as ICustomModelBinder);
   }
}

Cet attribut va permettre de retourner l'instance du binder nécessaire pour cette classe à partir de son type. Celui ci va être instancié en lui passant la liste des paramètres envoyés à la route.

Dans notre exemple de CreditCardVo, notre binder doit renseigner les propriétés Number, ExpirationDate et Cryptogram.

public class MyDerivedVoBinder
{
   private readonly MyDerivedVo _instance;
   private readonly NameValueCollection _nvc;
     
   public MyDerivedVoBinder(MyDerivedVo instance, NameValueCollection nvc)
   {
      this._instance = instance;
      this._nvc = nvc;
}
   //je crée une méthode qui va me permettre de mapper le paramètre envoyé dans mon objet
   public void Enrichi()
   {
      _instance.Number = _nvc["creditCardNumber"];
      _instance.ExpirationDate = _nvc["creditCardExpirationDate"];   
      _instance.Cryptogram= _nvc["creditCardCryptogram"];   
} }

Il s'agit d'une affectation très simple. Le binder va aller chercher dans la liste des paramètres ceux qui correspondent aux noms voulus pour les affecter à mes propriétés.

La dernière étape consiste à retourner dans notre CustomVoBinder et à enrichir le modèle via la méthode Enrichi de notre binder. Pour cela, je vais aller chercher le BinderAttribute dans les attributs de mon type dérivé et appeler sa méthode GetBinder. Il ne me reste plus qu'a appeler Enrichi() et le binding va se faire.

public class CustomVoBinder : IModelBinder
{
   public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
   {
      //... travail de la classe
            
      var monInstanceAEnrichir = Activator.CreateInstance(DerivedType, model.Payment);

      //Je recupère le CustomerAttribute associé au type dérivé et j'appelle sa méthode Enrichi()
      (Attribute.GetCustomAttributes(DerivedType).First() as BinderAttribute).GetBinder(monInstanceAEnrichir, controllerContext.HttpContext.Request.Params).Enrichi();

      //on retourne un moyen de paiement enrichi par sa classe dérivée.
      model.Payment = monInstanceAEnrichir as MyDerivedVo;
      return model;
    }
}

Commentaires

Enregistrer un commentaire

Posts les plus consultés de ce blog

Les Progressive Web Apps

Gérer la configuration des machines virtuelles avec Azure Automation et PowerShell DSC