Aus Fehlern lernen

Zentrales Exception-Handling für Web-APIs in ASP.NET Core
Keine Kommentare

Fehlerbehandlung – ein meist ungeliebter und vor allem nach hinten geschobener Aspekt in der Softwareentwicklung: „Welche Fehler? Was soll denn schon passieren?” Wenn auf die Validität von Argumenten eines Aufrufs o. Ä. geprüft wird, werfen viele Entwickler einfach eine der zahlreichen in .NET vorhandenen Standard-Exceptions. Doch was passiert danach und vor allem: Welchen Informationsgehalt benötigen Fehler?

Im Zuge der serviceorientierten Architektur vieler Systeme rückt die Fehlerbehandlung immer mehr in den Fokus. Die an den Service angebundenen Anwendungen wie zum Beispiel Webclients oder mobile Apps benötigen aussagekräftige und standardisierte Fehlermeldungen, auf die reagiert werden kann. Ein einfacher HTTP-Status reicht hier genauso wenig wie ein allgemeiner Fehlertext.

In diesem Artikel werde ich Ihnen aufzeigen, wie mittels ASP.NET Core ein zentrales Fehlerhandling inklusive aussagekräftiger Fehlerinformationen umgesetzt werden kann. Dies ermöglicht ein ebenfalls zentrales Fehlerhandling in Ihren Endanwendungen, die im besten Fall so generisch wie möglich umgesetzt werden können.

Fehler verstehen

Hier stellt sich die Frage, in welchen Situationen ein Fehler provoziert wird und an welchen Stellen in der Anwendung ein Fehler unbehandelt bzw. ungewünscht auftreten kann, denn Fehler sind prinzipiell nicht immer etwas Schlechtes.

In die erste Kategorie fallen zum Beispiel Fehler, die z. B. mit der Authentifizierung oder Autorisierung zu nennen sind, egal ob es sich nun um das fehlende Anmeldetoken, keine ausreichenden Rechte oder eine unzureichende Lizenzierung handelt. Dies sind gewünschte Fehler, die durch die Anwendungslogik provoziert werden können.

Kostenlos: Docker mit .NET auf einen Blick

