Pull to refresh

Про ошибки и исключения

Reading time 7 min
Views 17K


В прошлый раз я разобрал два примера (раз, два), как можно перейти от императивной валидации входных значений к декларативной. Второй пример действительно «слишком много знает» про аспекты хранения и имеет подводные камни (раз, два). Альтернатива – разбить валидацию на 3 части:

  1. Модел байндинг: ожидали int, пришел string – возвращаем 400
  2. Валидация значений: поле email, должно быть в формате your@mail.com, а пришло 123Petya – возвращаем 422
  3. Валидация бизнес-правил: ожидали что корзина пользователя активна, а она в архиве. Возвращаем 422

К сожалению стандартный механизм байндинга ASP.NET MVC не различает ошибки несоответствия типа (получили string вместо int) и валидаци, поэтому если вы хотите различать 400 и 422 коды ответа, то придется это сделать самостоятельно. Но речь не об этом.

Как слой бизнес-логики может вернуть в контроллер сообщение об ошибке?


Самый распространенный по мнению Хабра способ (раз, два, три) – выбросить исключение. Таким образом между понятием «ошибка» и «исключение» ставится знак равно. Причем «ошибка» трактуется в широком смысле слова: это не только валидация, но и проверка прав доступа и бизнес-правил. Так ли это? Является ли любая ошибка «исключительной ситуацией»? Если вы когда-нибудь сталкивались с бухгалтерским или налоговым учетом, то наверняка знаете, что существует специальный термин «корректировка». Он означает, что в прошлом отчетном периоде были поданы неверные сведения и их необходимо исправить. То есть в сфере учета, без которой бизнес не может существовать в принципе, ошибки – объекты первого класса. Для них введены специальные термины. Можно ли назвать их исключительными ситуациями? Нет. Это нормальное поведение. Люди ошибаются. Программисты — просто чересчур оптимистичный народ. Мы просто никогда не снимаем розовых очков.

Исключение = ошибка?


Хорошо, возможно «исключения» — это просто неудачное название, а на самом деле они отлично подходят для работы с «ошибками». Даже MSDN определяет «исключения» как «ошибки времени выполнения». Давайте проверим. Что происходит с программой, если в ней происходит необработанное исключение? Аварийное завершение. Веб-приложения не завершаются лишь потому что на самом деле все необработанные исключения обрабатываются глобально. Все серверные платформы предоставляют возможность подписаться на «необработанные» ошибки. Должна ли программа завершаться в случае ошибки в бизнес-логике? В ряде случае да, например, если вы разрабатываете ПО для высокочастотного трейдинга и что-то пошло не так в алгоритме торговли. Не важно, как быстро ты принимаешь решения, если они неверные. А в случае ошибки в пользовательском вводе? Нет, мы должны вывести пользователю осмысленное сообщение. Таким образом, ошибки бывают фатальными или «не очень». Использовать один тип для обозначения и тех и других чревато.

Представьте, что у вас на поддержке два проекта. Оба логируют все необработанные исключения в БД. В первом исключения случаются крайне редко: 1-2 раза в месяц, а во втором сотни в день. В первом случае вы будете очень внимательно изучать логи. Если в логе что-то появилось, значит есть какая-то фундаментальная проблема и в определенных случаях система может переходить в неопределенное состояние. Во втором соотношение сигнал / шум «сломано». Как узнать система работает нормально или вошла в зону турбулентности, если в логах всегда полно ошибок?

Мы можем создать тип BusinessLogicException и логировать их отдельно (или не логировать). Потом сделать аналогичный финт для HttpException, DbValidationException и других. Хм, надо бы запомнить какие исключения нужно ловить, а какие нет. Точно, в Java же есть checked exceptions, давайте завезем в .NET! Надо только еще учесть, что не все исключения можно поймать и обработать и не забыть про особенности работы с исключениями в TPL. И как его, ну этот перформанс.

Исключение = goto?


Еще один аргумент против повсеместного использования исключений – схожесть с goto. Нет никакой возможности узнать где в цепочке вызовов оно будет поймано, ведь сигнатура метода не раскрывает какие исключения могут быть выброшены внутри. Более того, сигнатуры методов в языках с исключениями не договаривают. Было бы правильнее писать не RequestDto -> IActionResult, а RequestDto -> IActionResult | Exception: метод может выполниться успешно или что-то может пойти не так.

