August 2016

Band 31, Nummer 8

ASP.NET Core – ASP.NET Core MVC-Filter in der Praxis

Von Steve Smith

Filter sind ein großartiges, häufig ungenutztes Feature von ASP.NET MVC und ASP.NET Core MVC. Sie bieten eine Möglichkeit der Einbindung in die MVC-Pipeline zum Aufrufen von Aktionen, weshalb sie sich besonders zum Herausziehen gängiger, sich wiederholender Aufgaben aus Ihren Aktionen eignen. Häufig weist eine App eine Standardrichtlinie auf, die zum Verarbeiten bestimmter Bedingungen angewendet wird, insbesondere derjenigen, die ggf. bestimmte HTTP-Statuscodes verursachen. Oder eine App führt bei jeder Aktion eine Fehlerbehandlung oder Protokollierung auf Anwendungsebene auf bestimmte Weise durch. Diese Arten von Richtlinien stellen übergreifende Probleme dar. Nach Möglichkeit möchten Sie das Prinzip der Vermeidung von Wiederholungen (Don’t Repeat Yourself, DRY) befolgen und diese in eine allgemeine Abstraktion auslagern. Anschließend können Sie diese Abstraktion global oder den Anforderungen entsprechend in Ihrer Anwendung anwenden. Filter eignen sich besonders gut für die entsprechende Umsetzung.

Was ist mit Middleware?

In der Ausgabe vom Juni 2016 habe ich beschrieben, wie ASP.NET Core-Middleware Ihnen ermöglicht, die Anforderungspipeline in Ihren Apps zu steuern (msdn.magazine.com/mt707525). Das klingt verdächtig danach, was Filter in Ihrer ASP.NET Core MVC-App leisten können. Der Unterschied zwischen den beiden ist der Kontext. ASP.NET Core MVC wird über Middleware implementiert. (MVC selbst ist keine Middleware, konfiguriert sich aber als das Standardziel für die Routingmiddleware). ASP.NET Core MVC bietet zahlreiche Features wie Modellbindung, Inhaltsaushandlung und Antwortformatierung. Im Kontext von MVC sind Filter vorhanden, sodass sie Zugriff auf diese Features und Abstraktionen auf MVC-Filterebene haben. Middleware ist dagegen auf einer niedrigeren Ebene vorhanden und hat kein direktes Wissen von MVC oder seinen Features.

Wenn es Funktionalität gibt, die Sie auf einer niedrigeren Ebene ausführen möchten und die nicht vom Kontext auf MVC-Ebene abhängt, erwägen Sie den Einsatz von Middleware. Wenn Ihre Controlleraktionen eher allgemeine Logik aufweisen, können Filter Ihnen eine Möglichkeit der Umsetzung des Prinzips der Vermeidung von Wiederholungen bieten, um diese einfacher verwalten und testen zu können.

Arten von Filtern

Sobald die MVC-Middleware übernimmt, ruft sie in ihrer Pipeline zum Aufrufen von Aktionen an verschiedenen Stellen eine Vielzahl von Filtern auf.

Zuerst werden Autorisierungsfilter ausgeführt. Wenn die Anforderung nicht autorisiert ist, unterbricht der Filter den Rest der Pipeline unverzüglich.

Als Nächstes folgen Ressourcenfilter, die (nach der Autorisierung) sowohl der erste als auch der letzte Filter zum Bearbeiten einer Anforderung sind. Ressourcenfilter können Code ganz am Anfang einer Anforderung und auch ganz am Ende kurz vor Verlassen der MVC-Pipeline ausführen. Ein gut geeigneter Anwendungsfall für einen Ressourcenfilter ist das Zwischenspeichern der Ausgabe im Cache. Der Filter kann den Cache überprüfen und das zwischengespeicherte Ergebnis am Anfang der Pipeline zurückgeben. Wenn der Cache noch nicht aufgefüllt ist, kann der Filter die Antwort aus der Aktion am Ende der Pipeline dem Cache hinzufügen.

Aktionsfilter werden unmittelbar vor und nach der Ausführung von Aktionen ausgeführt. Sie werden nach erfolgter Modellbindung ausgeführt, sodass sie Zugriff auf die an das Modell gebundenen Parameter, die an die Aktion gesendet werden, sowie auf den Modellüberprüfungsstatus haben.

