Freigeben über


Smart Client

Erstellen verteilter Anwendungen mit NHibernate und Rhino Service Bus

Oren Eini

Ich habe lange Zeit fast ausschließlich mit Webanwendungen gearbeitet. Als ich dazu überging, eine Smart Client-Anwendung zu erstellen, musste ich zunächst einmal über den richtigen Ansatz zum Erstellen einer solchen Anwendung nachdenken. Wie wird der Datenzugriff verarbeitet? Wie gestalte ich die Kommunikation zwischen der Smart Client-Anwendung und dem Server?

Außerdem hatte ich bereits sehr viel in ein bestehendes Toolset investiert, das die Entwicklungszeit und -kosten erheblich reduzierte. Diese Tools wollte ich unbedingt weiter verwenden können. Es dauerte eine Weile, die Einzelheiten zu meiner Zufriedenheit zu klären, und währenddessen dachte ich immer wieder daran, wie viel einfacher eine Webanwendung wäre – und sei es nur, weil ich mich mit diesen Anwendungen bereits auskannte.

Smart Client-Anwendungen haben Vor- und Nachteile. Zu den Vorteilen zählt, dass Smart Clients reaktionsfähig sind und die Interaktivität mit den Benutzern unterstützen. Durch die Verschiebung der Verarbeitung auf den Clientcomputer wird zudem die Serverlast reduziert, und die Benutzer können auch bei einer Trennung vom Back-End-System arbeiten.

Auf der anderen Seite bergen solche Smart Clients auch Herausforderungen in sich, wie zum Beispiel die Handhabung der Geschwindigkeit, Sicherheit und Bandbreitenbeschränkungen für den Zugriff auf Daten über das Intranet oder Internet. Zu den weiteren Aufgaben, die bewältigt werden müssen, gehören die Synchronisierung von Daten zwischen Front-End- und Back-End-Systemen, die verteilte Änderungsverfolgung und die Lösung von Problemen, die in einer zeitweise verbundenen Umgebung entstehen.

Eine Smart Client-Anwendung, wie sie in diesem Artikel erläutert wird, kann entweder mit WPF (Windows Presentation Foundation) oder Silverlight erstellt werden. Die Techniken und Ansätze, die ich hier beschreibe, gelten für beide, da Silverlight eine Untermenge von WPF-Features umfasst.

In diesem Artikel beginne ich mit der Planung und Erstellung einer Smart Client-Anwendung, wobei NHibernate für den Datenzugriff und Rhino Service Bus für die zuverlässige Kommunikation mit dem Server verwendet wird. Die Anwendung dient als Front-End für eine Onlineleihbücherei, die ich Alexandria genannt habe. Die Anwendung selbst ist in zwei Hauptbereiche unterteilt. Erstens gibt es einen Anwendungsserver, der einen Satz von Diensten ausführt. Hier ist der größte Teil der Geschäftslogik angesiedelt. Für den Zugriff auf die Datenbank wird NHibernate verwendet. Zweitens gibt es die Benutzeroberfläche des Smart Clients, die diese Dienste auf einfache Art und Weise für die Benutzer bereitstellt.

NHibernate ist ein objektrelationales Zuordnungsframework (O/RM), das entworfen wurde, um die Arbeit mit relationalen Datenbanken genauso einfach zu gestalten wie die mit Daten im Speicher. Rhino Service Bus ist eine Open Source-Dienstbusimplementierung auf Basis von Microsoft .NET Framework, die sich hauptsächlich auf die einfache Entwicklung, Bereitstellung und Verwendung konzentriert.

Verteilung der Aufgaben

Beim Erstellen der Leihbücherei muss zuerst klar getrennt werden, wofür die Front-End- und Back-End-Systeme jeweils verantwortlich sind. Eine Möglichkeit besteht darin, die Anwendung primär auf die Benutzeroberfläche zu fokussieren, sodass der Großteil der Verarbeitung auf dem Clientcomputer stattfindet. In diesem Fall dient das Back-End-System hauptsächlich als Datenrepository.

Im Grunde ist dies nur eine Wiederholung der herkömmlichen Client-/Serveranwendung, bei der das Back-End-System nur als Proxy für den Datenspeicher fungiert. Diese Entwurfsmöglichkeit ist richtig, wenn das Back-End-System nur ein Datenrepository ist. Eine solche Architektur kann zum Beispiel für einen persönlichen Bücherkatalog vorteilhaft sein, da das Anwendungsverhalten auf die Verwaltung von Daten für die Benutzer beschränkt ist und keine serverseitige Änderung der Daten stattfindet.

Ich empfehle für solche Anwendungen die Verwendung von WCF RIA Services oder WCF Data Services (Windows Communication Foundation). Wenn der Back-End-Server eine CRUD-Schnittstelle (Create, Read, Update, Delete) nach außen offenlegen soll, können Sie durch WCF RIA Services oder WCF Data Services die zum Erstellen der Anwendung erforderliche Zeit erheblich reduzieren. Doch obwohl Sie bei beiden Technologien Ihre eigene Geschäftslogik zur CRUD-Schnittstelle hinzufügen können, würde das Implementieren von relevantem Anwendungsverhalten bei diesem Ansatz zu einem anfälligen Durcheinander führen, das nicht verwaltet werden kann.