Container unter Linux und Windows nutzen? Unser Cheatsheet zeigt Ihnen wie Sie: Container starten, analysieren und Docker.DotNet (in C#) verwenden. Jetzt kostenlos herunterladen!

Download for free

Auf der Gegenseite (zum Beispiel einer mobilen App oder einer Webanwendung) ist es hierbei ebenfalls notwendig, möglichst an einer zentralen Stelle auf diese Fehler reagieren zu können, um zum Beispiel auf die Anmeldeseite zu springen. Zu dieser Kategorie zählen zudem auch Fehler wie unter anderem Validierungsfehler oder ungültige Argumente (z. B. OutOfRangeExceptions oder ArgumentNullExceptions). Hierbei sollte auf Clientseite bereits im Voraus alles unternommen werden, damit diese Fehler nicht auftreten können. Eine API-Abfrage mit ungültigen oder gar fehlenden Parametern sollte unbedingt vermieden werden. Sollte dies nicht möglich sein, kann ähnlich wie bei einer fehlenden Autorisierung in der Clientanwendung entsprechend darauf reagiert werden.

Die zweite Kategorie an Fehlern sind diejenigen, die es auf jeden Fall zu vermeiden gilt und selbst für die Clientanwendung nicht behandelbar sind. Dies sind zum einem Fehler wie z. B. NullReferenceExceptions oder Ähnliches, die den vorgesehenen Programmablauf unter- bzw. abbrechen. Solche Fehler müssen ebenfalls dem Client in einer Art und Weise mitgeteilt werden, dass dieser darauf reagieren kann (zum Beispiel durch Darstellen des Fehlers inklusive aussagekräftiger Fehlermeldung), auch wenn er den Fehler nicht zwingend behandeln kann.

Wie wurden solche Fehler dem Client bisher mitgeteilt? Wenn ein Web-API halbwegs sauber umgesetzt wurde, hat es zumindest einen HTTP-Statuscode und als Content eine einigermaßen nützliche Fehlermeldung mitgeteilt. Bei einem Validierungsfehler beispielsweise nutzt man den Statuscode „400 – Bad Request“.

Dieser Ansatz gibt der Clientanwendung die Möglichkeit, dem Nutzer mitzuteilen, dass irgendwelche Eingaben nicht korrekt gewesen sind. Die Information jedoch, welche Eingaben nicht korrekt waren, muss im Body des HTTP-Requests mitgeliefert werden. Hierfür muss nun ein System geschaffen werden, mit dem sowohl Client als auch Web-API möglichst generisch arbeiten können.

In der Realität sieht es jedoch häufig so aus, dass jeder Fehler, der in einem Web-API-Controller entstanden ist, oder eine geworfene Exception gänzlich undifferenziert als ein Fehler mit dem Status „500 – Internal Server Error“ und der Exception-Message als Content übermittelt wird. Mit diesen Fehlermeldungen will und kann die Client-Anwendung selbstverständlich nichts anfangen. Bevor also mit dem Entwickeln der eigentlichen Anwendungslogik begonnen wird, empfiehlt es sich, ein Error-Handling-System zu bauen, das genau diese Anforderungen umsetzt.

Fehler übermitteln

Neben den üblichen HTTP-Statuscodes, die auch zwingend verwendet werden müssen, stellt sich die Frage, welche weiterführenden Informationen zu einem Fehler an einen Client übermittelt werden sollen. Hierfür empfiehlt es sich, eine Basisklasse zu erstellen, die von der Klasse Exception erbt und grundsätzliche Fehlerinformationen in Bezug auf einen HTTPContext bereitstellt.

[DataContract]
[JsonObject(MemberSerialization=MemberSerialization.OptIn)]
  public abstract class HttpExceptionBase : Exception
  {
    [IgnoreDataMember]
    public abstract HttpStatusCode StatusCode { get; }

    [DataMember]
    public new string Message { get; set; }

    [DataMember]
    public new string Translated { get; set; }

    [DataMember]
    public string RequestUri { get; set; }

    [DataMember]
    public string Method { get; set; }

    [DataMember]
    public DateTime Timestamp { get; set; }

    public HttpExceptionBase(string message, HttpContext context, string translated = null)
    {
      this.Message = message;
      this.RequestUri = context?.Request?.Path;
      this.Method = context?.Request?.Method;
      this.Timestamp = DateTime.Now;

      CreateLogMessage();
    }

    protected virtual void CreateLogMessage()
    {
      Console.WriteLine($"{this.Timestamp.ToString()} [ERROR]: {this.GetType().Name} '{this.Message}'");
    }
  }
}

Wie in Listing 1 zu sehen ist, wird diese Klasse anhand einer Fehlermeldung, dem HTTPContext und einem optionalen übersetzten Fehlertext konstruiert. Zusätzlich werden Informationen wie z. B. angeforderter Pfad, Methode und ein Zeitstempel gesetzt. Diese Informationen können zum Beispiel genutzt werden, um bereits beim Konstruieren einer Fehlermeldung einen Logeintrag zu erzeugen.

In diesem Beispiel geschieht dies über den Aufruf der virtuellen Methode CreateLogMessage(), die wie hier gezeigt einen Eintrag über die Konsole erzeugt. Hier kann zum Beispiel ein Log-Framework wie log4net oder ein Monitoringsystem eingebunden werden. Das gesetzte Klassenattribut JsonObject wird benötigt, um dem JSON Serializer mitzuteilen, dass dies die zu serialisierende Klasse ist und nur explizit markierte Eigenschaften (DataMember) aufzunehmen sind. Sollte dieses Attribut fehlen, wird die Basisklasse der Exception (System.Exception), die das Interface ISerializable implementiert, herangezogen.

Nun können für diese allgemein gehaltene Basisklasse spezialisierte Klassen erstellt werden, die detaillierte Informationen unter anderem zu Authentifizierungs- oder Validierungsproblemen beinhalten. Für alle anderen Exceptions empfiehlt es sich, ebenfalls eine neue Fehlerklasse zu erstellen.

