В этой части мы рассмотрим как иметь дело со сбоями и ошибками ввода в функциональном стиле.


Работа с ошибками в C#: стандартный подход


Концепция валидации и обработки ошибок хорошо отработана, но код, необходимый для этого, может быть весьма неуклюжим в таких языках как C#. Эта статья написана под впечатлением от Railway Oriented Programming — идеи, представленной Скотом Влашиным (Scott Wlaschin) в его презентации на NDC Oslo.

Рассмотрим код ниже:

[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
    Customer customer = new Customer(name);
 
    _repository.Save(customer);
 
    _paymentGateway.ChargeCommission(billingInfo);
 
    _emailSender.SendGreetings(name);
 
    return new HttpResponseMessage(HttpStatusCode.OK);
}

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

Если мы начинаем рассматривать потенциальные неудачи, ошибки ввода и логирование, метод сильно разрастается:

[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
    Result<CustomerName> customerNameResult = CustomerName.Create(name);
    if (customerNameResult.Failure)
    {
        _logger.Log(customerNameResult.Error);
        return Error(customerNameResult.Error);
    }
 
    Result<BillingInfo> billingInfoResult = BillingInfo.Create(billingInfo);
    if (billingInfoResult.Failure)
    {
        _logger.Log(billingInfoResult.Error);
        return Error(billingInfoResult.Error);
    }
 
    Customer customer = new Customer(customerNameResult.Value);
 
    try
    {
        _repository.Save(customer);
    }
    catch (SqlException)
    {
        _logger.Log(“Unable to connect to database”);
        return Error(“Unable to connect to database”);
    }
 
    _paymentGateway.ChargeCommission(billingInfoResult.Value);
 
    _emailSender.SendGreetings(customerNameResult.Value);
 
    return new HttpResponseMessage(HttpStatusCode.OK);
}

Более того, если нам нужно отлавливать ошибки в обоих методах — Save и ChargeCommission, — возникает необходимость в компенсационном механизме: мы должны откатить изменения в случае если один из методов закончился неудачей:

[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
    Result<CustomerName> customerNameResult = CustomerName.Create(name);
    if (customerNameResult.Failure)
    {
        _logger.Log(customerNameResult.Error);
        return Error(customerNameResult.Error);
    }
 
    Result<BillingInfo> billingIntoResult = BillingInfo.Create(billingInfo);
    if (billingIntoResult.Failure)
    {
        _logger.Log(billingIntoResult.Error);
        return Error(billingIntoResult.Error);
    }
 
    try
    {
        _paymentGateway.ChargeCommission(billingIntoResult.Value);
    }
    catch (FailureException)
    {
        _logger.Log(“Unable to connect to payment gateway”);
        return Error(“Unable to connect to payment gateway”);
    }
 
    Customer customer = new Customer(customerNameResult.Value);
    try
    {
        _repository.Save(customer);
    }
    catch (SqlException)
    {
        _paymentGateway.RollbackLastTransaction();
        _logger.Log(“Unable to connect to database”);
        return Error(“Unable to connect to database”);
    }
 
    _emailSender.SendGreetings(customerNameResult.Value);
 
    return new HttpResponseMessage(HttpStatusCode.OK);
}

Наш 5-строчный метод превратился в 35 строк, т.е. стал в 7 раз больше! Такой код довольно сложно читать, т.к. 5 строк кода, несущих смысловую нагрузку, теперь «закопаны» в куче шаблонного кода.

Обработка ошибок в функциональном стиле


Давайте посмотрим как можно исправить этот метод.

Вы возможно заметили, что здесь используется тот же подход, что и в статье про primitive obsession: вместо использования строк в качестве имени и billing информации, мы оборачиваем их в классы CustomerName и BillingInfo.

Статический метод Create возвращает специальный класс Result, в котором инкапсулирована вся информация касательно результатов выполнения операции: сообщение об ошибке в случае если операция не удалась и результат в случае если она прошла успешно.

Также обратите внимание, что потенциальные ошибки отлавливаются блоками try/catch. Это не лучший способ работы с исключениями, т.к. здесь мы отлавливаем их не на самом нижнем уровне. Чтобы исправить ситуацию, мы можем отрефакторить методы ChargeCommission и Save таким образом, чтобы они возвращали объект класса Result, точно так же, как это делает метод Create:

[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
    Result<CustomerName> customerNameResult = CustomerName.Create(name);
    if (customerNameResult.Failure)
    {
        _logger.Log(customerNameResult.Error);
        return Error(customerNameResult.Error);
    }
 
    Result<BillingInfo> billingIntoResult = BillingInfo.Create(billingInfo);
    if (billingIntoResult.Failure)
    {
        _logger.Log(billingIntoResult.Error);
        return Error(billingIntoResult.Error);
    }
 
    Result chargeResult = _paymentGateway.ChargeCommission(billingIntoResult.Value);
    if (chargeResult.Failure)
    {
        _logger.Log(chargeResult.Error);
        return Error(chargeResult.Error);
    }
 
    Customer customer = new Customer(customerNameResult.Value);
    Result saveResult = _repository.Save(customer);
    if (saveResult.Failure)
    {
        _paymentGateway.RollbackLastTransaction();
        _logger.Log(saveResult.Error);
        return Error(saveResult.Error);
    }
 
    _emailSender.SendGreetings(customerNameResult.Value);
 
    return new HttpResponseMessage(HttpStatusCode.OK);
}

Класс Result довольно схож с Maybe, обсуждавшимся в прошлой статье: он позволяет нам обдумывать код, не глядя на детали имплементации вложенных методов. Вот как выглядит сам класс (некоторые детали опущены для краткости):

public class Result
{
    public bool Success { get; private set; }
    public string Error { get; private set; }
    public bool Failure { /* … */ }
 
    protected Result(bool success, string error) { /* … */ }
 
    public static Result Fail(string message) { /* … */ }
 
    public static Result<T> Ok<T>(T value) {  /* … */ }
}
 
public class Result<T> : Result
{
    public T Value { get; set; }
 
    protected internal Result(T value, bool success, string error)
        : base(success, error)
    {
        /* … */
    }
}

Теперь мы можем использовать функциональный подход:

[HttpPost]
public HttpResponseMessage CreateCustomer(string name, string billingInfo)
{
    Result<BillingInfo> billingInfoResult = BillingInfo.Create(billingInfo);
    Result<CustomerName> customerNameResult = CustomerName.Create(name);
 
    return Result.Combine(billingInfoResult, customerNameResult)
        .OnSuccess(() => _paymentGateway.ChargeCommission(billingInfoResult.Value))
        .OnSuccess(() => new Customer(customerNameResult.Value))
        .OnSuccess(
            customer => _repository.Save(customer)
                .OnFailure(() => _paymentGateway.RollbackLastTransaction())
        )
        .OnSuccess(() => _emailSender.SendGreetings(customerNameResult.Value))
        .OnBoth(result => Log(result))
        .OnBoth(result => CreateResponseMessage(result));
}

Если вы знакомы с функциональными языками, вы можете заметить, что метод OnSuccess — это в действительности Bind метод. Я назвал его OnSuccess потому, что так более понятно его назначение в этом конкретном случае.

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

Метод OnFailure выполняется только в случае если предыдущая операция прошла неуспешно. Это отличное место для компенсационной логики, которую мы должны привести в действие в случае если обращение к БД не удалось.

OnBoth размещается в конце цепочки. Основные сценарии использования для него — логирование результатов операции и создание результирующего сообщения.

Таким образом, мы имеем в точности такое же поведение, что и в первоначальном варианте, но с гораздо меньшим количеством шаблонного кода. Читать такой код намного проще.

Что насчет CQS принципа?


А как насчет принципа Command-Query Separation? Подход, описанный выше, использует возвращаемые значения (которые, в нашем случае, являются объектами класса Result) даже если сам метод является командой (т.е. меняет состояние объекта). Не противоречит ли этот подход CQS?

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

Проектирование с учетом неудачного выполнения расширяет количество информации, которую мы можем получить из сигнатуры метода. Вместо двух возможных вариантов (void для команд и какое-то значение для запросов), мы теперь имеем 4.

Метод является командой и не может закончиться неудачей:

public void Save(Customer customer)

Метод является запросом и не может закончиться неудачей:

public Customer GetById(long id)

Метод является командой и может закончиться неудачей:

public Result Save(Customer customer)

Метод является запросом и может закончиться неудачей:

public Result<Customer> GetById(long id)

Теперь мы можем видеть, что если метод возвращает Customer, а не Result<Customer>, это означает, что неудача в таком методе будет исключительной ситуацией.

Заключение


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

Исходники


Исходный код примеров из статьи

