Dot.Blog

C#, XAML, WinUI, WPF, Android, MAUI, IoT, IA, ChatGPT, Prompt Engineering

Intégrité bi-directionnelle. Utiliser IEnumerable et des propriétés read-only (C#)

[new:16/10/2010]Un peu de C#, ça faisait longtemps que je n’avais pas bloggé sur le sujet. Aujourd’hui quelques points essentiels dans la conception des classes…

Relations entre entités et invariants : un exemple

Pour éviter de trop nous perdre dans les méandres des explications, le plus simple est de regarder directement le code ci-dessous :

public class Customer
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Province { get; set; }
    public List<Order> Orders { get; set; }
 
    public string GetFullName()
    {
        return LastName + ", " + FirstName;
    }
}
 
public class Order
{
    public Order(Customer customer)
    {
        Customer = customer;
        customer.Orders.Add(this);
    }
 
    public Customer Customer { get; set; }
}

La question est : qu’est-ce qui ne va pas avec ces deux classes ?

Setter sur une collection

La première chose qui ne va pas est la présence d’un setter sur la collection Orders de la classe Customer. On ne fait jamais ça (mais on le voit hélas souvent). N’importe qui peut remplacer l’objet collection lui-même et couper l’herbe sous les pieds de Customer. Ici l’exemple est simpliste, mais imaginez que Customer gère des événements propres à la collection…

Rendre le setter d’une collection publique, voire tout simplement lui adjoindre un tel setter quel que soit sa visibilité est le plus généralement une grosse erreur de conception.

Publier une liste concrète

Second problème toujours posé par cette liste Orders : elle est visible sous la forme de son implémentation concrète, à savoir List<Order>. Que se passera-t-il si pour faire évoluer notre code dans le futur nous souhaitons utiliser une ObservableCollection ou tout autre structure à la place de List<T> ? Tout le code dépendant de Customer sera à revoir !

On ne fait jamais cela non plus. Les collections de ce type, sauf obligation dûment commentée et justifiée, sont toujours publiées sous forme d’interfaces, par exemple IList<T>.

Relation bi-directionnelle instable.

Il est évident à la lecture du code de Order que le constructeur de cette classe établit une relation bi-directionnelle avec Customer. Publier un setter pour la propriété Customer est une erreur, n’importe quel code pourra modifier le client attaché à la commande de façon anarchique ce qui laissera l’ensemble des données dans un bel état !

La solution

Voici comment les deux classes pourraient être corrigées pour éviter les problèmes indiqués :

public class Customer
{
    private readonly IList<Order> _orders = new List<Order>();
 
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Province { get; set; }
    public IEnumerable<Order> Orders { get { return _orders; } }
 
    public string GetFullName()
    {
        return LastName + ", " + FirstName;
    }
 
    internal void AddOrder(Order order)
    {
        _orders.Add(order);
    }
}
 
public class Order
{
    public Customer Customer { get; protected set; }
 
    public Order(Customer customer)
    {
        Customer = customer;
        customer.AddOrder(this);
    }
}

Le setter sur la collection a été supprimé, la liste est publiée sous la forme d’une interface IEnumerable<Order> et le constructeur de Order oblige a passer un Customer et c’est ce constructeur qui établit de façon définitive le lien bi-directionnel entre les entités. De même la propriété Customer de Order possède désormais un setter “protected” évitant toute manipulation depuis l’extérieur.

Regardons de plus près

Il y a ici un autre invariant qui a été pris en compte : c’est le fait qu’une commande ne peut pas exister sans client. Le constructeur de Order donne corps à cet invariant puisqu’il n’est pas possible de créer une commande sans passer un Customer et que Order s’occupe d’appeler les méthodes de Customer pour assurer le lien bi-directionnel.

Imaginons un instant qu’après d’âpres discussions au sommet entre experts, il soit décidé qu’une commande ne peut pas non plus exister sans lignes de commandes.

