Un « projet zéro » MAUI réaliste partie 3/5 –Modèle, Services et injection de dépendances !

Dans cette troisième partie de cours en 5 parties nous allons aborder le Modèle, la notion de service et d’injection de dĂ©pendances, le tout en augmentant le projet de tests unitaires. Suivez le guide !

Cet article fait partie d’une sĂ©rie de 5 Ă©pisodes dont voici les liens (apparaissant au fur et Ă  mesure des parutions au dĂ©but de chaque article) :
1- https://www.e-naxos.com/Blog/post/un-projet-zero-maui-realiste-partie-1-5-creation-du-projet-et-mvvm.aspx (CrĂ©ation du projet et MVVM)

2 - https://www.e-naxos.com/Blog/post/un-projet-zero-maui-realiste-partie-2-5-les-tests-unitaires.aspx (Les tests unitaires)
3 - https://www.e-naxos.com/Blog/post/un-projet-zero-maui-realiste-partie-3-5-modele-services-et-injection-de-dependances.aspx (Modèle, Services et injection de dĂ©pendances !)

Le Modèle

Sous MVVM le Modèle (premier M de MVVM) joue un rĂ´le essentiel puisqu’il fournit (et stocke Ă©ventuellement) la matière vivante de tout programme : les donnĂ©es. Mais il ne se limite pas qu’à cette tâche puisqu’il contient aussi les règles mĂ©tier. Que les donnĂ©es soient en mĂ©moire, dans une base de donnĂ©es, sur un serveur REST ou autre ne change rien au rĂ´le du Modèle.

Le but du modèle dans MVVM est le mĂŞme que dans MVC. C'est une classe qui modĂ©lise soit une partie du domaine (la façon dont l'entreprise se voit elle-mĂŞme), soit les donnĂ©es. Dans les applications d'entreprise pratiquant la conception pilotĂ©e par le domaine (DDD), un service remplit gĂ©nĂ©ralement le modèle de domaine Ă  partir de modèles de donnĂ©es (abstractions des diffĂ©rentes sources de donnĂ©es).

Voici un schĂ©ma plus prĂ©cis d'un système complexe :

Impossible dans notre Hello World amĂ©liorĂ© d’aller aussi loin dans les dĂ©tails mais nous ajouterons tout de mĂŞme un domaine, un modèle et un embryon de service avec injection de dĂ©pendances. Ce qui est tout de mĂŞme pas mal ! Et le tout en restant très simple au niveau fonctionnel et donc au niveau du code.

Architecture de la Solution Visual Studio

Au final aujourd’hui la solution ressemblera Ă  cela :

On y voit de nouveaux rĂ©pertoires, notamment « Domain Â» hĂ©bergeant lui-mĂŞme « Interfaces Â», « Models Â» et « Services Â». On voit en dernière ligne le projet de test sur lequel nous reviendrons.

Comment s’articule tout cela et pourquoi cette complexitĂ© apparente ?

Tout d’abord rappelez-vous que nous travaillons sur le projet de base fourni par Visual Studio avec juste quelques modifications, c’est vraiment quelque chose de simple qui ne correspond pas Ă  la rĂ©alitĂ© mĂŞme d’un petit projet… Raison pour laquelle cette sĂ©rie d’épisodes fait gonfler un peu ce code pour le rendre plus rĂ©aliste. Mais c’est effectivement cĂ´tĂ© structure qu’on agit le plus car faire enfler le code deviendrait vite trop lourd pour un article. Ainsi on peut avoir l’impression d’une complexitĂ© architecturale trop grande. Ce n’est pas elle qui est trop grande, c’est l’App qui est trop petite !

L’articulation est finalement simple : Le Modèle va contenir les structures de donnĂ©es, les services vont exposer des moyens d’accĂ©der Ă  ces dernières. Et pour conserver un système fortement dĂ©couplĂ© le service ne sera pas utilisĂ© directement mais au travers d’une interface stockĂ©e dans le rĂ©pertoire de mĂŞme nom. Vous voyez qu’il n’y a rien d’effrayant dans tout cela.