Ich gehe in diesem Artikel nicht auf diese Anwendungen ein, aber Brad Adams zeigt genau diesen Ansatz zum Erstellen einer Anwendung mithilfe von NHibernate und WCF RIA Services schrittweise in seinem Blog unter blogs.msdn.com/brada/archive/2009/08/06/business-apps-example-for-silverlight-3-rtm-and-net-ria-services-july-update-part-nhibernate.aspx.

Die andere Möglichkeit ist, den Großteil des Anwendungsverhaltens auf dem Back-End-System zu implementieren. Das Front-End dient dabei ausschließlich zur Darstellung. Dies scheint zunächst vernünftig zu sein, da webbasierte Anwendungen normalerweise so geschrieben werden. Allerdings verzichten Sie dabei auf die Vorteile, die das Ausführen einer echten clientseitigen Anwendung bietet. Die Statusverwaltung würde erschwert werden. Im Grunde bedeutet es, dass Sie wieder eine Webanwendung mit allen damit verbundenen Komplexitäten schreiben. Sie haben nicht die Möglichkeit, die Verarbeitung auf den Clientcomputer zu verlagern und Verbindungsunterbrechungen zu verarbeiten.

Noch schlimmer ist, dass sich die Benutzeroberfläche aus der Benutzerperspektive verlangsamt, da alle Aktionen einen Roundtrip zum Server erfordern.

Sicherlich erwarten Sie nun bereits, dass ich in diesem Beispiel einen Ansatz zwischen den beiden Möglichkeiten verfolge. Ich nutze die Möglichkeiten, die die Ausführung auf dem Clientcomputer bietet, führe aber gleichzeitig wichtige Bestandteile der Anwendung als Dienste auf dem Back-End-System aus, wie in Abbildung 1 dargestellt ist.

Figure 2 The Application's Architecture

Abbildung 1: Architektur der Anwendung

Die Beispiellösung besteht aus drei Projekten, die Sie unter github.com/ayende/alexandria herunterladen können. Alexandria.Backend ist eine Konsolenanwendung, die den Back-End-Code hostet. Alexandria.Client enthält den Front-End-Code, und Alexandria.Messages umfasst die gemeinsamen Nachrichtendefinitionen für beide Anwendungen. Zum Ausführen des Beispiels müssen sowohl Alexandria.Backend als auch Alexandria.Client ausgeführt werden.

Ein Vorteil beim Hosten des Back-Ends in einer Konsolenanwendung besteht darin, dass Sie Szenarios mit Trennungen leicht simulieren können, indem Sie einfach die Back-End-Konsolenanwendung schließen und später erneut starten.

Irrtümer der verteilten Verarbeitung

Nach den Architekturgrundlagen möchte ich darauf eingehen, was das Schreiben einer Smart Client-Anwendung mit sich bringt. Die Kommunikation mit dem Back-End findet über ein Intranet oder das Internet statt. Wenn die Tatsache berücksichtigt wird, dass die Hauptquelle für Remoteaufrufe in den meisten Webanwendungen eine Datenbank oder ein weiterer Anwendungsserver im selben Datencenter (häufig im selben Regal) ist, bedeutet dies eine wesentliche Änderung mit mehreren Auswirkungen.

Intranet- und Internetverbindungen leiden unter Problemen in den Bereichen Geschwindigkeit, Bandbreitenbeschränkungen und Sicherheit. Der große Unterschied in den Kommunikationskosten erzwingt eine andere Kommunikationsstruktur als die, die Sie anwenden würden, wenn alle Hauptbestandteile der Anwendung im selben Datencenter existierten.

Zu den größten Hürden, die Sie in Bezug auf verteilte Anwendungen überwinden müssen, zählen die Irrtümer der verteilten Verarbeitung. Entwickler halten beim Erstellen verteilter Anwendungen meist eine Reihe von Annahmen für richtig, die sich letztendlich als falsch herausstellen. Sich auf diese falschen Annahmen zu verlassen führt normalerweise zu reduzierter Leistungsfähigkeit oder sehr hohen Kosten für den Neuentwurf und die Neuerstellung des Systems. Es gibt die folgenden acht Irrtümer:

  • Das Netzwerk ist verlässlich.
  • Die Latenz ist null.
  • Die Bandbreite ist unendlich.
  • Das Netzwerk ist sicher.
  • Die Topologie ändert sich nicht.
  • Es gibt einen Administrator.
  • Die Transportkosten sind null.
  • Das Netzwerk ist homogen.

Jede verteilte Anwendung, die diese Irrtümer nicht berücksichtigt, wird mit Serverproblemen konfrontiert werden. Bei einer Smart Client-Anwendung müssen diese Aspekte im Voraus geklärt werden. Unter diesen Umständen spielt die Verwendung der Zwischenspeicherung eine große Rolle. Auch wenn das Arbeiten im getrennten Modus für Sie nicht von Interesse ist, ist ein Cache zur Steigerung des Antwortverhaltens der Anwendung fast immer nützlich.

Sie müssen sich außerdem mit dem Kommunikationsmodell für die Anwendung befassen. Ein Standarddienstproxy, der das Ausführen von Remoteprozeduraufrufen (Remote Procedure Call, RPC) ermöglicht, ist scheinbar das einfachste Modell, zieht jedoch häufig Probleme nach sich. Er führt zu komplexerem Code zum Verarbeiten eines getrennten Verbindungsstatus und erfordert eine explizite Verarbeitung asynchroner Aufrufe, wenn Sie eine Blockierung im UI-Thread vermeiden möchten.

