Le mapping d’objets avec AutoMapper, pertinent ou pas?

Lors d’un développement il arrive que l’on soit confronté à l’écriture de code pour convertir des objets d’une couche ou d’un service vers un autre, le mapping comme on dit couramment dans le milieu. C’est une tâche longue, répétitive, fastidieuse, pas franchement stimulante. Mais c’est également du code qui peut facilement être source d’erreurs. En effet, le code de mapping écrit manuellement en vient souvent à être écrit via une utilisation abusive de copier-coller. Et c’est là que les erreurs entrent en jeu.

De plus, j’ai déjà eu des débats enflammés sur la nécessité de tester unitairement ce genre de code, entendant des arguments du genre :

  • « Ça revient à tester a=a »
  • « Le code est trivial, ça ne sert à rien de le tester »
  • « Je passe plus de temps à écrire les tests que le code »
  • « Le test ne vaut rien, si on se trompe dans son écriture on ne verra pas l’erreur »

Nous ne reviendrons pas sur le fond de ces arguments qui démontrent un rejet particulièrement violent de l’écriture de tests unitaires et une incompréhension de l’intérêt de ceux-ci, notamment dans un logiciel faisant essentiellement du mapping entre 4 ou 5 couches différentes.

Mais l’histoire retiendra, que « grâce » à la résistance de certains développeurs, les mappers n’ont pas été testés et que ce qui devait arriver, arriva, il y a eu des bugs sur les mappers.

Pour tenter d’éviter les problèmes précédents, des librairies ont été créés afin de faire du mapping automatique et ainsi alléger et sécuriser le code écrit par le développeur.

On va regarder ça, en commençant par la lib la plus populaire, AutoMapper.

Les bases d’AutoMapper

Comme son nom l’indique, AutoMapper est une librairie qui permet de faire du mapping, de la conversion, d’un objet vers un autre objet, plus ou moins automatiquement. Sur les cas simples il suffit de déclarer quel type va correspondre à quel autre. Par exemple avec les classes suivantes :

public class MyObject
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public char C { get; set; }
}
    
public class MyObjectDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public char C { get; set; }
}

L’instruction suivante suffit pour configurer le mapping :

MapperConfiguration config = new(cfg => cfg.CreateMap<MyObject, MyObjectDto>());

Pour l’utiliser il faut ensuite créer un objet IMapper et utiliser sa méthode Map pour faire le mapping :

MyObject myObject = new();
// ...
IMapper mapper = config.CreateMapper();
MyObjectDto dto = mapper.Map<MyObjectDto>(myObject);

Et voilà, ça fonctionne, et si vous ne voulez pas me croire sur parole, voici un test unitaire qui le démontre :

[Fact]
public void TestAutoMappingSimple()
{
    MapperConfiguration config = new(cfg => cfg.CreateMap<MyObject, MyObjectDto>());
    IMapper mapper = config.CreateMapper();

    MyObject myObject = new()
    {
        C = 'a',
        Id = 42,
        Name = "Toto"
    };

    MyObjectDto dto = mapper.Map<MyObjectDto>(myObject);
    dto.Should().NotBeNull();
    dto.Id.Should().Be(42);
    dto.Name.Should().Be("Toto");
    dto.C.Should().Be('a');
}

Bon, après ce qui a été dit en introduction de l’article, ce test est contre-productif, on veut pouvoir faire du mapping simplement et sans avoir à écrire des tests unitaires à rallonge. Pas de soucis, AutoMapper propose une méthode de test pour ça, AssertConfigurationIsValid, qui va vérifier pour chaque type où un mapping a été ajouté dans la configuration si le mapping est correct :

[Fact]
public void TestMappingAuto()
{
    MapperConfiguration config = new(cfg => cfg.CreateMap<MyObject, MyObjectDto>());
    config.AssertConfigurationIsValid();
}

Le test passe, notre mapping est correct. Mais comment en être certain ? Utilisons une classe avec un nom de propriété différent :

public class MyObjectIncorrectDto
{
    public int Id { get; set; }
    public string Naame { get; set; } = string.Empty;
    public char C { get; set; }
}

Pour le test suivant :

[Fact]
public void TestMappingAutoWithIncorrectObject()
{
    MapperConfiguration config = new(cfg => cfg.CreateMap<MyObject, MyObjectIncorrectDto>());
    config.AssertConfigurationIsValid();
}

Cette fois-ci, le test ne passe pas :

