Über unsMediaKontaktImpressum
Jürgen Kotz 08. März 2016

Microsoft .NET: Entity Framework als Datenbank-Zugriffstechnologie in Enterprise-Anwendungen

So setzen Sie eine gelungene Architektur beim Einsatz des Entity Framework in Enterprise-Anwendungen auf!

In den vergangenen Jahren hat sich das Entity Framework im Microsoft .NET-Umfeld als Datenbank-Zugriffstechnologie etabliert. Im Internet finden Sie auch viele Beispiele, die zeigen, was das Entity Framework alles kann. Allerdings findet man nur selten gute Tipps, wie man das Entity Framework im Kontext einer großen Enterprise-Anwendung einsetzt. Fragen wie: "Wie kann ich Interfacedefinitionen im Kontext des Entity Framework nutzen?" oder "Wie soll ich meine Datenbankkontexte definieren um keine großen Performanceeinbußen zu erleiden?" werden meistens nicht beantwortet oder erst gar nicht angesprochen. Dieser Artikel gibt Ihnen einen Überblick, wie Sie eine saubere Architektur für Ihre Anwendung aufbauen können.

Architektur einer Enterprise-Anwendung

In einer sauberen Architektur ist es wichtig, ein Modell zu finden, das wenige bis gar keine Abhängigkeiten zwischen den verschiedenen Modulen oder auch Bibliotheken besitzt. Bibliotheken, die sich gegenseitig referenzieren, sind zumeist der Anfang vom Ende und sollten tunlichst vermieden werden. Aus diesem Grunde erstellt man eine Interfacebibliothek, in der alle in der Applikation verwendeten Typen als Interface implementiert sind. Über diese Bibliothek kann man alle zu verwendenden Typen verfügbar machen, ohne Abhängigkeiten in der konkreten Implementierung zu generieren. Diese Interfacebibliothek muss danach natürlich auch in allen verwendeten Bibliotheken mit den konkreten Implementierungen referenziert werden.

Das bedeutet auch, dass das Domainmodell, sprich die Klassen, die vom Entity Framework als Datencontainer verwendet werden, innerhalb dieser Interfaces definiert werden. Und hier gibt es bereits den ersten Stolperstein: Betrachten wir die in Listing 1 angezeigte Interfacedefinition eines Kunden und eines Bestellobjektes.

Listing 1 zeigt die Interfacedefinition für ein Objekt ICustomer und ein Objekt IOrder:

public interface ICustomer
    {
        int CustomerId { get; }
        string CompanyName { get; set; }
        string   City { get; set; }
        string Country { get; set; }
        IEnumerable<IOrder> Orders { get; set; }
    }
public interface IOrder
    {
        int OrderId { get; }
        ICustomer Customer { get; set; }
        DateTime OrderDate { get; set; }
    }

Die letzte Property innerhalb ICustomer bildet dabei eine Relation zu einem Orderobjekt ab. Ein Kunde hat also eine Liste von Bestellungen, wie man es aus vielen Szenarien heraus kennt. Ebenso besitzt das Interface IOrder eine Property Customer, um die Beziehung zum Kunden herstellen zu können.

Wenn Sie nun die konkreten Klassen Customer und Order generieren, wird Ihr Code in etwa wie in Listing 2 dargestellt aussehen.

Listing 2 zeigt die konkrete Implementierung der Klasse Customer:

public class Customer:ICustomer
    {
        public int CustomerId { get; private set; }       
        public string CompanyName{get;set;}
        public string City{get;set;}
        public string Country{get;set;}
        public virtual IEnumerable<IOrder> Orders {get;set;}       
    }
public class Order:IOrder
    {
        public int OrderId {get; private set;}
        public virtual ICustomer Customer{get;set;}
        public DateTime OrderDate{get;set;}       
    }

Die Ordnereigenschaft respektive die Customereigenschaft wurden hier selbstverständlich als virtual definiert, um Lazy Loading des Frameworks zu unterstützen. Wenn wir nun das Modell für diesen Context generieren wollen (s. dazu Listing 3), werden wir die erste böse Überraschung erleben.

