Функциональный C#. Обработка исключений

Многим из нас известна концепция проверки и обработки исключений, однако код, нужный для её реализации, иногда действительно раздражает. Особенно, если мы программируем на C#. Следующая статья написана с учётом концепции Railway Oriented Programming, которую представил в своей презентации Scott Wlaschin. Если вы программируете на C#, обязательно посмотрите это выступление полностью, ведь оно даст вам отличные знания по поводу того, каким же неудобным бывает «сишарп», и как это неудобство лучше всего обойти.

К примеру, перед нами следующий код:

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

На первый взгляд, всё в порядке. Мы создаём экземпляр класса Customer, потом сохраняем его в репозиторий и отправляем поздравления по e-mail. И всё бы ничего, но ровно до того момента, пока проходит без ошибок, ведь мы в коде предполагаем, что функции выполняются предельно точно. Но, увы, так не бывает. Когда же мы начинаем отлавливать ошибки, наш код превращается во что-то похожее:

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

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

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

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

Обрабатываем исключения и ошибки ввода в функциональном стиле

Можем ли мы обойтись без описанного выше расширения исходного кода? Ответ — да. Давайте подумаем, что тут можно предпринять.

Кстати, обратите внимание, что вместо использования примитивов, мы применяем классы (CustomerName, BillingInfo) — это даёт нам возможность ставить всю проверку на корректность в одном месте, придерживаясь принципа DRY.

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

Итак, перепишем наш код:

[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. Применяя Result, мы получаем возможность выполнять анализ кода, не изучая детали реализации. Вот каким образом выглядит сам класс (для краткости детали опущены):

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

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

А вот OnBoth находится в конце всей цепочки и применяется для вывода разного рода логов и сообщений.

В общем, нами написан нужный метод с обработками ошибок и исключений, причём мы сделали это гораздо меньшим количеством кода. Мало того, теперь стало намного легче понимать, что вообще делает метод. И у нас отсутствует конфликт с принципом императивного программирования CQS (Command-Query Separation), что не может не радовать.

По материалам «Functional C#: Handling failures, input errors».