Aktionen geben Ergebnisse zurück. Ergebnisfilter werden unmittelbar vor und nach der Rückgabe von Ergebnissen ausgeführt. Diese Filter können der Anzeige oder Ausführung des Formatierers Verhalten hinzufügen.

Ausnahmefilter dienen schließlich zum Behandeln nicht abgefangener Ausnahmen und Anwenden globaler Richtlinien auf diese Ausnahmen innerhalb der App.

In diesem Artikel konzentriere ich mich auf Aktionsfilter.

Umfang der Filterung

Filter können global oder auf Ebene des einzelnen Controllers oder der einzelnen Aktion angewendet werden. Filter, die als Attribute implementiert werden, können üblicherweise auf jeder Ebene hinzugefügt werden. Dabei wirken sich globale Filter auf alle Aktionen, Controllerattributfilter auf alle Aktionen innerhalb des jeweiligen Controllers und Aktionsattributfilter lediglich auf die jeweilige Aktion aus. Wenn für eine Aktion mehrere Filter gelten, wird ihre Reihenfolge zuerst von der „Order“-Eigenschaft und dann entsprechend ihrer Einschränkung auf die jeweilige Aktion bestimmt. Filter mit derselben „Order“-Eigenschaft werden von außen nach innen ausgeführt, d. h. zuerst die globalen Filter, dann die auf Controllerebene und danach die auf Aktionsebene. Nachdem die Aktion erfolgt ist, wird die Reihenfolge umgekehrt, sodass erst die Filter auf Aktionsebene, dann die auf Controllerebene und schließlich die globalen Filter ausgeführt werden.

Filter, die nicht als Attribute implementiert werden, können mithilfe des Typs „TypeFilterAttribute“ weiter auf Controller oder Aktionen angewendet werden. Dieses Attribut akzeptiert den Typ des Filters für die Ausführung als Konstruktorparameter. Um beispielsweise den „CustomActionFilter“ auf eine Methode mit einer einzelnen Aktion anzuwenden, schreiben Sie Folgendes:

[TypeFilter(typeof(CustomActionFilter))]
public IActionResult SomeAction()
{
  return View();
}

Das „TypeFilterAttribute“ arbeitet mit dem in die App integrierten Container für Dienste zusammen, um sicherzustellen, dass vom „Custom­ActionFilter“ verfügbar gemachte Abhängigkeiten zur Laufzeit aufgefüllt werden.

Eine API nach dem Prinzip der Vermeidung von Wiederholungen

Zum Demonstrieren einiger Beispiele, bei denen Filter den Entwurf einer ASP.NET Core MVC-App verbessern können, habe ich eine einfache API erstellt, die grundlegende CRUD-Funktionen (Create, Read, Update, Delete [Erstellen, Lesen, Aktualisieren, Löschen]) bietet und verschiedene Standardregeln zum Behandeln ungültiger Anforderungen befolgt. Da das Schützen von APIs ein eigenes Thema ist, lassen ich diesen Aspekt bei diesem Beispiel absichtlich außen vor.

Meine Beispiel-App macht eine API zum Verwalten von „Autoren“ verfügbar, bei denen es sich um einfache Typen mit nur ein paar Eigenschaften handelt. Die API befolgt die standardmäßigen auf Verben basierenden HTTP-Konventionen, um alle Autoren abzurufen, einen Autor anhand der ID abzurufen sowie einen neuen Autor zu erstellen, zu bearbeiten und zu löschen. Die API akzeptiert ein „IAuthorRepository“ über Dependency Injection (DI, Abhängigkeitsinjektion) zum Abstrahieren des Datenzugriffs. (Weitere Informationen zu DI finden Sie in meinem Artikel aus dem Monat Mai unter msdn.com/magazine/mt703433.) Sowohl die Controllerimplementierung als auch das Repository werden asynchron implementiert.

Die API befolgt zwei Richtlinien:

  1. API-Anforderungen, die eine bestimmte Autoren-ID angeben, erhalten als Antwort einen Fehler des Typs 404, wenn diese ID nicht vorhanden ist.
  2. API-Anforderungen, die eine ungültige Autorenmodellinstanz bereitstellen (ModelState.IsValid == false), geben „BadRequest“ samt Modellfehlern zurück.