Listing 3 zeigt den DbContext für unser Minimodell:

public class SampleContext:DbContext
    {
        public DbSet<Customer> Customers { get; set; }
        public DbSet<Order> Orders { get; set; }
    }

Wie Sie in Abb.1 sehen können, wurden bei der Generierung des Datenbankmodells die Eigenschaften Orders im Kundenobjekt und Customer beim Bestellobjekt schlichtweg ignoriert. Es wurde datenbankseitig auch keine Relation und kein Fremdschlüssel angelegt.

Ein kleiner Tipp am Rande: Um nicht bei jeder Änderung immer gleich die Datenbank neu zu generieren und sich dann in einem Datenbanktool das Modell zu betrachten ob alles stimmt, kann man auch die Entity Framework Power Tools [1] in Visual Studio verwenden. Mit diesen Tools kann man sich das in Abb.1 gezeigte Datenmodell, sowie auch die entsprechenden SQL-Statements zum Generieren der Datenbank anzeigen lassen.

Der Grund, warum die Relation nicht angelegt wurde, liegt darin, dass die Navigationsproperties als Interfaces definiert wurden und das Entity Framework (in der momentanen Version 6.x) damit nicht umgehen kann. Dies bestätigt auch die Recherche wenn man nach Entity Framework und Interfaces im Internet sucht. Sehr häufig wird auch beschrieben, dass dies nicht lösbar sei und man keine Interfacedefinitionen in Kombination mit dem Entity Framework verwenden kann, was jedoch falsch ist. Ansonsten wäre nämlich das Entity Framework für den Einsatz in Enterpriseanwendungen tatsächlich fragwürdig.

Wenn man die Properties einfach auf die konkreten Objekte umstellt, gibt sich der Compiler nicht mehr zufrieden, weil wir das Interface nicht mehr richtig implementieren.

Listing 4 zeigt die korrekte Implementierung der beiden Klassen, so dass das Entity Framework die Relation verstehen und erstellen kann und auch der Compiler zufrieden ist.

Listing 4 zeigt die korrekte Implementierung der beiden Klassen Customer und Order:

public class Customer:ICustomer
    {
        public Customer()
        {
            Orders = new List<Order>();
        }
        public int CustomerId { get; private set; }        
        public string CompanyName{get;set;}
        public string City{get;set;}
        public string Country{get;set;}

        IEnumerable<IOrder> ICustomer.Orders
        {
            get { return Orders; }
            set { Orders = (List<Order>)value;}
        }
        public virtual List<Order> Orders { get; set; }
    }
public class Order:IOrder
    {
        public int OrderId {get;private set;}

        ICustomer IOrder.Customer
        {
            get { return Customer; }
            set {Customer = (Customer)value;}
        }
        public virtual Customer Customer { get; set; }

        public DateTime OrderDate{get;set;}        
    }

Wir haben in beiden Klassen für die Navigationsproperty den konkreten Typ verwendet, so dass das Entity Framework die korrekte Beziehung zwischen beiden Tabellen herstellen kann, wie in Abb.2 zu sehen ist. Außerdem wurde aber zusätzlich noch die Property explizit für das Interface implementiert. Beides wurde im Listing 4 fett dargestellt. Der Getter und Setter in der expliziten Interfaceimplementierung gibt dabei den Wert der konkreten Property zurück, beziehungsweise setzt diesen.

Bei der Typumwandlung im Setter der Orderseigenschaft in der Klasse Customer wandeln wir IEnumerable<ICustomer> in List<Customer> um. Das funktioniert aber aufgrund von Kovarianz tatsächlich nur, wenn im Interface die Auflistung als IEnumerable<T> definiert wurde. Jegliche andere Definition wie zum Beispiel als IList<T> oder ICollection<T> würde an dieser Stelle zu einem Compilerfehler führen. Definieren Sie also prinzipiell in Ihren Interfaces die Auflistungen als IEnumerable<T>. Als Alternative würde auch noch IQueryable<T> gehen, da dies auch kovariant ist.

Ein Context oder doch lieber verteilte Contexte

