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)