Обработка исключений в трехзвенной архитектуре


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

Таким образом, если в слое бизнес-логике для всех типов «ошибок» используются исключения, мы должны будем либо оборачивать каждый метод контроллера в try / catch – блок, либо переопределить метод обработки на уровне приложения. Первый вариант плох тем, что приходится повсеместно дублировать try / catch и следить за типами отлавливаемых ошибок. Второй — тем, что мы теряем контекст выполнения.

Скотт Влашин предложил альтернативный подход к работе с ошибками в своем докладе Railway Oriented Programming (перевод на Хабре), а vkhorikov адаптировал для C#. Я взял на себя смелость слегка доработать этот вариант.

Дорабатываем Result


public class Result
{
    public bool Success { get; private set; }
    public string Error { get; private set; }

    public bool Failure
    {
        get { return !Success; }
    }

    protected Result(bool success, string error)
    {
        Contracts.Require(success || !string.IsNullOrEmpty(error));
        Contracts.Require(!success || string.IsNullOrEmpty(error));

        Success = success;
        Error = error;
    }
    //...
}

Тип string не совсем удобен для работы с ошибками. Заменим строку на тип Failure. В отличие от варианта Скотта Failure будет не union-type, а обычный класс. Pattern matching для работы с ошибками заменим на полиморфизм. Для того, чтобы сохранить дополнительные сведения об ошибке будем использовать свойство Data. Часто эти данные нужно просто сериализовать, поэтому конкретный тип не так важен.


    public class Failure
    {                
        public Failure(params Failure[] failures)
        {
            if (!failures.Any())
            {
                throw new ArgumentException(nameof(failures));
            }
            
            Message = failures.Select(x => x.Message).Join(Environment.NewLine);
            var dict = new Dictionary<string, object>();

            for(var i = 0; i < failures.Length; i++)
            {
                dict[(i + 1).ToString()] = failures[i];
            }
            
            Data = new ReadOnlyDictionary<string, object>(dict);
        }
        
        public Failure(string message)
        {
            Message = message;
        }

        public Failure(string message, IDictionary<string, object> data)
        {
            Message = message;
            Data = new ReadOnlyDictionary<string, object>(data);
        }
        
        public string Message { get; }        
        
        public ReadOnlyDictionary<string, object> Data { get; protected set; }
    }

Объявим специализированные классы-наследники для ошибок валидации и прав доступа.


    public class ValidationFailure: Failure
    {
        public ValidationResult[] ValidationResults { get; }

        public ValidationFailure(IEnumerable<ValidationResult> validationResults) 
            : base(ValidationResultsToStrings(validationResults))
        {
            ValidationResults = validationResults?.ToArray();
            if (ValidationResults == null || !ValidationResults.Any())
            {
                throw new ArgumentException(nameof(validationResults));
            }

            
            Data = new ReadOnlyDictionary<string, object>(
                ValidationResults.ToDictionary(
                x => x.MemberNames.Join(","),
                x => (object)x.ErrorMessage));
        }

        private static string ValidationResultsToStrings(
            IEnumerable<ValidationResult> validationResults)
            => validationResults
                .Select(x => x.ErrorMessage)
                .Join(Environment.NewLine);
    }

Перегружаем операторы и прячем Value


Добавим в Result перегрузку операторов &, | и true и false, чтобы работали && и ||. Закроем value и вместо этого предоставим функцию Return. Теперь невозможно ошибиться и не проверить свойство IsFaulted: метод обязывает привести к типу TDestination как параметр T, так и Failure. Это решает проблему с кодами возврата, которые можно забыть проверить. Результат просто нельзя получить, не обработав вариант с ошибкой.


    public class Result
    {
        public static implicit operator Result (Failure failure)
            => new Result(failure);
        
        // https://stackoverflow.com/questions/5203093/how-does-operator-overloading-of-true-and-false-work
        public static bool operator false(Result result) => result.IsFaulted;
        
        public static bool operator true(Result result) => !result.IsFaulted;

        public static Result operator &(Result result1, Result result2)
            => Result.Combine(result1, result2);

        public static Result operator |(Result result1, Result result2)
            => result1.IsFaulted ? result2 : result1;

        public Failure Failure { get; private set; }

        public bool IsFaulted => Failure != null;
}