In Enterpriseanwendungen wird Ihr Domainmodell mit sehr großer Wahrscheinlichkeit aus mehr als zwei Objekten bestehen. 100 und mehr Objekte sind da keine Seltenheit. Im ersten Teil dieses Artikels ging es vor allem darum, wenig Abhängigkeiten zu generieren und deswegen stellt sich die Frage: Will man tatsächlich das gesamte Modell in einen einzigen Context packen, oder soll man doch lieber mehrere, sogenannte verteilte Contexte bilden, die in den einzelnen Modulen verwendet werden? Dabei wird es nicht ungewöhnlich sein, dass verschiedene Objekte auch in verschiedenen Contexten öfters vorkommen können.

Ich würde Ihnen auf jeden Fall empfehlen, verteilte und auch kurzlebige Contexte zu verwenden. Das bedeutet, Sie haben in Ihrer Applikation nicht einen großen Kontext, sondern mehrere verschiedene themenbezogene Kontexte, die jedoch allesamt in dieselbe Datenbank schreiben und Daten lesen.

Erweitern wir unser Beispiel noch um eine kleine Produktverwaltung. Diese läuft in der Anwendung völlig unabhängig von Kunden und Bestellungen. Listing 5 zeigt die Implementierung des Produktobjektes.

Listing 5 zeigt die Interfacebeschreibung und die konkrete Klasse Product:

public interface IProduct:
    {
        int Id { get; }
        string Name { get; set; }
        double Price { get; set; }
    }

public class Product : IProduct
    {
        public int Id { get; private set; }
        public string Name { get; set; }
        public double Price { get; set; }
    }

Listing 3 zeigte eine Klasse SampleContext in der unsere beiden bisherigen DbSets Customers und Orders definiert wurden. Da wir jetzt verschiedene Kontexte verwenden, benenne ich diese Klasse in einen etwas sprechenderen Namen wie CustomerContext um. Außerdem füge ich einen weiteren Context ProductContext hinzu, der das Produkt als DbSet bekanntgibt. Die beiden Contexte sehen Sie in Listing 6.

Listing 6 zeigt zwei verteilte Datenbankcontexte:

public class CustomerContext:DbContext
    {
        public DbSet<Customer> Customers { get; set; }
        public DbSet<Order> Orders { get; set; }
    }

public class ProductContext : DbContext
    {
        public DbSet<Product> Products { get; set; }        
    }

Jetzt stellt sich natürlich noch die Frage, wie das mit dem Zugriff auf die Datenbank ist und wie es mit Migrationsstrategien ausschaut, wenn sich Teile des Modells verändern.

Ohne zu weit vorgreifen zu wollen werde ich später noch auf Repositories eingehen und dabei feststellen, dass die Datenbankkontexte gekapselt werden sollen und nur die Repositories Wissen über diese Kontexte haben.

Deshalb können wir einen weiteren Context definieren, der alle DbSets beinhaltet, jedoch in keinem einzigen Repository später verwendet wird. Dieser komplette Context ist nur für die Migrationsstrategie meiner Datenbank verantwortlich und alle anderen reellen Contexte werden mit der Migration nichts zu tun haben. Listing 7 zeigt die Definition des DbContextes mit allen DbSets und der zugehörigen Migrationskonfiguration.

Listing 7 – Kompletter SampleContext mit Konfiguration:

internal class SampleContext : DbContext
    {
        public SampleContext()
            : base("name=SampleDatabase")
        { }
        
        static SampleContext()
        {
            Database.SetInitializer(
               new MigrateDatabaseToLatestVersion<SampleContext, Configuration>());
        }

        public DbSet<Customer> Customers { get; set; }
        public DbSet<Order> Orders { get; set; }
        public DbSet<Product> Products { get; set; }        
    }

internal sealed class Configuration : DbMigrationsConfiguration<SampleContext>
    {
        public Configuration()
        {
            AutomaticMigrationsEnabled = false;
        }
    }

