Если вы являетесь c# бекенд разработчиком — наверняка у вас рано или поздно появилась необходимость найти унифицированный способ обработки исключительных ситуаций. Хотя, даже если вы довольствуетесь кодом 500 в ответе, эта статья все равно поможет улучшить ваш способ, при этом не заставляя что-либо переписывать.

Речь пойдет об ASP.NET библиотеке, которая позволяет решить эту проблему максимально изящно. Для тех кому лень читать длинную статью — ридми и сама библиотека тут, пример тут. Доступна на nuget.org и я буду только рад если она принесет кому-то пользу. И так, перейдем к коду. Для начала давайте разберем альтернативы.

Одна из первых вещей, которые могут прийти в голову — создать DTO (Data transfer object) для обработки исключений, ловить исключение в контроллере (хотя не обязательно что это будет исключение, возможно это просто проверка на null или что-то в этом роде), заполнять данные в DTO и отправлять их клиенту. Код такого метода может выглядеть примерно так:

public IActionResult Get()
{
    try
    {
        //Code with exception.
    }
    catch (Exception ex)
    {
        return new JsonResult(
            new ErrorDto
            {
                IsError = true,
                Message = ex.Message
            });
    }
}

Еще один вариант — использовать для этого коды состояния HTTP.

public IActionResult Get()
{
    try
    {
        //Code with exception.
    }
    catch (Exception ex)
    {
        return BadRequest();
    }
}

Довольно распространенная практика, однако имеющая свои недостатки: бывает сложно описать одним из стандартных кодов всю суть вашей ситуации, из-за чего один и тот же код может трактоваться по-разному даже в одной системе, также он дает слишком мало информации для отладки разработчику.

И тут некоторые могут вообще начать комбинировать оба эти способа, причем в разных пропорция. Где-то забудут отправить DTO, где-то код не отправят или отправят не тот что нужно, а где-то вообще ее сериализируют не с теми настройками json и вернут не то что нужно.

Столкнувшись с вышеописанным, многие пытаются решить эту проблему используя app.UseExceptionHandler();, обрабатывая исключения через него. Это неплохая попытка, однако она не позволит вам легко забыть о проблеме. Во-первых, перед вами все еще будет стоят проблема выбора DTO для исключений. Во-вторых, такой обработчик не позволит обрабатывать http коды ошибок, которые вернули из контроллеров, т.к. исключения не произошло. В-третьих, таким способом неудобно решить проблему классификации ошибок, вам придется написать много кода чтоб привязать к каждому исключению свое сообщение, http код или еще что. Ну и в-четвертых, вы теряете возможность использовать асповскую DeveloperExceptionPage, что очень неудобно для отладки. Даже если вы как-то решите эту проблему, то всем разработчикам на этом проекте придется строго следовать спецификации, строить обработку ошибок именно на исключениях, не возвращать свои DTO, иначе ошибки в вашем апи могут выглядеть по-разному от метода к методу.

Выбранный вариант обработки исключений


Перед тем как я покажу как позволяет обрабатывать исключительные ситуации IRO.Mvc.MvcExceptionHandler, для начала опишу как я вижу идеальную обработку исключений. Для этого установим ряд требований:

  1. Это должна быть DTO, но от http кодов мы тоже не отказываемся, т.к. для многих ошибок они все же хорошо подходят, могут быть использованы везде и в старом проекте который вам приходится поддерживать тоже, да и просто они универсальны. Стандартная DTO будет включать в себя поле IsError (что позволит написать универсально обработку ошибок на клиенте), также она должна содержать строковый код ошибки ErrorKey, который разработчик может сразу распознать только взглянув на него и который предоставляет уже больше информации. Дополнительно можно добавить ссылку на страницу с описанием этой ошибки, если нужно.
  2. Это все в проде. В режиме разработки эта DTO должна возвращать стектрейс, данные о запросе: cookies, заголовки, параметры. Забегая вперед, описываемая в статье мидлвера возвращает даже ссылку на сгенерированную DeveloperExceptionPage, которая позволяет смотреть стектрейс исключения в удобном виде, но об этом позже.
  3. Разработчик может связать вместе исключение, http код ошибки и ErrorKey. Это значит, что если он отправит с контроллера код 403, то в случае если разработчик к нему привязал конкретный ErrorKey — будет возвращена DTO с ним. И наоборот, если возникнет исключение UnauthorizedAccessException — оно будет привязано к http коду и ErrorKey.

Вот такой формат используется в библиотеке по-умолчанию:

{
  "__IsError": true,
  "ErrorKey": "ClientException",
  "InfoUrl": "https://iro.com/errors/ClientException"
}

Сразу скажу, что вид в котором данные будут переданы на клиент можно задать абсолютно любой, это просто один из вариантов.

IRO.Mvc.MvcExceptionHandler


