Aplikace obsahují chyby a nelze se tomu vyhnout. Musíme s nimi počítat a když k nim dojde, ošetřit je. Což znamená zejména zařídit dvě věci: nějakým přiměřeným způsobem o ní informovat uživatele a nějakým přiměřeným způsobem o ní zaznamenat data pro programátora. Ukážeme si, jak tyto dvě věci realizovat v aktuální verzi ASP.NET Core.

Ošetřování HTTP chyb

První druhem jsou HTTP chyby, tj. chyby na web serveru. Zejména se jedná o neexistující stránky (populární HTTP status 404 Object Not Found), v menší míře pak chyby týkající se autentizace a autorizace (stavy 401 Unauthorized, 403 Forbidden). Nejnověji pak třeba 451 Unavailable For Legal Reasons dle RFC 7725, který se dočkal masivního rozšíření poté, co se EU ve své nekonečné moudrosti začala snažit nutit GDPR i subjektům mimo svou jurisdikci.

Ve výchozí konfiguraci Kestrel (HTTP server pro ASP.NET Core) v případě chyby vrátí prázdnou odpověď s odpovídajícím stavovým kódem, což se v prohlížeči zobrazí jako prázdná stránka. To z hlediska UX není úplně optimální a chceme dělat něco jiného. ASP.NET Core obsahuje několik middleware, které s HTTP chybami dokáží naložit jinak - stačí je aplikovat v metodě Configure třídy Startup.

UseStatusCodePages

app.UseStatusCodePages();

Toto prosté volání způsobí, že server vrátí čistě textovou (content-type text/plain) odpověď, např. Status Code: 404; Not Found.

Metoda UseStatusCodePages má několik overloadů, jeden z nich umožní určit, jaký má mít vrácená odpověď typ a obsah. Můžete použít placeholder {0}, na jehož místo se doplní číselný kód.Následující volání vygeneruje jednoduchou HTML stránku:

app.UseStatusCodePages("text/html", "<html><head><title>HTTP {0}</title></head><body><h1>HTTP Error {0}</h1></body></html>");

UseStatusCodePagesWithRedirects

app.UseStatusCodePagesWithRedirects("/Errors/{0}.html");

Tento middleware způsobí přesměrování na zadanou adresu (i zde můžete použít placeholder {0}). Velice důrazně doporučuji tento způsob nepoužívat, neboť není strojově detekovatelné, že se jedná o chybu. Uživatel je prostě přesměrován (standardním redirectem) na stránku, která vrátí 200 OK.

UseStatusCodePagesWithReExecute

app.UseStatusCodePagesWithReExecute("/Errors/{0}");

Lepší varianta je poslední middleware, který funguje velmi podobně, ale uživatele nepřesměruje. Interně přepíše požadavek na novou adresu, ale adresa na klientovi ani HTTP status se nezmění. Záleží na vás, co umístíte na adresu /Errors/{0} (kde placeholder opět odpovídá číslu chyby).

Může to být MVC controller, který zobrazí vhodnou chybovou stránku. Já dávám přednost Razor Pages. Vytvořím jednoduchou Razor Page ~/Pages/Errors/Index.cshtml (nemusí mít viewmodel) s přibližně následujícím obsahem:

@page "{statusCode}"
@{ ViewBag.Title = "HTTP Error " + this.RouteData.Values["statusCode"]; }
<h1>@ViewBag.Title</h1>
<p>V průběhu zpracování vašeho požadavku došlo k chybě.</p>

Pro obsluhu nejčastější chyby číslo 404 pak mám speciální stránku ~/Pages/Errors/404.cshtml:

@page
@{ ViewBag.Title = "HTTP Error 404: Object Not Found"; }
<h1>@ViewBag.Title</h1>
<p>Požadovaná stránka neexistuje.</p>

Obojí předpokládá existenci layout page, která do titulku stránky vloží to, co najde ve ViewBag.Title.

Ošetřování výjimek

Výše uvedené si poradí se všemi chybami, kromě vnitřní chyby aplikace - 500 Internal Server Error. Pokud vaše aplikace vyhodí výjimku (exception), zobrazí se opět prázdná bílá stránka.

Pro ošetřování výjimek má ASP.NET Core zvláští middleware, Exception Handler. Aktivujete jej takto:

app.UseExceptionHandler("/Errors/Internal");

Funguje podobně jako UseStatusCodePagesWithReExecute v případě HTTP chyb. Vnitřně se adresa přepíše na adresu, kterou jste zde uvedli.

Logování výjimek v chybové stránce

Jak již bylo řečeno, chybová stránka by měla udělat dvě základní věci:

Obecně lze pro sledování aplikace použít mnoho způsobů, od rozličného logování po sofistikované služby typu Application Insights. Mně se nicméně pro výjimky výtečně osvědčuje jednoduché posílání mailů nebo logování do souboru. Snahou je, aby programátor získal maximum informací ohledně okolností, za nichž se výjimka vyskytla (protože na smysluplný popis od uživatele spoléhat nelze).

Kromě základních informací (jako je čas, URL, kompletní chybová zpráva a stack trace) se hodí znát:

Z těchto informací zpravidla dokážete vyčíst, co se v aplikaci stalo za průšvih. Zcela záměrně pro jejich zaznamenávání nepoužívám žádný sofistikovaný logovací framework, ale primitivní kód, který je uloží do textového souboru. Pokud aplikace spadla na exception, bude se nejspíš nacházet v nějakém divném stavu a kód pro jeho vyřešení by měl být pokud možno co nejjednodušší, protože pokud spadne i on, nikdo nepomůže.