public class CommonException : HttpExceptionBase
  {
    public override HttpStatusCode StatusCode => HttpStatusCode.InternalServerError;

    [DataMember]
    public string StackTrace { get; set; }

    public CommonException(Exception exception, HttpContext context) : base(exception.Message, context)
    {
#if DEBUG
      this.StackTrace = exception.StackTrace;
#endif
    }
  }

Diese allgemein gehaltene Fehlerklasse, die von der Basisklasse HttpExceptionBase erbt, nimmt im Konstruktor eine Exception entgegen und setzt neben den Basisinformationen aus der HttpExceptionBase zusätzlich den StackTrace, sofern die Anwendung mit der DEBUG-Konstante kompiliert wurde (Listing 2). Dies ermöglicht es dem Exception-Handling (das wir später näher betrachten werden) sämtliche auftretende Exceptions in der Anwendung zu einem per Web-API übermittelbaren und serialisierbaren Inhalt zu konvertieren, mit dem selbst der Client arbeiten kann.

Ein typischer Anwendungsfall eines Web-API ist das Anlegen von Daten, wie beispielsweise einer Bestellung, in einem System. Hier spielen viele Faktoren zusammen, die sich nicht rein über die Attributannotationen, wie z. B. Datentypen, Required-Attribute, etc., behandeln lassen. Zum Teil ist es erforderlich, zur Laufzeit den Lagerbestand eines Artikels, die Verfügbarkeit für ein bestimmtes Land oder allgemein konditionell erwartete Angaben zu prüfen. All diese Validierungen geschehen zur Laufzeit im Web-API-Controller oder in einer tieferen Ebene der Businesslogik. Validierungsfehler können über die folgende spezialisierte Klasse ValidationException behandelt werden, in der Informationen über fehlerhafte Argumente bzw. Formularfelder gesammelt und in einem Objekt an den Client übergeben werden (Listing 3).

[DataContract]
public class ValidationException : HttpExceptionBase
{
  [DataContract]
  public enum ValidationExceptionReason
  {
    Missing = 0,
    MaximumValueExceeded = 1,
    MinimumValueExceeded = 2,
    Invalid = 3
  }

  [DataContract]
  public class ValidationExceptionEntry
  {
    [DataMember]
    public string FieldName { get; set; }

    [DataMember]
    public ValidationExceptionReason Reason { get; set; }
  }

  public override HttpStatusCode StatusCode => HttpStatusCode.BadRequest;

  [DataMember]
  public List Entries { get; set; }

  public ValidationException(string field, ValidationExceptionReason reason, HttpContext context) : base("Validation failed", context)
  {
    this.Entries = new List();
    AddEntry(field, reason);
  }

  public ValidationException(HttpContext context, params ValidationExceptionEntry[] entries) : base("Validation failed", context)
  {
    this.Entries = new List(entries);
  }

  public void AddEntry(string field, ValidationExceptionReason reason)
  {
    Entries.Add(new ValidationExceptionEntry() { FieldName = field, Reason = reason });
  }
}

Der ValidationException-Klasse kann im Konstruktor entweder ein einzelner Feldname inkl. Fehlergrund (ValidationExceptionReason) oder direkt eine Liste mit entsprechenden Einträgen zugewiesen werden. Ebenfalls ist es über die Methode AddEntry möglich, nachträglich weitere Einträge hinzuzufügen. Durch das Mapping von FieldName und Reason kann im Client ausgewertet werden, welche Felder aufgrund welcher Fehler nicht valide sind. Durch diesen Informationsgehalt umgeht man eine allgemeine Fehlermeldung, wie z. B. „Formular ist nicht valide“, und kann deshalb exakt definieren, welche Felder wie befüllt werden müssen. Je nach Anwendungsfall können logischerweise weitere und speziellere Informationen mit solch einer Exception mitgeliefert werden.

Fehler behandeln