Back-End-Grundlagen

Als Nächstes gibt es das Problem, das Back-End der Anwendung so zu strukturieren, dass sowohl eine gute Leistung als auch eine bestimmte Trennung von der Art, wie die Benutzeroberfläche strukturiert ist, erreicht wird.

Aus der Leistungs- und Antwortperspektive besteht das ideale Szenario im Ausführen eines einzelnen Aufrufs an das Back-End, um alle für den dargestellten Bildschirm erforderlichen Daten zu erhalten. Das Problem dabei ist, dass Sie schließlich eine Dienstoberfläche erhalten, die genau die Benutzeroberfläche des Smart Clients imitiert. Dagegen sprechen zahlreiche Gründe. In erster Linie ist die Benutzeroberfläche der wandelbarste Bestandteil einer Anwendung. Wenn die Dienstoberfläche auf diese Art an die Benutzeroberfläche gekoppelt wird, erwachsen daraus häufige Änderungen des Dienstes, die nur durch Benutzeroberflächenänderungen entstehen.

Dies wiederum bedeutet, dass die Bereitstellung der Anwendung deutlich erschwert wird. Sie müssen Front-End und Back-End gleichzeitig bereitstellen, und der Versuch, mehrere Versionen gleichzeitig zu unterstützen, erzeugt meistens größere Komplexität. Außerdem kann die Dienstschnittstelle nicht zum Erstellen weiterer Benutzeroberflächen oder als Integrationspunkt für Dienste von Drittanbietern oder zusätzliche Dienste verwendet werden.

Wenn Sie einen Versuch in die andere Richtung unternehmen, d. h. die Erstellung einer detaillierten Standardschnittstelle, steuern Sie direkt auf die Irrtümer zu. Eine detaillierte Schnittstelle führt zu einer hohen Anzahl von Remoteaufrufen, was Probleme mit der Latenz, Verlässlichkeit und Bandbreite nach sich zieht.

Die Antwort auf diese Herausforderung ist die Abkehr vom gebräuchlichen RPC-Modell. Ich verwende einen lokalen Cache und ein nachrichtenorientiertes Kommunikationsmodell, anstatt remote aufzurufende Methoden offenzulegen.

In Abbildung 2 wird das Packen mehrerer Anforderungen vom Front-End an das Back-End gezeigt. Sie können dadurch einen einzelnen Remoteaufruf ausführen, aber serverseitig ein Programmiermodell beibehalten, dass nicht eng an die Anforderungen der Benutzeroberfläche gekoppelt ist.

Figure 2 A Single Request to the Server Contains Several Messages

Abbildung 2: Einzelanforderung an den Server, die mehrere Nachrichten enthält

Zur Steigerung des Antwortverhaltens können Sie einen lokalen Cache hinzufügen, der einige Abfragen umgehend beantwortet und die Anwendung dadurch reaktionsfähiger macht.

In diesen Szenarios müssen Sie unter anderem berücksichtigen, welche Datentypen vorliegen und wie aktuell die angezeigten Daten sein müssen. In der Alexandria-Anwendung verlasse ich mich stark auf den lokalen Cache, da es akzeptabel ist, den Benutzern zwischengespeicherte Daten anzuzeigen, während die Anwendung aktuelle Daten vom Back-End-System anfordert. Andere Anwendungen, beispielsweise für den Aktienhandel, zeigen sicherlich besser gar nichts anstatt veraltete Daten.

Vorgänge bei getrennter Verbindung

Das nächste Problem ist die Behandlung von Szenarios mit getrennten Verbindungen. In vielen Anwendungen können Sie festlegen, dass eine Verbindung obligatorisch ist, d. h. Sie können den Benutzern einfach eine Fehlermeldung zeigen, falls die Back-End-Server nicht verfügbar sind. Allerdings bieten Smart Client-Anwendungen den Vorteil, dass sie offline funktionieren können, und die Alexandria-Anwendung profitiert davon in vollem Maße.

Allerdings wird der Cache dadurch noch wichtiger, da er verwendet wird, um die Kommunikationsgeschwindigkeit zu erhöhen, und Daten aus dem Cache geholt werden, wenn das Back-End-System nicht erreicht wird.

Sie wissen nun viel über die Herausforderungen beim Erstellen einer solchen Anwendung, daher fahre ich mit den Lösungen für diese Probleme fort.

Warteschlangen zählen zu den Favoriten

In Alexandria gibt es keine RPC-Kommunikation zwischen dem Front-End und dem Back-End. Stattdessen wird die gesamte Kommunikation über unidirektionale Nachrichten verarbeitet, die zu Warteschlangen hinzugefügt werden, wie in Abbildung 3 gezeigt wird.

Figure 3 The Alexandria Communication Model

Abbildung 3 Alexandria-Kommunikationsmodell

Warteschlangen stellen eine elegante Methode zur Lösung der zuvor genannten Kommunikationsprobleme dar. Anstelle der direkten Kommunikation zwischen dem Front-End und dem Back-End – und der dadurch erschwerten Unterstützung von Szenarios mit Trennungen – überlassen Sie dies vollständig dem Subsystem für die Warteschlangen.