Cela ne pose pas trop de problèmes puisque nous savons maintenant comment résoudre la question : en ajoutant un nouveau paramètre au constructeur de Order pour qu’une liste de lignes de commandes soit passée. On ajoutera aussi les appels nécessaires pour que le lien bi-directionnel soit assuré.

C’est là que tout cela pose un problème de conception… Parce que si notre API semble parfaite vue de l’extérieur, à l’intérieur ça va commencer à se compliquer et à devenir plus difficile à maintenir à force d’ajouter des paramètres, des appels à des méthodes ici et là…

Méthodes d’exentions, génériques, expressions Lambda et Reflexion

En voici une belle brochette !

On se met à rêver quelques instants et on se demande si devant le fatras qui s’annonce dans les classes ainsi modifiées on ne ferait pas mieux de prévoir tout cela dès le départ et de se créer une petite boite à outils versatile pour régler une bonne fois pour toute les problèmes soulevés…

Par exemple il serait vraiment sympa de pouvoir ajouter un item à un IEnumerable depuis une entité en relation sans avoir besoin d’appeler des méthodes internes, non ? Il pourrait être pratique de pouvoir modifier des propriétés protégées (non pas privées, là ça serait pousser le bouchon trop loin). Existe-t-il un moyen de créer un infrastructure simplifiant ensuite la prise en charge des problèmes soulevés à la fois par la première implémentation de Customer et Order et par l’implémentation de la solution qui en soulève d’autres ?

Comme nous ne souhaitons pas exposer des fonctionnalités aussi dangereuses que celles évoquées dans toute notre application, il s’agit juste de nous aider à créer des implémentations “propres” tout en préservant la clarté de notre API, nous allons ajouter tout cela dans notre Layer Supertype.

Layer Supertype ?

Dans ”Patterns of Enterprise Application Architecture” de Martin Fowler, la description de cette pattern est donnée page 475. Comme il n’existe pas de version française, le numéro de page devrait être le bon si vous possédez cet indispensable ouvrage chez vous…

En gros il n’est pas idiot pour tous les objets dans un même layer de posséder des méthodes que vous ne voulez pas dupliquer dans tout le système. Dans ce cas vous pouvez toutes les déplacer dans un Layer Supertype commun, une classe spécifique du layer en question qui regroupe donc tous les comportements utilisables ici et là dans le layer mais uniquement dans ce layer.

La lecture du livre de Fowler vous en dira bien plus que quelques lignes ici. C’est une pattern très intéressante (comme le reste du bouquin d’ailleurs) mais que je ne peux pas traiter en profondeur dans ce billet.

Les méthodes du SuperType

protected void SetInaccessibleProperty<TObj, TValue>(TObj target, TValue value,
    Expression<Func<TObj, TValue>> propertyExpression)
{
    propertyExpression.ToPropertyInfo().SetValue(target, value, null);
}
 
protected TValue GetInaccessibleProperty<TObj, TValue>(TObj target,
    Expression<Func<TObj, TValue>> propertyExpression)
{
    return (TValue)propertyExpression.ToPropertyInfo().GetValue(target, null);
}
 
protected void AddToIEnumerable<TEntity, TValue>(TEntity target, TValue value,
    Expression<Func<TEntity, IEnumerable<TValue>>> propertyExpression)
{
    IEnumerable<TValue> enumerable = GetInaccessibleProperty(target, propertyExpression);
 
    if (enumerable is ICollection<TValue>)
        ((ICollection<TValue>)enumerable).Add(value);
    else
        throw new ArgumentException(
            string.Format("Property must be assignable to ICollection<{0}>", typeof(TValue).Name));
}
 
protected void RemoveFromIEnumerable<TEntity, TValue>(TEntity target, TValue value,
    Expression<Func<TEntity, IEnumerable<TValue>>> propertyExpression)
{
    IEnumerable<TValue> enumerable = GetInaccessibleProperty(target, propertyExpression);
 
    if (enumerable is ICollection<TValue>)
        ((ICollection<TValue>)enumerable).Remove(value);
    else
        throw new ArgumentException(string.Format("Property must be assignable to ICollection<{0}>",
            typeof(TValue).Name));

Il est sûr que vu comme ça, au petit déjeuner, ça peut sembler un peu indigeste. Mais relisez ce code au calme, vous verrez ça a du sens :-)

