Aller au contenu
Back.NET

Simplifier la transformation d'objets avec AutoMapper

Présentation et cas pratique d'utilisation de la bibliothèque .NET de référence pour le mapping d'objet.

bibliothèque .NET

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.

public abstract class Entity
{
    public Guid Id { get; init; }
}

public class Blog : Entity
{
    public string Title { get; set; } = string.Empty;

    public string Description { get; set; } = string.Empty;

    public Author? Author { get; set; }

    public ICollection<Post> Posts { get; set; } = new List<Post>();
}

public class Author : Entity
{
    public string Name { get; set; } = string.Empty;

    public string Description { get; set; } = string.Empty;

    public ICollection<Blog> Blogs { get; set; } = new List<Blog>();
}

public class Post : Entity
{
    public string Title { get; set; } = string.Empty;

    public string Content { get; set; } = string.Empty;

    public DateTimeOffset CreatedAt { get; set; }

    public Blog? Blog { get; set; }
}
Classes que l'on utilisera dans le contexte Entity Framework

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.

public record BlogOverviewDto
{
    public required Guid Id { get; init; }

    public required string Title { get; init; }

    public required string Description { get; init; }

    public required string AuthorName { get; init; }

    public required DateTimeOffset LastActivity { get; init; }
}
DTO contenant des informations de base sur le blog

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  :

public interface IBlogService
{
    IEnumerable<BlogOverviewDto> GetAll();
}

internal class BlogService : IBlogService
{
    private readonly AppDbContext context;

    public BlogService(AppDbContext context)
    {
        this.context = context;
    }

    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(b => new BlogOverviewDto
        {
            Id = b.Id,
            Title = b.Title,
            Description = b.Description,
            AuthorName = b.Author.Name,
            LastActivity = b.Posts.Single().CreatedAt
        });
    }
}
Pour simplifier l'exemple, on supposera qu'un blog a toujours au moins un post.

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 :

public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<Blog, BlogOverviewDto>(MemberList.Destination)
            .ForMember(
            	blogDto => blogDto.LastActivity, 
                option => option.MapFrom(blog => blog.Posts
                                                     .Single()
                                                     .CreatedAt));
    }
}
Classe de configuration des mappings

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.

💡
Il est également possible de customiser les conventions utilisées pour le matching implicite, par Map ou par Profile : Convention de nommage des propriétés, remplacement de mots, filtres... 

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.

AutoMapper.AutoMapperConfigurationException : 
Unmapped members were found. Review the types and members below.

...

Blog -> BlogOverviewDto (Destination member list)

Unmapped properties:
LastActivity
Erreur levée par AssertConfigurationIsValid

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.

CreateMap<Blog, BlogOverviewDto>(MemberList.Destination)
    .ForMember(
        blogDto => blogDto.LastActivity, 
        option => option.MapFrom(blog => blog.Posts
                                             .Single()
                                             .CreatedAt))
    .ReverseMap()
    .ValidateMemberList(MemberList.Source);
Configuration avec ReverseMap
⚠️
Cette configuration ne passera pas la validation : On n'a pas précisé comment la propriété LastActivity du DTO devait être mappée dans l'objet Blog.

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.

⚠️
Bien sûr, comme la transformation de l'objet se fait en base de données, cela limite la configuration que l'on peut mettre en place : Par exemple, impossible d'utiliser des classes de conversion customisées ou du mapping conditionnel.

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.

Dernier