Die Verwendung von Warteschlangen ist einfach. Sie beauftragen das lokale Warteschlangensubsystem damit, eine Nachricht an eine Warteschlange zu senden. Das Warteschlangensubsystem übernimmt den Besitz der Nachricht und stellt sicher, dass diese an einem bestimmten Punkt ihr Ziel erreicht. Die Anwendung wartet allerdings nicht darauf, dass die Nachricht ihr Ziel erreicht, und kann weiterarbeiten.

Ist die Zielwarteschlange derzeit nicht verfügbar, wartet das Warteschlangensubsystem auf die erneute Verfügbarkeit und übermittelt dann die Nachricht. Das Warteschlangensubsystem speichert die Nachricht bis zu ihrer Übermittlung auf dem Datenträger. Ausstehende Nachrichten erreichen daher auch bei einem Neustart des Quellcomputers ihr Ziel.

Bei der Verwendung von Warteschlangen fällt es leicht, an Nachrichten und Ziele zu denken. Eine Nachricht, die auf einem Back-End-System eingeht, löst eine Aktion aus, die anschließend zum Senden einer Antwort an den ursprünglichen Absender führen kann. Da jedes System vollständig unabhängig ist, gibt es an keinem Ende Blockierungen.

Zu den Warteschlangensubsystemen gehören MSMQ, ActiveMQ und RabbitMQ. Die Alexandria-Anwendung verwendet Rhino Queues (github.com/rhino-queues/rhino-queues), ein mithilfe von Xcopy bereitgestelltes Open-Source-Warteschlangensubsystem. Ich habe Rhino Queues aus dem einfachen Grund gewählt, dass es keine Installation oder Verwaltung erfordert. Es ist daher ideal zur Verwendung in Beispielen und Anwendungen geeignet, die auf vielen Computern bereitgestellt werden müssen. Daneben habe ich Rhino Queues auch geschrieben. Ich hoffe, es gefällt Ihnen.

Einsetzen von Warteschlangen

Wie erhalte ich die Daten für den Hauptbildschirm, wenn ich Warteschlangen verwende? Dies ist die ApplicationModel-Initialisierungsroutine:

protected override void OnInitialize() {
  bus.Send(
    new MyBooksQuery { UserId = userId },
    new MyQueueQuery { UserId = userId },
    new MyRecommendationsQuery { UserId = userId },
    new SubscriptionDetailsQuery { UserId = userId });
}

Ich sende einen Stapel von Nachrichten an den Server und fordere mehrere Informationen an. Hier gibt es einige Punkte zu beachten. Die Nachrichten sind sehr detailliert. Anstatt einer einzelnen, allgemeinen Nachricht wie beispielsweise MainWindowQuery sende ich viele Nachrichten (MyBooksQuery, MyQueueQuery usw.), von denen jede eine sehr spezifische Information anfordert. Wie bereits erläutert, können Sie auf diese Weise sowohl mehrere Nachrichten in einem einzelnen Stapel senden und somit die Netzwerkroundtrips reduzieren als auch die Kopplung zwischen dem Front-End und dem Back-End verringern.

Der Feind heißt RPC

Einer der häufigsten Fehler beim Erstellen einer verteilten Anwendung besteht darin, den Verteilungsaspekt der Anwendung zu ignorieren. WCF beispielsweise erleichtert es, die Tatsache zu ignorieren, dass eine Methode über das Netzwerk aufgerufen wird. Es ist ein sehr einfaches Programmiermodell. Das bedeutet aber auch, dass Sie sehr darauf achten müssen, die Irrtümer der verteilten Verarbeitung zu vermeiden.

Frameworks wie WCF bieten ein Programmiermodell, das dem zum Aufrufen von Methoden auf dem lokalen Computer verwendeten sehr ähnlich ist. Und genau dieser Umstand verleitet zu diesen falschen Annahmen.

Eine Standard-API für RPCs bedeutet Blockierung bei einem Aufruf über das Netzwerk, höhere Kosten für jeden Remotemethodenaufruf und die Möglichkeit von Fehlern, wenn der Back-End-Server nicht verfügbar ist. Es ist sicherlich möglich, auf dieser Grundlage eine gute verteilte Anwendung zu erstellen, erfordert aber größere Sorgfalt.

Wenn Sie einen anderen Ansatz wählen, gelangen Sie zu einem Programmiermodell, das auf explizitem Nachrichtenaustausch basiert, im Gegensatz zum impliziten Nachrichtenaustausch in den meisten SOAP-basierten RPC-Listen. Dieses Modell kann zunächst ungewohnt aussehen, und Sie müssen umdenken. Aber das Ergebnis dieses Umdenkens ist, dass die Komplexität insgesamt deutlich abnimmt.

Meine Alexandria-Beispielanwendung wird auf Basis einer unidirektionalen Messagingplattform erstellt und nutzt diese vollständig. Die Anwendung ist daher bewusst eine verteilte Anwendung und profitiert davon.

Alle Nachrichten enden mit dem Begriff „Query“. Ich verwende dies als einfache Konvention, um reine Abfragenachrichten zu kennzeichnen, die keinen Status ändern und eine Art Antwort erwarten.