Comment cela va se passer est le sujet du jour.

Pour résumer nous séparons les représentations des données de la façon de les obtenir (ou modifier, créer…). Le tout étant isolé du reste de l’App par une (ou plusieurs) interface(s).

Dans la rĂ©alitĂ© les Modèles et les Services autant que les Interfaces seront placĂ©s dans une ou plusieurs DLL extĂ©rieures au projet. A cela de bonnes raisons : soit votre App sera bien obligĂ©e d’accepter ce dĂ©coupage parce qu’elle va utiliser justement du code commun avec des projets .NET existant. Evitant ainsi les redites et les erreurs qui en dĂ©coulent (notamment les règles mĂ©tier), mais respectant DRY et surtout permettant de pĂ©renniser le code existant en le rĂ©utilisant. Mais si le passĂ©, l’existant, est une bonne raison, le futur l’est tout autant ! Il s’avère que si votre projet met en place quelque chose de nouveau, il sera prĂ©fĂ©rable de sĂ©parer tout ce qui concerne le Domaine afin que les projets futurs puissent profiter de votre code et ĂŞtre cohĂ©rent avec celui-ci. On voit trop souvent des ensembles de projets oĂą cette architecture n’a pas Ă©tĂ© respectĂ©e et qui contiennent chacun des règles mĂ©tier lĂ©gèrement diffĂ©rentes… La porte garantie vers l’enfer, pire que le code spaghetti !

La fonction ajoutée

Il faut prĂ©ciser un peu le but de la modification du jour. C’est vraiment très simple : quand on clique sur le bouton il affiche le compte en indiquant « vous avez cliquĂ© xxx fois Â». Nous allons limiter ce nombre de clics Ă  10 et offrir un affichage en toutes lettres du nombre. C’est tout. DĂ©composer une telle fonction en Modèle, Services et Interfaces sera bien entendu tout Ă  fait exagĂ©rĂ©. Mais encore une fois ce ne sont pas les mĂ©thodes utilisĂ©es qui sont trop complexes ou trop lourdes, c’est le projet qui est bien trop petit…

Mais est-ce que cela existe vraiment un projet « trop petit Â» ?

La question mérite d’être posée car comme nous allons le voir les chiffres de 1 à 10 vont être écrits dans le programme en toutes lettres. Ce projet tout minus est obligé de se limiter car on se voir mal écrire dans le code tous les nombres possibles… Donc on limite à 10 clics possibles non pas parce qu’on ne sait en gérer plus mais parce qu’une partie de notre logique est trop naïve. Il faudrait soit concevoir un algorithme capable d’écrire en toutes lettres n’importe quel nombre, soit par exemple accéder à service Web qui donnerait le nombre et ce dans plusieurs langues différentes.

Dès lors on voit bien que partant d’une App presque ridicule fonctionnellement on pourra vite connaître une escalade dans la complexité.

De fait, tout comme la question se posait il y a longtemps pour les toolkits MVVM, il n’existe pas de « petits projets Â» qui ne mĂ©riteraient pas d’être Ă©crits correctement. Dans la vĂ©ritable vie d’un programme il y a deux possibilitĂ©s : soit il ne sert Ă  rien, alors arrĂŞter de l’écrire, soit il est utile et il va forcĂ©ment Ă©voluer… Autant prĂ©voir dès le dĂ©part une architecture correcte autorisant ces futures amĂ©liorations.

Le Modèle

Notre Modèle sera forcĂ©ment simple, nous avons besoin d’une liste qui associe un entier Ă  une chaĂ®ne. Commençons par la classe qui reprĂ©sente chaque entrĂ©e de cette liste :

namespace
RealisticHelloWorld.Domain.Models;
public class NumberMapItem
{
    public int Number { get; init; }
    public string Word { get; init; } = string.Empty;
}

La classe NumberMapItem représente un Item de la Map des Nombres. Un item associe deux propriétés, le nombre (Number) et le mot à afficher (Word).

