Dot.Blog

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

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)
Faites des heureux, PARTAGEZ l'article !