В прошлый раз я разобрал два примера (раз, два), как можно перейти от императивной валидации входных значений к декларативной. Второй пример действительно «слишком много знает» про аспекты хранения и имеет подводные камни (раз, два). Альтернатива – разбить валидацию на 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) => false;
        
        public static bool operator true(Result result) => false;

        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# на сегодняшний день нет встроенного в язык механизма для работы с «не исключительными» ситуациями, т.е. ошибками, которые наверняка возникнут и которые мы должны обрабатывать. Возможно, в будущих версиях мы получим такие возможности. Резервировать специальные типы исключений как «не исключительные ситуации» или вводить другие специализированные типы, например, как в этой статье, решать вам.

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


  1. unsafePtr
    22.01.2018 20:45

    Не знаю почему многие так любят Linq синтаксис. Всегда ему предпочитал последовательный вызов методов.


    1. Free_ze
      23.01.2018 12:11
      +1

      Более высокоуровневые конструкии — ближе к человеческому языку, поэтому читаются легче, чем циклы с хитрыми счетчиками и итераторами.


  1. Szer
    22.01.2018 21:13

    Зачем пытаться из C# сделать F#?
    Тем более этот SQL синтаксис


    from x in xs select x

    выглядит чужеродно для не коллекций.


  1. Bonart
    22.01.2018 22:32

    Чего только люди ни придумают лишь бы класс Exception не использовать. А в нем уже есть все для обработки ошибок.
    И про исключения очередные мифы дядюшки Римуса.


    1. marshinov Автор
      22.01.2018 23:09

      Что конкретно вы считаете «мифом»?


      1. Bonart
        22.01.2018 23:35

        Что конкретно вы считаете «мифом»?

        Все типовые доводы против исключений в вашей статье.
        Возьмем один самых распространенных и вредных:


        ошибки – объекты первого класса

        Нет. Объекты первого класса — данные, ошибки — просчеты при работе с данными, дефекты — разница между неверными и правильными данными, сбой — результат обработки неверных данных без коррекции.
        Это частный случай более общего мифа о равноценности отработки основного алгоритма и обработке сбоев.
        На деле же все наоборот:


        1. Если не реализован основной алгоритм — у вас вообще ничего нет, никакого продукта и никакой ценности не создано. Про обработку ошибок речь даже не заходит.
        2. Если не реализована обработка ошибок — вы несете убытки при сбоях. Это может быть очень неприятно, но программа без обработки ошибок все равно имеет определенную ценность. Более того, в львиной доле вариантов использования этого вполне достаточно.
        3. Сложность основного алгоритма в общем случае — существенная. Сложность обработки ошибок даже в самых запутанных случаях — акцидентная по Бруксу.


        1. marshinov Автор
          23.01.2018 10:11

          ошибки – объекты первого класса

          Вы вырвали из контекста. Полная фраза такая:

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

          В налоговом и бух.учетах «корректировки» — такие же участники предметной области как и «отчетный период» и «подаваемые сведения». Более того, если налоговая находит ошибки в отчетности, то вы обязаны подать корректировку и оплатить штраф. Т.е. для обработки ошибок есть отдельный бизнес-процесс. Это не миф, это реальность в странах, где существует налоговая система, коих большинство.

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

          Knight capital потеряла $440.000.000 за 30 минут из-за ошибки. Да она была не в программе, а из-за человеческого фактора при выкладке. Кто знает, если бы в их ПО была система самодиагностики оно бы аварийно завершилось и компания понесла бы убытки, но не обанкротилась. Один из наших клиентов занимается разработкой медицинских тренажеров. Стоимость ошибок в их отрасли — человеческие жизни. «Львиная доля» — понятие весьма субъективное. Если в сфере, где вы работает нужно, чтобы программа «делала вид, что как-то работает», это не значит, что критерии успешности других людей совпадают с вашими.

          Сложность основного алгоритма в общем случае — существенная. Сложность обработки ошибок даже в самых запутанных случаях — акцидентная по Бруксу.

          Если бы эта сложнасть была «акцидентная», Брукс не предлагал бы тратить половину (Карл!) времени на системное тестирование и отладку. Да не все это время уйдет на исправлние именно ошибок. Очень много «съест» работа с изменениями требований. Ноги этой проблемы все-равно растут из нежелания разбираться с «побочными» путями выполнения программы: тут не учли, здесь забыли, тут доработочка. Сдвинем ка релиз на пол года.


  1. fillpackart
    22.01.2018 22:50

    ФП стиль обработки исключений хорош, но когда твой ЯП набит сайд-эффектами под завязку, он ничем не поможет, особенно когда весь остальной C# мир (который будет использовать твой код и чей код ты используешь) использует try/catch/finally trow


  1. ijsgaus
    22.01.2018 23:28

    Railway стиль хорош, но для C# не родной. Имеет право на жизнь внутри конвейерной обработки на internal уровне в библиотеках, так как способен уменьшить количество объявленных Exception type. Однако, во первых — Result должен быть структурой, чтоб на нуль не проверять. Result должен быть Ether c Left type = Exception, должен испльзовать моноидную природу AggregateException, и нужна Try обертка над обычными функциями. Тогда возможна такая запись:
    Try(() => File.Create(«abc»)).Correct(() => File.Create(«dfg»)).Bind(p => p.Write(a)).AndAlso(p => p.Close);


    1. Bonart
      22.01.2018 23:57

      Почему так сразу и резко не родной?
      Task содержит в себе все что нужно.
      Конечно, размеченных объединений и нормальной do-нотации не хватает, но сделать можно немало.


  1. vintage
    23.01.2018 09:06

    Сначала вводим тип-объединение, чтобы заставить вызывающий код проверять ошибки. А потом используем слово на букву М, чтобы их тупо игнорировать. Что-то тут не так..


    1. marshinov Автор
      23.01.2018 09:59

      Вы их не игнорируете. Если не использовать Return вы не вынете значение из Value, значит придется обработать и успешное завершение и ошибку.


      1. Szer
        23.01.2018 11:03

        А вы не пробовали F#? Там уже из коробки есть Discriminated Union и монструозные конструкции превращаются в:


        type MyResult<'T> = 
            | Success of 'T
            | Error   of exn list
        
        //Это ваш Select
        let map f = function
            | Success v -> Success (f v)
            | x -> x
        
        //Это ваш SelectMany
        let bind f = function
            | Success v -> f v
            | x -> x
        
        //Это ваш SelectMany от 2 функций
        let bind2 f g = bind (fun x -> f x |> bind g)

        А вообще в F# уже есть готовый тип Result с map, bind и пр, поэтому это всё не пригодилось бы.
        Если у вас такая сложная доменная логика, вы можете отдельный проект под неё на F# запилить и 90% кодовой базы уйдёт за ненадобностью.
        Меньше кода — меньше ошибок. А у вас за дженерик параметрами "леса не видно":


        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)));


        1. marshinov Автор
          23.01.2018 12:18

          Вы все правильно поняли. Этот Result — калька с F# с поправкой на реалии C#. Union заменил на T,Failure. Метод Return, чтобы обязать проверить оба варианта, потому что компилятор C# не предупреждает, если не все случаи обработаны в pattern matching. Приходится выкручиваться. LINQ-синтаксис — замена computation expressions.

          Не всегда можно добавить в стек ещё один ЯП, зачастую по организационным причинам. Зато можно какие-то инструменты портировать, хотя они и могут смотреться чужеродно. Сигнатуры SelectMany для IEnunerable такие же страшные. Думаете их кто-то видит и вообще задумывается о них? Многие просто используют готовые инструменты и не вникают. Мне вообще кажется, что скоро мы войдём в эпоху, когда прикладные программисты не будут понимать как Машина выполняет код. Будем писать только DSL.


      1. vintage
        23.01.2018 11:14

        Найтите 10 различий:


        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));
                }

        public IResult Post(ChangeUserNameCommand command)
                {
                    command.Validate();
        
                    try {
                        return SendEmail(ChangeUserName(command))
                    } catch( Exception x ) {
                       throw new BadRequest(x.Message);
                    }
                }

        Давайте я начну:


        1. В первом коде вы узнаете об ошибке где-то далеко от места её возникновения. Во втором отладчик услужливо остановит исполнение там, где она произошла.


        1. mayorovp
          23.01.2018 11:28

          Во втором отладчик услужливо остановит исполнение там, где она произошла.

          Только если включен отлов First-chance exceptions, но эта настройка зачастую мешает нормальной отладке из-за исключений в библиотеках. В противном случае отладчик остановит исполнение только для тех исключений которые "утекают" из пользовательского кода.


          Применительно к вашему коду это означает, что исключение в SendEmail или ChangeUserName поймано отладчиком не будет ни в каком из двух вариантов (а ведь именно эти исключения как правило наиболее интересны — валидация-то зачастую слишком очевидна чтобы ее отлаживать, да и сообщения об ошибках там самые понятные).


          По мне, так лучше всего вот так делать:


          [HandleErrors]
          public IActionResult Post(ChangeUserNameCommand command)
          {
                var res = command.Validate();
                if (res.IsFaulted) return Fault(res);
          
                return SendEmail(ChangeUserName(command));
          }

          То есть ошибки валидации обрабатываем как возвращаемое значение (это необходимо хотя бы потому что их может быть много, а не одно) — а ошибки обработки запроса надо делать уже исключениями, причем с обязательным утеканием исключений в системный код перед перехватом (тут может помочь [DebuggerNonUserCode] если используемый фреймворк не позволяет обрабатывать исключения какими-нибудь фильтрами).


  1. mayorovp
    23.01.2018 10:35

    Более того, сигнатуры методов в языках с исключениями не договаривают.

    На самом деле, сигнатуры "не договаривают" в любых языках программирования кроме тех что заточены на доказательство корректности программы. По одной простой причине — в сигнатурах не учитывается тот факт, что функция может вовсе не вернуть управления.


    Исключение обычно рассматривается лишь как одна из разновидностей ситуации "функция не вернула управления" (две другие разновидности — завершение процесса и вечный цикл).


    1. marshinov Автор
      23.01.2018 10:51

      «Проблему остановки» кажется же еще не решили:) Я как-то даже не стал уточнять.


      1. mayorovp
        23.01.2018 11:16

        Это для Тьюринг-полных языков она неразрешима.


        1. 0xd34df00d
          24.01.2018 01:21

          Более того, даже для Тьюринг-полных языков зачастую можно продуктивно и эффективно разделить тотальную логику и частичную узенькую обёртку вокруг неё. Ну, подобно тому, как чистые языки разделяют, собственно, чистые функции и функции с сайд-эффектами.

          Скрытый текст
          Разве что нет никаких функций с сайд-эффектами, есть функции, описывающие последовательность действий над эффектами, которые потом выполняет рантайм.