Beachten Sie schließlich noch, dass ich anscheinend keine Antwort vom Server erhalte. Da ich Warteschlangen verwende, ist der Kommunikationsmodus „ Fire-and-Forget“. Ich sende jetzt eine Nachricht (oder einen Stapel von Nachrichten) und befasse mich zu einem späteren Zeitpunkt mit den Antworten.

Bevor ich dazu übergehe, wie das Front-End die Antworten behandelt, möchte ich erläutern, wie das Back-End die soeben von mir gesendeten Nachrichten verarbeitet. In Abbildung 4 wird gezeigt, wie der Back-End-Server eine Abfrage für Bücher verarbeitet. Hier können Sie zum ersten Mal sehen, wie ich sowohl NHibernate als auch Rhino Service Bus einsetze.

Abbildung 4: Verarbeiten einer Abfrage auf dem Back-End-System

public class MyBooksQueryConsumer : 
  ConsumerOf<MyBooksQuery> {

  private readonly ISession session;
  private readonly IServiceBus bus;

  public MyBooksQueryConsumer(
    ISession session, IServiceBus bus) {

    this.session = session;
    this.bus = bus;
  }

  public void Consume(MyBooksQuery message) {
    var user = session.Get<User>(message.UserId);
    
    Console.WriteLine("{0}'s has {1} books at home", 
      user.Name, user.CurrentlyReading.Count);

    bus.Reply(new MyBooksResponse {
      UserId = message.UserId,
      Timestamp = DateTime.Now,
      Books = user.CurrentlyReading.ToBookDtoArray()
    });
  }
}

Ich möchte zunächst die Struktur erörtern, in welcher der Code ausgeführt wird, und später detaillierter auf den eigentlichen Code zur Verarbeitung der Nachricht eingehen.

Alles dreht sich um Nachrichten

Rhino Service Bus (hibernatingrhinos.com/open-source/rhino-service-bus) ist, wie schon der Name verrät, eine Dienstbusimplementierung. Es handelt sich um ein Kommunikationsframework auf Basis eines unidirektionalen Nachrichtenaustauschs unter Verwendung von Warteschlangen. Eine wichtige Inspirationsquelle für Rhino Service Bus war NServiceBus (nservicebus.com).

Eine über den Bus gesendete Nachricht kommt an ihrer Zielwarteschlange an, wo ein Nachrichtenconsumer aufgerufen wird. Der Nachrichtenconsumer in Abbildung 4 ist MyBooksQueryConsumer. Ein Nachrichtenconsumer ist eine Klasse, die ConsumerOf<TMsg> implementiert, und die Consume-Methode wird mit der entsprechenden Nachrichteninstanz zur Verarbeitung der Nachricht aufgerufen.

Aufgrund des MyBooksQueryConsumer-Konstruktors vermuten Sie wahrscheinlich schon, dass ich einen IoC-Container (Inversion of Control) verwende, um Abhängigkeiten für den Nachrichtenconsumer bereitzustellen. Im Falle von MyBooksQueryConsumer sind diese Abhängigkeiten der Bus selbst und die NHibernate-Sitzung.

Der eigentliche Code zur Verarbeitung der Nachricht ist einfach. Sie holen den entsprechenden Benutzer aus der NHibernate-Sitzung und senden eine Antwort mit den angeforderten Daten an den Urheber der Nachricht.

Auch das Front-End hat einen Nachrichtenconsumer. Für MyBooksResponse ist dies folgender Consumer:

public class MyBooksResponseConsumer : 
  ConsumerOf<MyBooksResponse> {

  private readonly ApplicationModel applicationModel;

  public MyBooksResponseConsumer(
    ApplicationModel applicationModel) {
    this.applicationModel = applicationModel;
  }

  public void Consume(MyBooksResponse message) {
    applicationModel.MyBooks.UpdateFrom(message.Books);
  }
}

Dieser Code aktualisiert einfach das Anwendungsmodell mit den Daten aus der Nachricht. Beachten Sie dabei allerdings, dass die Consume-Methode nicht auf dem UI-Thread aufgerufen wird. Stattdessen wird dazu ein Hintergrundthread verwendet. Das Anwendungsmodell hingegen ist an die Benutzeroberfläche gekoppelt, sodass dessen Aktualisierung auf dem UI-Thread erfolgen muss. Die UpdateFrom-Methode berücksichtigt dies und wechselt zum UI-Thread, um das Anwendungsmodell im richtigen Thread zu aktualisieren.

Der Code zum Verarbeiten der anderen Nachrichten auf dem Back-End und dem Front-End ist ähnlich. Diese Kommunikation ist vollständig asynchron. Es gibt kein Warten auf eine Antwort vom Back-End, und die asynchrone .NET Framework-API wird nicht verwendet. Stattdessen findet ein expliziter Nachrichtenaustausch statt, der normalerweise fast umgehend vor sich geht, sich aber auch über einen längeren Zeitraum erstrecken kann, wenn Sie in einem Offlinemodus arbeiten.

Beim Senden der Abfragen an das Back-End vorhin habe ich den Bus nur angewiesen, die Nachrichten zu senden, aber kein Ziel angegeben. In Abbildung 4 habe ich nur Reply aufgerufen und wieder nicht angegeben, wohin die Nachricht zu senden ist. Wie weiß der Bus, wohin er die Nachrichten senden soll?