Abbildung 1 zeigt die Implementierung dieser API mit diesen geltenden Regeln.

Abbildung 1: AuthorsController

[Route("api/[controller]")]
public class AuthorsController : Controller
{
  private readonly IAuthorRepository _authorRepository;
  public AuthorsController(IAuthorRepository authorRepository)
  {
    _authorRepository = authorRepository;
  }
  // GET: api/authors
  [HttpGet]
  public async Task<List<Author>> Get()
  {
    return await _authorRepository.ListAsync();
  }
  // GET api/authors/5
  [HttpGet("{id}")]
  public async Task<IActionResult> Get(int id)
  {
    if ((await _authorRepository.ListAsync()).All(a => a.Id != id))
    {
      return NotFound(id);
    }
    return Ok(await _authorRepository.GetByIdAsync(id));
  }
  // POST api/authors
  [HttpPost]
  public async Task<IActionResult> Post([FromBody]Author author)
  {
    if (!ModelState.IsValid)
    {
      return BadRequest(ModelState);
    }
    await _authorRepository.AddAsync(author);
    return Ok(author);
  }
  // PUT api/authors/5
  [HttpPut("{id}")]
  public async Task<IActionResult> Put(int id, [FromBody]Author author)
  {
    if ((await _authorRepository.ListAsync()).All(a => a.Id != id))
    {
      return NotFound(id);
    }
    if (!ModelState.IsValid)
    {
       return BadRequest(ModelState);
    }
    author.Id = id;
    await _authorRepository.UpdateAsync(author);
    return Ok();
  }
  // DELETE api/values/5
  [HttpDelete("{id}")]
  public async Task<IActionResult> Delete(int id)
  {
    if ((await _authorRepository.ListAsync()).All(a => a.Id != id))
    {
      return NotFound(id);
    }
    await _authorRepository.DeleteAsync(id);
    return Ok();
  }
  // GET: api/authors/populate
  [HttpGet("Populate")]
  public async Task<IActionResult> Populate()
  {
    if (!(await _authorRepository.ListAsync()).Any())
    {
      await _authorRepository.AddAsync(new Author()
      {
        Id = 1,
        FullName = "Steve Smith",
        TwitterAlias = "ardalis"
      });
      await _authorRepository.AddAsync(new Author()
      {
        Id = 2,
        FullName = "Neil Gaiman",
        TwitterAlias = "neilhimself"
      });
    }
    return Ok();
  }
}

Wie Sie erkennen können, gibt es in diesem Code eine ganze Menge an duplizierter Logik, insbesondere dahingehend, wie die Ergebnisse „NotFound“ und „BadRequest“ zurückgegeben werden. Die Prüfungen „ValidateModel/BadRequest“ kann ich rasch durch einen einfachen Aktionsfilter ersetzen:

public class ValidateModelAttribute : ActionFilterAttribute
{
  public override void OnActionExecuting(ActionExecutingContext context)
    {
    if (!context.ModelState.IsValid)
    {
      context.Result = new BadRequestObjectResult(context.ModelState);
    }
  }
}

Dieses Attribut kann anschließend auf die Aktionen angewendet werden, die eine Modellüberprüfung durchführen müssen, indem [ValidateModel] der Aktionsmethode hinzugefügt wird. Beachten Sie, dass die Anforderung bei Festlegung der „Result“-Eigenschaft auf „Action­ExecutingContext“ unterbrochen wird. In diesem Fall gibt es keinen Grund, das Attribut nicht auf jede Aktion anzuwenden, weshalb ich es dem Controller und nicht jeder Aktion hinzufüge.

Das Prüfen, ob der Autor vorhanden ist, ist ein wenig komplizierter, da dies vom „IAuthorRepository“ abhängig ist, das per DI an den Controller übergeben wird. Es wäre am einfachsten, ein Aktionsfilterattribut zu erstellen, das einen Konstruktorparameter verwendet. Doch leider erwarten Attribute, dass diese Parameter an der Stelle angegeben werden, an der sie deklariert wurden. Ich kann die Repository-Instanz nicht dort bereitstellen, wo das Attribut angewendet wird. Ich möchte, dass es zur Laufzeit vom Container „services“ injiziert wird.

