Vor einem Monat stellte Robert die Sinnfrage bezüglich der Verwendung einer „3-Schichten-Architektur“, dem wohl gängigsten Modell für (kleine) Applikationen und Websites. Auch ich arbeite so. Seit wenigstens 5 Jahren, auch wenn ich damals die Klassen für den Datenzugriff noch nicht Repositories genannt habe.
Vor drei Wochen bin ich an den Punkt gekommen, an dem ich mit meinen Repositories und Linq to Sql als OR-Mapper in ein Dillema kam. Nehmen wir mal folgendes Szenario: es gibt einen Artikel, der von einem User veröffentlicht worden ist. Dieser Artikel kann von anderen Usern kommentiert werden. In Code ausgedrückt sähe das so aus:
1: public class Article
2: {
3: public int ID { get; set; }
4: public string Headline { get; set; }
5: public string Text { get; set; }
6: public User Author { get; set; }
7: public IEnumerable<Comment> Comments { get; set; }
8: }
9:
10: public class User
11: {
12: public int ID { get; set; }
13: public string Name { get; set;
14: }
15:
16: public class Comment
17: {
18: public int ID { get; set; }
19: public User Author { get; set; }
20: public string Text { get; set; }
21: }
Nun wird der Artikel in unterschiedlichen Formen verwendet. Bei einer Auflistung aller Artikel beispielsweise interessiert als Information lediglich die Überschrift und eventuell noch der Name des Autors und die Anzahl der Kommentare. Nicht aber, wer die Autoren der einzelnen Kommentare sind. Was also tun?
Möglichkeit 1:
An Stelle der „echten“ Objekte werden nur die Foreign-Keys verwendet. Article und Comment enthalten also keine Eigenschaft „User Author“, sondern nur „int AuthorID“. Damit ist die Möglichkeit geschaffen die weiteren Informationen bei Bedarf zu holen, man spart sich aber den zeitintensiven Aufwand dies automatisch zu tun.
1: public interface IArticleRepository
2: {
3: Article GetArticleByID(int id);
4: IEnumerable<Article> GetAllArticles();
5: }
6:
7: public interface IUserRepository
8: {
9: User GetUserByID(int id);
10: }
11:
12: public interface ICommentRepository
13: {
14: IEnumerable<Comment> GetCommentsByArticle(int articleID);
15: }
Möglichkeit 2:
Man nutzt die Objekte wie oben dargestellt und lädt immer alle Informationen und mappt diese dann auch.
Möglichkeit 3:
Man nutzt „Lazy Loading“, ein Design Pattern, welches kurz gesagt dafür sorgt, dass die benötigten Informationen erst dann geladen werden, wenn sie auch benötigt werden. Bei der Auflistung aller Artikel würden die Autoren der Kommentare zu den Artikeln also nicht geladen, bei der Detaildarstellung des Artikels plus aller seiner Kommentare hingegen schon. Den Aufrufer muss das aber nicht interessieren, er nimmt sich, was er benötigt und was das API hergibt.
Bisher
Die Realität sah bei mir jahrelang so aus, wie in 1. beschrieben. Im Web muss man einfach noch mehr auf Performance achten, als anderswo. Doch das hat natürlich massive Implikationen auf das „Design“ der Anwendung, wenigstens auf die Art und Anzahl der Methoden des Repositories. Einfach weil man für jeden (Sonder-)Fall eigene Zugriffsmethoden braucht, insbesondere bei Collections.
Irgendwann kam mir dann die Erkenntnis, dass „richtige“ Objekte natürlich attraktiver sind und man sich so einen Haufen Methoden spart. Nur ist es ziemlich gefährlich und blödsinnig, beim Kommentar den User nur bei GetArticleByID() zu setzen, nicht aber bei GetAllArticles(). Das funktioniert höchstens so lange, wie man selbst damit arbeitet, und auch nur am Anfang. Nach 2 Monaten bemerkt man erst bei der nächsten NullReferenceException, dass der Autor nicht gesetzt ist. Und welche Implikationen hat es dann, wenn man ihn nun doch setzt? Also erweitert man das Repository um eine weitere Methode ...
Dass das keinen Sinn macht, war schnell klar. Also füllte ich einfach jedes Objekt, so dass es in jeder Situation verfügbar war. Die Performance-Implikationen wurden daraufhin sofort deutlich. Um beim Beispiel zu bleiben: Nehmen wir drei Artikel mit insgesamt 50 Kommentaren. Für die Darstellung dieser drei Artikel in der Übersicht wird nun 53 Mal IUserRepository. GetUserByID() aufgerufen – obwohl 50 Aufrufe davon völlig blödsinnig sind.
Dagegen hilft dann nur massives Caching – was aber nicht die Aufrufe reduziert und damit das Grundübel beseitigt, sondern lediglich den Effekt der einbrechenden Performance kaschiert.
Der neue Ansatz
Da ich nichts davon halte, die vom OR-Mapper generierten Objekte als mein „Model“ in der gesamten Anwendung zu verwenden, habe ich schon immer eigene POCOs verwendet, auf die dann gemappt wurde.
Früher schon mit dem SqlDataReader, in den letzten Jahren dann mit Linq to Sql. Dazu habe ich am Repository immer eine harte Grenze gezogen: das heißt ich habe immer List<> statt IEnumerable oder IQueryable geliefert, um zu verhindern, dass Datenzugriffe zu einem späteren Zeitpunkt ausgeführt werden und zu ungewünschten Seiteneffekten führen (wenn die Verbindung zur Datenbank z.B. wieder geschlossen ist). Das alles verhinderte auch den leichten Einsatz von Lazy Loading.
Also war es an der Zeit sich nach Alternativen umzusehen. Meine Wahl fiel dabei auf Fluent NHibernate und Linq to NHibernate. Das hat mich zwar viele Stunden und sicher einige graue Haare gekostet (insbesondere bei den Mapping-Tests), doch unterm Strich habe ich genau das erhalten, was ich haben wollte: Ich kann meine eigenen Objekte weiterverwenden und dabei zu 100% Lazy Loading nutzen.
Das hat natürlich enorme Auswirkungen auf das Design, da viele Methoden der vorhandenen Repositories wegfallen. Im Grunde benötige ich jetzt nur noch das:
1: public interface IArticleRepository
2: {
3: Article GetArticleByID(int id);
4: IEnumerable<Article> GetAllArticles();
5: }
Und wie ist das nun mit den Repositories?
Der Charme, der Repositories innewohnt, bassiert auf dem Gedanken, dass man die dahinter liegende Technologie für den Datenzugriff beliebig austauschen kann. Z.B. habe ich kürzlich eine zwei Jahre alte Komponente erweitert, bei der ich damals noch mit ADO.NET und Stored Procedures arbeitete [sic!]. Hier war es kein Problem neue Funktionen mit Linq to Sql im Repository zu erweitern.
Allerdings funktioniert das immer nur dann, wenn man keine massiven Feature-Unterschiede zwischen den Technologien hat, deren Vorteile man dann hinter den Mauern des Repositories verstecken muss. Ein Punkt, vor dem Ayende gewarnt hat, und der auch Anlass für Roberts Post gewesen ist. Und welcher nun erreicht ist.
Mit jeder Technologie, das wird hier deutlich, ändern sich sehr wahrscheinlich auch die Anforderungen an das Repository bzw. sein API. Wo ich mit Linq to Sql mangels Lazy Loading zwangsläufig andere bzw. mehr Methoden benötigte, reduziert sich das mit NHibernate ganz deutlich.
In diesem, zugegeben sehr reduzierten, Beispiel entfallen nun sogar IUserRepository und ICommentRepository komplett, da die hier bereitgestellten Methoden nicht mehr benötigt werden – das Mapping von NHibernate macht sie obsolet.
Was mich zur nächsten Frage bringt:
Kann man auf Repositories nicht gänzlich verzichten?
Der Hauptkritikpunkt aus der Praxis, die stupiden Aufrufweiterleitungen aus der Businessschicht, die auf den ersten Blick nur Redundanzen im Code erzeugen, bleibt ja bestehen. Und wenn die einfache Austauschbarkeit entfällt und das API der Repositories und damit auch deren Nutzung in den Business-Klassen bei einer Änderung sowieso mit hoher Wahrscheinlichkeit geändert werden müssen, dann kann man doch gleich darauf verzichten und wie von Ayende vorgeschlagen NHibernate direkt dort verwenden. Oder?
Ein Punkt bleibt: die Testbarkeit. Der zweite massive Vorteil der Repositories sind ihre lose Koppelung vom Rest der Anwendung, was auch bedeutet, dass sich die Business-Klassen sehr einfach testen lassen. Man mockt den Datenzugriff dort einfach weg und spart sich eine Menge Overhead.
Wie ist das nun mit NHibernate? Ich fasse mich kurz: ISession lässt sich natürlich mocken, da aber der Zugriff auf das mir lieb gewonnene Linq to NHibernate über eine Extension-Method funktioniert, ist es unmöglich den Datenzugriff ohne Verwendung einschlägiger kommerzieller Mocking-Tools zu faken.
Was bliebe wäre der Einsatz einer echten Datenbank, was aus den Unit- nicht nur Integrationstests machen würde sondern auch viel Performance und Zeit kostet. Zumal ich aus meinem steinigen Weg hin zu funktionierenden Mapping-Tests weiß, dass es einen massiven Unterschied macht, ob man nun eine In-Memory-SQLite-Datenbank oder eben eine „richtige“ SQL-Server-Datenbank verwendet (wenn man diese auch produktiv einsetzen möchte).
Fazit
Das Konzept von Fluent NHibernate und NHibernate überhaupt gefällt mir gut, nicht nur weil es mir die Möglichkeiten des Lazy Loadings geschenkt hat und den Code für den Datenzugriff massiv reduziert hat.
Ich werde es definitiv in der Praxis testen, ein größeres Projekt, an dem ich arbeite, ist bereits vollständig umgestellt.
Allerdings werde ich die Zugriffe weiterhin in Repositories kapseln, um meine Business-Klassen so einfach testbar zu halten, wie bisher auch. Zumindest so lang, bis mir jemand einen Weg zeigt, wie die Aufrufe zu mocken sind ;-).