Nachdem alle für die Anwendung notwendigen spezialisierten Exceptions angelegt wurden, können diese im Web-API-Controller verwendet werden. Im folgenden Beispiel existiert ein Controller Orders, der über die PUT-Methode eine neue Bestellung anhand von Kundennummer, Artikelnummer, Lieferadresse und Menge entgegennimmt. Zwei Prüfungen werden hierbei durchgeführt: Zum einen wird geprüft, ob die angegebene Menge größer als 0 ist; zum anderem wird – sofern der Artikel „Pizza“ geordert wird – die Lieferadresse geprüft und angegeben, dass nur nach Deutschland geliefert werden kann (Listing 4).

[HttpPost]
protected void PostOrder(
  [FromForm] string name,
  [FromForm] string address,
  [FromForm] string country,
  [FromForm] string articleId,
  [FromForm] int amount)
{
  ErrorHandling.Exceptions.ValidationException validException = null;

  // Bestellmenge < 1
  if(amount < 1)
  {
    if (validException == null) validException = new ValidationException(this.HttpContext);
    validException.AddEntry("amount", ValidationException.ValidationExceptionReason.MinimumValueExceeded);
  }

  // Pizza kann nicht in die USA geliefert werden
  if(String.Equals(articleId, "Pizza", StringComparison.CurrentCultureIgnoreCase) && !String.Equals(country, "Germany", StringComparison.CurrentCultureIgnoreCase))
  {
    if (validException == null) validException = new ValidationException(this.HttpContext);
    validException.AddEntry("articleId", ValidationException.ValidationExceptionReason.Invalid);
  }

if (validException != null)
  throw validException;

  // Logik zum Erstellen dieser Bestellung
  // ...
}

Sofern also eine dieser Prüfungen fehlschlägt, wird erst einmal ein neues ValidationException-Objekt instanziiert und entsprechend befüllt. Existiert nach Ausführen sämtlicher Prüfungen eine Exception, wird diese geworfen. Nun tritt der gewünschte Fall ein: Die Methode kann nicht erfolgreich ausgeführt werden und der entsprechende Exception-Handler der ASP.NET-Core-Anwendung kommt ins Spiel. Ziel ist es nun, die geworfenen Exceptions zentral über einen Handler zu serialisieren, evtl. zu loggen und dann dem Client als Ergebnis zurückzuliefern. Für diese Aufgabe muss ein entsprechender Handler erstellt werden, der eine statische Methode beinhält (Listing 5).