Glücklicherweise stellt das „TypeFilter“-Attribut die DI-Unterstützung zur Verfügung, die dieser Filter erfordert. Ich kann einfach das „TypeFilter“-Attribut auf die Aktionen anwenden und den „ValidateAuthorExistsFilter“-Typ angeben:

[TypeFilter(typeof(ValidateAuthorExistsFilter))]

Wenngleich dies funktioniert, ist dies nicht mein bevorzugter Ansatz, da er schlechter lesbar ist. Außerdem finden Entwickler, die einen von mehreren gängigen Attributfiltern anwenden möchten, das „ValidateAuthorExists“-­Attribut nicht über IntelliSense. Der vor mir bevorzugter Ansatz ist das Erstellen einer Unterklasse für das „TypeFilter“-Attribut, es mit einem geeigneten Namen zu versehen und die Filterimplementierung in einer privaten Klasse innerhalb dieses Attributs abzulegen. Abbildung 2 veranschaulicht diesen Ansatz. Die tatsächliche Arbeit wird von der privaten „ValidateAuthorExistsFilterImpl“-Klasse geleistet, deren Typ an den Konstruktor des „TypeFilter“-Attributs übergeben wird.

Abbildung 2: ValidateAuthorExistsAttribute

public class ValidateAuthorExistsAttribute : TypeFilterAttribute
{
  public ValidateAuthorExistsAttribute():base(typeof
    (ValidateAuthorExistsFilterImpl))
  {
  }
  private class ValidateAuthorExistsFilterImpl : IAsyncActionFilter
  {
    private readonly IAuthorRepository _authorRepository;
    public ValidateAuthorExistsFilterImpl(IAuthorRepository authorRepository)
    {
      _authorRepository = authorRepository;
    }
    public async Task OnActionExecutionAsync(ActionExecutingContext context,
      ActionExecutionDelegate next)
    {
      if (context.ActionArguments.ContainsKey("id"))
      {
        var id = context.ActionArguments["id"] as int?;
        if (id.HasValue)
        {
          if ((await _authorRepository.ListAsync()).All(a => a.Id != id.Value))
          {
            context.Result = new NotFoundObjectResult(id.Value);
            return;
          }
        }
      }
      await next();
    }
  }
}

Beachten Sie, dass das Attribut als Teil des „ActionExecutingContext“-Parameters Zugriff auf die Argumente hat, die an die Aktion übergeben werden. Dadurch kann der Filter prüfen, ob ein „id“-Parameter vorhanden ist, und dessen Werte abrufen, bevor geprüft wird, ob ein Autor mit dieser ID vorhanden ist. Beachten Sie auch, dass die „ValidateAuthorExistsFilterImpl“-Klasse ein asynchroner Filter ist. Bei diesem Muster gibt es nur eine zu implementierende Methode. Die Arbeit kann erfolgen, bevor oder nachdem die Aktion ausgeführt wurde, in dem sie vor oder nach dem Aufruf von „next“ ausgeführt wird. Wenn Sie jedoch den Filter unterbrechen, indem Sie ein „context.Result“ festlegen, muss eine Rückgabe erfolgen, ohne dass „next“ aufgerufen wird (andernfalls erhalten Sie eine Ausnahme).

Ein weiterer bei Filtern zu beachtender Aspekt ist, dass sie keine Status auf Objektebene einbeziehen dürfen, wie z. B. ein Feld für einen „IActionFilter“ (insbesondere einen, der als Attribut implementiert ist), der während „OnActionExecuting“ festgelegt und in „OnActionExecuted“ gelesen oder geändert wird. Falls diese Art von Logik erforderlich sein sollte, können Sie diesen Typ von Status vermeiden, indem Sie zu einem „IAsyncActionFilter“ wechseln, der einfach innerhalb der „OnActionExecutionAsync“-Methode lokale Variablen verwenden kann.

Was ist, nach dem Wechseln der Modellüberprüfung und Prüfung des Vorhandenseins von Datensätzen innerhalb der Controlleraktionen zu gängigen Filter, die Auswirkung auf meinen Controller? Abbildung 3 zeigt zum Vergleich „Authors2Controller“, der dieselbe Logik wie „AuthorsController“ verwendet, aber diese beiden Filter für sein allgemeines Richtlinienverhalten nutzt.