In Listing 7 sehen wir den Context mit allen DbSet-Definitionen. In einem Konstruktor wird dabei auf den in der Konfigurationsdatei hinterlegten Connectionstring verwiesen. In einem statischen Konstruktor wird die für diesen Context zu verwendende Migrationsstrategie (hier Migration auf die aktuellste Version) definiert und auf die entsprechende Configurationsklasse verwiesen.

Jetzt müssen wir unseren bestehenden Contexten nur noch beibringen, dass Sie für die Migration oder für das Erstellen der Datenbank nicht verantwortlich sind. Um diesen Code nicht in vielen verschiedenen Contexten abzubilden, kann man dafür eine abstrakte Basisklasse implementieren. Diese Klasse sowie die von Ihr abgeleiteten DbContexte sind in Listing 8 dargestellt.

Listing 8 – Abstrakter Basiscontext mit den entsprechenden Implementierungen:

internal abstract class BaseContext<TContext> : DbContext where TContext : DbContext
    {
        static BaseContext()
        {
            Database.SetInitializer<TContext>(null);
        }
        protected BaseContext()
            : base("name=SampleDatabase")
        {

        }
    }


internal class CustomerContext:BaseContext<CustomerContext>
    {
        public DbSet<Customer> Customers { get; set; }
        public DbSet<Order> Orders { get; set; }
    }

internal class ProductContext : BaseContext<ProductContext>
    {
        public DbSet<Product> Products { get; set; }        
    }

Somit kann man jetzt sehr einfach verteilte DbContexte nutzen und hat wiederum weniger Abhängigkeiten in seinen Modulen. Manchmal hat man auch das Problem; dass man Überschneidungen von bestimmten Klassen in verschiedenen Kontexten hat, wobei die entsprechenden Überschneidungen in den meisten Kontexten nur zum Lesen benötigt werden. Hier kann man die entsprechenden Objekte zusätzlich als schreibgeschützte Entitätsklassen definieren.

Im Listing 9 sehen Sie eine Beispielimplementierung einer CustomerReference-Klasse, die eine read only-Variante der Customer-Klasse ist.

Listing 9 – Schreibgeschütztes Referenzobjekt der Klasse Customer:

[Table("Customers")]
public class CustomerReference
    {
        public int CustomerId { get; private set; }        
        public string CompanyName{get; private set;}
        public string City{get; private set;}
        public string Country{get; private set;}

   }

Wie Sie in Listing 9 sehen können, sind alle Setter der Properties als private definiert. Außerdem wurde auf die Implementierung des Interface verzichtet (würde mit den private Settern zu Problemen führen). Selbstverständlich kann man bei Bedarf auch für solche Objekte eine entsprechende Interfacedefinition definieren.

Das entscheidende ist jedoch das Table-Attribut, das dem Entity Framework mitteilt, aus welcher Tabelle die Daten geladen werden sollen. Der in Listing 6 dargestellte ProductContext könnte, wie in Listing 10 dargestellt, erweitert werden.

Listing 10 – Erweiterung des ProductContext:

public class ProductContext : DbContext
    {
        public DbSet<Product> Products { get; set; }
        public DbSet<CustomerReference> Customers { get; set; }        
    }

Repositories – der Schlüssel, um den DataLayer zu abstrahieren

Die nächste Fragestellung bezieht sich auf die Abhängigkeit der einzelnen Module zum Entity Framework. Wollte man die entsprechenden Contexte jetzt direkt in den anderen Modulen nutzen, so müsste in jedem dieser Module auch das Entity Framework bekannt gemacht werden. Ein späteres Austauschen des DataLayers wird somit große Bauchschmerzen verursachen. Stattdessen empfiehlt es sich, den DataLayer zu abstrahieren und in keinem Modul eine Abhängigkeit zum Entity Framework zu generieren. Repositories sind dabei der Schlüssel, um den DataLayer zu abstrahieren. Repositories sind also weitere Klassen, über die der Zugriff auf die Datenbank gesteuert wird. In diesen Repositories kann man die Funktionalität kapseln, die ein Benutzer zum Lesen bzw. Schreiben in die Datenbank aus den verschiedenen Modulen heraus benötigt. Es können spezielle Methoden bereitgestellt werden, um häufig benutzte Abfragen sehr einfach bereitzustellen, ohne immer wieder komplexe LINQ-Abfragen in den Modulen erstellen zu müssen.

