Сбер AIJ 11.12.24
Сбер AIJ 11.12.24
Сбер AIJ 11.12.24

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

Аватар Типичный программист
Отредактировано

7К открытий7К показов

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

  1. Функциональный C#: Неизмяемные объекты
  2. Функциональный C#: Одержимость примитивами
  3. Функциональный C#: Ненулевые ссылочные типы
  4. Функциональный 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, сохраняем его в репозиторий, а затем отправляем ему поздравления по электронной почте. Но все верно ровно до тех пор, пока у нас все проходит без ошибок. Мы в этом коде предполагаем, что все функции выполнятся абсолютно точно. Но, к превеликому сожалению, такое практически невозможно.

Когда же вы начинаете отлавливать ошибки, то код становится похожим на что-то подобное:

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

Ну наконец-то! Теперь уж точно все. Но есть одна проблема… Наш метод ранее состоял из 5 строк, а сейчас их целых 35! Семикратное увеличение! И это только один простой метод. Более того, теперь нам трудно ориентироваться в написанном. Эти самые 5 строчек значимого кода погребены под толщей обработок исключений.

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

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

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

CQS — Command-Query Separation — принцип императивного программирования. Он гласит, что каждый метод должен быть либо командой, которая выполняет какое-то действие, либо запросом, возвращающим данные. Есть ли у нас конфликт с данным принципом?

Нет. И более того, наш подход увеличивает читаемость кода таким же способом, что и в принципе CQS. Но теперь потенциальный диапазон информации, которую мы получаем от наших методов, расширился вдвое. Вместо 2 (значение null и какой-либо объект, возвращаемый запросом) у нас их 4:

public void Save(Customer customer)

public Customer GetById(long id)

public Result Save(Customer customer)

public Result GetById(long id)

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

Вывод

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

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

Перевод статьи «Functional C#: Handling failures, input errors»

Следите за новыми постами
Следите за новыми постами по любимым темам
7К открытий7К показов