Abbildung 3: Authors2Controller

[Route("api/[controller]")]
[ValidateModel]
public class Authors2Controller : Controller
{
  private readonly IAuthorRepository _authorRepository;
  public Authors2Controller(IAuthorRepository authorRepository)
  {
    _authorRepository = authorRepository;
  }
  // GET: api/authors2
  [HttpGet]
  public async Task<List<Author>> Get()
  {
    return await _authorRepository.ListAsync();
  }
  // GET api/authors2/5
  [HttpGet("{id}")]
  [ValidateAuthorExists]
  public async Task<IActionResult> Get(int id)
  {
    return Ok(await _authorRepository.GetByIdAsync(id));
  }
  // POST api/authors2
  [HttpPost]
  public async Task<IActionResult> Post([FromBody]Author author)
  {
    await _authorRepository.AddAsync(author);
    return Ok(author);
  }
  // PUT api/authors2/5
  [HttpPut("{id}")]
  [ValidateAuthorExists]
  public async Task<IActionResult> Put(int id, [FromBody]Author author)
  {
    await _authorRepository.UpdateAsync(author);
    return Ok();
  }
  // DELETE api/authors2/5
  [HttpDelete("{id}")]
  [ValidateAuthorExists]
  public async Task<IActionResult> Delete(int id)
  {
    await _authorRepository.DeleteAsync(id);
    return Ok();
  }
}

Beachten Sie bei diesem umgestalteten Controller zwei Dinge. Erstens ist er kürzer und klarer. Zweitens gibt es in keiner der Methoden Bedingungen. Die allgemeine Logik der API wurde vollständig in Filter verlagert, die bei Bedarf angewendet werden, damit die Arbeit des Controllers so übersichtlich wie möglich ist.

Besteht eine Testmöglichkeit?

Das Verlagern von Logik aus Ihrem Controller in Attribute eignet sich besonders zum Reduzieren der Komplexität von Code und Erzwingen eines einheitlichen Verhaltens zur Laufzeit. Wenn Sie Ihre Komponententests direkt auf Ihre Aktionsmethoden anwenden, wird für Ihre Tests leider nicht das Attribut- oder Filterverhalten angewendet. Dies ist beabsichtigt, und selbstverständlich können Sie Komponententests auf Ihre Filter unabhängig von einzelnen Aktionsmethoden anwenden, um sicherzustellen, dass sie wie gewünscht funktionieren. Doch was ist, wenn Sie nicht nur sicherstellen müssen, dass Ihre Filter funktionieren, sondern dass sie ordnungsgemäß eingerichtet sind und auf einzelne Aktionsmethoden angewendet werden? Was ist zu tun, wenn Sie bereits vorhandenen API-Code umgestalten möchten, damit die zuvor vorgestellten Filter genutzt werden, und Sie sichergehen möchten, dass die API sich im Anschluss immer noch ordnungsgemäß verhält? Hier sind Integrationstests gefragt. Zum Glück bietet ASP.NET Core eine umfassende Unterstützung schneller und einfacher Integrationstests.

Meine Beispielanwendung ist für das Verwenden eines Entity Framework Core DbContext im Arbeitsspeicher konfiguriert. Doch auch wenn ich mit SQL Server arbeiten würde, könnte ich einen Speicher im Arbeitsspeicher für meine Integrationstests verwenden. Dies ist wichtig, da dadurch das Tempo solcher Tests drastisch erhöht und ihre Einrichtung vereinfacht wird, da keine Infrastruktur erforderlich ist.

Die Klasse, die die meiste Arbeit für Integrationstests in ASP.NET Core leistet, ist die „TestServer“-Klasse, die im „Microsoft.AspNetCore.TestHost“-Paket enthalten ist. Sie konfigurieren die „TestServer“-Klasse identisch mit der Konfiguration Ihrer Web-App im Einstiegspunkt „Program.cs“ mithilfe von WebHostBuilder. Für meine Tests wähle ich dieselbe „Startup“-Klasse wie bei meiner Beispiel-Web-App. Ich gebe an, dass sie in der Umgebung „Testing“ ausgeführt wird. Dadurch werden beim Start der Website einige Beispieldaten ausgelöst:

var builder = new WebHostBuilder()
  .UseStartup<Startup>()
  .UseEnvironment("Testing");
var server = new TestServer(builder);
var client = server.CreateClient();

Der Client in diesem Fall ist ein standardmäßiger „System.Net.Http.HttpClient“, mit dessen Hilfe Sie Anforderungen an den Server so richten, als erfolgten diese über das Netzwerk. Doch da alle diese Anforderungen im Arbeitsspeicher gestellt werden, verlaufen die Tests überaus schnell und zuverlässig.

Für meine Tests verwende ich xUnit, das die Möglichkeit bietet, für eine bestimmte Testmethode mehrere Tests mit verschiedenen Datasets auszuführen. Um zu bestätigen, dass sich meine beiden Klassen „AuthorsController“ und „Authors2Controller“ identisch verhalten, nutze ich dieses Feature, um für jeden Test beide Controller anzugeben. In Abbildung 4 werden mehrere Tests der „Put“-Methode gezeigt.

Abbildung 4: Tests der „Put“-Methode für Autoren

[Theory]
[InlineData("authors")]
[InlineData("authors2")]
public async Task ReturnsNotFoundForId0(string controllerName)
{
  var authorToPost = new Author() { Id = 0, FullName = "test",
    TwitterAlias = "test" };
  var jsonContent = new StringContent(JsonConvert.SerializeObject(authorToPost),
     Encoding.UTF8, "application/json");
  var response = await _client.PutAsync($"/api/{controllerName}/0", jsonContent);
  Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
  var stringResponse = await response.Content.ReadAsStringAsync();
  Assert.Equal("0", stringResponse);
}
[Theory]
[InlineData("authors")]
[InlineData("authors2")]
public async Task ReturnsBadRequestGivenNoAuthorName(string controllerName)
{
  var authorToPost = new Author() {Id=1, FullName = "", TwitterAlias = "test"};
  var jsonContent = new StringContent(
    JsonConvert.SerializeObject(authorToPost),
     Encoding.UTF8, "application/json");
  var response = await _client.PutAsync($"/api/{controllerName}/1", jsonContent);
  Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  var stringResponse = await response.Content.ReadAsStringAsync();
  Assert.Contains("FullName", stringResponse);
  Assert.Contains("The FullName field is required.", stringResponse);
}
[Theory]
[InlineData("authors")]
[InlineData("authors2")]
public async Task ReturnsOkGivenValidAuthorData(string controllerName)
{
  var authorToPost = new Author() {
    Id=1,FullName = "John Doe",
    TwitterAlias = "johndoe" };
  var jsonContent = new StringContent(JsonConvert.SerializeObject(authorToPost),
     Encoding.UTF8, "application/json");
  var response = await _client.PutAsync($"/api/{controllerName}/1", jsonContent);
  response.EnsureSuccessStatusCode();
}

Beachten Sie, dass für diese Integrationstests keine Datenbank, Internetverbindung und Ausführung eines Webservers erforderlich sind. Sie sind fast so schnell und einfach wie Komponententests, erlauben Ihnen aber, was noch wichtiger ist, das Testen Ihrer ASP.NET-Apps in der gesamten Anforderungspipeline und nicht bloß einer isolierten Methode innerhalb einer Controllerklasse. Ich empfehle dennoch, nach Möglichkeit Komponententests zu schreiben und auf Integrationstests für Verhalten zurückzugreifen, für die keine Komponententests möglich sind. Doch es ist sehr hilfreich, über eine solche leistungsfähige Möglichkeit der Ausführung von Integrationstests in ASP.NET Core zu verfügen.

Nächste Schritte

Filter sind ein großes Thema, weshalb ich mich in diesem Artikel auf einige wenige Beispiel beschränken musste. In der offiziellen Dokumentation auf „docs.asp.net“ finden Sie weitere Informationen zu Filtern und dem Testen von ASP.NET Core-Apps.

Der Quellcode für dieses Beispiel ist unter bit.ly/1sJruw6 verfügbar.


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 unterstützt Teams dabei, sich schnell mit ASP.NET Core vertraut zu 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 ASP.NET-Team bei Microsoft.