В контексте web-операции реализация метода преобразования может выглядеть так:

result.Return<IActionResult>(Ok, x => BadRequest(x.Message));

Или для кейса из примера Cкотта: получить запрос, выполнить валидацию, обновить информацию в БД и в случае успеха отправить email с подтверждением так:


    public IActionResult Post(ChangeUserNameCommand command)
        {
            var res = command.Validate();
            if (res.IsFaulted) return res;
            
            return ChangeUserName(command)
                .OnSuccess(SendEmail)
                .Return<IActionResult>(Ok, x => BadRequest(x.Message));
        }

Поддержка LINQ-синтаксиса (на любителя)


Если шагов будет больше, то строку if(res.IsFaulted) return res; придется повторять после каждого шага. Хотелось бы этого избежать. Тут как нельзя кстати цикл статей Эрика Липперта о природе SelectMany и слове на букву М. Вообще LINQ-синтаксис поддерживает не только IEnumerable, но и любые другие типы. Главное реализовать SelectMany aka Bind. Добавим немного страшного кода с шаблонами. Здесь я не буду вдаваться в подробности как работает bind. Если интересно, прочитайте у Липперта или Влашина.

public static class ResultExtensions
    {
        public static Result<TDestination> Select<TSource, TDestination>(
            this Result<TSource> source,
            Func<TSource, TDestination> selector)
            => source.IsFaulted
                ? new Result<TDestination>(source.Failure)
                : selector(source.Value);

        public static Result<TDestination> SelectMany<TSource, TDestination>(
            this Result<TSource> source,
            Func<TSource, Result<TDestination>> selector)
            => source.IsFaulted
                ? new Result<TDestination>(source.Failure)
                : selector(source.Value);
    
        public static Result<TDestination>
            SelectMany<TSource, TIntermediate, TDestination>(
            this Result<TSource> result,
            Func<TSource, Result<TIntermediate>> inermidiateSelector,
            Func<TSource, TIntermediate, TDestination> resultSelector)
            => result.SelectMany<TSource, TDestination>(s => inermidiateSelector(s)
                .SelectMany<TIntermediate, TDestination>(m => resultSelector(s, m)));
}

Выглядит немного непривычно, зато можно строить цепочки вызовов и объединять их в один pipe. При этом все проверки if(result.IsFaulted) выполняются «под капотом» с помощью LINQ-синтаксиса.

public Result<UserNameChangedEvent> Declarative(ChangeUserNameCommand command) =>
    from validatedCommand in command.Validate()
    from domainEvent in ChangeUserName(validatedCommand).OnSuccess(SendEmail)
    select domainEvent;

Заключение


Я не призываю отказаться от исключений. Это очень хороший иснструмент для, сюрприз, «исключительных ситуаций» — ошибок, которых ну мы совсем не ждали. Они позволяют предотвратить переход системы в неопределенное состояние и могут служить отличным индикатором штатной / аварийной работы приложения. Однако, используя исключения повсеместно, мы лишаем себя этого инструмента. Регрулярная ситуация по определению не может считаться исключительной.

В версии C# на сегодняшний день нет встроенного в язык механизма для работы с «не исключительными» ситуациями, т.е. ошибками, которые наверняка возникнут и которые мы должны обрабатывать. Возможно, в будущих версиях мы получим такие возможности. Резервировать специальные типы исключений как «не исключительные ситуации» или вводить другие специализированные типы, например, как в этой статье, решать вам.
Only registered users can participate in poll. Log in, please.
Как выглядит LINQ-синтаксис не для коллекций?
28.26% Странно 26
19.57% Хорошо читается 18
20.65% Плохо читается 19
4.35% Похоже на do в Haskel 4
53.26% Наркомания 49
92 users voted. 37 users abstained.
Tags:
Hubs:
+14
Comments 21
Comments Comments 21

Articles