Mai 2016
Band 31, Nummer 5
ASP.NET – Schreiben von sauberem Code in ASP.NET Core mit Abhängigkeitsinjektion (Dependency Injection)
Von Steve Smith
Mit ASP.NET Core 1.0 wurde ASP.NET vollständig neu geschrieben. Eines der Hauptziele dieses neuen Frameworks ist ein modularer Entwurf. Dies bedeutet, dass Apps in der Lage sein sollten, nur die Teile des Frameworks zu nutzen, die sie benötigen. Dabei stellt das Framework Abhängigkeiten bereit, wenn diese angefordert werden. Außerdem sollten Entwickler, die Apps mithilfe von ASP.NET Core erstellen, diese Funktionen nutzen können, um ihre Apps lose gekoppelt und modular zu gestalten. Mit ASP.NET MVC hat das ASP.NET-Team die Unterstützung des Frameworks für das Schreiben lose gekoppelten Codes wesentlich verbessert. Es war jedoch noch immer recht einfach, in die Fangstricke fester Kopplung zu geraten, insbesondere in Controllerklassen.
Feste Kopplung
Feste Kopplung ist gut für Demosoftware geeignet. Wenn Sie sich eine typische Beispielanwendung ansehen, die zeigt, wie ASP.NET MVC-Websites (Version 3 bis 5) erstellt werden, sieht der Code wahrscheinlich folgendermaßen aus (aus der DinnersController-Klasse des NerdDinner MVC 4-Beispiels):
private NerdDinnerContext db = new NerdDinnerContext();
private const int PageSize = 25;
public ActionResult Index(int? page)
{
int pageIndex = page ?? 1;
var dinners = db.Dinners
.Where(d => d.EventDate >= DateTime.Now).OrderBy(d => d.EventDate);
return View(dinners.ToPagedList(pageIndex, PageSize));
}
Für diese Art von Code sind Komponententests außerordentlich schwierig, weil der „NerdDinnerContext“ als Teil der Konstruktion der Klasse erstellt wird und eine Datenbank erfordert, mit der eine Verbindung hergestellt wird. Es überrascht nicht, dass solche Demoanwendungen häufig keine Komponententests umfassen. Ihr Anwendung profitiert jedoch möglicherweise selbst dann von einigen Komponententests, wenn Sie Ihre Entwicklung nicht testen. Daher wäre es besser, den Code so zu schreiben, dass er getestet werden kann. Außerdem verletzt dieser Code das DRY-Prinzip (Don’t Repeat Yourself, wiederhole Dich nicht), weil jede Controllerklasse, die Datenzugriffe ausführt, den gleichen Code zum Erstellen eines EF-Datenbankontexts (Entity Framework) enthält. Auf diese Weise werden zukünftige Änderungen aufwändiger und fehleranfälliger – insbesondere dann, wenn die Anwendung im Lauf der Zeit größer wird.
Wenn Sie Code untersuchen, um seine Kopplung auszuwerten, denken Sie an den Merksatz „new is glue“ („new“ fungiert als Klebstoff). Überall dort, wo das Schlüsselwort „new“ eine Klasse instanziiert, sollte Ihnen also bewusst sein, dass Sie Ihre Implementierung an diesen spezifischen Implementierungscode „ankleben“. Das Dependency-Inversion-Prinzip (bit.ly/DI-Principle) sagt Folgendes aus: „Abstraktionen sollten nicht von Details abhängen. Details sollten von Abstraktionen abhängen“. In diesem Beispiel hängen die Details der Zusammenstellung der Daten, die an die Ansicht übergeben werden sollen, durch den Controller von den Details der Abrufart der Daten ab – nämlich EF.
Neben dem Schlüsselwort „new“ ist „statischer Zusammenhang“ eine weitere Ursache fester Kopplung, durch die Anwendungen schwieriger zu testen und zu warten sind. Im Beispiel oben besteht eine Abhängigkeit von der Systemuhr des ausführenden Computers in Form eines Aufrufs von „DateTime.Now“. Durch diese Kopplung würde das Erstellen einer Sammlung von Testdinnern schwierig, die in Komponententests verwendet werden, weil ihre EventDate-Eigenschaften relativ zur aktuellen Einstellung der Uhr festgelegt werden müssten. Diese Kopplung kann aus dieser Methode auf verschiedene Weise entfernt werden. Das einfachste Verfahren besteht darin, der neuen Abstraktion, die die Dinner zurückgibt, diese Verarbeitung nicht zu überlassen. Damit ist sie nicht mehr Teil dieser Methode. Alternativ könnte ich den Wert in einen Parameter umwandeln, damit die Methode alle Dinner nach einem angegebenen DateTime-Parameter zurückgibt, anstatt immer „DateTime.Now“ zu verwenden. Schließlich könnte ich auch eine Abstraktion für die aktuelle Uhrzeit erstellen und über diese Abstraktion auf die aktuelle Uhrzeit verweisen. Dies kann ein guter Ansatz sein, wenn die Anwendung häufig auf „DateTime.Now“ verweist. (Sie sollten außerdem beachten, dass der Typ „DateTimeOffset“ in einer echten Anwendung ggf. die bessere Wahl ist, weil diese Dinner vermutlich in verschiedenen Zeitzonen stattfinden.)
Ehrlichkeit ist Trumpf
Ein weiteres Problem hinsichtlich der Wartbarkeit von Code wie diesem besteht darin, dass er nicht ehrlich zu seinen zusammenarbeitenden Elementen ist. Sie sollten das Erstellen von Klassen vermeiden, die in ungültigen Zuständen instanziiert werden können, weil diese häufige Fehlerquellen sind. Alles, was Ihre Klasse zum Ausführen ihrer Aufgaben benötigt, sollte daher durch ihren Konstruktor bereitgestellt werden. Das Explicit-Dependencies-Prinzip (bit.ly/ED-Principle) besagt Folgendes: „Methoden und Klassen sollten explizit alle benötigten zusammenarbeitenden Objekte erfordern, um ordnungsgemäß zu funktionieren“. Die DinnersController-Klasse weist nur einen Standardkonstruktor auf. Dies impliziert, dass sie keine zusammenarbeitenden Objekte benötigt, um ordnungsgemäß zu funktionieren. Was geschieht aber, wenn Sie diesen Code testen? Wie verhält sich dieser Code, wenn Sie ihn aus einer neuen Konsolenanwendung ausführen, die auf das MVC-Projekt verweist?
var controller = new DinnersController();
var result = controller.Index(1);
In diesem Fall tritt der erste Fehler tritt beim Versuch der Instanziierung des EF-Kontexts auf. Der Code löst eine „InvalidOperationException“ aus: „No connection string named ‚NerdDinnerContext‘ could be found in the application config file“. (In der Anwendungskonfigurationsdatei wurde keine Zeichenfolge namens ‚NerdDinnerContext‘ gefunden.) Ich wurde getäuscht! Diese Klasse benötigt doch mehr als das, was vom Konstruktor impliziert wird, um ordnungsgemäß zu funktionieren! Wenn die Klasse auf Sammlungen von Dinner-Instanzen zugreifen muss, sollte dies durch ihren Konstruktor (oder alternativ als Parameter für ihre Methoden) angefordert werden.
Abhängigkeitsinjektion (Dependency Injection)
Dependency Injection (DI) bezieht sich auf das Übergeben der Abhängigkeiten einer Klasse oder Methode als Parameter, anstatt diese Beziehungen über Aufrufe von „new“ oder „static“ hart zu codieren. Dies ist ein zunehmend gängiges Verfahren in der .NET-Entwicklung, da es die Entkopplung für Anwendungen ermöglicht, die es verwenden. Frühe Versionen von ASP.NET boten keine Unterstützung für DI, und obwohl ASP.NET MVC und Web API diese anstrebten, wurde niemals vollständige Unterstützung (einschließlich eines Containers zum Verwalten der Abhängigkeiten und ihrer Objektlebenszyklen) in diesen Produkten erreicht. Mit ASP.NET Core 1.0 wird DI nicht nur standardmäßig unterstützt, sondern durch das Produkt selbst auch umfassend genutzt.
ASP.NET Core unterstützt nicht nur DI, sondern umfasst auch einen DI-Container, der auch als IoC-Container (Inversion of Control) oder Dienstecontainer bezeichnet wird. Jede ASP.NET Core-App konfiguriert ihre Abhängigkeiten mithilfe dieses Containers in der ConfigureServices-Methode der Startup-Klasse. Dieser Container stellt die grundlegende Unterstützung bereit, die erforderlich ist. Er kann bei Bedarf jedoch auch durch eine benutzerdefinierte Implementierung ersetzt werden. Außerdem verfügt EF Core über integrierte Unterstützung für DI. Die Konfiguration von DI in einer ASP.NET Core-Anwendung ist daher so einfach wie das Aufrufen einer Erweiterungsmethode. Ich habe für diesen Artikel einen Ableger von „NerdDinner“ namens „GeekDinner“ erstellt. EF Core wurde wie hier gezeigt konfiguriert:
public void ConfigureServices(IServiceCollection services)
{
services.AddEntityFramework()
.AddSqlServer()
.AddDbContext<GeekDinnerDbContext>(options =>
options.UseSqlServer(ConnectionString));
services.AddMvc();
}
Nachdem das erledigt ist, kann DI auf recht einfache Weise zum Anfordern einer Instanz von „GeekDinnerDbContext“ aus einer Controllerklasse wie „DinnersController“ verwendet werden:
public class DinnersController : Controller
{
private readonly GeekDinnerDbContext _dbContext;
public DinnersController(GeekDinnerDbContext dbContext)
{
_dbContext = dbContext;
}
public IActionResult Index()
{
return View(_dbContext.Dinners.ToList());
}
}
Beachten Sie, dass nicht eine einzige Instanz des Schlüsselworts „new“ vorhanden ist. Alle vom Controller benötigten Abhängigkeiten werden über seinen Konstruktor übergeben, und der ASP.NET DI-Container übernimmt diesen Vorgang für mich. Ich kann mich ganz auf das Schreiben der Anwendung konzentrieren und muss mir keine Gedanken um Code bezüglich der Abhängigkeiten machen, die meine Klassen über ihre Konstruktoren anfordern. Wenn gewünscht, kann ich dieses Verhalten natürlich anpassen und sogar den Standardcontainer vollständig durch eine andere Implementierung ersetzen. Da meine Controllerklasse nun das Explicit-Dependencies-Prinzip anwendet, weiß ich, dass ich eine Instanz eines „GeekDinnerDbContext“ bereitstellen muss, damit meine Klasse funktioniert. Ich kann (nachdem ich „DbContext“ eingerichtet habe) den Controller in Isolation instanziieren. Die folgende Konsolenanwendung zeigt dies:
var optionsBuilder = new DbContextOptionsBuilder();
optionsBuilder.UseSqlServer(Startup.ConnectionString);
var dbContext = new GeekDinnerDbContext(optionsBuilder.Options);
var controller = new DinnersController(dbContext);
var result = (ViewResult) controller.Index();
Das Erstellen eines EF Core-DbContext ist mit ein wenig mehr Aufwand im Vergleich zu EF6 verbunden. EF6 nahm einfach eine Verbindungszeichenfolge an. Dies liegt daran, dass der Entwurf von EF Core ebenso wie von ASP.NET Core modularer ist. Normalerweise müssen Sie „DbContextOptionsBuilder“ nicht direkt einsetzen, weil dieses Element hinter den Kulissen verwendet wird, wenn Sie EF über Erweiterungsmethoden wie „AddEntityFramework“ und „AddSqlServer“ konfigurieren.
Besteht eine Testmöglichkeit?
Das manuelle Testen Ihrer Anwendung ist wichtig – Sie möchten in der Lage sein, die Anwendung auszuführen, und sicherstellen können, dass sie tatsächlich ausgeführt wird und die erwartete Ausgabe generiert. Das Ausführen dieser Schritte bei jeder Änderung ist jedoch eine Zeitverschwendung. Einer der großen Vorteile lose gekoppelter Anwendungen besteht darin, dass sie tendenziell besser für Komponententests als fest gekoppelte Apps geeignet sind. Besser noch: ASP.NET Core und EF Core lassen sich viel einfacher als ihre Vorgänger testen. Als ersten Schritt schreibe ich einen einfachen Test direkt für den Controller, indem ich einen „DbContext“ übergebe, der für die Verwendung eines In-Memory-Speichers konfiguriert wurde. Ich konfiguriere den „GeekDinnerDbContext“ mithilfe des DbContextOptions-Parameters, der über seinen Konstruktor bereitgestellt wird, als Teil des Setupcodes meines Tests:
var optionsBuilder = new DbContextOptionsBuilder<GeekDinnerDbContext>();
optionsBuilder.UseInMemoryDatabase();
_dbContext = new GeekDinnerDbContext(optionsBuilder.Options);
// Add sample data
_dbContext.Dinners.Add(new Dinner() { Title = "Title 1" });
_dbContext.Dinners.Add(new Dinner() { Title = "Title 2" });
_dbContext.Dinners.Add(new Dinner() { Title = "Title 3" });
_dbContext.SaveChanges();
Nachdem diese Konfiguration in meiner Testklasse erfolgt ist, ist es ganz einfach, einen Test zu schreiben, der zeigt, dass die richtigen Daten im Modell von „ViewResult“ zurückgegeben werden:
[Fact]
public void ReturnsDinnersInViewModel()
{
var controller = new OriginalDinnersController(_dbContext);
var result = controller.Index();
var viewResult = Assert.IsType<ViewResult>(result);
var viewModel = Assert.IsType<IEnumerable<Dinner>>(
viewResult.ViewData.Model).ToList();
Assert.Equal(1, viewModel.Count(d => d.Title == "Title 1"));
Assert.Equal(3, viewModel.Count);
}
Natürlich muss hier noch nicht viel Programmlogik getestet werden. Dieser Test testet also nur wenig. Kritiker werden bemängeln, dass dieser Test nicht wirklich wertvoll ist, und ich stimme ihnen zu. Er stellt jedoch einen Ausgangspunkt dar, wenn mehr Programmlogik vorhanden ist. Dies wird bald der Fall sein. Zuvor vermeide ich jedoch auch weiterhin eine direkte Kopplung mit EF in meinem Controller, obwohl EF Core Komponententests mit der In-Memory-Option unterstützen kann. Es gibt keinen Grund, Benutzeroberflächenanforderungen mit Datenzugriffs-Infrastrukturanforderungen zu koppeln – tatsächlich würde dann ein anderes Prinzip verletzt: die Trennung von Anforderungen.
Machen Sie sich nicht von etwas abhängig, das Sie nicht verwenden
Das Interface-Segregation-Prinzip (bit.ly/LS-Principle) besagt, dass Klassen nur von den Funktionen abhängen sollten, die sie tatsächlich auch verwenden. Im Fall des neuen, für DI aktivierten „DinnersController“ besteht noch immer eine Abhängigkeit vom gesamten „DbContext“. Anstatt die Controllerimplementierung an EF „anzukleben“, kann eine Abstraktion verwendet werden, die die erforderlichen Funktionen (und nur diese oder wenig mehr) bereitstellt.
Was ist für diese Aktionsmethode wirklich erforderlich, damit sie funktioniert? Gewiss nicht der gesamte „DbContext“. Sie benötigt nicht einmal Zugriff auf die vollständige Dinners-Eigenschaft des Kontexts. Sie muss nur die Dinner-Instanzen der entsprechenden Seite anzeigen können. Die einfachste .NET-Abstraktion, die dies leistet, ist „IEnumerable<Dinner>“. Ich definiere daher eine Schnittstelle, die einfach ein IEnumerable<Dinner>-Objekt zurückgibt, und die die (meisten) Anforderungen der Index-Methode erfüllt:
public interface IDinnerRepository
{
IEnumerable<Dinner> List();
}
Ich bezeichne dies als Repository, weil das folgende Muster befolgt wird: Der Datenzugriff wird hinter einer sammlungsähnlichen Schnittstelle abstrahiert. Wenn Sie aus bestimmten Gründen das Repositorymuster oder den -namen nicht mögen, können Sie den Typ auch „IGetDinners“ oder „IDinnerService“ nennen oder einen beliebigen anderen Namen auswählen (mein technischer Lektor hat „ICanHasDinner“ vorgeschlagen). Der Name des Typs besitzt keinen Einfluss auf seine Funktion.
Nachdem dies implementiert wurde, passe ich „DinnersController“ nun so an, dass ein „IDinnerRepository“ als Konstruktorparameter (anstatt eines „GeekDinnerDbContext“) akzeptiert wird, und rufe die List-Methode auf, anstatt direkt auf das DbSet „Dinners“ zuzugreifen:
private readonly IDinnerRepository _dinnerRepository;
public DinnersController(IDinnerRepository dinnerRepository)
{
_dinnerRepository = dinnerRepository;
}
public IActionResult Index()
{
return View(_dinnerRepository.List());
}
An diesem Punkt können Sie Ihre Webanwendung erstellen und ausführen. Es tritt jedoch eine Ausnahme auf, wenn Sie zu „/Dinners“ navigieren: InvalidOperationException: Unable to resolve service for type „GeekDinner.Core.Interfaces.IdinnerRepository“ while attempting to activate GeekDinner.Controllers.DinnersController. (Der Dienst für den Typ „GeekDinner.Core.Interfaces.IdinnerRepository“ kann beim Versuch der Aktivierung von „GeekDinner.Controllers.DinnersController“ nicht aufgelöst werden.) Ich habe die Schnittstelle noch nicht implementiert. Sobald dies geschieht, muss ich außerdem meine Implementierung so konfigurieren, dass sie verwendet wird, wenn DI Anforderungen für „IDinnerRepository“ erfüllt. Das Implementieren der Schnittstelle ist trivial:
public class DinnerRepository : IDinnerRepository
{
private readonly GeekDinnerDbContext _dbContext;
public DinnerRepository(GeekDinnerDbContext dbContext)
{
_dbContext = dbContext;
}
public IEnumerable<Dinner> List()
{
return _dbContext.Dinners;
}
}
Beachten Sie, dass es kein Problem ist, eine Repositoryimplementierung direkt mit EF zu koppeln. Wenn ich EF auslagern muss, erstelle ich einfach eine neue Implementierung dieser Schnittstelle. Diese Implementierungsklasse ist Teil der Infrastruktur meiner Anwendung – dem einen Ort in der Anwendung, an dem meine Klassen von bestimmten Implementierungen abhängen.
Wenn ich ASP.NET Core so konfigurieren möchte, dass die richtige Implementierung injiziert wird, wenn Klassen ein „IDinnerRepository“ anfordern, muss ich die folgende Codezeile am Ende der weiter oben gezeigten ConfigureServices-Methode hinzufügen:
services.AddScoped<IDinnerRepository, DinnerRepository>();
Diese Anweisung weist den ASP.NET Core DI-Container an, eine DinnerRepository-Instanz zu verwenden, wenn der Container einen Typ auflöst, der von einer IDinnerRepository-Instanz abhängt. „Scoped“ (bereichsbezogen) bedeutet, dass eine Instanz für jede Webanforderung verwendet wird, die ASP.NET verarbeitet. Dienste können ebenfalls mithilfe von Transient- oder Singleton-Lebensdauern hinzugefügt werden. In diesem Fall ist „Scoped“ geeignet, weil mein „DinnerRepository“ von einem „DbContext“ anhängt, der ebenfalls die Scoped-Lebensdauer verwendet. Im Folgenden finden Sie eine Zusammenfassung der verfügbaren Objektlebensdauern:
- Transient (vorübergehend): Eine neue Instanz des Typs wird bei jeder Anforderung des Typs verwendet.
- Scoped (bereichsbezogen): Eine neue Instanz des Typs wird bei seiner erstmaligen Anforderung in einer bestimmten HTTP-Anforderung erstellt und anschließend für alle nachfolgenden Typen verwendet, die während dieser HTTP-Anforderung aufgelöst werden.
- Singleton: Eine Instanz des Typs wird ein Mal erstellt und von allen nachfolgenden Anforderungen für diesen Typ verwendet.
Der integrierte Container unterstützt verschiedene Verfahren zum Generieren der Typen, die er bereitstellt. Normalerweise wird einfach ein Typ für den Container bereitgestellt. Er versucht dann, diesen Typ zu instanziieren, und stellt alle Abhängigkeiten bereit, die dieser Typ benötigt. Sie können auch einen Lambdaausdruck zum Generieren des Typs angeben oder für eine Singleton-Lebensdauer die vollständig generierte Instanz in „ConfigureServices“ bei der Registrierung bereitstellen.
Nachdem DI eingerichtet wurde, wird die Anwendung wie zuvor ausgeführt. Nun kann ich (wie in Abbildung 1 gezeigt) Tests der Anwendung mit dieser neu erstellten Abstraktion mithilfe einer Fake- oder Pseudoimplementierung der IDinnerRepository-Schnittstelle ausführen, anstatt EF direkt in meinem Testcode zu verwenden.
Abbildung 1: Testen von „DinnersController“ mithilfe eines Pseudoobjekts
public class DinnersControllerIndex
{
private List<Dinner> GetTestDinnerCollection()
{
return new List<Dinner>()
{
new Dinner() {Title = "Test Dinner 1" },
new Dinner() {Title = "Test Dinner 2" },
};
}
[Fact]
public void ReturnsDinnersInViewModel()
{
var mockRepository = new Mock<IDinnerRepository>();
mockRepository.Setup(r =>
r.List()).Returns(GetTestDinnerCollection());
var controller = new DinnersController(mockRepository.Object, null);
var result = controller.Index();
var viewResult = Assert.IsType<ViewResult>(result);
var viewModel = Assert.IsType<IEnumerable<Dinner>>(
viewResult.ViewData.Model).ToList();
Assert.Equal("Test Dinner 1", viewModel.First().Title);
Assert.Equal(2, viewModel.Count);
}
}
Dieser Test funktioniert unabhängig davon, woher die Liste der Dinner-Instanzen stammt. Sie können den Datenzugriffscode so umschreiben, dass eine andere Datenbank, Azure Table Storage oder XML-Dateien verwendet werden, ohne dass sich die Funktionsweise des Controllers ändert. In diesem Fall weist er nicht viele Funktionen auf, wie Sie sicher bemerkt haben…
Wie sieht es also mit echter Programmlogik aus?
Bis jetzt habe ich nicht wirklich echte Geschäftslogik implementiert – nur einfache Methoden, die einfache Sammlungen von Daten zurückgeben. Der wahre Wert von Tests zeigt sich, wenn Sie über Programmlogik und Sonderfälle verfügen, die wie beabsichtigt funktionieren müssen. Ich zeige dies, indem ich meiner GeekDinner-Website einige Anforderungen hinzufüge. Die Website stellt eine API bereit, die es jedem Benutzer ermöglicht, eine Einladung zu einem Dinner zu bestätigen. Die Dinner weisen jedoch eine optionale maximale Kapazität auf, und die Bestätigungen sollten diese Kapazität nicht überschreiten. Benutzer, die Bestätigungen jenseits der maximalen Kapazität anfordern, sollten einer Warteliste hinzugefügt werden. Schließlich können Dinner auch einen Stichtag angeben, bis zu dem relativ zu ihrer Startzeit Bestätigungen empfangen werden müssen. Nach diesem Stichtag werden Bestätigungen nicht mehr akzeptiert.
Ich könnte diese gesamte Programmlogik in einer Aktion codieren. Ich glaube jedoch, dass damit viel zu viel Verantwortung an eine Methode übertragen würde – insbesondere an eine UI-Methode, die sich auf UI-Anforderungen und nicht auf Geschäftslogik konzentrieren sollte. Der Controller sollte überprüfen, ob die von ihm empfangenen Eingaben gültig sind, und er sollte sicherstellen, dass die von ihm zurückgegebenen Antworten für den Client geeignet sind. Entscheidungen, die über diese Funktionalität hinausgehen (und insbesondere Geschäftslogik), gehören nicht in Controller.
Der beste Ort für Geschäftslogik ist das Domänenmodell der Anwendung, das nicht von Infrastrukturanforderungen (wie Datenbanken oder Benutzeroberflächen) abhängen sollte. Die Dinner-Klasse eignet sich am besten zum Verwalten der in den Anforderungen beschriebenen Bestätigungsanforderungen, weil sie die maximale Kapazität für das Dinner speichert. Außerdem ist ihr die Anzahl der bisher erfolgten Bestätigungen bekannt. Ein Teil der Programmlogik hängt jedoch auch vom Zeitpunkt der Bestätigung ab (davon, ob diese vor oder nach dem Stichtag erfolgt). Die Methode benötigt daher auch Zugriff auf die aktuelle Uhrzeit.
Ich könnte einfach „DateTime.Now“ verwenden. Die Programmlogik wäre dann jedoch schwierig zu testen und würde mein Domänenmodell mit der Systemuhr koppeln. Eine weitere Option ist die Verwendung einer IDateTime-Abstraktion und deren Injektion in die Dinner-Entität. Meiner Erfahrung nach sollten Entitäten wie „Dinner“ jedoch besser frei von Abhängigkeiten sein. Dies gilt insbesondere dann, wenn Sie die Verwendung eines O/RM-Tools wie EF planen, um diese aus einer Persistenzebene abzurufen. Ich möchte nicht Abhängigkeiten von Entitäten als Teil dieses Vorgangs mit Daten auffüllen müssen, und EF ist dazu ohne zusätzlichen Code, den ich schreiben müsste, gewiss nicht in der Lage. Eine gängige Vorgehensweise an diesem Punkt besteht darin, die Programmlogik aus der Dinner-Entität abzurufen und in einen Dienst einzufügen (z. B. „DinnerService“ oder „RsvpService“), in den auf einfache Weise Abhängigkeiten injiziert werden können. Dies führt jedoch tendenziell zum Anemic Domain Model-Anti-Pattern (bit.ly/anemic-model), in dem Entitäten wenig oder kein Verhalten aufweisen und nur Statusbehälter sind. Nein, in diesem Fall ist die Lösung unkompliziert – die Methode kann die aktuelle Uhrzeit einfach als Parameter annehmen und durch den aufrufenden Code übergeben.
Mit diesem Ansatz ist die Programmlogik zum Hinzufügen einer Bestätigung einfach (siehe Abbildung 2). Diese Methode verfügt über mehrere Tests, die zeigen, dass sie sich wie erwartet verhält. Diese Tests sind im Beispielprojekt für diesen Artikel verfügbar.
Abbildung 2: Geschäftslogik im Domänenmodell
public RsvpResult AddRsvp(string name, string email, DateTime currentDateTime)
{
if (currentDateTime > RsvpDeadlineDateTime())
{
return new RsvpResult("Failed - Past deadline.");
}
var rsvp = new Rsvp()
{
DateCreated = currentDateTime,
EmailAddress = email,
Name = name
};
if (MaxAttendees.HasValue)
{
if (Rsvps.Count(r => !r.IsWaitlist) >= MaxAttendees.Value)
{
rsvp.IsWaitlist = true;
Rsvps.Add(rsvp);
return new RsvpResult("Waitlist");
}
}
Rsvps.Add(rsvp);
return new RsvpResult("Success");
}
Durch Verschieben dieser Programmlogik in das Domänenmodell habe ich sichergestellt, dass die API-Methode meines Controllers klein bleibt und sich auf ihre eigenen Anforderungen konzentriert. Daher kann auf einfache Weise getestet werden, ob der Controller wie gewünscht arbeitet, weil relativ wenige Pfade durch die Methode vorhanden sind.
Aufgaben des Controllers
Eine Aufgabe des Controllers besteht darin, „ModelState“ zu überprüfen und sicherzustellen, dass das Element gültig ist. Ich verwende auch Gründen der Klarheit die action-Methode. In einer größeren Anwendung würde ich diesen repetitiven Code in jeder Aktion mithilfe eines Aktionsfilters beseitigen:
[HttpPost]
public IActionResult AddRsvp([FromBody]RsvpRequest rsvpRequest)
{
if (!ModelState.IsValid)
{
return HttpBadRequest(ModelState);
}
Wenn „ModelState“ gültig ist, muss die Aktion im nächsten Schritt die entsprechende Dinner-Instanz mithilfe des in der Anforderung angegebenen Bezeichners abrufen. Wenn die Aktion keine Dinner-Instanz finden kann, die mit dieser ID übereinstimmt, sollte sie ein Ergebnis des Typs „Nicht gefunden“ zurückgeben:
var dinner = _dinnerRepository.GetById(rsvpRequest.DinnerId);
if (dinner == null)
{
return HttpNotFound("Dinner not found.");
}
Nachdem diese Überprüfungen abgeschlossen wurden, kann die Aktion den Geschäftsvorgang, der durch die Anforderung dargestellt wird, an das Domänenmodell delegieren, indem die AddRsvp-Methode für die bereits zuvor gezeigte Dinner-Klasse aufgerufen und der aktualisierte Zustand des Domänenmodells (in diesem Fall die Dinner-Instanz und deren Sammlung von Bestätigungen) gespeichert wird, bevor die Antwort „OK“ zurückgegeben wird:
var result = dinner.AddRsvp(rsvpRequest.Name,
rsvpRequest.Email,
_systemClock.Now);
_dinnerRepository.Update(dinner);
return Ok(result);
}
Denken Sie daran, dass ich entschieden habe, dass die Dinner-Klasse keine Abhängigkeit von der Systemuhr aufweisen soll. Stattdessen wird die aktuelle Uhrzeit an die Methode übergeben. Im Controller übergebe ich „_systemClock.Now“ für den currentDateTime-Parameter. Dabei handelt es sich um ein lokales Feld, das mithilfe von DI mit Daten aufgefüllt wird. Auch dieser Vorgang verhindert, dass der Controller eng an die Systemuhr gekoppelt ist. Es ist sinnvoll, DI (anstatt einer Domänenentität) für den Controller zu verwenden, weil Controller immer durch ASP.NET-Dienstcontainer erstellt werden. Damit werden alle Abhängigkeiten erfüllt, die der Controller in seinem Konstruktor deklariert. „_systemClock“ ist ein Feld vom Typ „IDateTime“. Es wird mit nur wenigen Zeilen Code definiert und implementiert:
public interface IDateTime
{
DateTime Now { get; }
}
public class MachineClockDateTime : IDateTime
{
public DateTime Now { get { return System.DateTime.Now; } }
}
Natürlich muss ich auch sicherstellen, dass der ASP.NET-Container für die Verwendung von „MachineClockDateTime“ konfiguriert ist, wenn eine Klasse eine Instanz von „IDateTime“ benötigt. Dies erfolgt in „ConfigureServices“ in der Startup-Klasse. Obwohl jede Objektlebenszeit funktionieren würde, entscheide ich mich in diesem Fall für die Verwendung von Singleton, weil eine Instanz von „MachineClockDateTime“ für die gesamte Anwendung funktioniert:
services.AddSingleton<IDateTime, MachineClockDateTime>();
Nachdem diese einfache Abstraktion implementiert wurde, kann ich das Verhalten des Controllers basierend auf der Frage testen, ob der Bestätigungsstichtag überschritten wurde, und sicherstellen, dass das richtige Ergebnis zurückgegeben wird. Da ich die Dinner.AddRsvp-Methode bereits hinsichtlich ihres erwarteten Verhaltens getestet habe, benötige ich nicht zahlreiche Tests des gleichen Verhaltens über den Controller, damit ich darauf vertrauen kann, dass das Zusammenspiel zwischen Controller und Domänenmodell ordnungsgemäß funktioniert.
Nächste Schritte
Laden Sie das zugehörige Beispielprojekt herunter, um sich mit den Komponententests für „Dinner“ und „DinnersController“ zu beschäftigen. Denken Sie daran, dass Komponententests für lose gekoppelten Code normalerweise viel einfacher als für fest gekoppelten Code ausgeführt werden können, der vor new- oder static-Methodenaufrufen strotzt, die von Infrastrukturanforderungen abhängen. „New is glue“ und das Schlüsselwort „new“ sollten in Ihrer Anwendung mit Absicht und nicht zufällig verwendet werden. Weitere Informationen zu ASP.NET Core und der Unterstützung für DI finden Sie unter docs.asp.net.
Steve Smithist ein unabhängiger Trainer, Mentor und Berater sowie ein ASP.NET MVP. Er hat Dutzende von Artikeln für die offizielle ASP.NET Core-Dokumentation (docs.asp.net) verfasst und arbeitet mit Teams zusammen, die sich mit dieser Technologie vertraut machen. Nehmen Sie unter ardalis.com Kontakt mit ihm auf, oder folgen Sie ihm auf Twitter: @ardalis.
Unser Dank gilt dem folgenden technischen Experten bei Microsoft für die Durchsicht dieses Artikels: Doug Bunting
Doug Bunting ist ein Entwickler aus dem MVC-Team bei Microsoft. Er übt diese Funktion schon seit einiger Zeit aus und ist begeistert vom neuen DI-Paradigma in der Neufassung von MVC Core.