Il nous faut ensuite la liste de tous les nombres que nous allons gĂ©rer (de 1 Ă  10) qui seront stockĂ©s dans une NumberMap :

namespace RealisticHelloWorld.Domain.Models;

public class NumberMap
{
    public List<NumberMapItem> Map { get; set; } = new();
 
    public string? ToWord(int number)
        => Map.SingleOrDefault(a => a.Number.Equals(number))?.Word;
}

Cette classe expose une propriĂ©tĂ© Map qui contient des NumberMapItem dĂ©finis juste au-dessus. Cette propriĂ©tĂ© pourrait ĂŞtre un simple champ privĂ© mais peut-ĂŞtre que d’autres parties de l’App se serviront un jour de la liste d’une autre façon, autant rester ouvert surtout que cela ne pose aucun problème. Mais dans notre App toute petite il est clair qu’un champ aurait Ă©tĂ© presque prĂ©fĂ©rable. La classe propose aussi et surtout une mĂ©thode essentielle « ToWord Â» qui retourne le mot correspondant Ă  un nombre de 1 Ă  10. C’est cette mĂ©thode qui sera utilisĂ©e par l’App.

Services

Mais comme vous le constater que cela soit le NumberMapItem ou la NumberMap elle-mĂŞme, il ne s’agit que d’abstraction des donnĂ©es. Aucune de ces structures ne contient les fameux mots ! Alors d’oĂą viennent-ils ?

Il nous faut un code qui va s’occuper de créer les structures de données utilisées (instanciations donc) mais aussi de les remplir de véritables données si besoin est. Dans un programme plus complexe ce Service de Données serait en charge aussi des opérations CRUD si cela s’appliquait. Mais toujours pour rester le plus découpler possible le service de données ne va s’occuper que d’un type de données, ici le mappage entre un nombre et sa représentation en toutes lettres.

Ce code devra donc ĂŞtre interrogeable par notre App pour obtenir soit directement un couple nombre / mot directement, soit une liste de couples, ce qui sera le cas ici.

Bien entendu impensable de laisser l’App accĂ©der directement Ă  ce Service ! Le dĂ©couplage fort (toujours lui !) nous oblige Ă  penser les choses de façon plus fine.

Le meilleur moyen de dĂ©coupler un code de son utilisation est de crĂ©er une interface qui sera dĂ©couplĂ©e de l’instance qui la supporte. CrĂ©ons ainsi l’interface suivante :

using RealisticHelloWorld.Domain.Models;
namespace RealisticHelloWorld.Domain.Interfaces;
 
public interface INumberMapper
{
    NumberMap GetNumberMap();
}

 La mĂ©thode GetNumberMap sera la seule chose visible par l’App. Nous allons voir par quel procĂ©dĂ© un peu plus loin. Mais maintenant que nous avons l’interface nous pouvons Ă©crire le code du Service :

 using
RealisticHelloWorld.Domain.Interfaces;
using RealisticHelloWorld.Domain.Models;
namespace RealisticHelloWorld.Domain.Services;
 
public class NumberMapper : INumberMapper
{
    private readonly List<string> nums =
        new(new[] {"one","two","three","four","five","six","seven","eight","nine","ten"});
 
    private NumberMap data = new NumberMap();
 
    public NumberMapper()
    {
        for (var i = 0; i < 10; i++)
        {
            data.Map.Add(new NumberMapItem { Number = i + 1, Word = nums[i] });
        }
    }
 
    public NumberMap GetNumberMap()
    {
        return data;
    }
}

La technique utilisĂ©e pour initialisĂ©e la liste, la « map Â», est très simples. Dans une vraie App on utilisera plutĂ´t une source de donnĂ©es comme un SGBD, un service Web ou un algorithme plus puissant. Toutefois ce code minimaliste mime assez bien la rĂ©alitĂ©.

Reste un dĂ©tail… D’oĂą va provenir l’instance du NumberMapper qui se cachera derrière son interface INumberMapper et comment l’App va-t-elle pouvoir y accĂ©der ?