Surtout, ce code va nous service à construire quelque chose de plus intelligent :

protected void AddManyToOne<TOne, TMany>(
    TOne one, Expression<Func<TOne, IEnumerable<TMany>>> collectionExpression,
    TMany many, Expression<Func<TMany, TOne>> propertyExpression)
{
    AddToIEnumerable(one, many, collectionExpression);
    SetInaccessibleProperty(many, one, propertyExpression);
}
 
protected void RemoveManyToOne<TOne, TMany>(
    TOne one, Expression<Func<TOne, IEnumerable<TMany>>> collectionExpression,
    TMany many, Expression<Func<TMany, TOne>> propertyExpression)
    where TOne : class
{
    RemoveFromIEnumerable(one, many, collectionExpression);
    SetInaccessibleProperty(many, null, propertyExpression);
}
 
protected void RemoveManyToMany<T1, T2>(
    T1 entity1, Expression<Func<T1, IEnumerable<T2>>> expression1,
    T2 entity2, Expression<Func<T2, IEnumerable<T1>>> expression2)
{
    RemoveFromIEnumerable(entity1, entity2, expression1);
    RemoveFromIEnumerable(entity2, entity1, expression2);
}
 
protected void AddManyToMany<T1, T2>(
    T1 entity1, Expression<Func<T1, IEnumerable<T2>>> expression1,
    T2 entity2, Expression<Func<T2, IEnumerable<T1>>> expression2)
{
    AddToIEnumerable(entity1, entity2, expression1);
    AddToIEnumerable(entity2, entity1, expression2);
}

Déjà on commence à voir l’intérêt de la manœuvre. Cette “seconde couche” exploite les première méthodes pour autoriser des comportements de plus haut niveau, notamment l’ajout et la suppression d’éléments à des IEnumerable sans avoir accès aux implémentations concrètes !

Vous noterez que toutes les méthodes sont “protected” donc uniquement utilisable dans les classes dérivées, celles du layer en cours qui descendent donc toutes du Layer SuperType…

En réalité il y a un petit morceau qui fait exception et qui est tellement pratique qu’il a été transformé en méthode d’extension :

public static class ExpressionExtensions
{
    public static PropertyInfo ToPropertyInfo(this LambdaExpression expression)
    {
        var prop = expression.Body as MemberExpression;
 
        if (prop != null)
        {
            var info = prop.Member as PropertyInfo;
            if (info != null)
                return info;
        }
 
        throw new ArgumentException("The expression target is not a Property");
    }
}

Ce code est très simple, par le biais de la Reflection il permet d’atteindre une propriété passée sous la forme d’une expression Lambda et de modifier son contenu. C’est un peu tordu mais c’est très utile, vous allez le voir dans l’exemple ci-dessous.

La solution améliorée par le SuperType et ses méthodes

Le SuperType s’appelle DomainBase, une convention que chacun pourra utiliser ou non (mais après avoir lu le livre de Fowler !).

public class Customer : DomainBase
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Province { get; set; }
    public IEnumerable<Order> Orders { get; protected set; }
 
    public string GetFullName()
    {
        return LastName + ", " + FirstName;
    }
}
 
public class Order : DomainBase
{
    public Customer Customer { get; protected set; }
 
    public Order(Customer customer)
    {
        AddManyToOne(customer, x => x.Orders, this, x => x.Customer);
    }
}

La nouvelle implémentation a tout de même fière allure ! Elle est plus simple à lire, son API est aussi claire que son contenu et nous avons préparé le terrain avec le SuperType pour régler de façon aussi élégante les mêmes problèmes dans tout le layer concerné !

Conclusion

Comme toute solution générique démontrée sur un pauvre ensemble de deux mini classes, forcément, on semble utiliser un tank pour tuer une mouche.

