Pull to refresh

Обработка ошибок с помощью IExceptionHandler в ASP.NET Core 8.0

Level of difficultyMedium
Reading time5 min
Views10K

Давайте рассмотрим, как вы можете реализовать обработку ошибок, используя IExceptionHandler в .NET Core 8.0. Этот подход следует похожим паттернам предыдущих методов обработки ошибок в ASP.NET Core, но добавляет дополнительную возможность внедрения вашей собственной логики обработки исключений в exception handling middleware.

Я уже описывал мой подход к обработке необработанных исключений в ASP.NET Core Web API в предыдущей статье. Там вы можете найти пример обработчика, который соответствует наиболее распространенным требованиям для мониторинга и поддержки приложений. Здесь я хочу обновить этот подход, используя новый интерфейс IExceptionHandler.

Exception handling middleware эффективно управляет несколькими ключевыми аспектами: он обрабатывает случаи, когда клиент закрывает запрос (499 Client Closed Request) или когда ответ уже начал отправляться. Он также очищает контекст HTTP, устанавливает соответствующий HTTP-код, логирует ошибку и добавляет диагностику.

Вызовите UseExceptionHandler() для настройки пайплайна ASP.NET Core на использование exception handling middleware. Рекомендуется разместить этот вызов в начале пайплайна, чтобы перехватывать любые исключения, возникающие во время обработки запроса, как показано в коде ниже.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddProblemDetails();
        services.AddControllers();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseExceptionHandler(); // Should be always in first place 

        app.UseRouting();
        app.UseCors();

        app.UseAuthentication();
        app.UseAuthorization();

        app.UseMiddleware<YourCustomMiddleware>();
        app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
    }
}

Кроме того, .NET Core требует от нас регистрации ProblemDetails service, после чего exception handling middleware также будет генерировать стандартизированные ответы ProblemDetails в соответствии со спецификацией RFC 7807. Однако стоит отметить, что это может случайно раскрыть конфиденциальную внутреннюю информацию клиентам Web API, такую как stack trace и другие данные исключения, что может представлять угрозу безопасности. Однако в средах разработки подробная информация об исключениях может быть бесценной. Поэтому я рекомендую использовать свойство Exception.Data для логирования пользовательской информации. Этот подход, особенно полезен для отладки, я его подробно описал в моей первой статье об обработке исключений.

Следовательно, целесообразно реализовать собственную логику обработки исключений, которая обеспечивает безопасность, соответствует другим требованиям и возвращает ответы в подходящем формате. Интерфейс IExceptionHandler состоит только из метода TryHandleAsync. Этот метод должен возвращать true, если исключение обработано. Если он возвращает false, исключение передается следующему обработчику или, если других обработчиков нет, применяется стандартная логика обработки.

Давайте создадим класс GlobalExceptionHandler, который наследуется от IExceptionHandler.

public class GlobalExceptionHandler(IHostEnvironment env, ILogger<GlobalExceptionHandler> logger)
    : IExceptionHandler
{
    private const string UnhandledExceptionMsg = "An unhandled exception has occurred while executing the request.";

    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
    {
        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
    };

    public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception,
        CancellationToken cancellationToken)
    {
        exception.AddErrorCode();
        logger.LogError(exception, exception is YourAppException ? exception.Message : UnhandledExceptionMsg);

        var problemDetails = CreateProblemDetails(context, exception);
        var json = ToJson(problemDetails);

        const string contentType = "application/problem+json";
        context.Response.ContentType = contentType;
        await context.Response.WriteAsync(json, cancellationToken);

        return true;
    }

    private ProblemDetails CreateProblemDetails(in HttpContext context, in Exception exception)
    {
        var errorCode = exception.GetErrorCode();
        var statusCode = context.Response.StatusCode;
        var reasonPhrase = ReasonPhrases.GetReasonPhrase(statusCode);
        if (string.IsNullOrEmpty(reasonPhrase))
        {
            reasonPhrase = UnhandledExceptionMsg;
        }

        var problemDetails = new ProblemDetails
        {
            Status = statusCode,
            Title = reasonPhrase,
            Extensions =
            {
                [nameof(errorCode)] = errorCode
            }
        };

        if (!env.IsDevelopmentOrQA())
        {
            return problemDetails;
        }

        problemDetails.Detail = exception.ToString();
        problemDetails.Extensions["traceId"] = context.TraceIdentifier;
        problemDetails.Extensions["data"] = exception.Data;

        return problemDetails;
    }

    private string ToJson(in ProblemDetails problemDetails)
    {
        try
        {
            return JsonSerializer.Serialize(problemDetails, SerializerOptions);
        }
        catch (Exception ex)
        {
            const string msg = "An exception has occurred while serializing error to JSON";
            logger.LogError(ex, msg);
        }

        return string.Empty;
    }
}

Затем зарегистрируйте этот класс, как показано в следующем коде.

services.AddExceptionHandler<GlobalExceptionHandler>();

Ниже пример ответа для Dev и QA окружения.

{
  "title": "Internal Server Error",
  "status": 500,
  "detail": "TestApp.Exceptions.YourAppException: Unable to get order info\r\n ---> System.Exception: Some trouble with connection :)",
  "errorCode": "523f53f52",
  "traceId": "0HN0OCHPUOMUU:00000001",
  "data": {
    "userName": "Anton Antonov",
    "id": 999,
    "invoice": {
      "id": 111111,
      "date": "2024-01-19T01:31:30.5287118+05:00",
      "status": "unpaid"
    },
    "errorCode": "523f53f52"
  }
}

В Prod это будет выглядить так.

{
  "title": "Internal Server Error",
  "status": 500,
  "errorCode": "523f53f52"
}

Я против использования исключений для управления потоком приложений по нескольким причинам. Во-первых, это противоречит их первоначальному назначению: обработке ошибок, а не управлению обычной логикой программы. Это может привести к путанице в коде, затруднять понимание для других разработчиков и увеличивать сложность обслуживания. Во-вторых, исключения требуют много ресурсов, включая захват stack trace, что может негативно сказаться на производительности, особенно в приложениях с высокой нагрузкой или чувствительных к производительности. Наконец, частое использование исключений для нормального потока может скрыть настоящие условия ошибок, усложняя отладку.

Поэтому я считаю, что лучше всего иметь один глобальный обработчик ошибок для неперехваченных исключений. Однако exception handling middleware дает гибкость для внедрения дополнительных обработчиков при необходимости. Давайте создадим примерный класс под названием ValidationExceptionHandler.

public class ValidationExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is ValidationException validationException)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(validationException.ValidationResult, cancellationToken);

            return true;
        }

        return false;
    }
}

Затем зарегистрируйте этот класс и разместите его выше GlobalExceptionHandler, как показано в следующем примере кода.

services.AddExceptionHandler<ValidationExceptionHandler>();
services.AddExceptionHandler<GlobalExceptionHandler>();

Ниже пример ответа после обработки ValidationException.

{
  "errorMessage": "Date isn't in the correct format"
}

Я надеюсь, что этот подход поможет вам в поддержке ваших приложений. Если у вас есть предложения по улучшению этого метода, пожалуйста, делитесь ими в комментариях. Спасибо! :)

Tags:
Hubs:
Total votes 8: ↑6 and ↓2+4
Comments28

Articles