L’injection de dépendances

Comme soulignĂ© dans les prĂ©cĂ©dents articles ce petit voyage pour rendre le Hello World plus rĂ©aliste nous fait passer devant des paysages très vastes. Comme un voyage en train pour traverser un pays entier. Ses vallĂ©es, ses rivières, ses montagnes… Il y a tellement Ă  dire sur chacune ! Mais soit vous optez pour un voyage Ă  dos d’âne en compagnie d’un guide local Ă  chaque Ă©tape pour tout vous expliquer, et vous pouvez y consacrer un ou deux ans, soit vous prenez le train et en quelques heures vous aurez eu une bonne idĂ©e gĂ©nĂ©rale du type de paysage que le pays propose. En sachant que vous ĂŞtes loin de tout savoir et qu’il faudra peut-ĂŞtre revenir Ă  certains endroits pour mieux les dĂ©couvrir…

Notre voyage est de mĂŞme type. MVVM, les tests unitaires, MAUI lui-mĂŞme, .NET 6+, le DDD ou l’injection de dĂ©pendances sont des sujets de taille ! Il faudra bien sĂ»r que vous reveniez sur les uns ou les autres pour les creuser. Mais ne vous inquiĂ©tez pas trop, Dot.Blog fourni des tas d’articles qui ont dĂ©jĂ  abordĂ© pour beaucoup ces aspects. MĂŞme après avoir supprimĂ© quelques centaines de parutions qui n’ont plus d’intĂ©rĂŞt aujourd’hui (techniques mortes principalement), Dot.Blog qui existe depuis plus de 14 ans contient toujours près de 800 articles actifs ! N’hĂ©sitez pas utiliser le moteur de recherche de Dot.Blog pour accĂ©der Ă  tous ces trĂ©sors parfois bien cachĂ©s !

Par exemple, mĂŞme s’ils sont anciens, vous pouvez lire ces articles : MVVM, Unity, Prism, Inversion of Control… ou MVVM : Service, Factory, IoC, injection de dĂ©pendances, oui mais pourquoi ? . S’agissant de thèmes quasi Ă©ternels, l’âge de l’article ne compte pas vraiment et vous pourrez en savoir plus sur la DI ou Injection de DĂ©pendances dont c’est le sujet de ce passage de l’article…

Maintenant que vous en savez plus (en revenant d’une de ces saines lectures !) nous allons pouvoir mettre en Ĺ“uvre la magie de la DI…

Tous les projets MAUI utilisent un Builder, comme ASP.NET Core. ET Il propose déjà une gestion de la DI. Il existe d’autres moteurs qu’on utilisait avec les Xamarin.Forms ou .NET (Unity, Autofac, NInject…) mais aujourd’hui il semble inutile de le faire, tout est dans la boîte.

Nous allons ainsi modifier le « MauiProgram.cs Â» de notre projet pour utiliser la DI. On va enregistrer le service que nous avons conçu mais aussi le ViewModel et la page principale MainPage. C’est une bonne façon de pratiquer et puis cela nous permettre de tester plus facilement chaque partie du code tout en pouvant contrĂ´ler la vie de chaque service. Par exemple notre mappeur de donnĂ©es n’a pas besoin d’exister en cent exemplaires en mĂ©moire, pas plus que la page principale de l’App ni son ViewModel. On pourra si on le dĂ©sire comme ici indiquer que ces codes seront gĂ©rĂ©s comme des Singleton. Il ne s’agit pas de Singletons « vrais Â», ceux qu’on peut crĂ©er en appliquant ce design pattern Ă  une classe, mais de Singletons de « circonstance Â», c’est le moteur de DI qui va stocker la première instance créée et qui ne fournira plus que celle-ci. L’effet final est le mĂŞme dans notre contexte mais techniquement les classes n’implĂ©mentent pas le pattern Singleton.

using
RealisticHelloWorld.Domain.Interfaces;
using RealisticHelloWorld.Domain.Services;
using RealisticHelloWorld.ViewModels;
 