AutoMapperSimple1UnitTests.AutoMapperTests.TestMappingAutoWithIncorrectObject
   Source: AutoMapperTests.cs ligne 49
   Durée: 63 ms

  Message: 
AutoMapper.AutoMapperConfigurationException : 
Unmapped members were found. Review the types and members below.
Add a custom mapping expression, ignore, add a custom resolver, or modify the source/destination type
For no matching constructor, add a no-arg ctor, add optional arguments, or map all of the constructor parameters
=====================================================================
MyObject -> MyObjectIncorrectDto (Destination member list)
AutoMapperSimple1.MyObject -> AutoMapperSimple1.MyObjectIncorrectDto (Destination member list)

Unmapped properties:
Naame

Cette méthode est bien pratique. Mais elle commence à mettre en évidence les limitations d’AutoMapper quand les choses commencent à se compliquer.

On vient de voir que si les noms sont différents entre les deux modèles ça ne marche plus. Que se passe-t-il si le DTO a une propriété en plus :

public class MyObjectPlusDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public char C { get; set; }
    public double Value { get; set; }
}
    
[Fact]
public void TestMappingAutoWrong()
{
    MapperConfiguration config = new(cfg => cfg.CreateMap<MyObject, MyObjectPlusDto>());
    config.AssertConfigurationIsValid();
}

Et bien on a la même erreur et c’est logique car AutoMapper doit créer un objet MyObjectPlusDto avec une propriété qu’il ne sait pas remplir.

A noter cependant, que si MyObject a une propriété de plus que MyObjectPlusDto, cela fonctionne.

On va voir comment gérer ce cas par la suite, mais avant cela, regardons comment s’en sort AutoMapper niveau performance avec un petit benchmark le comparant avec un mapping manuel :

public class AutoMapperBenchmarks
{
    private readonly IMapper _mapper;

    private readonly MyObject _myObject;

    public AutoMapperBenchmarks()
    {
        MapperConfiguration config = new(cfg => cfg.CreateMap<MyObject, MyObjectDto>());
        _mapper = config.CreateMapper();
        _myObject = new()
        {
            C = 'a',
            Id = 42,
            Name = "Toto"
        };
    }

    [Benchmark]
    public MyObjectDto AutoMapper() => _mapper.Map<MyObjectDto>(_myObject);

    [Benchmark]
    public MyObjectDto ManualMap()
    {
        return new MyObjectDto
        {
            Id = _myObject.Id,
            Name = _myObject.Name,
            C = _myObject.C,
        };
    }
}

Résultat :

Résultats du benchmark :
| Method     | Mean      | Error     | StdDev    |
|----------- |----------:|----------:|----------:|
| AutoMapper | 55.116 ns | 0.2296 ns | 0.1918 ns |
| ManualMap  |  4.557 ns | 0.1338 ns | 0.2936 ns |
Meme de Denis Brogniart disant "Ah!"

Ça pique un peu. Les performances d’AutoMapper ne sont pas terribles comparativement à un mapping manuel et on est seulement sur un cas simple.

On va voir ce que ça donne pour des choses plus complexes.

Utilisation plus avancée

Mapping avec noms différents

Comme on l’a vu précédemment, si les noms de propriétés diffèrent, ça ne marche pas. Comment faire alors ?

L’objet MapperConfiguration propose une méthode afin de déclarer le mapping entre les membres de la classe source et la classe de destination. Par exemple, en gardant les classes utilisées dans les exemples vus plus tôt dans l’article, si on modifie les propriétés dans la classe de destination comme suit :

public class MyObjectDto
{
    public int Identity { get; set; }
    public string FullName { get; set; } = string.Empty;
    public char Char { get; set; }
}

Il suffit de modifier la configuration ainsi :

MapperConfiguration config = new(cfg => cfg.CreateMap<MyObject, MyObjectDto>()
                                .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name))
                                .ForMember(dest => dest.Identity, opt => opt.MapFrom(src => src.Id))
                                .ForMember(dest => dest.Char, opt => opt.MapFrom(src => src.C)));

Si on relance un test avec AssertConfigurationIsValid cela fonctionnera et un petit test avec un mapping manuel le confirmera également.

[Fact]
public void TestAutoMappingSimple()
{
    MapperConfiguration config = new(cfg => cfg.CreateMap<MyObject, MyObjectDto>()
                                        .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name))
                                        .ForMember(dest => dest.Identity, opt => opt.MapFrom(src => src.Id))
                                        .ForMember(dest => dest.Char, opt => opt.MapFrom(src => src.C)));
    IMapper mapper = config.CreateMapper();

    MyObject myObject = new()
    {
        C = 'a',
        Id = 42,
        Name = "Toto"
    };

    MyObjectDto dto = mapper.Map<MyObjectDto>(myObject);
    dto.Should().NotBeNull();
    dto.Identity.Should().Be(42);
    dto.FullName.Should().Be("Toto");
    dto.Char.Should().Be('a');
}

Niveau perf, on reste dans le même ordre de grandeur que sans utilisation du ForMember.

Avec cette méthode on peut aussi faire du mapping vers un objet destination « flattened » (aplati) :

public class ComplexObject
{
    public SimpleObject Simple { get; set; }
}
    
public class SimpleObject
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class FlattenedObject
{
    public int Id { get; set; }
    public string Name { get; set; }
} 

[Fact]
public void TestFlatteningAuto()
{
    MapperConfiguration config = new(cfg => cfg.CreateMap<ComplexObject, FlattenedObject>()
                                        .ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Simple.Name))
                                        .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Simple.Id)));
    config.AssertConfigurationIsValid();
} 

Il est possible d’utiliser la méthode ReverseMap pour faire l’inverse, d’un objet flat vers un objet composé :

[Fact]
public void TestReverseFlatteningAuto()
{
    MapperConfiguration config = new(cfg => cfg.CreateMap<ComplexObject, FlattenedObject>()
                                    .ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Simple.Name))
                                    .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Simple.Id)).ReverseMap());
    IMapper mapper = config.CreateMapper();

    FlattenedObject flat = new()
    {
        Id = 42,
        Name = "Toto"
    };

    ComplexObject dto = mapper.Map<ComplexObject>(flat);
    dto.Should().NotBeNull();
    dto.Simple.Id.Should().Be(42);
    dto.Simple.Name.Should().Be("Toto");
}

Nesting d’objet

Le cas précédent est proche du nesting, il faut remplacer l’objet flat par un dto ayant le même format que la source :

public class ComplexDto
{
    public SimpleDto Simple { get; set; }
}

public class SimpleDto
{
    public int Id { get; set; }
    public string Name { get; set; }
}

On se dit que ça devrait marcher comme sur des roulettes :

[Fact]
public void TestNestingAuto()
{
    MapperConfiguration config = new(cfg => cfg.CreateMap<ComplexObject, ComplexDto>());
    config.AssertConfigurationIsValid();
}

Mais non, ce n’est pas aussi simple ! Pour que cela fonctionne, il faut aussi définir le mapping avec les objets contenus dans les plus gros objets :

[Fact]
public void TestNestingAuto()
{
    MapperConfiguration config = new(cfg =>
    {
        cfg.CreateMap<ComplexObject, ComplexDto>();
        cfg.CreateMap<SimpleObject, SimpleDto>();
    });
    config.AssertConfigurationIsValid();
}

Niveau perf, c’est toujours assez violent :

Résultat du benchmark :
| Method     | Mean      | Error     | StdDev    |
|----------- |----------:|----------:|----------:|
| AutoMapper | 57.595 ns | 0.3905 ns | 0.3653 ns |
| ManualMap  |  7.112 ns | 0.0502 ns | 0.0419 ns |

N-Objects->1 & 1->N-Objects

Regardons un dernier cas, il y en a plein d’autres que l’on pourrait regarder, mais le but de cet article n’est pas de faire un tutoriel exhaustif sur AutoMapper. J’ai rencontré ce cas récemment et il a été une de mes motivations à écrire cet article (sans parler de celles présentées dans l’introduction).

Imaginons que nous ayons un gros objet qui doit être distribué dans deux objets différents :

public class BigOne
{
    public int A { get; set; }
    public string B  { get; set; }
    public string C { get; set; }
    public int D { get; set; }
    public bool E { get; set; }
}

public class Small1
{
    public int A { get; set; }
    public string B { get; set; }
}

public class Small2
{
    public string C { get; set; }
    public int D { get; set; }
    public bool E { get; set; }
}

On obtient le mapping suivant :

[Fact]
public void TestBigToSmallAuto()
{
    MapperConfiguration config = new(cfg =>
    {
        cfg.CreateMap<BigOne, Small1>();
        cfg.CreateMap<BigOne, Small2>();
    });
    config.AssertConfigurationIsValid();

    IMapper mapper = config.CreateMapper();

    BigOne flat = new()
    {
        A = 42,
        B = "Toto",
        C = "Plop",
        D = 19,
        E = true
    };

    Small1 dto1 = mapper.Map<Small1>(flat);
    dto1.Should().NotBeNull();
    dto1.A.Should().Be(42);
    dto1.B.Should().Be("Toto");

    Small2 dto2 = mapper.Map<Small2>(flat);
    dto2.Should().NotBeNull();
    dto2.C.Should().Be("Plop");
    dto2.D.Should().Be(19);
    dto2.E.Should().Be(true);
}

Il faut faire deux CreateMap, chacun partant du gros objet vers un des petits. Ensuite, il faut mapper le gros objet vers le petit ciblé. Ce n’est pas beaucoup plus compliqué que les exemples vus plus tôt. En terme de perf ça pique un peu plus cependant :

| Method     | Mean      | Error    | StdDev   |
|----------- |----------:|---------:|---------:|
| AutoMapper | 110.18 ns | 0.282 ns | 0.236 ns |
| ManualMap  |  11.66 ns | 0.177 ns | 0.157 ns |

Mais au final ce n’est pas exactement ça que je veux montrer, c’est le cas inverse : plusieurs petits objets vers un plus gros :

[Fact]
public void TestSmallToBigAuto()
{
    MapperConfiguration config = new(cfg =>
    {
        cfg.CreateMap<Small1, BigOne>();
        cfg.CreateMap<Small2, BigOne>();
    });

    IMapper mapper = config.CreateMapper();

    Small1 small1 = new()
    {
        A = 2,
        B = "b"
    };

    Small2 small2 = new()
    {
        C = "c",
        D = 5,
        E = true
    };

    BigOne dto1 = mapper.Map<BigOne>(small1);
    dto1 = mapper.Map(small2, dto1);

    dto1.Should().NotBeNull();
    dto1.A.Should().Be(2);
    dto1.B.Should().Be("b");
    dto1.C.Should().Be("c");
    dto1.D.Should().Be(5);
    dto1.E.Should().Be(true);
}

Le début est similaire aux autres cas, mais c’est au moment de faire le mapping que cela change, il faut d’abord faire un mapping avec le premier objet, puis un deuxième via une surcharge de la méthode Map qui prend en paramètre le deuxième petit objet en source et le gros créé à l’instruction précédente en destination. Ce n’est toujours pas très compliqué, mais je ne trouve pas ça particulièrement intuitif. De plus, la méthode AssertConfigurationIsValid ne fonctionne plus ici, il souhaite avoir l’objet BigOne complet à partir des mapping déclarés et cette façon de faire ne semble pas lui plaire.

Les performances sont du même ordre que précédemment.

Il est dommage qu’il n’y ait pas une façon plus claire de faire ce genre de mapping, sur des objets très complexes cela risque d’être assez lourd à écrire.

Conclusion

En conclusion, un avis plutôt mitigé sur AutoMapper. La promesse est intéressante, ne pas avoir à écrire des mapper, de faire les conversions à la main, se concentrer sur le métier plutôt que sur le code boilerplate.

Pros

  • Les cas triviaux sont très simples à prendre en compte et demandent peu ou pas de code
  • De manière générale il y a moins de code à écrire qu’avec des mappeur manuels
  • Pas de tests unitaires à écrire pour tester chaque mapping

Cons

  • Mauvaises performances
  • Les cas non triviaux demandent d’écrire du code et peuvent revenir plus ou moins à écrire un mapper manuel
  • La complexité des mapper peut vite augmenter avec la taille des objets.

Il faut donc se poser les bonnes questions avant de chercher à l’utiliser :

  • Quelle est la taille du projet ?
  • Est-ce que la performance est un critère primordial ?
  • Est-ce qu’il y a beaucoup d’objets à mapper ?
  • Est-ce que mes objets sont simples ou complexes ?
  • Quelle sera la fréquence de mapping ?

Sur un gros projet, où les performances ne sont pas au premier plan, avec beaucoup d’objets relativement simples, ça peut être pertinent de l’utiliser. À l’inverse, si les performances sont importantes, qu’il y a des conversions à faire régulièrement, qu’il y a peu d’objets, il faudrait plutôt envisager de garder le mapping à la main.

Mais…

Here Comes a New Challenger

Il existe plusieurs alternatives à AutoMapper. Un second article viendra compléter celui-ci avec une analyse de certains de ceux-ci.

Sources

https://docs.automapper.org/en/latest/index.html

Laissez un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.