Innerhalb des Repositories wird der entsprechende Datenbankkontext verwendet, die Kapselung des Contextes erfolgt jedoch über das Repository, so dass in den Modulen der tatsächliche DbContext nicht bekannt ist. Dies hat auch sehr große Vorteile beim Testing, da der gesamte DataLayer komplett gemockt werden kann.

Ein wesentlicher Punkt bei der Implementierung der Repositories, der natürlich sehr viel Auswirkungen auf die Performance hat, ist, dass LINQ-Abfragen tatsächlich auf der Datenbank ausgeführt werden und nicht erst alle Daten aus der Datenbank geholt werden und die Selektion der LINQ-Abfrage dann lokal durchgeführt wird. Dies wird dadurch gewährleistet, dass die entsprechenden Methoden des Repositories einen Rückgabetyp vom Typ IQueryable<T> besitzen.

Auch hier empfiehlt es sich, wieder Interfacedefinitionen zu definieren, über die ein lesendes und schreibendes Repository implementiert werden kann. In der tatsächlichen Implementierung können dann natürlich spezifische kontextabhängige Methoden beliebig hinzugefügt werden. Listing 11 und 12 zeigen dabei die entsprechenden Interfacedefinitionen.

Listing 11 – Beispiel einer Interfacedefinition für ein readonly-Repository:

/// <summary>
/// generisches Interface für Repositories
/// </summary>
/// <typeparam name="T">Typ des DatenContextes</typeparam>
public interface IEntityReadOnlyRepository<T> : IDisposable
{
    /// <summary>
    /// Gibt eine abfragbare Liste aller Entitäten zurück
    /// </summary>
    IQueryable<T> All { get; }
    /// <summary>
    /// gibt eine abfragbare Liste aller Entitäten mit angegebenen Relationen zurück
    /// </summary>
    /// <param name="includeProperties">zu inkludierende Relationsdaten</param>
    /// <returns>abfragbare Liste von Enitäten mit enthaltenen Relationen</returns>
    IQueryable<T> AllIncluding(params Expression<Func<T,object>>[] includeProperties);
    /// <summary>
    /// Gibt eine Entität mit einer übergebenen ID zurück
    /// </summary>
    /// <param name="id">ID der Entität</param>
    /// <returns>gefundene Entität</returns>
    T Find(int id);       
}

Listing 12 – Beispiel einer Interfacedefinition für ein Repository mit Schreibzugriff:

/// <summary>
/// generisches Interface für Repositories
/// </summary>
/// <typeparam name="T">Typ des DatenContextes</typeparam>
public interface IEntityRepository<T>:IEntityReadOnlyRepository<T>
{       
    /// <summary>
    /// fügt eine Entität in den Context ein oder ändert einen bestehenden Knoten
    /// </summary>
    /// <param name="entity">Hinzuzufügende oder zu ändernde Entität</param>
    void InsertOrUpdate(T entity);
    /// <summary>
    /// Löscht einen bestimmte Entität aus der Datenbank
    /// </summary>
    /// <param name="id">ID der zu löschenden Entität</param>
    void Delete(int id);
    /// <summary>
    /// Speichert Änderungen in der Datenbank
    /// </summary>
    void Save();
}

In den beiden Listings 11 und 12 finden Sie grundlegende Funktionen, um aus der Datenbank zu lesen (Listing 11) oder Daten in die Datenbank zurückzuschreiben (Listing 12). Die dargestellten Interfaces sind nur ein Vorschlag und können natürlich beliebig erweitert oder umgestaltet werden. Die Methoden entsprechen in etwa dem, was Ihnen das NuGet-Paket T4Scaffolding [2] generieren würde. Mit diesem NuGet-Paket können Sie sich über die NuGet-Befehlszeile automatisch Repositories generieren lassen.

Nun fehlt zur Abrundung der Sache nur noch ein konkretes Repository, das Sie dann in den Modulen verwenden. Listing 13 zeigt dazu ein Beispiel für das Produktrepository.