Im Falle des Sendens von Nachrichten an das Back-End lautet die Antwort: durch die Konfiguration. In App.config finden Sie die folgende Konfiguration:

<messages>
  <add name="Alexandria.Messages"
    endpoint="rhino.queues://localhost:51231/alexandria_backend"/>
</messages>

Der Bus erhält dadurch die Anweisung, dass alle Nachrichten, deren Namespace mit Alexandria.Messages beginnt, an den Endpunkt alexandria_backend zu senden sind.

Bei der Verarbeitung der Nachrichten im Back-End-System bedeutet das Aufrufen von Reply einfach, dass die Nachricht an ihren Urheber zurückgesendet wird.

Diese Konfiguration legt den Besitzer einer Nachricht fest, d. h. an wen die Nachricht zu senden ist, wenn sie auf dem Bus platziert wird, und wohin eine Abonnementanforderung zu senden ist, damit Sie in der Verteilerliste sind, wenn Nachrichten dieses Typs veröffentlicht werden. Ich gehe auf dieses Thema nicht ein, da ich in der Alexandria-Anwendung keine Nachrichtenveröffentlichung verwende.

Sitzungsverwaltung

Sie haben gesehen, wie der Kommunikationsmechanismus jetzt arbeitet, aber vor den nächsten Schritten sind noch Aspekte der Infrastruktur zu berücksichtigen. Wie in jeder NHibernate-Anwendung müssen Sie den Sitzungslebenszyklus verwalten und Transaktionen ordnungsgemäß verarbeiten.

Der Standardansatz für Webanwendungen ist, eine Sitzung pro Anforderung zu erstellen, sodass jede Anforderung über ihre eigene Sitzung verfügt. Das Verhalten bei einer Messaginganwendung ist fast identisch. An die Stelle einer Sitzung pro Anforderung tritt dabei eine Sitzung pro Nachrichtenstapel.

Die entsprechende Verarbeitung wird fast vollständig von der Infrastruktur übernommen. In Abbildung 5 wird der Initialisierungscode für das Back-End-System gezeigt.

Abbildung 5: Initialisieren von Messagingsitzungen

public class AlexandriaBootStrapper : 
  AbstractBootStrapper {

  public AlexandriaBootStrapper() {
    NHibernateProfiler.Initialize();
  }

  protected override void ConfigureContainer() {
    var cfg = new Configuration()
      .Configure("nhibernate.config");
    var sessionFactory = cfg.BuildSessionFactory();

    container.Kernel.AddFacility(
      "factory", new FactorySupportFacility());

    container.Register(
      Component.For<ISessionFactory>()
        .Instance(sessionFactory),
      Component.For<IMessageModule>()
        .ImplementedBy<NHibernateMessageModule>(),
      Component.For<ISession>()
        .UsingFactoryMethod(() => 
          NHibernateMessageModule.CurrentSession)
        .LifeStyle.Is(LifestyleType.Transient));

    base.ConfigureContainer();
  }
}

In Rhino Service Bus ist Bootstrapping ein explizites Konzept und wird durch von AbstractBootStrapper abgeleitete Klassen implementiert. Der Bootstrapper hat die gleiche Aufgabe wie Global.asax in einer typischen Webanwendung. In Abbildung 5 wird Folgendes gezeigt: Ich erstelle erst die NHibernate-Sitzungsfactory und lege dann den Container (Castle Windsor) fest, um die NHibernate-Sitzung aus NHibernateMessageModule bereitzustellen.

Ein Nachrichtenmodul hat den gleichen Zweck wie ein HTTP-Modul in einer Webanwendung: die Verarbeitung von anforderungsübergreifenden Aspekten. Ich verwende NHibernateMessageModule, um die Sitzungslebensdauer zu verwalten, wie in Abbildung 6 gezeigt wird.

Abbildung 6: Verwalten der Sitzungslebensdauer

public class NHibernateMessageModule : IMessageModule {
  private readonly ISessionFactory sessionFactory;
  [ThreadStatic]
  private static ISession currentSession;

  public static ISession CurrentSession {
    get { return currentSession; }
  }

  public NHibernateMessageModule(
    ISessionFactory sessionFactory) {

    this.sessionFactory = sessionFactory;
  }

  public void Init(ITransport transport, 
    IServiceBus serviceBus) {

    transport.MessageArrived += TransportOnMessageArrived;
    transport.MessageProcessingCompleted 
      += TransportOnMessageProcessingCompleted;
  }

  private static void 
    TransportOnMessageProcessingCompleted(
    CurrentMessageInformation currentMessageInformation, 
    Exception exception) {

    if (currentSession != null)
        currentSession.Dispose();
    currentSession = null;
  }

  private bool TransportOnMessageArrived(
    CurrentMessageInformation currentMessageInformation) {

    if (currentSession == null)
        currentSession = sessionFactory.OpenSession();
    return false;
  }
}

Der Code ist ziemlich einfach: Nehmen Sie eine Registrierung für die entsprechenden Ereignisse vor, und erstellen und verwerfen Sie die Sitzung an den geeigneten Orten. Das ist alles.

Eine interessante Folge dieses Ansatzes ist, dass alle Nachrichten in einem Stapel eine gemeinsame Sitzung nutzen. Dadurch können Sie in vielen Fällen den E1-Cache von NHibernate vorteilhaft nutzen.

