Содержание
Обработка исключений позволяет нам избежать аварийного завершения работы приложения. Для обработки исключений в приложениях C#, обычно, используются блоки try...catch
с ключевым словом final
для того, чтобы очистить ресурсы приложения. Это стандартная практика обработки исключений, которая может также применяться и в приложениях ASP.NET Core Web API. Однако, платформа ASP.NET Core позволяет извлечь всю логику обработки исключений и сосредоточить её в одном централизованном месте, избежав тем самым использования блоков try…catch
в действиях контроллера и обеспечить, при необходимости, единый формат сообщений о возникших исключительных ситуациях в приложении. Такой подход к обработке исключений в приложениях ASP.NET Core часто называют Global Exception Handling – глобальная обработка исключений, хотя, на мой взгляд, правильнее было бы назвать этот механизм так, как указано в заголовке — централизованная обработка исключений, так как всё же, рассматриваемые ниже способы обработки исключений помогают обработать не все возможные исключения в приложении.
Метод расширения UseExceptionHandler()
Если Вы следите за публикациями в этом блоге, то про метод UseExceptionHandler() уже была заметка под названием «Обработка ошибок в ASP.NET Core. UseDeveloperExceptionPage и UseExceptionHandler» в разделе про «базовый» ASP.NET Core. Однако, стоит ещё раз упомянуть про такой способ централизованной обработки исключений, но применительно уже к ASP.NET Core Web API.
Метод расширения UseExceptionHandler() имеет несколько перегруженных версий и добавляет в конвейер обработки запросов компонент middleware ExceptionHandlerMiddleware
, который, в случае возникновения ошибки, может перенаправить пользователя на заданную страницу и уже на этой странице мы можем каким-либо образом проинформировать пользователя. Применительно к Web API более предпочтительной является другая версия этого метода расширения, а именно:
public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app, Action<IApplicationBuilder> configure)
Используя эту версию метода, мы можем встроить в конвейер обработки запросов свой компонент middleware в котором мы можем обработать исключение и, при необходимости, проинформировать пользователя. Чтобы продемонстрировать работу этого метода, создадим новое «пустое» приложение ASP.NET Core Web API на основе контроллеров и изменим действие Get() в контроллере WeatherForecastController следующим образом:
[HttpGet] public IEnumerable<WeatherForecast> Get() { throw new Exception("Что-то случилось"); /*return Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }) .ToArray();*/ }
Теперь при попытке получения доступа к этому действию будет сгенерировано исключение, которое пока никак не обрабатывается. Теперь выполним запрос к API, который уже имеется в http-файл проекта:
@WebApplication2_HostAddress = http://localhost:5124 GET {{WebApplication2_HostAddress}}/weatherforecast/ Accept: application/json
В результате, после пропуска остановки Visual Studio на строке с исключением, мы увидим следующий ответ сервера:
Эту же информацию мы увидим и в логе приложения, например, в консоли. Если же запустить приложение не из Visual Studio и попытаться выполнить запрос, то в браузере мы увидим только код статуса HTTP 500 Internal Server Error, что, естественно, нас не может устроить ни нас, ни будущих клиентов API. Попробуем использовать метод UseExceptionHandler()
для обработки исключения. Изменим файл Program.cs следующим образом:
using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(); var app = builder.Build(); // Configure the HTTP request pipeline. app.UseExceptionHandler(configure => { configure.Run(async context => { context.Response.StatusCode = 500; context.Response.ContentType = "application/json"; var exception = context.Features.Get<IExceptionHandlerFeature>(); if (exception != null) { var problem = new ProblemDetails() { Status = context.Response.StatusCode, Title = "Internal Server Error", Detail = exception.Error.Message, Instance = exception.Path, Type = "ServerError" }; problem.Extensions["traceId"] = context.TraceIdentifier; await context.Response.WriteAsJsonAsync(problem); } }); }); app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();
Первое, на что стоит обратить внимание — это расположение UseExceptionHandler()
. Так как расположение компонентов middleware в конвейере обработки запросов имеет значение, то мы размещаем необходимые компоненты middleware для обработки исключений как можно выше в конвейере, чтобы иметь возможность обрабатывать максимум возможных исключений при выполнении запросов в приложении. Внутри метода UseExceptionHandler()
мы добавляем в конвейер терминальный компонент middleware в котором обрабатываем исключение:
configure.Run(async context => { context.Response.StatusCode = 500; context.Response.ContentType = "application/json"; var exception = context.Features.Get<IExceptionHandlerFeature>(); if (exception != null) { var problem = new ProblemDetails() { Status = context.Response.StatusCode, Title = "Internal Server Error", Detail = exception.Error.Message, Instance = exception.Path, Type = "ServerError" }; problem.Extensions["traceId"] = context.TraceIdentifier; await context.Response.WriteAsJsonAsync(problem); } });
В компоненте middleware мы, во-первых, устанавливаем код статуса HTTP для ответа — 500, хотя, при желании, вы можете установить любой другой код, сообщающий об ошибке сервера. Далее мы запрашиваем из коллекции Features
контекста запроса получаем сведения об исключении:
var exception = context.Features.Get<IExceptionHandlerFeature>();
и, используя полученный объект (если он действительно был получен) создаем объект ProblemDetails, который представляет собой сообщение о проблеме, соответствующее формату, представленному в RFC 7807 «Problem Details for HTTP APIs«:
var problem = new ProblemDetails() { Status = context.Response.StatusCode, Title = "Internal Server Error", Detail = exception.Error.Message, Instance = exception.Path, Type = "ServerError" }; problem.Extensions["traceId"] = context.TraceIdentifier;
При этом мы не передаем в этот объект информацию об исключении, которая может считаться конфиденциальной, например трассировку стека вызовов, что немаловажно при разработке приложений Web API.
Теперь запустим приложение (не важно откуда — из Visual Studio или нет) и при попытке выполнения запроса мы уже увидим в браузере следующее сообщение сервера:
При необходимости, мы можем создать свой собственный метод расширения для встраивания компонентов обработки исключений в конвейер, добавить какую-либо дополнительную логику обработки исключения и так далее.
Интерфейс IExceptionHandler
В ASP.NET Core 8.0 также для перехвата необработанных исключений и их централизованной обработки можно использовать интерфейс IExceptionHandler
, который используется компонентом middleware для обработки исключений и предоставляет нам всего один метод
public interface IExceptionHandler { ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken); }
Метод TryHandleAsync()
должен вернуть true, если исключение и false в противоположном случае. При этом если исключение не было обработано, то оно передается в следующий обработчик до тех пор, пока оно не будет обработано или же очередь обработчиков не закончится.
Чтобы создать и использовать в приложении обработчик исключений на основе IExceptionHandler
мы должны выполнить следующие действия:
- Реализовать интерфейс
IExceptionHandler
(собственно, создать обработчик) - Зарегистрировать обработчик в контейнере зависимостей, используя специальный метод расширения
AddExceptionHandler<T>()
. Этот метод зарегистрирует обработчик как singleton-сервис. - Добавить в приложение сервисы для генерации исключений в формате RFC 7807 (вызвать метод
AddProblemDetails()
) - Добавить в конвейер обработки запросов
ExceptionHandlerMiddleware
, используя рассмотренный выше методUseExceptionHandler()
без параметров.
Реализуем все эти пункты в нашем приложении и перенесем всю логику обработки исключения из предыдущего примера в новый обработчик исключений.
Реализация интерфейса IExceptionHandler
Добавим в проект новый класс с названием CentralExceptionHandler
:
using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; namespace WebApplication2 { public class CentralExceptionHandler : IExceptionHandler { public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken) { context.Response.StatusCode = 500; context.Response.ContentType = "application/json"; var problem = new ProblemDetails() { Status = context.Response.StatusCode, Title = "Internal Server Error", Detail = exception.Message, Instance = context.Request.Path, Type = "ServerError" }; problem.Extensions["traceId"] = context.TraceIdentifier; await context.Response.WriteAsJsonAsync(problem, cancellationToken); return true; } } }
Так как в параметрах метода нам уже передается объект Exception
, то у нас отпадает необходимость использовать коллекцию Features
контекста запроса. В остальном же работа метода соответствует работе компонента middleware, который мы разрабатывали в предыдущем разделе. Метод TryHandleAsync()
возвращает true
, что будет говорить, что исключение успешно обработано. Перейдем к реализации следующих пунктов списка.
Доработка Program.cs
Теперь нам необходимо зарегистрировать новый обработчик, добавить ещё один сервис и настроить конвейер обработки запросов. Все эти операции делаются в файле Program.cs. Вот как будет выглядеть файл Program.cs нашего приложения после доработки:
using WebApplication2; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler<CentralExceptionHandler>(); var app = builder.Build(); // Configure the HTTP request pipeline. app.UseExceptionHandler(); app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run()
Здесь мы добавили в контейнер необходимый для дальнейшей работы сервис и и наш обработчик исключений:
builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler<CentralExceptionHandler>();
и сделали вызов UseExceptionHandler()
без параметров, добавив в конвейер обработки запросов компонент middleware ExceptionHandlerMiddleware
app.UseExceptionHandler();
Теперь можно запустить приложение и убедиться, что приложение возвращает тот же ответ, что и на рисунке выше. С одной стороны, логика работа нашего приложения никак не изменилась, а, с другой стороны, используя IExceptionHandler
код нашего приложения становится более понятным — мы сразу видим какие обработчики исключений регистрируются в приложении (их может быть несколько). Более того, при необходимости, мы можем предусмотреть в приложении логику обработки различных типов исключений и разнести её по нескольким обработчикам. Чтобы продемонстрировать такую логику работы, создадим ещё одно действие в контроллере:
[HttpGet("devnull")] public IActionResult GetException() { throw new DivideByZeroException("Деление на ноль!"); }
Теперь у нас имеется два типа исключений, которые мы планируем обрабатывать с использованием различных обработчиков IExceptionHandler
. Добавим в приложение новый обработчик:
using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; namespace WebApplication2 { public class DivisionByZeroHandler: IExceptionHandler { private readonly ILogger _logger; public DivisionByZeroHandler(ILogger<DivisionByZeroHandler> logger) { _logger = logger; } public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken) { if (exception.GetType() != typeof(DivideByZeroException)) return false; context.Response.StatusCode = 501; context.Response.ContentType = "application/json"; var problem = new ProblemDetails() { Status = context.Response.StatusCode, Title = "Divide By Zero", Detail = exception.Message, Instance = context.Request.Path, Type = "ServerError" }; _logger.LogError($"Получено исключение: {exception.Message}"); problem.Extensions["traceId"] = context.TraceIdentifier; await context.Response.WriteAsJsonAsync(problem, cancellationToken); return true; } } }
Во-первых, в этом обработчике мы запрашиваем в конструкторе сервис для ведения лога, чтобы иметь возможность вывести свой текст исключения в лог. Во-вторых, в методе TryHandleAsync()
мы проверяем тип исключения и если это не DivideByZeroException
, то метод возвратит false и исключение будет передано в следующий обработчик. Если тип исключения подходящий для обработки, то мы также формируем и отправляем пользователю сообщение об ошибке, но уже с другим заголовком (свойство Title) и кодом состояния HTTP, а также выводим в лог свой собственное сообщение.
Важный момент — порядок регистрации обработчиков исключений в приложении. Так как обработчик DivisionByZeroHandler
будет обрабатывать исключения более конкретного типа, чем CentralExceptionHandler
, то новый обработчик следует регистрировать раньше. То есть в Program.cs это будет выглядеть следующим образом:
builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler<DivisionByZeroHandler>(); //обрабатывает более конкретный тип исключений builder.Services.AddExceptionHandler<CentralExceptionHandler>(); //обрабатывает все оставшиеся исключения
Такой порядок регистрации проистекает из логики работы метода TryHandleAsync()
— если исключение не обработано, то оно передается в следующий обработчик. Проверить это достаточно легко — поменяйте порядок регистрации обработчиков и вы увидите, что любой тип исключений сразу обрабатывается обработчиком CentralExceptionHandler
и до DivisionByZeroHandler
исключение типа DivideByZeroException
никогда не дойдет.
Что касается работы нашего приложения, то можно запустить приложение, выполнить запрос по адресу https://localhost:7180/weatherforecast/devnull и убедиться, что исключение обработано новым обработчиком:
При этом, в консоль выводится также наше сообщение:
Таким образом, используя различные обработчики, мы можем создать, если можно так выразиться, конвейер из обработчиков исключений.
Итого
В приложениях ASP.NET Core мы можем организовать централизованную обработку исключений двумя способами — добавить в конвейер обработки запросов свой компонент middleware, в котором организовать логику обработки исключения или же создать свои обработчики исключений, реализуя интерфейс IExceptionHandler
. В этом случае каждый обработчик регистрируется в приложении как сервис с жизненным циклом singleton. Обработчики на основе IExceptionHandler
вызываются последовательно в порядке их добавления в контейнер зависимостей, что позволяет организовать свою логику обработки исключений различных типов.