namespace RealisticHelloWorld;
 
public static class MauiProgram
{
       public static MauiApp CreateMauiApp()
       {
             var builder = MauiApp.CreateBuilder();
             builder
                    .UseMauiApp<App>()
                    .ConfigureFonts(fonts =>
                    {
                           fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                           fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
                    });
        builder.Services.AddSingleton<INumberMapper>(new NumberMapper());
        builder.Services.AddSingleton<Counter>();
        builder.Services.AddSingleton<MainPage>();
 
             return builder.Build();
       }
}

On voit en fin de code que nous appelons les Services du builder pour enregistrer nos classes (Counter et MainPage) ainsi que le mappeur qui est associĂ© Ă  son interface. Ainsi il sera possible d’obtenir cette dernière sans se soucier du code d’implĂ©mentation. Ce dĂ©couplage fort (… et oui, encore !) permettra de faire Ă©voluer l’implĂ©mentation, voire la changer totalement, sans que le reste de l’App ne puisse connaĂ®tre le moindre problème (tant que le nouveau code respectera le contrat de l’Interface mais si tel n’est pas le cas la compilation le dira). Voici encore un beau sujet Ă  aborder, le versioning des Interfaces ! Mais le train avance trop vite pour s’y arrĂŞter, aujourd’hui en tout cas.

Est-ce que la modification du code du builder est suffisante pour que la magie s’opère ?

Non, bien sûr… La magie ça n’existe pas. Il va être nécessaire de modifier certaines parties existantes pour qu’elles tirent profit de la DI.

Par exemple prenons le code-behind de la MainPage :

using
RealisticHelloWorld.ViewModels;
 
namespace RealisticHelloWorld;
 
public partial class MainPage : ContentPage
{
      
       public MainPage(Counter viewModel)
       {
             InitializeComponent();
        BindingContext = viewModel;
    }
}

Comme vous le constatez, le constructeur prend désormais un paramètre, le viewModel. Quand la MainPage sera créé le moteur de DI de MAUI verra que le constructeur impose un paramètre. Il va chercher dans les services enregistrés s’il trouve une correspondance et il va alors fournir une instance automatiquement (en la créant si nécessaire, et en cascade si elle-même utilise la DI).

De fait notre MainPage ne crée plus elle-même une instance de son ViewModel, elle l’obtient automatiquement par la DI.

Le code n’est ici pas encore parfait, en effet nous typons le paramètre viewModel, ce qui empêcherait d’enregistrer un nouveau ViewModel pour MainPage dans la DI sans que la page XAML ne soit au courant de la substitution. Un niveau d’abstraction supplémentaire serait nécessaire pour atteindre un découplage parfait. Toutefois il y aurait une hypocrise terrible puisque, rappelez-vous, nous avons donn�� le type du ViewModel dans le code XAML de Mainpage… Donc la Vue connait le type de son ViewModel, ce n'est pas interdit et c’est assez pratique (pour les contrôles XAML à la compilation, Intellisense, etc). Libre à vous d’utiliser des procédés plus sophistiqués pour isoler la Vue de son ViewModel.

La classe Counter sera elle aussi impactĂ©e et aura un constructeur avec paramètre :

Il s’agit pour le ViewModel de récupérer le service du Modèle. Ici l’abstraction est totale puisque nous avons mis en place une interface. Le ViewModel Counter ne connait ainsi jamais la classe d’implémentation du service qui peut être modifiée à souhait. Il ne pourra en voir que la partie exposée par l’interface. Et il s’empresse de le faire en appelant GetNumberMap, la seule méthode de l’interface (voir plus haut) qui va lui retourner la liste des couples nombres / mots.

Cette liste sera stockĂ©e localement dans « map Â» puis utilisĂ©e dans la propriĂ©tĂ© CountText pour retourner le texte du bouton :

(extrait) 

Je ne vais tout dĂ©tailler, je vous mettrai le projet zippĂ© en fin d’article. 

Les tests