Transaktionsverwaltung

Die Sitzungsverwaltung ist geregelt, aber was ist mit den Transaktionen?

Es stellt eine bewährte Methode für NHibernate dar, alle Interaktionen mit der Datenbank über Transaktionen zu verarbeiten. Aber an dieser Stelle verwende ich keine Transaktionen von NHibernate. Warum?

Die Transaktionen werden von Rhino Service Bus verarbeitet. Anstatt dass jeder Consumer seine eigenen Transaktionen verwaltet, verwendet Rhino Service Bus einen anderen Ansatz. Rhino Service Bus nutzt System.Transactions.TransactionScope, um eine einzelne Transaktion zu erstellen, die alle Consumer für Nachrichten in dem Stapel umfasst.

Dadurch sind alle Aktionen, die in einer Antwort auf einen Nachrichtenstapel (im Gegensatz zu einer einzelnen Nachricht) erfolgen, Teil derselben Transaktion. NHibernate trägt automatisch eine Sitzung in die Umgebungstransaktion ein, sodass Sie sich bei Verwendung von Rhino Service Bus nicht explizit mit Transaktionen befassen müssen.

Durch die Kombination einer einzelnen Sitzung und einer einzelnen Transaktion wird die Zusammenfügung mehrerer Vorgänge in einer einzelnen Transaktionseinheit vereinfacht. Zudem können Sie direkt vom E1-Cache von NHibernate profitieren. Als Beispiel hier der relevante Code zum Verarbeiten von MyQueueQuery:

public void Consume(MyQueueQuery message) {
  var user = session.Get<User>(message.UserId);

  Console.WriteLine("{0}'s has {1} books queued for reading",
    user.Name, user.Queue.Count);

  bus.Reply(new MyQueueResponse {
    UserId = message.UserId,
    Timestamp = DateTime.Now,
    Queue = user.Queue.ToBookDtoArray()
  });
}

Der eigentliche Code zum Verarbeiten von MyQueueQuery und MyBooksQuery ist fast identisch. Was bedeutet also eine einzelne Transaktion pro Sitzung für den folgenden Code im Hinblick auf die Leistung?

bus.Send(
  new MyBooksQuery {
    UserId = userId
  },
  new MyQueueQuery {
    UserId = userId
  });

Auf den ersten Blick sieht es aus, als ob vier Abfragen nötig sind, um alle erforderlichen Informationen zu erhalten. In MyBookQuery: eine Abfrage zum Erlangen des entsprechenden Benutzers und eine weitere zum Laden der Bücher des Benutzers. In MyQueueQuery scheint es genauso zu sein: eine Abfrage zum Erlangen des Benutzers und eine weitere zum Laden der Warteschlange des Benutzers.

Die Verwendung einer einzelnen Sitzung für den gesamten Stapel zeigt allerdings, dass Sie den E1-Cache verwenden, um unnötige Abfragen zu vermeiden, wie Sie in der Ausgabe von NHibernate Profiler (nhprof.com) in Abbildung 7 sehen können.

image: The NHibnerate Profiler View of Processing RequestsAbbildung 7: Ansicht der verarbeiteten Anforderungen in NHibernate Profiler

Unterstützen zeitweise verbundener Szenarios

Derzeit gibt die Anwendung keinen Fehler zurück, wenn der Back-End-Server nicht erreicht wird, ist aber auch nicht sehr nützlich.

Die Anwendung muss im nächsten Schritt zu einem echten, zeitweise verbundenen Client weiterentwickelt werden. Dazu wird ein Cache eingeführt, durch den die Anwendung auch dann weiter ausgeführt werden kann, wenn der Back-End-Server nicht antwortet. Ich verwende allerdings nicht die traditionelle Zwischenspeicherungsarchitektur, in der der Anwendungscode explizite Aufrufe an den Cache ausführt. Ich wende den Cache stattdessen auf der Infrastrukturebene an.

In Abbildung 8 wird die Vorgangsreihenfolge bei einem als Bestandteil der Messaginginfrastruktur implementierten Cache angezeigt, wenn Sie eine einzelne Nachricht senden, in der Informationen über die Bücher eines Benutzers angefordert werden.

image: Using the Cache in Concurrent Messaging Operations

Abbildung 8: Verwenden des Cache in gleichzeitigen Messagingvorgängen

Der Client sendet eine MyBooksQuery-Nachricht. Die Nachricht wird auf dem Bus gesendet. Gleichzeitig wird der Cache abgefragt, um zu ermitteln, ob dort die Antwort für diese Anforderung gespeichert ist. Wenn der Cache die Antwort für die vorige Anforderung enthält, veranlasst der Cache umgehend die Verarbeitung der zwischengespeicherten Nachricht, so als ob sie eben vom Bus übermittelt worden wäre.

Die Antwort des Back-End-Systems geht ein. Die Nachricht wird normal verarbeitet und ebenfalls im Cache platziert. Oberflächlich betrachtet scheint dies ein komplizierter Ansatz zu sein. Das Ergebnis ist jedoch ein effektives Zwischenspeicherungsverhalten, und Sie können Zwischenspeicherungsaspekte im Anwendungscode fast vollständig ignorieren. Durch einen beständigen Cache, der bei Neustarts der Anwendung erhalten bleibt, können Sie die Anwendung vollständig unabhängig ausführen, ohne Daten vom Back-End-Server zu benötigen.