Moje obvyklá chybová stránka obsahuje ještě jednu funkcionalitu: při každém pádu vygeneruje unikátní číslo a zobrazí ho uživateli. Pokud ten kontaktuje podporu, lze jeho konkrétní pád spojit se záznamem v logu.

Abychom zjistili informace o výjimce, musíme použít HTTP Context Features. Co to je, o tom detailně napíšu někdy jindy, ale nyní je podstatné, že potřebujeme získat informace o výjimce a také o té stránce, kde došlo k chybě (vlastnosti typu Request.Path odkazují na stránku, která ošetřuje chybu):

var err = this.HttpContext.Features.Get<IExceptionHandlerPathFeature>();
var errUrl = new UriBuilder(this.Request.GetDisplayUrl()) { Path = err.Path };

V proměnné err budeme mít k dispozici vlastnost Error, která obsahuje odpovídající Exception a vlastnost Path, která obsahuje cestu původní stránky s chybou. Pomocí URI Builderu pak vytvoříme odpovídající kompletní adresu errUrl.

Kompletní zdrojový kód ViewModelu stránky ~/Pages/Errors/Internal.cshtml.cs vypadá takto:

public class InternalModel : PageModel {
    private readonly IWebHostEnvironment environment;

    public InternalModel(IWebHostEnvironment environment) {
        this.environment = environment;
    }

    public string LogId { get; set; }

    public async Task OnGet() {
        // Gather information
        this.LogId = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{this.HttpContext.TraceIdentifier}";
        var err = this.HttpContext.Features.Get<IExceptionHandlerPathFeature>();
        var errUrl = new UriBuilder(this.Request.GetDisplayUrl()) { Path = err.Path };

        // Log basic info
        var sb = new StringBuilder();
        sb.AppendLine($"ID: {this.LogId}");
        sb.AppendLine($"Date: {DateTime.UtcNow:o}");
        sb.AppendLine($"Exception: {err.Error.Message}");
        sb.AppendLine($"URL: {errUrl}");
        sb.AppendLine($"Method: {this.Request.Method}");
        sb.AppendLine($"HTTPS: {this.Request.IsHttps}");
        sb.AppendLine($"Connection: {this.HttpContext.Connection.RemoteIpAddress} (:{this.HttpContext.Connection.RemotePort}) -> {this.HttpContext.Connection.LocalIpAddress} (:{this.HttpContext.Connection.LocalPort})");
        sb.AppendLine();

        // User
        if (this.User.Identity.IsAuthenticated) {
            sb.AppendLine("# Authenticated user");
            var cid = this.User.Identity as ClaimsIdentity;
            foreach (var item in cid.Claims) sb.AppendLine($"{item.Type} = {item.Value}");
        } else {
            sb.AppendLine("# No authenticated user");
        }
        sb.AppendLine();

        // Form data
        if (this.Request.HasFormContentType) {
            sb.AppendLine("# Form data");
            foreach (var item in this.Request.Form) sb.AppendLine($"{item.Key} = {item.Value}");
        } else {
            sb.AppendLine("# No form data");
        }
        sb.AppendLine();

        // Cookies
        if (this.Request.Cookies.Any()) {
            sb.AppendLine("# Cookies");
            foreach (var item in this.Request.Cookies) sb.AppendLine($"{item.Key} = {item.Value}");
        } else {
            sb.AppendLine("# No cookies");
        }
        sb.AppendLine();

        // Headers
        sb.AppendLine("# HTTP headers");
        foreach (var item in this.Request.Headers) sb.AppendLine($"{item.Key} = {item.Value}");
        sb.AppendLine();

        // Exception
        sb.AppendLine("# Exception");
        sb.AppendLine(err.Error.ToString());
        sb.AppendLine();

        // Write log file
        sb.AppendLine("# End of file");
        var logFileName = Path.Combine(this.environment.ContentRootPath, "App_Data/AppErrorLogs", this.LogId + ".log");
        Directory.CreateDirectory(Path.GetDirectoryName(logFileName));
        await System.IO.File.WriteAllTextAsync(logFileName, sb.ToString()).ConfigureAwait(false);
    }
}

Při pádu aplikace se veškeré informace uloží do souboru ve složce App_Data/AppErrorLogs v rootu aplikace. Název souboru je složen z aktuálního datumu a času a ID požadavku. Odpovídá tedy ID chyby, které je zobrazeno uživateli a podle něj lze odpovídající log dohledat.

Pro pořádek, soubor ~/Pages/Errors/Internal.cshtml, který zobrazuje zprávu uživateli, vypadá takto:

@page
@model InternalModel
@{ ViewBag.Title = "HTTP Error 500: Internal Server Error"; }
<h1>@ViewBag.Title</h1>
<p>V průběhu zpracování vašeho požadavku došlo k chybě.</p>
<p>Chyba byla zaznamenána pod následujícím ID: <code>@Model.LogId</code></p>

Jiné nastavení pro vývoj a produkci

Shora uvedená nastavení jsou určena pro staging a produkci. Pro vývojovou verzi je samozřejmě rozumné zobrazovat informace o chybě přímo. Do metody Configure si tedy nechte nainjectovat IWebHostEnvironment a podle něj rozhodněte, jak se k ošetřování chyb postavíte:

if (env.IsDevelopment()) {
    app.UseDeveloperExceptionPage();
    app.UseDatabaseErrorPage();
} else {
    app.UseExceptionHandler("/Errors/Internal");
    app.UseStatusCodePagesWithReExecute("/Errors/{0}");
}