Si votre logiciel ne contient qu’une classe Customer et qu’une classe Order comme ici, alors nous ne parlons pas du même type de logiciel et je vous présente mes excuses, vous n’en êtes certainement qu’au second ou troisième chapitre de “C# pour les Nuls”. Je ne pouvais pas le deviner :-)

Bien entendu, pour tous les autres lecteurs, nous savons tous que ce type de solution ne prend son intérêt que dans de larges solutions exposants de dizaines voire des centaines de classes.

La solution présentée ici a un impact sur les performances, c’est certain. De combien ? Je n’ai pas mesuré. Mais si faire la balance entre performances brutes et code maintenable est toujours quelque chose de grave et délicat, j’opte systématiquement pour la maintenabilité et la clarté du code. D’autres développeurs préféreront dupliquer du code partout dans le fol espoir de gratter quelques millisecondes. Chacun ses choix, cela ne me dérange pas. Mais je serai curieux de voir alors combien de lignes dupliquées contiendra ce code et au final combien de clients et de commandes un tel code sera capable de traiter réellement par secondes…

Bref, si l’idée de Fowler du SuperType est à exploiter sans trop de contrainte, les transformations à outrance présentées ici mélangeant Expression Lambda, Reflexion, méthodes d’extensions le tout à la sauce générique sont plutôt à prendre comme des possibilités et des idées d’implémentation ouvrant de nouvelles façons de concevoir des couches riches en classes (un BOL, un DAL par exemple). Tout n’est peut-être pas à prendre au pied de la lettre et le but du jeu était principalement de vous faire réfléchir à ces possibilités.

Si ce but est atteint alors c’est une bonne chose !

PS: j’ai donné les coordonnées du livre de Fowler par l’hyperlien qu’il suffit de suivre (ça tombe chez Amazon France pour le commander). Concernant le billet lui-même je me suis inspiré d’un publication de Sean Blakemore, non par paresse ou manque d’idée mais parce qu’il me semblait que son propos valait largement l’intérêt d’être proposé dans notre belle langue pour en faire profiter tous les lecteurs de Dot.Blog qui, je le sais, ne sont pas tous des amis de l’anglais…

Stay Tuned !

Faites des heureux, PARTAGEZ l'article !

Commentaires (5) -

  • Laur3nT

    25/10/2011 18:04:08 | Répondre

    Bonjour,

    D'abord bravo pour cet excellent blog. Je l'ai découvert il y a quelques jours et je le dévore avec délice ! (littéralement parlant)

    Je me suis posé la question des relations bi-directionnelles il y a quelques semaines.

    Mon problème étant -légèrement- différent du votre. Je ne peux pas mettre de paramètre dans le constructeur de "Order". Je dois passer par un Setter.
    Cela implique :
    1) De retirer l'"Order" de l'ancien "Customer" s'il existe lors d'un set
    1.2) D'ajouter l'"Order" dans le "Customer" lors du set

    Je doit également pouvoir ajouter des "Order" dans "Customer" qui vont s'auto "setter" dans l'order.

    Pour résumer, j'ai une relation bi-directionnelle, qui doit être accessible des deux bouts et qui doit se maintenir à jour toute seule.

    C'est pas très compliqué à mettre en place en ajouter les méthodes AddOrder & RemoveOrder dans Customer et en jouant avec le setter Customer dans Order.

    Là où je bloque, c'est rendre ce mécanisme générique. C'est très pénible de ré-écrire toujours le même morceau de code. J'ai cherché en utilisant de la généricité, des lambdas et de la généricité... Mais jusque là, je bloque !

    A mon grand désarroi, je n'ai pas la science infuse. Avez-vous une idée ?

  • Olivier

    27/10/2011 15:48:47 | Répondre

    @Laur3ent: Merci de votre message, cela fait toujours plaisir.
    Concernant le problème que vous soulevez il y a semble-t-il en effet des choses qui coincent. Si l'Order ne peut pas avoir de paramètre on ne peut inventer un code qui ferait de la devination pour savoir quel Customer est concerné... Il faut bien que ce lien soit exprimé à moment ou un autre. Dans un cas extrême il n'existe qu'un seul customer "actif" et donc cette info peut être stockée dans une variable statique quelque part et être utilisée par l'Order quand elle est ajoutée par exemple. C'est là où il devient difficile d'aller plus loin sans connaître votre code et le fonctionnel qui impose qu'il n'y ait pas de paamètre à Order. Si vous avez une petite simulation de votre code, postez là, je regarderai si je trouve une astuce ...

  • Laur3nT

    29/10/2011 01:07:28 | Répondre

    Je ne vais pas être très original dans l'exemple. J'ai deux classes : Parent et Enfant.
    Parent contient une liste d'enfant et un enfant et associé à un parent.
    Il est possible d'avoir des parents sans enfant et des enfants sans parent.
    Je peux donc créer des enfants et des parents totalement indépendant.

    Soit le code suivant :
        public class Enfant
        {
            private Parent _parent;

            public Parent Parent
            {
                get { return _parent; }
                set
                {
                    if (_parent != null && _parent != value)
                    {
                        _parent.RemoveEnfant(this);
                    }

                    _parent = value;

                    if (value != null && !value.Enfants.Contains(this))
                    {
                        value.AddEnfant(this);
                    }
                }
            }

            public string ID { get; set; }
        }


        public class Parent
        {
            private List<Enfant> _enfants;

            public Parent()
            {
                _enfants = new List<Enfant>();
            }

            public ReadOnlyCollection<Enfant> Enfants
            {
                get { return _enfants.AsReadOnly(); }
            }

            public void AddEnfant(Enfant enfant)
            {
                if (!_enfants.Contains(enfant))
                {
                    _enfants.Add(enfant);
                }

                enfant.Parent = this;
            }

            public void RemoveEnfant(Enfant enfant)
            {
                _enfants.Remove(enfant);
            }

            public string ID { get; set; }
        }


    L'exemple montre une association 0.1<->*. J'ai d'autres associations *<->* et 0.1<->0.1

    Avec ce morceau de code, je ne me demande pas quel poco porte l'association et je sais que quelques soit le sens d'utilisation, l'intégrité est respectée.

    Dans le cadre d'un projet avec plusieurs dizaines de poco, je cherche à généraliser ces quelques méthodes.

    Je souhaite mettre en place une sorte de mapping du type :
    Association(e => e.Enfants, p => p.Parent);
    et que tout le reste soit totalement caché.

  • Olivier

    02/11/2011 14:14:41 | Répondre

    Le code montré semble correspondre au besoin en tout cas.
    Appliquer l'astuce du billet n'est pas très compliqué mais cette astuce oblige à descendre d'une superclasse ce qui n'est pas toujours possible. Notamment dans le cadre d'entity model par exemple.
    Peut-être suffit-il de déplacer la superclasse en superInterface qui serait supportée par toutes les POCO.
    C'est une voie à investiguer.

  • Laur3nT

    03/11/2011 23:44:09 | Répondre

    Bonjour,

    J'ai trouvé un compromis acceptable. J'ai pas encore terminé tous les tests unitaires, mais cela semble d'ores et déjà fonctionner correctement.

    Si cela vous intéresse, envoyer moi un mail, je mettrai les sources au propre et je vous les envoient dans le week-end.

    J'ai enlevé les constructeurs et je passe par les setter. J'ai déjà un super type sur l'ensemble de mes pocos, je n'ai eu qu'à ajouter une version modifiée de vos méthodes.

    Cela donne des choses comme ça :

        public class Enfant : DomainObject
        {
    // ReSharper disable UnassignedField.Local
            private Parent _parent;
    // ReSharper restore UnassignedField.Local
            public virtual Parent Parent
            {
                get { return _parent; }
                set { ManyToOne(value, x => x.Enfants, this, x => x._parent); }
            }
    }


    L'intégration avec FluentNH est opérationnelle. Laughing

    Une fois de plus, merci pour votre excellent blog !

Ajouter un commentaire