Service-Fassade
Die Service-Fassade, eine klassische Fassade mit Remote-Zugriff, kapselt die fachliche Funktionalität und ist optimiert bzgl. des Netzwerkzugriffs. Die Service-Fassade wird in einer äußeren Schale des Onion-Models implementiert. Diese Schale wird Service Facade genannt.
Die Schicht der Service-Fassade besteht aus den Bausteinen Service, Contract und Assembler, die im Folgenden beschrieben werden.
API-First - Modellierung der Contracts der Service-Fassade
Services werden in Schleupen.CS 3.0 gemäß des Contract-First-Design-Ansatzes entwickelt. Hierzu wird im Kanonischen Service Modell der Service mit Hilfe der UML modelliert, auf dessen Basis schlussendlich der C#-Code der Schnittstelle generiert wird. Die folgende Abbildung zeigt ein Beispiel einer in UML modellierten Service-Schnittstelle.
Mithilfe des Tools Kamoto (Kanonisches Modell-Tool) kann aus diesem UML-Modell einfach eine WSDL exportiert werden.
Beim Export wird das definierte UML-Modell für den Service insbesondere auf folgende Dinge geprüft:
- Validierung, dass keine Typen aus dem kanonischen Datenmodell referenziert werden etc.
- Dokumentation des Mengengerüsts und des Zeitverhaltens des Services
- Vorhandensein einer Dokumentation
- Kompatibilität des Services
Die generierte WSDL ist eine klassische WSDL, die keine Microsoft-Spezifika enthält und somit einfach aus anderen Nutzungskontexten heraus wie Java genutzt werden kann.
Diese WSDL bildet die Grundlage der Implementierung der Service-Fassade.
Komponenten und Implementierung der Service-Fassade
Ein Überblick über die beteiligten Komponenten und deren Bedeutung in der Implementierungs- und Laufzeitsicht der Service-Fassade gibt folgende Abbildung.
Die Fassade besteht demnach aus der Service-Klasse, den Daten- (/ Message-)verträgen und den Assembler, die wir weiter unten genau beschreiben werden.
Die Abbildung und Ablage dieser Komponenten in einer Visual Studio-Solution ist einfach:
Contracts
Die Datenverträge (DataContracts aus der WCF) sind wie in Domain-Driven Design beschrieben eine kanonisierte Published-Language gemäß DDD.
Die WCF besteht aus sogenannten
- DataContracts - den zu transportierenden Daten
- MessageContracts - den Request- und Responsetypen
- OperationContracts - den ausführbaren Operationen
- ServiceContracts - den Schnittstellen
die in C# als Klassen oder Schnittstellen ausgeprägt werden.
Die DataContracts, MessageContracts, OperationContracts und ServiceContracts werden mithilfe des benutzerdefinierten Tools Sigento (Service Interface Generation Tool) des Visual Studio für C# durch Anwendung auf eine WSDL generiert. Aus technischer Sicht sind diese Verträge andere C#-Typen als die der Domäne.
Einen Ausschnitt der generierten C#-Klassen und -Schnittstellen ist in der vorstehenden Abbildung zu sehen. Auf diese Weise wird auch die Schnittstelle IBuecherAusleihenActivityService
des Beispiels generiert. Diese wird anschließend ausimplementiert.
Service
Der Service ist eine normale C#-Klasse mit WCF-Attributierung. Diese implementiert die durch Sigento generierte Schnittstelle. Hierbei delegiert diese an den zugehörigen Usecase Controller und die Assembler, die die Datenverträge in Domänenobjekte übersetzen.
[ServiceBehavior(IncludeExceptionDetailInFaults = true)] [ErrorHandlerBehavior(typeof(IdempotentOperationPendingFaultAssembler<IdempotentOperationPendingFaultContract>), typeof(ValidationFaultAssembler), typeof(UnhandledFaultAssembler<UnhandledFaultContract>))] public class BuecherAusleihenActivityService : IBuecherAusleihenActivityService { private readonly IBuecherAusleihenController leiheBuecherAusController; private readonly IAusleiheAssembler ausleiheAssembler; ... [CSOperationBehavior(TransactionScopeRequired = true)] [IdempotentOperationBehavior] public virtual async Task<AusleihenResponse> AusleihenAsync(AusleihenRequest request) { if (request == null) { throw new ArgumentNullException(nameof(request)); } IEnumerable<BuchId> buecher = buchAssembler.ToDomainObjectIds(request.Buecher); IAusleihen ausleihen = await leiheBuecherAusController.LeiheAusAsync(new Sid(authenticationProvider.SessionCreatorSid), buecher); return new AusleihenResponse(ausleiheAssembler.ToDataContract(ausleihen.Items).ToList()); } ... }
Diese Klasse wird um Folgendes erweitert:
- Transaktionshandling durch Attributierung (explizit im Code:
TransactionScopeRequired = true
, implizit über den AOP Interceptor DynamicProxy:TransactionIsolationLevel = ReadUncommitted
) - Fehlerhandling (explizit im Code: ErrorHandlerBehavior)
- Diagnoseprotokollierung (implizit über eine AOP-Interceptoren)
- Anschluss einer Idempotenz-API (implizit über eine AOP-Interceptoren und explizit per Attribut zur Nutzung
IdempotentOperationBehavior
).
Zur Laufzeit wird der Service per Dependency-Injection zusammengestellt, wobei der Dependency-Injection-Container Castle Windsor verwendet wird. Details: siehe Dependency-Injection.
Hierzu und zur erweiterten Testbarkeit wird an diversen Stellen das Strategiemuster der GoF verwendet!
Assembler
Mithilfe von Assemblern wird der Anticorruption Layer implementiert. Diese übersetzen die Datenverträge der Services in das interne Domänenmodell und umgekehrt. Der Service kann dann stabil gehalten und die interne Implementierung geändert werden. Häufig wird anstelle des Begriffs Datenvertrag der Begriff DTO verwendet: Ein Data-Transfer-Object ist wie folgt definiert: "An object that carries data between processes in order to reduce the number of method calls."
Die Implementierung eines Assemblers ist sehr einfach, wie das folgende Beispiel zeigt:
public class AusleiheAssembler : IAusleiheAssembler { public IEnumerable<AusleiheContract> ToDataContract(IEnumerable<Ausleihe> ausleihen) { if(ausleihen == null) { return Enumerable.Empty<AusleiheContract>(); } return ausleihen.Select(ToDataContract).ToList(); } public AusleiheContract ToDataContract(Ausleihe ausleihe) { if (ausleihe == null) { return new AusleiheContract(); } IAusleiheData ausleiheData = ausleihe; AusleiheContract ausleiheContract = new(); ausleiheContract.Id = ausleiheData.Id.Value; ausleiheContract.AusgeliehenAm = ausleiheData.AusgeliehenAm.ToDateTime(); ausleiheContract.Buch = new BuchContract { Id = ausleiheData.BuchId.Value }; return ausleiheContract; } }
Im Allgemeinen wird ein Assembler pro Aggregat erstellt. Dabei gilt folgende Namenskonvention:
<AggregateRoot-Name>Assembler
wobei <AggrgateRoot-Name> der Name des Aggregate-Roots ist.
Die Operationen für die Übersetzung von Domänenobjekt zu Datenvertrag wird ToDataContract(...)
und ToDomainObject(...)
für die Übersetzung von DataContract zu Domänenobjekt benannt.
Auch Ausnahmen müssen übersetzt werden, da Fehler in WCF als sogenannte FaultContracts propagiert werden. Diese sind strukturell den DataContracts sehr ähnlich. Zur Anbindung an die Service-Implementierung wird das Attribut ErrorHandlerBehavior
verwendet und entsprechend die entsprechenden Assembler wie folgt angebunden:
... [ErrorHandlerBehavior(typeof(IdempotentOperationPendingFaultAssembler<IdempotentOperationPendingFaultContract>), typeof(ValidationFaultAssembler), typeof(UnhandledFaultAssembler<UnhandledFaultContract>))] public class BuecherAusleihenActivityService : IBuecherAusleihenActivityService { ... }
Wichtig dabei ist, dass seitens der WCF versucht wird, den zuerst angegebenen Assembler zu nutzen, danach den zweiten und so weiter. Daher sollte zuletzt stets der UnhandledFaultAssembler<UnhandledFaultContract>
angegeben werden, über den als Fallback alle Ausnahmen übersetzt werden.