Ich fahre nun mit der Implementierung dieser Funktionalität fort. Ich setze einen beständigen Cache voraus (der Beispielcode stellt eine einfache Implementierung bereit, die zum Speichern der Werte auf dem Datenträger binäre Serialisierung verwendet), und definiere die folgenden Konventionen:

  • Eine Nachricht kann zwischengespeichert werden, wenn sie Teil eines Austauschs von Anforderungs-/Antwortnachrichten ist.
  • Sowohl die Anforderungs- als auch die Antwortnachricht übertragen den Cacheschlüssel für den Nachrichtenaustausch.

Der Nachrichtenaustausch wird durch eine ICacheableQuery-Schnittstelle mit einer einzelnen Key-Eigenschaft und eine ICacheableResponse-Schnittstelle mit Key- und Timestamp-Eigenschaften definiert.

Zum Implementieren dieser Konvention schreibe ich ein CachingMessageModule, dass auf dem Front-End ausgeführt wird und eingehende sowie ausgehende Nachrichten abfängt. In Abbildung 9 wird gezeigt, wie eingehende Nachrichten verarbeitet werden.

Abbildung 9: Zwischenspeichern eingehender Verbindungen

private bool TransportOnMessageArrived(
  CurrentMessageInformation
  currentMessageInformation) {

  var cachableResponse = 
    currentMessageInformation.Message as 
    ICacheableResponse;
  if (cachableResponse == null)
    return false;

  var alreadyInCache = cache.Get(cachableResponse.Key);
  if (alreadyInCache == null || 
    alreadyInCache.Timestamp < 
    cachableResponse.Timestamp) {

    cache.Put(cachableResponse.Key, 
      cachableResponse.Timestamp, cachableResponse);
  }
  return false;
}

Hier passiert nur wenig: Wenn die Nachricht eine Antwort ist, die zwischengespeichert werden kann, speichere ich sie im Cache. Ein Punkt ist allerdings bemerkenswert: Ich verarbeite die Nachrichten, die außerhalb der Reihenfolge eingehen, also Nachrichten mit einem früheren Zeitstempel, die nach Nachrichten mit späterem Zeitstempel übermittelt werden. Dadurch wird sichergestellt, dass nur die aktuellsten Informationen im Cache gespeichert werden.

In Abbildung 10 können Sie erkennen, dass das Verarbeiten ausgehender Nachrichten und Verteilen der Nachrichten aus dem Cache interessanter ist.

Abbildung 10: Verteilen von Nachrichten

private void TransportOnMessageSent(
  CurrentMessageInformation 
  currentMessageInformation) {

  var cacheableQuerys = 
    currentMessageInformation.AllMessages.OfType<
    ICacheableQuery>();
  var responses =
    from msg in cacheableQuerys
    let response = cache.Get(msg.Key)
    where response != null
    select response.Value;

  var array = responses.ToArray();
  if (array.Length == 0)
    return;
  bus.ConsumeMessages(array);
}

Ich sammle die zwischengespeicherten Antworten aus dem Cache und rufe ConsumeMessages für sie auf. Dadurch ruft der Bus die normale Logik zum Aufrufen der Nachrichten auf, sodass es aussieht, als ob die Nachricht erneut eingetroffen wäre.

Aber obwohl es eine zwischengespeicherte Antwort gibt, senden Sie dennoch die Nachricht. Der Grund dafür ist, dass Sie den Benutzern eine schnelle (zwischengespeicherte) Antwort bereitstellen und diese den Benutzern angezeigte Information aktualisieren, wenn das Back-End auf neue Nachrichten antwortet.

Nächste Schritte

Ich habe die grundlegenden Bausteine einer Smart Client-Anwendung erläutert: die Strukturierung des Back-Ends und den Kommunikationsmodus zwischen der Smart Client-Anwendung und dem Back-End. Letzterer spielt eine wichtige Rolle, da der falsche Kommunikationsmodus zu den Irrtümern der verteilten Verarbeitung führen kann. Ich bin auch auf die Stapelverarbeitung und Zwischenspeicherung eingegangen, die zwei maßgebliche Ansätze zum Verbessern der Leistung einer Smart Client-Anwendung darstellen.

Auf dem Back-End habe ich gezeigt, wie Transaktionen und die NHibernate-Sitzung verwaltet werden, wie Nachrichten vom Client verarbeitet und beantwortet werden und wie im Bootstrapper alles zusammenkommt.

In diesem Artikel habe ich mich hauptsächlich mit Belangen der Infrastruktur befasst. In der nächsten Folge erläutere ich bewährte Methoden zum Senden von Daten zwischen dem Back-End und der Smart Client-Anwendung sowie Muster für ein verteiltes Änderungsmanagement.

Oren Eini arbeitet unter dem Pseudonym Ayende Rahien. Er ist aktives Mitglied verschiedener Open Source-Projekte, darunter NHibernate und Castle, und Gründer vieler anderer, darunter Rhino Mocks, NHibernate Query Analyzer und Rhino Commons. Eini ist auch für NHibernate Profiler (nhprof.com) verantwortlich, einen visuellen Debugger für NHibernate. Sie können Einis Arbeiten unter ayende.com/blog verfolgen.