La conception du modèle de données d'une application requiert souvent une séparation des objets en deux types distincts : les DAO (Data Access Object), qui incarnent les données brutes, et les DTO (Data Transfer Object), également appelés ViewModel dans le cadre du modèle MVC (Modèle-Vue-Contrôleur). Ces derniers définissent la structure des données à échanger entre les différentes couches de l'application. La couche de service, chargée de la logique métier, est responsable de la transformation de ces objets DAO en DTO. Grâce à la bibliothèque AutoMapper, cette tâche potentiellement laborieuse peut être entièrement automatisée.
Mise en place du projet
Supposons que l'objectif soit de créer une API pour la gestion de blogs. Trois entités principales seraient nécessaires : les blogs, les auteurs et les articles (ou "posts"). Le projet serait conçu en utilisant le framework ASP.NET Core et l'ORM (Object-Relational Mapping) Entity Framework Core pour l'accès à la base de données.
Réalisation du modèle de données
Nous commencerons par définir nos DAO.
Notre API proposera une route permettant de récupérer une liste d'informations sur tous les blogs : leur nom, leur description, le nom de leur auteur, ainsi que la date de leur dernière activité, qui correspondra à la date de publication du dernier article.
Nous formulons ensuite ces informations en tant que DTO.
Le Service de Blog (BlogService)
Pour effectuer la transition de DAO à DTO, une classe de service sert d'intermédiaire entre le contrôleur et le contexte d'Entity Framework (la base de données). Elle englobe toute la logique métier de l'API et est injectée dans le contrôleur ASP.
Mettons en place une implémentation simple d'un BlogService
:
On récupère la liste de nos blogs, en ordonnant les posts par ordre de création et on crée ensuite notre DTO.
Nous pourrions certes créer une classe « BlogMapper » dotée d'une méthode ToBlogOverviewDto
(et des tests unitaires associés), mais cela serait à la fois chronophage et source potentielle d'erreurs. En effet, toute évolution des objets DAO ou DTO nécessiterait une mise à jour manuelle du processus de mappage.
Nous préférerons donc utiliser le package AutoMapper, qui nous permet de supprimer cette redondance et de simplifier grandement la conversion entre DAO et DTO.
Présentation d'AutoMapper
AutoMapper est une bibliothèque conçue pour simplifier le mappage d'objet à objet, tout en minimisant la configuration requise. Si une propriété porte le même nom dans l'objet source et dans l'objet de destination, le mappage se fait automatiquement entre ces deux propriétés.
Par exemple, la propriété Blog.Title
sera assignée à la propriété BlogOverviewDto.Title
automatiquement. Le "Flattening" de propriété est également supporté : Blog.Author.Name
sera donc mappé vers BlogOverviewDto.AuthorName
.
Pour configurer la bibliothèque, il suffit de créer une classe héritant de Profile
dans laquelle mettre notre configuration :
CreateMap
est utilisé pour configurer quels objets peuvent être mappés. Ici, on a juste besoin de pouvoir transformer un objet de type Blog
en objet de type BlogOverviewDto
.
Toutes les propriétés du DTO sont mappées implicitement, à l'exception de : LastActivity
, qui ne correspond à aucune propriété côté DAO. Il faut donc préciser à AutoMapper comment retrouver la valeur pour cette propriété dans l'objet source.
La méthode ForMember
permet d'indiquer comment récupérer cette propriété en manuel. Il est possible de définir une opération à réaliser sur l'objet source via option.MapFrom
, de retourner une valeur statique, d'utiliser une classe de conversion via option.ConvertUsing
, ou bien d'ignorer la propriété avec option.Ignore
.
AutoMapper s'intègre parfaitement à l'injection de dépendance d'ASP.NET Core, via la méthode AddAutoMapper
, ce qui nous permettra d'injecter l'interface IMapper
dans le BlogService
.
services.AddAutoMapper(configuration =>
configuration.AddProfile<MappingProfile>());
On peut également facilement vérifier que notre mapping est correct et que l'on a oublié aucune propriété, au démarrage de l'application ou dans un test unitaire par exemple, grâce à la méthode AssertConfigurationIsValid
, fournie par la bibliothèque :
public void MappingProfile_ShouldHaveValidConfiguration()
{
var configuration = new MapperConfiguration(cfg =>
cfg.AddProfile<MappingProfile>());
configuration.AssertConfigurationIsValid();
}
Si, par exemple, la propriété LastActivity
n'était pas correctement configurée, AssertConfigurationIsValid
lèverait une exception, conduisant à l'échec du test.
Le paramètre passé à CreateMap
permet de spécifier quelles propriétés seront validées :
MemberList.Destination
: Valide que toutes les propriétés de l'objet de destination sont mappées, c'est le comportement par défaut.MemberList.Source
: Valide que toutes les propriétés de l'objet source sont mappées.MemberList.None
: Pas de validation.
Si l'on voulait également transformer un BlogOverviewDto
en Blog
, on pourrait ajouter un ReverseMap
à notre configuration.
Attention cependant : ReverseMap
ne valide pas le mapping ! Pour valider la configuration dans les deux sens, il serait nécessaire d'ajouter également ValidateMemberList
et préciser si l'on veut valider les membres source ou destination.
Transformer nos objets
Mettons maintenant à jour le BlogService
pour utiliser notre mapping :
internal class BlogService : IBlogService
{
private readonly AppDbContext context;
private readonly IMapper mapper;
public BlogService(AppDbContext context, IMapper mapper)
{
this.context = context;
this.mapper = mapper;
}
public IEnumerable<BlogOverviewDto> GetAll()
{
List<Blog> blogs = context.Blogs
.Include(b => b.Author)
.Include(b => b.Posts.OrderByDescending(p => p.CreatedAt).Take(1))
.ToList();
return blogs.Select(mapper.Map<BlogOverviewDto>);
}
}
On a un code beaucoup plus lisible, et surtout la logique de mapping est sortie du service.
Intégration avec les ORM
Jetons un œil à la requête effectuée à notre base de données :
SELECT
[b].[id], [b].[AuthorId], [b].[Description], [b].[Title],
[a].[id], [a].[Description], [a].[Name],
[t0].[id], [t0].[BlogId], [t0].[Content], [t0].[CreatedAt], [t0].[Title]
FROM
[Blogs] AS [b]
INNER JOIN
[Authors] AS [a] ON [b].[AuthorId] = [a].[id]
LEFT JOIN (
SELECT
[t].[id], [t].[BlogId], [t].[Content], [t].[CreatedAt], [t].[Title]
FROM (
SELECT
[p].[id], [p].[BlogId], [p].[Content], [p].[CreatedAt], [p].[Title],
ROW_NUMBER() OVER(PARTITION BY [p].[BlogId] ORDER BY [p].[CreatedAt] DESC) AS [row]
FROM
[Posts] AS [p]
) AS [t]
WHERE
[t].[row] <= 1
) AS [t0] ON [b].[id] = [t0].[BlogId]
ORDER BY
[b].[id], [a].[id], [t0].[BlogId], [t0].[CreatedAt] DESC
On récupère beaucoup plus de données que nécessaire, qui ne seront pas utilisées puisque pas présentes dans notre DTO.
Pour ne récupérer que ce dont on a besoin, on peut effectuer un Select
pour choisir quelles propriétés demander dans la requête :
public IEnumerable<BlogOverviewDto> GetAll()
{
return context.Blogs
.Select(b => new BlogOverviewDto
{
Id = b.Id,
Title = b.Title,
Description = b.Description,
AuthorName = b.Author.Name,
LastActivity = b.Posts
.OrderByDescending(p => p.CreatedAt)
.First()
.CreatedAt
});
}
Ce qui nous donne la requête SQL suivante :
SELECT
[b].[id] AS [Id], [b].[Title], [b].[Description],
[a].[Name] AS [AuthorName], (
SELECT TOP(1) [p].[CreatedAt]
FROM [Posts] AS [p]
WHERE [b].[id] = [p].[BlogId]
ORDER BY [p].[CreatedAt] DESC) AS [LastActivity]
FROM
[Blogs] AS [b]
INNER JOIN
[Authors] AS [a] ON [b].[AuthorId] = [a].[id]
C'est bien mieux, par contre, on se retrouve à nouveau à faire du mapping manuel, ce qui est précisément ce que l'on voulait éviter en utilisant AutoMapper.
Heureusement pour nous, celui-ci va une fois de plus nous simplifier la vie, grâce à la méthode ProjectTo
cette fois.
Projection en base de données
AutoMapper peut très facilement s'intégrer avec Entity Framework (ou n'importe quel autre ORM d'ailleurs), grâce à des extensions de l'interface IQueryable
, notamment la méthode ProjectTo
.
Contrairement à la méthode Map
qui effectue la transformation d'objet dans la mémoire, ProjectTo
va construire une instruction SELECT
à partir d'un IQueryable
, le mapping va donc se faire directement côté base de données.
Mettons à jour la configuration pour y intégrer la même logique que dans notre Select
précédent :
CreateMap<Blog, BlogOverviewDto>(MemberList.Destination)
.ForMember(
blogDto => blogDto.LastActivity,
option => option.MapFrom(blog => blog.Posts
.OrderByDescending(p => p.CreatedAt)
.First()
.CreatedAt));
Il suffit ensuite d'appeler ProjectTo
, en lui passant en paramètre la configuration du mapper :
public IEnumerable<BlogOverviewDto> GetAll()
{
return context.Blogs.ProjectTo<BlogOverviewDto>(mapper.ConfigurationProvider);
}
On peut difficilement faire plus simple.
Si on vérifie notre requête SQL, on obtient exactement la même chose qu'avec notre Select
manuel.
On a donc à la fois une requête SQL simplifiée, et la validation de toute notre logique de mapping via AutoMapper.
Mesures de performances
Pour essayer de mesurer la différence entre les différentes solutions, on peut réaliser un benchmark à l'aide de la bibliothèque BenchmarkDotNet :
Méthode de mapping | Temps d'execution | Mémoire allouée |
---|---|---|
En mémoire, manuel | 118.07 ms | 8001.74 KB |
En mémoire, avec AutoMapper | 133.85 ms | 8257.86 KB |
En base de données, manuel | 290.3 ms | 758.67 KB |
En base de données, avec AutoMapper | 285.2 ms | 757.82 KB |
Test réalisé sur une base de données avec 1000 blogs, chacun avec 100 posts.
On peut déjà voir que l'impact de l'utilisation d'AutoMapper est négligeable.
Le mapping côté base de données est légèrement plus lent dans notre cas, dû à des optimisations faites par défaut par Entity Framework dans sa requête initiale (via le partitionnement et la jointure de la table Blogs
) qu'on ne retrouve pas dans la requête simplifiée.
Pour essayer de comparer à requête SQL équivalente, modifions la requête utilisée par Entity Framework lors du mapping en mémoire :
SELECT
[b].[id], [b].[AuthorId], [b].[Description], [b].[Title],
[a].[id], [a].[Description], [a].[Name],
[p].[id], [p].[BlogId], [p].[Content], [p].[CreatedAt], [p].[Title]
FROM
[Blogs] AS [b]
INNER JOIN
[Authors] AS [a] ON [b].[AuthorId] = [a].[id]
LEFT JOIN [Posts] AS [p] ON [p].[id] = (
SELECT TOP 1 [p2].[id]
FROM [Posts] AS [p2]
WHERE [p2].[BlogId] = [b].[id]
ORDER BY [p2].[CreatedAt] DESC
)
Ce qui nous donne :
Méthode de mapping | Temps d'execution | Mémoire allouée |
---|---|---|
En mémoire | 280.1 ms | 10080.33 KB |
En base de données | 265.5 ms | 757.45 KB |
Non seulement le mapping en base de données est légèrement plus rapide, mais surtout, dans tous les cas, nous pouvons consommer jusqu'à 10 fois moins de mémoire, ce qui peut être intéressant si on a à mapper des collections de taille importante.
Si la configuration d'un mapping est compatible avec une execution en base de données, utiliser la projection peut donc être un bon moyen de facilement optimiser ses requêtes.
Le mot de la fin
AutoMapper est extrêmement pratique pour éviter la mise en place de logiques de mapping répétitives. Néanmoins, comme l'énonce son créateur Jimmy Bogard dans son post AutoMapper's Design Philosophy :
AutoMapper works because it enforces a convention. It assumes that your destination types are a subset of the source type. It assumes that everything on your destination type is meant to be mapped. It assumes that the destination member names follow the exact name of the source type. It assumes that you want to flatten complex models into simple ones.
AutoMapper est parfaitement adapté à un use case en particulier : pour transformer des objets complexes vers une représentation plus simple.
Comme on l'a vu avec la propriété LastActivity
, dès que les objets à mapper ne correspondent pas exactement, on se retrouve à mettre en place de la configuration manuelle plus ou moins complexe. Et plus on a besoin d'adapter manuellement sa configuration, plus on perd le bénéfice d'avoir un mapping automatique.
Il est donc important de déterminer si la mise en place d'AutoMapper est pertinente selon les besoins de l'application.
If you find yourself hating a tool, it's important to ask - for what problems was this tool designed to solve? And if those problems are different than yours, perhaps that tool isn't a good fit.