Теперь покажу как я решил эту проблему для себя написав библиотеку IRO.Mvc.MvcExceptionHandler.

Подключаем обработчик исключений точно также как и любую другую мидлверу — в классе Startup.

app.UseMvcExceptionHandler((s) =>
{
    //Settings...
});

Внутри передаваемого делегата нам нужно настроить нашу мидлверу. Нужно провести маппинг (привязку) исключений к http кодам и ErrorKey. Ниже самый простой вариант настройки.

    s.Mapping((builder) =>
    {      
        builder.RegisterAllAssignable<Exception>(
            httpCode: 500,
            errorKeyPrefix: "Ex_"
        );        
    });

Как я и обещал самым ленивым хардкорным разработчикам, которые не привыкли обрабатывать исключения — больше ничего не нужно делать. Этот код будет биндить все исключения в ASP.NET пайплайне к общей DTO с кодом 500, при этом в ErrorKey будет записано название самого исключения.

Стоит понимать, что метод RegisterAllAssignable регистрирует не только исключение указанного типа, но и всех его наследников. Если вы хотите отдавать на клиент только информацию по конкретным исключениям — вполне разумным решением будет создать свой ClientException и замаппить только его. При этом, если вы зададите один http код для ClientException, а для его наследника SpecialClientException зададите другой, то код SpecialClientException будет использоваться уже для всех его наследников, игнорируя настройку ClientException. Все это кешируется, так что проблем с производительностью не возникнет.

Можно провести более тонкую настройку и зарегистрировать свой ErrorKey и http код для конкретного исключения:

    s.Mapping((builder) =>
    {
        //By exception, custom error key.
        builder.Register<ArgumentNullException>( 
            httpCode: 555,
            errorKey: "CustomErrorKey"
            );
        //By http code.
        builder.Register(
            httpCode: 403,
            errorKey: "Forbidden"
            );
        //By exception, default ErrorKey and http code.
        builder.Register<NullReferenceException>();

        //Alternative registration method.
        builder.Register((ErrorInfo) new ErrorInfo()
        {
            ErrorKey = "MyError",
            ExceptionType = typeof(NotImplementedException),
            HttpCode = 556
        });  
    });

Помимо маппинга стоит, провести настройку мидлверы. Можно указать настройки сериализации json, адрес вашего сайта, ссылку на страницу описания ошибок, режим работы мидлверы через IsDebug, стандартный http код для необработанных исключений.

    s.ErrorDescriptionUrlHandler = new FormattedErrorDescriptionUrlHandler("https://iro.com/errors/{0}");
    s.IsDebug = isDebug;
    s.DefaultHttpCode = 500;    
    s.JsonSerializerSettings.Formatting = Formatting.Indented;
    s.Host="https://iro.com";
    s.CanBindByHttpCode = true;

Последнее свойство указывает можно ли биндить по http коду нашу DTO.
Можно также указать как стоит разрешать ситуации с внутренними исключениями, например TaskCanceledException с внутренней зарегистрированной ошибкой из-за .Wait(). Например, вот стандартный резолвер, который достает внутренние исключения из таких вот исключений и работает уже с ними:

    s.InnerExceptionsResolver = InnerExceptionsResolvers.InspectAggregateException;

Если нужна тонкая настройка сериализации — можете задать метод FilterAfterDTO. Верните true для отключения стандартной обработки и сериализируйте errorContext.ErrorDTO как захотите. Там есть доступ к HttpContext и самой ошибке.

    s.FilterAfterDTO = async (errorContext) =>
    {
        //Custom error handling. Return true if MvcExceptionHandler must ignore current error,
        //because it was handled.
        return false;
    };

DeveloperExceptionPage и другие плюсы режима отладки


С настройкой разобрались, теперь давайте разберемся как это все отлаживать. В проде DTO в ответе простая и выше я ее уже показывал, теперь покажу как выглядит эта же DTO в режиме отладки:



Как видите, информации здесь намного больше, есть стектрейс и данные о реквесте. Но еще удобней просто перейти по ссылке в поле DebugUrl и рассмотреть данные об ошибке не напрягаясь:



Реализовать эту функцию было довольно сложно, т.к. DeveloperExceptionPage просто не рассчитана на использование сторонними разработчиками. Изначально нельзя было открыть ссылку в браузере с другой сессией, контент переставал отображаться после перезагрузки. Все это удалось решить только кешированием html ответа этой мидлверы. Теперь вы можете хоть передать ссылку на исключение своему тиммейту, если вы используете общий выделенный сервер.

Заключение


Надеюсь разработчики, прочитавшие эту статью, нашли для себя интересный и полезный инструмент. Для меня же эта статья — частично проверка полезности своих разработок и статей по ним. У меня есть еще несколько уже готовых более крутых проектов, о которых мне хотелось бы рассказать сообществу Хабра.

Комментарии (0)