{
  public static class ErrorHandler
  {
    public async static Task Handle(HttpContext context)
    {
      context.Response.ContentType = "application/json";
      var ex = context.Features.Get();
      if (ex != null)
      {
        var error = ex.Error;
        HttpStatusCode status = HttpStatusCode.InternalServerError;

        if (error as HttpExceptionBase != null)
        {
          status = (error as HttpExceptionBase).StatusCode;
        }
        else
        {
          error = new Exceptions.CommonException(error, context);
        }
        context.Response.StatusCode = (int)status;

        await context.Response.WriteAsync(Newtonsoft.Json.JsonConvert.SerializeObject(error)).ConfigureAwait(false);
      }

    }
  }

Wie zu erkennen ist, nimmt die asynchrone Methode Handle den HttpContext entgegen und erhält darüber die aufgetretene Exception. Ist diese Exception eine spezialisierte Variante von HttpExceptionBase, wird der Status aus der Eigenschaft StatusCode herangezogen. Sollte es sich um eine anderweitige Exception handeln, wird diese in die in Listing 2 ersichtliche CommonException gepackt. Zu guter Letzt wird in diesem Beispiel durch den Newtonsoft Serializer unser Exception-Objekt als JSON konvertiert und in den Response Body geschrieben.

Der letzte Schritt zu einem funktionsfähigen ErrorHandler liegt nun darin, diesen für die ASP.NET Core Pipe kenntlich zu machen. Dies geschieht in der Configure-Methode der Startup-Klasse. Hier kann zum Beispiel vor dem Hinzufügen der MVC-Funktionalitäten zur Pipeline app.UseMvc(); der in Listing 6 dargestellte Eintrag zur Registrierung hinzugefügt werden. Wichtig ist hierbei, dass keine weiteren Logger oder Exception Handler davor registriert wurden, da die geworfenen Fehler diese in der Pipe zuerst durchlaufen und eventuell niemals von dem eigenen ErrorHandler behandelt werden können.

//error-handling
app.UseExceptionHandler(
  options => {
    options.Run(ErrorHandling.ErrorHandler.Handle);
  }
);

Hierdurch wird beim Auftreten einer Exception die unter options.Run angegebene Methode ausgeführt. Dazu muss die statische Handle-Methode aus dem unter Listing 5 ersichtlichen ErrorHandler als Argument übergeben werden.

Nach der Umsetzung kann das Web-API gestartet und das Fehlerhandling getestet werden. In der Beispielanwendung existiert nun ein Controller Order, der die in Listing 4 ersichtliche PUT-Methode beinhaltet. Er verfügt desweiteren über eine GET-Methode, die für den Zweck der Veranschaulichung eine IndexOutOfRange-Exception forciert, indem auf einen ungültigen Indexer eines Arrays zugegriffen wird.

Wird eine dieser Methoden aufgerufen (in diesem Beispiel über das Testing-Tool Postman), so erscheint zum Beispiel für die GET-Methode die in Abbildung 1 dargestellte Response.

Abb. 1: „IndexOutOfRange“ im Postman-Client

Als Statuscode ist „500 – Internal Server Error“ zurückgegeben worden. Dies ist der Statuscode, der in der CommonException-Klasse festgelegt wurde. Als Inhalt erhält der Client eben die Instanz, die durch den ErrorHandler serialisiert wurde.

Wird nun die POST-Methode aufgerufen und hier mit invaliden Daten befüllt (in diesem Beispiel die Lieferung des Artikels „Pizza“ außerhalb des Landes „Germany“), ist das in Abbildung 2 dargestellte Ergebnis zu beobachten.

Abb. 2: Validierungsfehler im Postman-Client

Nun wird als Status-Code nicht einfach ein „500 – Internal Server Error“ übergeben, sondern es kann nun differenziert werden, dass dieses Problem auf fehlende/invalide Daten auf der Clientseite zurückzuführen ist. Der Statuscode lautet hier nun „400 – Bad Request“ und als Inhalt wird die geworfene ValidationException inkl. der Eigenschaft Entries mitgeliefert, die explizit angibt, welche Felder welchen Fehlergrund aufweisen.

Fazit

Mit diesen Vorbereitungen und dem eigenmächtigen Behandeln von aufgetretenen Exceptions ist man in der Lage, Fehler an einer zentralen Stelle zu überwachen und für den Client aufzubereiten. Eine mobile App zum Beispiel kann dadurch ebenfalls an einer zentralen Stelle ihres API-Clients Fehler entgegennehmen, aufarbeiten und je nach Architektur und Fehlerfall dem Benutzer entsprechende Lösungsansätze bereitstellen.

Es ist abzuwägen, ob sich der Aufwand, der im Vorhinein für das Implementieren eines solchen Fehlerhandlings nötig ist, lohnt, anstatt die Standard-Mechanismen aus ASP.NET Core zu verwenden. Jedoch komme ich persönlich zu dem Schluss, dass die hier aufgezeichneten Mechanismen spätestens bei der Umsetzung von clientseitigen Anwendungen eine enorme Zeitersparnis und darüber hinaus einen informellen Mehrwert bieten. Eventuell fehlende Informationen können einfacher ergänzt oder Sonderfälle dadurch wesentlich komfortabler umgesetzt werden.

Windows Developer

Windows DeveloperDieser Artikel ist im Windows Developer erschienen. Windows Developer informiert umfassend und herstellerneutral über neue Trends und Möglichkeiten der Software- und Systementwicklung rund um Microsoft-Technologien.

Natürlich können Sie den Windows Developer über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist der Windows Developer ferner im Abonnement oder als Einzelheft erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -