Kurz vorgestellt: Automapper

Kurz vorgestellt: Automapper
Page content

Jeder Entwickler kennt es und manchmal besteht daraus der größte Teil der Arbeit: Daten und Eigenschaften von Klasse A nach Klasse B übertragen. Wie können wir den Aufwand reduzieren und den Code robuster bzw. sicherer machen?

Die Projektstruktur

Das “Problem” bzw. der zusätzliche Aufwand entsteht durch die Trennung der Schichten einer Architektur.

Nehmen wir folgendes Beispiel:

  • Eine Anwendung hat eine Datenbankanbindung und nutzt einen ORM wie Entity Framework oder NHibernate, um die Tabellen und Daten komfortabel im Code verwalten zu können.
  • Dann gibt es die Anwendungslogik, welche die Vermittlerschicht ist. Diese Zwischenschicht lädt und speichert die Informationen in das Datenbankobjekt.
  • Als dritte Ebene gibt es eine externe Schnittstelle, welche die Daten an die “Oberfläche” bringt. Dies kann beispielsweise ein ViewModel in klassischen Desktopanwendungen oder eine WebAPI sein.

Das Datenmodell kann (und wird) sehr wahrscheinlich auf jeder Ebene etwas anders aussehen.

# Ebene Beschreibung
1 Datenbank Diese Klasse ist die Datenbanktabelle 1:1
2 Anwendungslogik Hier wird die reine Datenklasse (Datenmodell) in eine Klasse überführt, die ggf. Logik beinhaltet oder die Darstellung aufbereitet
3 Darstellung Dieses Objekt besitzt Zusatzeigenschaften, welche ggf. aus weiteren Quellen ermittelt wurden. Dadurch soll 1 gesamtes Objekt an den Client übergeben werden

Zudem ist diese Trennung wichtig, da sie die Darstellungsschicht von der Datenbankschicht trennt. Wenn sich die Datenhaltung ändert, bleiben die Schnittstellen zwischen 2 und 3 unangetastet. Im Optimalfall müssen nur wenige Klassen in Schicht 2 angepasst werden, um die gleichen Informationen wie vorher zu liefern.

Mapping im Detail

Zum besseren Verständnis verwende ich die Klassen CustomerEntity und CustomerDto. CustomerEntity ist das Modell aus der Datenbank. CustomerDto ist das Data Transfer Object, welches in der Businesslogik verwendet wird. Mit den Attributen verdeutliche ich die Unterschiede, wenn wir den Code-First Ansatz nutzen.

[Table("Customers")]
public class CustomerEntity {

  [Key]
  [Column("Id")]
  public int Id { get; set; }

  [MaxLength(250)]
  [Column("FirstName")]
  public string FirstName { get; set; }

  [MaxLength(250)]
  [Column("LastName")]
  public string LastName { get; set; }

}

public class CustomerDto {

  public int Id { get; set; }
  public string FirstName { get; set; }
  public string LastName { get; set; }

  public string GetFullName() {
    return $"{FirstName} {LastName}";
  }

}

Die Standardprozedur bei einem Datenzugriff sieht meistens so aus:

public class DataProvider {

  public DataProvider(MyDatabaseContext database) {
    _database = database;
  }

  public CustomerDto GetCustomerById(int id) {
    var customer = _database.Customers.GetById(id);

    var result = new CustomerDto() {
      Id = customer.Id,
      FirstName = customer.FirstName,
      Lastname = customer.LastName
    };

    return result;
  }

}

Diese manuelle Übertragung der Daten von einer Klasse in die andere kann viele Probleme bereiten:

  • Properties werden bei großen Tabellen oder Klassen vergessen
  • Es werden neue Properties in der Tabelle hinzugefügt, die im DTO fehlen
  • Ein Mapping wird an mehreren Stellen benötigt und der Code wurde kopiert

Eine mögliche Lösung ist das Erstellen einer CreateInstance-Methode, welchem ein Customer übergeben wird und wir dafür ein CustomerDto erhalten.

public static CustomerDto GetInstance(Customer customer) {
  return new CustomerDto() {
    Id = customer.Id
    FirstName = customer.FirstName,
    Lastname = customer.LastName
  };
}

Doch was ist mit der Umwandlung in die andere Richtung, wenn es eine CreateCustomer Methode gibt, wo wir das DTO in die Datenbankklasse umwandeln müssen? Auch hier kann wieder eine Methode wie Customer GetInstance(CustomerDto customerDto) erstellt werden. Das Problem, das neue Properties vielleicht vergessen werden, bleibt dennoch bestehen. Man kann auch hier argumentieren: Wir schreiben Tests und Funktionen, die sicherstellen, dass das nicht passiert. Wozu aber den Code doppelt schreiben, wenn es dazu bereits ein etabliertes Projekt gibt?

Automapper

Das Projekt findet ihr auf Github: https://github.com/AutoMapper/AutoMapper

Automapper bietet verschiedene Funktionen:

  • Konventionsbasiertes Mapping (Anhand des Namens)
  • Mappen von den verschiedensten Typen und Klassen
  • Regeln für das Mapping (z.B. Konvertierungen)
  • Validieren der Mappings

Vor allem der letzte Punkt ist aus meiner Sicht wichtig, auf welchen ich am Ende des Artikels eingehen werde. Grundsätzlich wird Automapper wie folgt verwendet:

// Erstellen der Konfiguration, was gemappt werden soll
var configuration = new MapperConfiguration(x => {
  // Mappe 'Customer' -> 'CustomerDto'
  x.CreateMap<Customer, CustomerDto>();
};

// Erstellen der Instanz anhand der Konfiguration
var mapper = configuration.CreateMapper();

var customer = new Customer() {
  Id = 5,
  FirstName = "John",
  LastName = "Doe"
};

var customerDto = mapper.Map<CustomerDto>(customer);

Grundsätzlich könnt ihr erstmal alle Klassen mit allen Klassen kombinieren. Automapper verwendet beim Mapping Reflection, um die Typen und Namen der Properties zu ermitteln und wenn die Namen (wie hier: Id, FirstName, LastName) identisch sind, werden diese gemappt.

Was passiert, wenn die Klassen unterschiedliche Properties haben? Solche Abweichungen lassen sich ebenfalls im Automapper konfigurieren:


public class CustomerDto2 {
  public int Id { get; set; }
  public string GivenName { get; set; }
  public string Surname { get; set; }
  public int AdditionalProperty { get; set; }
}

var configuration = new MapperConfiguration(x => {
  x.CreateMap<Customer, CustomerDto2>()
     // Customer.FirstName -> CustomerDto2.GivenName
     .ForMember(destination => destination.GivenName, options => options.MapFrom(source => source.FirstName))
     // Customer.LastName -> CustomerDto2.Surname
     .ForMember(destination => destination.Surname, options => options.MapFrom(source => source.LastName))
     // Das Property wird beim Mapping ignoriert
     .ForMember(destination => destination.AdditionalProperty, options => options.Ignore());
};

Wie ihr seht, ist die Syntax recht einfach aufgebaut und gut lesbar. Zudem bieten die Optionen viele Möglichkeiten das Mapping den eigenen Bedürfnissen anzupassen. Für Details empfehle ich in der sehr guten Dokumentation nachzuschlagen.
Am Rande erwähnt: Es gibt auch eine Möglichkeit, das Mapping über Mapping-Attribute zu steuern. Aus meiner Erfahrung heraus kommt diese bei komplexen Strukturen schnell an die Grenzen. Zudem bevorzuge ich es, wenn Konfigurationen oder Mappings an einer zentralen Stelle hinterlegt sind und jeder einen schnellen Einstieg finden kann.

Validieren der Konfiguration

Wie anfangs erwähnt, funktioniert das Konfigurieren immer und liefert keine Fehler. Die Funktion mapper.Map<T>(object) erzeugt allerdings zur Laufzeit eine Fehlermeldung, wenn Properties fehlen oder die Klassen inkompatibel sind.

Für einen Unit-Test bietet Automapper die Funktion AssertConfigurationIsValid() an und der Test ist schnell geschrieben:

// Diese Klasse beinhaltet die Konfiguration und stellt den Mapper bereit
public class MyAutomapperHelper {
  private static MapperConfiguration _configuration = new MapperConfiguration(x => {
    x.CreateMap<Customer, CustomerDto>();
    x.CreateMap<Customer, CustomerDto2>()
     .ForMember(destination => destination.GivenName, options => options.MapFrom(source => source.FirstName))
     .ForMember(destination => destination.Surname, options => options.MapFrom(source => source.LastName))
     .ForMember(destination => destination.AdditionalProperty, options => options.Ignore());
  };

  public static IMapper CreateMapper() {
    return _configuration.CreateMapper();
  }

  public static void AssertConfigurationIsValid() {
    _configuration.AssertConfigurationIsValid();
  }
}

// ----------

using Xunit;

public class AutomapperHelperTest {

  // Hier testen wir die Konfiguration
  [Fact]
  public void VerifyMappingIsValid() {
    MyAutomapperHelper.AssertConfigurationIsValid();
  }
}

Wenn das Mapping in Ordnung ist, läuft der Test einfach durch und ist grün. Falls das Mapping einen Fehler hat, wirft diese Funktion eine Exception, welche den Test fehlschlagen lässt. Was mir an dem Projekt sehr gut gefällt ist die ausführliche Fehlermeldung. Anbei findet ihr ein Beispiel:

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
==================================================================================
Customer -> CustomerDto2 (Destination member list)
MyProject.Entites.Customer -> MyProject.Dtos.CustomerDto2 (Destination member list)

Unmapped properties:
GivenName

Die Fehlermeldung zeigt euch genau an, welches Mapping betroffen ist. Ihr seht alle Quellen und das jeweilige Ziel, je nach Anzahl der betroffenen Klassen. Unter Unmapped properties: werden alle Properties des Ziels aufgelistet, welche noch kein Mapping haben. Damit habt ihr die Möglichkeit, alle Fehler zu beheben, anstatt peu à peu immer nur ein einzelnes Mapping zu korrigieren und den Test neu laufen lassen zu müssen.

Fazit

Ich habe Automapper schon in einigen Anwendungen eingesetzt und bin damit sehr zufrieden. In vielen Projekten hat es die Struktur vom Code verbessert, weil es nur noch eine zentrale Stelle für alle Mappings und notwendige Transformationen gab. Die Funktion AssertConfigurationIsValid() ist sehr praktisch, um das aktuelle Mapping automatisiert testen zu können. Gebt dem Projekt eine Chance und schaut es euch an :)


Bildnachweis: Pixabay