Остальные статьи в серии


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


  1. Trueteller
    19.09.2015 15:36
    +2

    В какой-то степени это напоминает статус коды в стиле С… История движется по спирали.
    Исключения тоже не на ровном месте появились. В частности, если мой метод не знает, что делать с ошибкой, он должен передать ошибку на уровень выше. При исключениях я не должен делать ничего. При «результатах» я должен не забыть их правильно скомбинировать и пробросить наверх. И таких пробросов может наслоиться несколько уровней. И не забудьте тестами все это покрыть…


    1. kstep
      20.09.2015 20:34
      +3

      Основное отличие типа Result от простого кода ошибки — типобезопасность. Этот тип (точнее семейство типов Result<T, E> в общем случае) сохраняет тип ошибки, что позволяет проверять валидность кода во время компиляции. С простыми кодами ошибок в виде int это не прокатит, т.к. компилятор не знает семантическую нагрузку этого int-а.

      P.S. Я не претендую на знание C#, но довольно давно пишу в функциональном стиле в т.ч. на Scala, Rust, Python и немного баловался с Haskell, так что в этой области чувствую себя относительно хорошо.


    1. Ivanhoe
      21.09.2015 08:49

      При «результатах» я должен не забыть их правильно скомбинировать и пробросить наверх.
      Также добавлю к ответу kstep, что правильно скомбинировать и пробросить наверх ошибку при «результатах» гораздо проще, чем при голых кодах ошибки, по крайней мере в языках типа Scala и Haskell.


  1. withkittens
    19.09.2015 15:49
    +7

    Читать такой код намного проще.
    Мм, нет, извините. На мой дилетантский взгляд простыня из .OnSuccess намного менее читаема.


    1. nsinreal
      19.09.2015 22:45
      +2

      Возможно вам просто привычнее стандартный подход. На деле пример в статье очень неудачный для пропаганды: он очень простой. В данном примере есть разница между тридцаткой строк почти линейного кода и десяткой строк совсем линейного кода. Поэтому безоговорочно выигрывает привычный код.
      В случае, когда кода гораздо больше и необходима мультивалидация (т.е. нужно вернуть все ошибки) данный подход является более читабельным. По крайней мере, я самостоятельно пришел к такому подходу.


  1. DrReiz
    19.09.2015 16:32
    +2

    Чем плохи исключения для ошибок?


    1. cs0ip
      19.09.2015 22:37
      +5

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

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

      А исключение, опять же в идеале, никто перехватывать не должен. Если оно возникло, то нужно доработать код.

      Проблема сейчас в C#, такая же как и в java — отсутствие краткого синтаксиса для работы в функциональном стиле. Поэтому получается достаточно ущербный код, типа приведенного в статье. Но сама идея верна и при правильной реализации увеличивает производительность, безопасность и читаемость кода.

      Я к похожим выводам в статье для scala прихожу habrahabr.ru/post/262971, где пытаюсь сравнивать исключения и монады для обработки ошибок. Правда, боюсь, рассуждения там могут быть немного сумбурными.


      1. DrReiz
        20.09.2015 12:40
        +1

        Резюмирую.

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

        Плюсы исключений:
        — нативный синтаксис
        — согласованность с другими конструкциями языка
        — нативная поддержка компилятором
        — нативная поддержка framework-ом и сторонними библиотеками


        1. withkittens
          20.09.2015 20:09
          +1

          отсутствует проверка уровня компиляции, что исключения формируются определенного вида и что они все преобразуются в адекватное сообщение для пользователя
          Тесты.


          1. kstep
            20.09.2015 20:40
            +2

            Не всегда помогают. Они предусматривают только то, о чём подумал программист. Гораздо важнее то, о чём программист не подумал, и строгая типизацию + функциональный подход здесь спасают, т.к. заставляют компилятор «думать» о том, о чём программист (To err is human, кстати) может и забыть.


    1. nsinreal
      19.09.2015 23:25
      +3

      Для начала, нужно понимать, что есть разного рода ошибки. Есть исключительные ситуации, а есть валидация. Исключительные ситуации очень похожи на валидацию, но это как разница между интерфейсом и абстракным классом (особенно в C++) — она на уровне семантики (смысла кода). Исключительные ситуации нужны для проверки корректности работы системы. Валидация нужна для проверки корректности данных от пользователя.

      Исключительные ситуации очень хороши, когда вы применяете какую-нибудь разновидность контрактного программирования и убеждаетесь, что входные параметры у метода корректны. Попытался поделить целочисленное число на ноль? Получай исключением в морду. Тут исключения рулят и бибикают.

      Теперь рассмотрим другой пример. После редактирования сущности сервер должен в любом случае провалидировать корректность данных (даже если такие проверки есть на фронтовой части). При этом пользователь должен получить список всех ошибок, иначе его будет раздражать процесс работы с системой. Но вы можете бросить только одно исключение, которое увидит пользователь. Поэтому тут исключения враг. В данном случае очень хорошо работает подход с набором различных независимых валидаторов и собиранием результатов их работы. Тот же ASP.NET MVC/Web API с их ModelState.IsValid — это классический пример данного подхода. При этом при самой валидации нам не нужен стектрейс, а иногда еще и даже вреден — вы же не хотите, чтобы до юзера дошла информация о стектрейсе приложения?

      Но, у подхода с набором различных независимых валидаторов есть проблемы.

      Первая проблема: они независимы друг от друга.
      Пример: у вас есть поля password и confirmPassword. На первое поле навешана валидация «пароль должен быть сильным», на второе поле навешана валидация «confirmPassword должен совпадать с password». Показывать обе ошибки немного странно.

      Вторая проблема: они весьма криво работают как только у вас появляется мультишаговая валидация.
      Пример: юзер аплоадит файл с данными. Сначала вы пытаетесь распарсить файл. В случае неуспеха вам нужно сообщить о ошибке. После парсинга файла вы уже можете валидитировать сами данные. И тут тоже нужно сообщить об ошибки. Как только таких прикольных шагов становится много — код становится очень сильно нечитаемым.

      Третья проблема: они очень плохо работают когда нужно гонять запросы к бд для того, чтобы выполнить валидацию.
      Пример: вы делаете штуковину, которая валидирует изменения нескольких сущностей (банальная система с выгрузкой и загрузкой данных). На одну сущность может выпасть несколько ошибок. Причем, есть некоторые ошибки, которые пожирают другие ошибки. К примеру — есть ошибка «нельзя менять данную сущность при таких-то условиях» и есть ошибки «это свойство сущности нельзя менять при таких-то условиях». Причем под каждое валидационное правило нужно подгружать разнообразные данные, что медленно. Ваш код должен показывать максимум полезных ошибок и работать минимум времени. Соберите синхрофазотрон, короче.

      Прикол заключается в том, что бросить исключение это легко, привычно и требует меньше действих, поскольку интегрировано в язык. Писать набор валидаторов или Result/Validation фокусы — непривычно, да и не всегда так явно и очевидно в рамках языка. Поэтому пока что разумным подходом является смешивание подходов. Но не стоит забывать о том, что требования рано или поздно могут измениться, а времени на рефакторинг не будет.


      1. aisek
        21.09.2015 16:13

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

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


        1. nsinreal
          21.09.2015 16:33

          Либо выключенный джаваскрипт на мобильном браузере )


          1. withkittens
            21.09.2015 21:18

            Отключальщики джаваскрипта на десктопах добрались до мобильных телефонов? Пусть идут в лес. ;)


    1. nsinreal
      19.09.2015 23:46

      Если рассматривать в отрыве от валидации, то исключения и ifы просто вносят дополнительную сложность в код, делая его менее линейным и более объемным. Та же Maybe монада — это всего-лишь фокус, чтобы запрятать nullcheckи и сделать код линейным и менее объемным. Никакой магии, никакого профита кроме читаемости и линейности кода. В принципе, это всего-лишь разные подходы для управления control flow. В разных ситуациях получается разные по читаемости код.


      1. DrReiz
        20.09.2015 12:26
        +1

        Исключение — это та же монада, но в профиль. Со следующей семантикой: (Func f, IEnumerable<Union<Result, Exception>> args) -> Union<Result, Exception>. var exception = args.FirstOrDefault(arg => arg.IsException)?.Exception; if (exception != null) return exception; return f(args.Select(arg => arg.Result));


        1. kstep
          20.09.2015 20:46

          Да, монада. Только монада выраженная через тип явно объявляет требуемую семантику компилятору, а исключение (которое может вылететь откуда угодно и когда угодно) — нет. В Java пытались это решить с помощью checked exceptions и ключевого слова throws, но побоялись пойти до конца и оставили лазейку в виде unchecked exceptions. Так что в случае исключений компилятор программисту не помощник, программист один в поле воин, а в случае явных типов (вроде Option или Result) компилятор друг и союзник.


          1. bigfatbrowncat
            21.09.2015 12:44

            Так вот, откуда этот подход вырос :) из ФП… Интересно.

            Занятно — по поводу checked exceptions мнения совершенно полярны. Одни говорят, что это — ненужная унылая тягомотина, которая была «неуспешным экспериментом», другие (коих меньше) считают, что, напротив, это — «свет в конце тоннеля» в смысле надежности.

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

            Что же касается unchecked… Есть ведь исключения, которые почти нельзя обработать и которые, вследствие этого, бессмысленно декларировать. Например, OutOfMemory. Или еще чего похуже…

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


            1. mayorovp
              15.10.2015 14:31
              +1

              Checked Exceptions являются отличной штукой при наличии в языке такой вещи, как вывод типов — при условии что вывод типов автоматически подхватывает еще и исключения.


              1. kstep
                15.10.2015 19:11

                Checked exceptions по сути заувалированный Either<Error, T>, как и следует его рассматривать.