Listing 13 – Beispiel für ein Produktrepository:

public class ProduktRepository:IEntityRepository<IProduct>
{
    /// <summary>
    /// interner Context
    /// </summary>
    internal readonly ProductContext context = null;

    public ProduktRepository()
    {
        context = new ProductContext();
    }
                
    public void InsertOrUpdate(IProduct entity)
    {
        if (entity.Id == default(int))
        {
            // New entity
            context.Products.Add((Product)entity);
        }
        else
        {
            // Existing entity                
            context.Entry(entity).State = System.Data.Entity.EntityState.Modified;
        }
        Context.SaveChanges();
    }

    public void Delete(int id)
    {
        Product product = context.Products.Find(id);
        context.Products.Remove(product);
    }

    public void Save()
    {
        context.SaveChanges();
    }

    public IQueryable<IProduct> All
    {
        get { return context.Products; }
    }

    public IQueryable<IProduct> AllIncluding(
           params System.Linq.Expressions.Expression<Func<IProduct,
           object>>[] includeProperties)
    {
        IQueryable<IProduct> query = context.Products;
        foreach (var includeProperty in includeProperties)
        {
            query = query.Include(includeProperty);
        }
        return query;
    }

    public IProduct Find(int id)
    {
        return (IProduct)context.Products.Find(id);
    }

    public void Dispose()
    {
        context.Dispose();
    }
}

Wie Sie in Listing 13 sehen, wird der DbContext nur intern verwendet und somit sind der Datalayer und die gesamte Entity Frameworkschicht komplett gekapselt. Des Weiteren werden in den Methoden nur Interfacetypen verwendet, was einen weiteren Vorteil in der Reduzierung von Abhängigkeiten bietet. Die tatsächlichen Domainobjekte werden nur intern verwendet.

Lebensdauer von Contexten

Schrecken Sie nicht davor zurück, die Lebensdauer von Contexten oder Repositories so kurz wie möglich zu halten. Sie können gerne für jeden Dialog eine neue Instanz des Repository und somit auch des Contextes instanzieren. Sie verlieren damit zwar den Vorteil, dass bestimmte Daten schon im Speicher geladen waren und somit nachgeladen werden, doch die Schreibzugriffe werden bei großen und langlebigen Contexten unverhältnismäßig langsam, da der ChangeTracker sehr viele Objekte im Speicher hat und alle überprüfen muss ob eine Änderung erfolgt ist. Diese Überprüfung der Änderung wird nicht nur beim Aufruf der Speicher-Methoden durchgeführt, sondern auch bei jedem Add eines neuen Objektes auf einen DbSet. Vermeiden Sie deshalb eine Liste von Objekten in einer Schleife mittels Add() dem Context hinzuzufügen, sondern verwenden Sie stattdessen auch die AddRange()-Methode, um die Liste sofort in einem hinzuzufügen.

Auch die Fehlersuche wird um einiges einfacher, unter Umständen bringt nämlich die Anwendung bei der Verwendung von globalen Kontexten eine Exception beim Speichern von Daten an einer Stelle, die gar nichts mit dem ursächlichen Problem zu tun hat. All dies können Sie mit kleinen und kurzlebigen Kontexten vermeiden.

Zusammenfassung

In diesem Artikel wollte ich Ihnen zeigen, was Sie bei der Verwendung von Entity Framework in Enterpriseanwendungen beachten sollten. Die korrekte Definition der Interfaces, das Erstellen von verteilten Contexten und die Verwendung von Repositories sollen Ihnen helfen, eine saubere Architektur in Ihre Applikation zu implementieren, so dass Sie in möglichst wenig Stolperfallen im Verlauf der Implementierung Ihrer Applikation stolpern. Für weitere Fragen stehe ich Ihnen gerne zur Verfügung.

Autor

Jürgen Kotz

Jürgen Kotz ist seit über 20 Jahren als freiberuflicher Trainer, Berater, Entwickler und Autor tätig. Mit .NET beschäftigt er sich seit der ersten Betaversion.
>> Weiterlesen
Bücher des Autors:

botMessage_toctoc_comments_9210