Nous pourrions en ajouter beaucoup pour chaque facette de ce qui a Ă©tĂ© modifiĂ© ou ajoutĂ©. Nous pourrons aussi aller encore plus loin dans le mimĂ©tisme d’une vĂ©ritable application de test en utilisant du Moqing. Mais cela deviendrait vite rĂ©barbatif de mĂ©langer autant d’informations en un seul petit potage si concentrĂ© !

Occupons-nous d’ajouter deux unités de code au projet de test. L’un pour tester la classe NumberMap et l’autre pour tester le service NumberMapService.

using
RealisticHelloWorld.Domain.Models;
 
namespace RealisticHelloWorld.Tests;
 
public class NumberMapTest
{
    [Fact]
    public void ReturnWordsForNumbers()
    {
        // prepare
        var map = new NumberMap
        {
            // act
            Map = new List<NumberMapItem>()
            {
                new() { Number = 0, Word = "zero"},
                new() { Number = 1, Word = "one"},
                new() { Number = 2, Word = "two"},
                new() { Number = 3, Word = "three"},
                new() { Number = 4, Word = "four"},
                new() { Number = 5, Word = "five"},
                new() { Number = 6, Word = "six"},
                new() { Number = 7, Word = "seven"},
                new() { Number = 8, Word = "eight"},
                new() { Number = 9, Word = "nine"},
                new() { Number = 10, Word ="ten"}
            }
        };
 
        // assert
        Assert.Equal("zero", map.ToWord(0));
        Assert.Equal("one", map.ToWord(1));
        Assert.Equal("two", map.ToWord(2));
        Assert.Equal("three", map.ToWord(3));
        Assert.Equal("four", map.ToWord(4));
        Assert.Equal("five", map.ToWord(5));
        Assert.Equal("six", map.ToWord(6));
        Assert.Equal("seven", map.ToWord(7));
        Assert.Equal("eight", map.ToWord(8));
        Assert.Equal("nine", map.ToWord(9));
        Assert.Equal("ten", map.ToWord(10));
    }
 
    [Fact]
    public void ReturnNullIfNumberNotFound()
    {
        // arrange
        var map = new NumberMap();
 
        // act
        // assert
        Assert.Empty(map.Map);
        Assert.Null(map.ToWord(0));
 
    }
}

Pour le service nous utiliserons ce code :

 

using RealisticHelloWorld.Domain.Services;
namespace RealisticHelloWorld.Tests;
public class NumberMapperServiceTest
{
    readonly NumberMapper numberMapper;
 
    public NumberMapperServiceTest()
    {
        numberMapper = new NumberMapper();
    }
 
    [Fact]
    public void ReturnPopulatedNumberMap()
    {
        // prepare
        // act
        var result = numberMapper.GetNumberMap();
 
        // assert
        Assert.NotEmpty(result.Map);
        Assert.Equal(10, result.Map.Count);
        Assert.Equal("one", result.ToWord(1));
        Assert.Equal("three", result.ToWord(3));
        Assert.Equal("five", result.ToWord(5));
    }
}

Bien entendu les tests unitaires rĂ©ussissent tous :

Exécutons

Bien sĂ»r, d’autant plus que les tests passent, notre App fonctionne toujours aussi bien (ici compilĂ©e en mode Windows Machine pour changer) :

Conclusion de la partie 3/5

Nous avons parcouru un long chemin depuis le projet de base MAUI créé automatiquement par Visual Studio ! Nous en avons vu des paysages, nous en avons visitĂ© des cathĂ©drales (MVVM, la DI, les tests unitaires…). Mais Il nous reste quelques petites choses Ă  voir encore pour complĂ©ter notre tour d’horizon.

Le prochain article de la série sera très court, mais le dernier abordera le styling et risque lui aussi de plus ressembler à un chapitre de livre classique qu’à un simple billet de Blog… Alors…

Stay Tuned !

Le zip de la solution de la partie 3/5 : 

RealisticHelloWorldEp3.zip (165,5KB)