Железнодорожно-ориентированное программирование. Обработка ошибок в функциональном стиле

http://fsharpforfunandprofit.com/rop/
  • Перевод
  • Tutorial

Как пользователь я хочу изменить ФИО и email в системе.

Для реализации этой простой пользовательской истории мы должны получить запрос, провести валидацию, обновить существующую запись в БД, отправить подтверждение на email пользователю и вернуть ответ браузеру. Код будет выглядеть примерно одинаково на C#:

string ExecuteUseCase() 
{ 
  var request = receiveRequest();
  validateRequest(request);
  canonicalizeEmail(request);
  db.updateDbFromRequest(request);
  smtpServer.sendEmail(request.Email);
  return "Success";
}

и F#:

let executeUseCase = 
  receiveRequest
  >> validateRequest
  >> canonicalizeEmail
  >> updateDbFromRequest
  >> sendEmail
  >> returnMessage

Отклоняясь от счастливого пути




Дополним историю:
Как пользователь я хочу изменить ФИО и email в системе
И увидеть сообщение об ошибке, если что-то пойдет не так.

Что же может пойти не так?




  1. ФИО может оказаться пустым, а email – не корректным
  2. пользователь с таким id может быть не найден в БД
  3. во время отправки письма с подтверждением SMTP-сервер может не ответить
  4. ...

Добавим код обработки ошибок


string ExecuteUseCase() 
{ 
  var request = receiveRequest();
  var isValidated = validateRequest(request);
  if (!isValidated) {
     return "Request is not valid"
  }
  canonicalizeEmail(request);
  try {
    var result = db.updateDbFromRequest(request);
    if (!result) {
      return "Customer record not found"
    }
  } catch {
    return "DB error: Customer record not updated"
  }

  if (!smtpServer.sendEmail(request.Email)) {
    log.Error "Customer email not sent"
  }

  return "OK";
}

Вдруг вместо 6 мы получили 18 строк кода с ветвлениями и большей вложенностью, что сильно ухудшило читаемость. Каким будет функциональный эквивалент этого кода? Он выглядит абсолютно также, но теперь в нем есть обработка ошибок. Можете мне не верить, но, когда мы доберемся до конца, вы убедитесь, что это действительно так.

Архитектура запрос-ответ в императивном стиле




У нас есть запрос, ответ. Данные передаются по цепочке от одного метода к другому. Если происходит ошибка мы просто используем early return.

Архитектура запрос-ответ в функциональном стиле




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

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

Как функция может возвращать больше одного значения?


В функциональных ЯП широко распространены типы-объединения. С их помощью можно моделировать несколько возможных состояний в рамках одного типа. У функции остается одно возвращаемое значение, но теперь оно принимает одно из четырех возможных значений: успех или тип ошибки. Осталось только обобщить данных подход. Объявим тип Result, состоящий из двух возможных значений Success и Failure и добавим generic-аргумент с данными.

type Result<'TEntity> = 
    | Success of 'TEntity
    | Failure of string 

Функциональный дизайн




  1. Каждый вариант использования реализуется с помощью одной функции
  2. Функции возвращают объединение из Success и Failure
  3. Функция для обработки варианта использования создана с помощью композиции более мелких функций, каждая из которых соответствует одному шагу преобразования данных
  4. Ошибки на каждом шаге будут скомбинированы так, чтобы возвращать одно значение

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




Если у вас есть очень умный друг, отлично разбирающийся в ФП у вас может состоятся диалог вроде такого:

  • Я хотел бы использовать композицию функций, но мне не хватает удобного способа обработки ошибок
  • О, это просто. Тебе нужна монада
  • Звучит сложно. А что такое монада?
  • Монада – это просто моноид в категории эндофункторов.
  • ???
  • В чем проблема?
  • Я не знаю, что такое эндофунктор
  • Это просто. Функтор – это гомоморфизм между категориями. А эндофунктор – это просто функтор, отображающий категорию на саму себя
  • Ну конечно! Теперь все стало ясно...

Далее в оригинале идет непереводимая игра слов, на основе Maybe (может быть) и Either (или то или другое). Maybe и Either – это также названия монад. Если вам по душе английский юмор и вы тоже считаете терминологию ФП чересчур «академической» обязательно посмотрите оригинальный доклад.

Связь с монадой Either и композицией Клейсли



Любой поклонник Haskell заметит, что описанный мной подход является монадой Either, специализрованной типом списка ошибок для «левого» (Left) случая. В Haskell мы могли бы записать так:

type Result a b = Either [a] (b,[a])

Конечно-же я не пытаюсь выдать себя за изобретателя данного подхода, хотя и претендую на авторство глупой аналогии с железной дорогой. Так почему же я не использовал стандартную терминологию Haskell? Во-первых, это не очередное руководство по монадам. Вместо этого основной фокус смещен на решение конкретной проблемы обработки ошибок. Большинство людей, начинающих изучение F# не знакомы с монадами, поэтому я предпочитаю менее пугающий, более визуальный и интуитивный для многих подход.

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

В-третьих, Either – слишком общая концепция. Я хотел бы представить рецепт, а не инструмент. Рецепт приготовления хлеба, в котором написано «просто воспользуйтесь мукой и духовкой» не слишком полезен. Абсолютно также бесполезно руководство по обработке ошибок в стиле «просто воспользуйтесь bind и Either». Поэтому я предлагаю комплексный подход, включающий в себя целый набор техник:

  1. Список специализированных типов-ошибок, вместо просто Either String a
  2. bind (>>=) для композиции монадических функций в pipeline
  3. композиция Клейсли (>=>) для композиции монадических функций
  4. функции map и fmap для интеграции немонадических функций в pipeline
  5. функция tee для интеграции функций, возвращающих unit (аналог void в F#)
  6. маппинг исключений в коды ошибок
  7. &&& для комбинирования монадических функций в параллельной обработке (например, для валидации)
  8. преимущества использования кодов ошибок в Domain Driven Design (DDD)
  9. очевидные расширения для логгирования, доменных событий, компенсаторных транзакций и другое

Надеюсь, что это вам понравится больше, чем просто «воспользуйтесь монадой Either».

Аналогия с железной дорогой



Мне нравится представлять функцию как железнодорожные пути и тоннель трансформации. Если у нас есть две функции, одна преобразующая яблоки в бананы (apple → banana), а другая бананы в вишни (banana → cherry), объединив их мы получим функции преобразования яблок в вишни (apple → cherry). С точки зрения программиста нет разницы получена эта функция с помощью композиции или написана вручную, главное – ее сигнатура.

Развилка


Но у нас немного другой случай: одно значение на входе и два возможных – на выходе: одна ветка для успешного завершения и одна – для ошибки. В «железнодорожной» терминологии нам потребуется развилка. Validate и UpdateDb – такие функции-развилки. Мы можем объединять их друг с другом. Добавим к Validate и UpdateDb функцию SendEmail. Я называю это «двухколейная модель». Некоторые предпочитают называть этот подход к обработке ошибок «монадой Either», но мне больше нравится мое название (хотя бы потому что в нем нет слова «монада»).


Теперь есть «одноколейные» и «двухколейные» функции. По отдельности и те, и другие компонуются, но они не компонуются друг с другом. Для этого нам потребуется небольшой «адаптер». В случае успеха, вызываем функцию и передаем ей значение, а в случае ошибки просто передаем значение ошибки дальше без изменений. В ФП такая функция называется bind.



bind



let bind switchFunction = 
    fun twoTrackInput -> 
        match twoTrackInput with
        | Success s -> switchFunction s
        | Failure f -> Failure f

// ('a -> Result<'b>) -> Result<'a> -> Result<'b>

Как видите эта функция очень проста: всего несколько строчек кода. Обратите внимание на сигнатуру функции. Сигнатуры очень важны в ФП. Первый аргумент – это «адаптер», второй аргумент – это входное значение в двухколейной модели и на выходе – также значение в двухколейной модели. Если вы увидите эту сигнатуру с любыми другими типами: с list, asynс, feature или promise, перед вами все тот же bind. Функция может называться по-другому, например SelectMany в LINQ, но суть не меняется.

Валидация


Например, есть три правила валидации. Мы можем «сцепить» несколько правил валидации с помощью bind (чтобы преобразовать каждую из них к «двухколейной модели») и композиции функций. Вот и весь секрет обработки ошибок.

let validateRequest = 
  bind nameNotBlank 
  >> bind name50 
  >> bind emailNotBlank

Теперь у нас есть «двухколейная» функция, принимающая на вход запрос и возвращающая ответ. Мы можем использовать ее в качестве строительного блока для других функций.
Часто bind обозначается с помощью оператора >>=. Он заимствован из Haskell. В случае использования >>= код будет выглядеть следующим образом:

let (>>=) twoTrackInput switchFunction = 
  bind switchFunction twoTrackInput

let validateRequest twoTrackInput = 
  twoTrackInput 
  >>= nameNotBlank 
  >>= name50 
  >>= emailNotBlank

При использовании bind проверка типов работает также, как и прежде. Если у вас были компонуемые функции, то они останутся компонуемыми после применения bind. Если функции не были компонуемыми, то bind не сделает их таковыми.

Итак, база для обработки ошибок следующая: преобразуем функции к «двухколейной модели» с помощью bind и объединяем их с помощью композиции. Двигаемся по зеленой колее пока все хорошо или сворачиваем на красную в случае ошибки.

Но это еще не все. Нам потребуется вписать в эту модель


  1. одноколейные функции без ошибок
  2. тупиковые функции
  3. функции, выбрасывающие исключения
  4. управляющие функции

Одноколейные функции без ошибок



let canonicalizeEmail input =
   { input with email = input.email.Trim().ToLower() }

Функция canonicalizeEmail – очень простая. Она обрезает лишние пробелы и преобразует email к нижнему регистру. В ней не должно быть ошибок и исключений (кроме NRE). Это просто преобразование строки.

Проблема в том, что мы научились компоновать с помощью bind только двухколейные функции. Нам потребуется еще один адаптер. Этот адаптер называется map (Select в LINQ).

let map singleTrackFunction twoTrackInput = 
  match twoTrackInput with
  | Success s -> Success (singleTrackFunction s)
  | Failure f -> Failure f

// map : ('a -> 'b) -> Result<'a> -> Result<'b>

map – более слабая функция чем bind, потому что map можно создать с помощью bind, но не наоборот.

Тупиковые функции



let updateDb request =
    // do something
    // return nothing at all

Тупиковые функции – это операции записи в духе fire & forget: вы обновляете значение в БД или пишете файл. У них нет возвращаемого значения. Они также не компонуются с двухколейными функциями. Все, что нам нужно это получить входное значение, выполнить «тупиковую» функцию и передать значение дальше по цепочке. По аналогии с bind и map объявим функции tee (иногда ее называют tap).

let tee deadEndFunction oneTrackInput = 
    deadEndFunction oneTrackInput 
    oneTrackInput 

// tee : ('a -> unit) -> 'a -> 'a


Функции, выбрасывающие исключения


Вы, наверное, уже заметили, что начал вырисовываться определенный «паттерн». Особенно функции, работающие с вводом / выводом. Сигнатуры таких методов лгут, потому что кроме успешного завершения, они могут выбросить исключение, создавая таким образом дополнительные exit points. Из сигнатуры этого не видно, вам нужно ознакомиться с документацией, чтобы знать какие исключения выбрасывает та или иная функция.

Исключения не подходят для этой «двухколейной» модели. Давайте обработаем их: функция SendEmail выглядит безопасной, но она может выбросить исключение. Добавим еще один «адаптер» и обернем все такие функции в try / catch-блок.

Do or do not, there is no try” — даже Йода не рекомендует использовать исключения для control flow. Много интересного на эту тему в докладе Exceptional Exceptions Адама Ситника (на английском языке).

Управляющие функции



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

Собираем все вместе



Мы объединили функции Validate, Canonicalize, UpdateDb и SendEmail. Осталась одна проблема. Браузер не понимает «двухколейной модели». Теперь необходимо снова вернуться к «одноколейной» модели. Добавляем функцию returnMessage. Возвращаем http-код 200 и JSON случае успеха или BadRequest и сообщение, в случае ошибки.

let executeUseCase = 
  receiveRequest
  >> validateRequest
  >> updateDbFromRequest
  >> sendEmail
  >> returnMessage

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

Расширяем фреймворк


  1. Учитываем возможные ошибки при проектировании
  2. Параллелизация
  3. Доменные события

Учитываем возможные ошибки при проектировании


Я хочу особо отметить, что обработка ошибок входит в состав требований к ПО. Мы концентрируемся только на успешных сценариях. Нужно уровнять успешные сценарии и ошибки в правах.

let validateInput input =
   if input.name = "" then 
      Failure "Name must not be blank"
   else if input.email = "" then 
      Failure "Email must not be blank"
   else 
      Success input  // happy path

type Result<'TEntity> = 
  | Success of 'TEntity
  | Failure of string

Рассмотрим нашу функцию валидации. Мы используем строки для ошибок. Это отвратительная идея. Введем специальные типы для ошибок. В F# обычно вместо enum используется union type. Объявим тип ErrorMessage. Теперь в случае ошибки появления новой ошибки нам придется добавить еще один вариант в ErrorMessage. Это может показаться обузой, но я думаю, что это, наоборот, хорошо, потому что такой код является самодокументируемым.

let validateInput input =
   if input.name = "" then 
      Failure NameMustNotBeBlank
   else if input.email = "" then 
      Failure EmailMustNotBeBlank
   else if (input.email doesn't match regex) then 
      Failure EmailNotValid input.email
   else 
      Success input  // happy path

type ErrorMessage = 
  | NameMustNotBeBlank
  | EmailMustNotBeBlank
  | EmailNotValid of EmailAddress

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

Такой подход очень похож на checked exceptions в Java. Стоит отметить, что они не взлетели.

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

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

let returnMessage result = 
  match result with
  | Success _ -> "Success"
  | Failure err -> 
      match err with
      | NameMustNotBeBlank -> "Name must not be blank" 
      | EmailMustNotBeBlank -> "Email must not be blank" 
      | EmailNotValid (EmailAddress email) -> 
            sprintf "Email %s is not valid" email

      // database errors
      | UserIdNotValid (UserId id) ->
            sprintf "User id %i is not a valid user id" id
      | DbUserNotFoundError (UserId id) ->
            sprintf "User id %i was not found in the database" id
      | DbTimeout (_,TimeoutMs ms) ->
            sprintf "Could not connect to database within %i ms" ms
      | DbConcurrencyError -> 
            sprintf "Another user has modified the record. Please resubmit" 
      | DbAuthorizationError _ ->
            sprintf "You do not have permission to access the database" 

      // SMTP errors
      | SmtpTimeout (_,TimeoutMs ms) ->
            sprintf "Could not connect to SMTP server within %i ms" ms
      | SmtpBadRecipient (EmailAddress email) ->
            sprintf "The email %s is not a valid recipient" email

Логика конвертации может быть контекстно-зависимой. Это сильно облегчает задачу интернационализации: вместо того, чтобы искать разбросанные по всей кодовой базы строки вам достаточно внести изменение в одну функцию, прямо перед передачей управления в слой UI. Резюмируя можно сказать, что такой подход дает следующие преимущества:

  1. документация для всех случаев, в которых что-то пошло не так
  2. типо-безопасно, не может устареть
  3. раскрывает скрытые требования к систиеме
  4. упрощает модульное тестирование
  5. упрощает интернационализацию

Параллелизация



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

Если вы можете применить операцию к паре и получить в результате объект того же тип, то вы можете применить такие операции и к спискам. Это свойство моноидов. Для более глубоко понимания темы вы можете ознакомиться со статьей «моноид без слез».

Доменные события




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

За рамками данной статьи


  1. Обработка ошибок, пересекающих границы сервисов
  2. Асинхронная модель
  3. Компенсаторные транзакции
  4. Логгирование

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




  1. Создаем тип Result. Классический Either еще более абстрактный и содержит свойства Left и Right. Мой тип Result лишь более специализирован.
  2. Используем bind для преобразования функций к «двухколейной модели»
  3. Используем композицию для сцепления отдельных функций между собой
  4. Рассматриваем коды ошибок как объекты первого класса

Ссылки


  1. Исходный код с примером доступен на github
  2. Статья на Хабре по мотивам доклада с реализацией на C#
Что вы используете для работы с ошибками

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

Поделиться публикацией
Похожие публикации
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 15
  • +1
    Хоть бы и так, а то что всё F#, да F#:
    [RequiredResult]
    enum PostBoostInteractionKeyDecodingError
    {
        InvalidKeyFormat,
        MismatchingUser,
        MismatchingIdentity,
    }
    ...
    interface IPostBoostInteractionKeyDecoder
    {
        Result<PostBoostInteractionSource, PostBoostInteractionKeyDecodingError> DecodeInteractionKey(EntityId<SimpleUserProfile> profileId, BoostTarget target, string interactionKey);
    }
    ...
    [RequiredResult]
    public enum RegisterBoostedPostInteractionResult
    {
        Success,
        MissingOrInactiveBoost,
        AlreadyVisited,
        PaymentFailed,
    }
    ...
    interface IPostBoostInteractionTracker
    {
        RegisterBoostedPostInteractionResult RegisterInteraction(EntityId<SimpleUserProfile> profileId, PostBoostInteractionSource source, PostBoostInteractionType type);
    }
    ...
    bool RegisterInteraction(string interactionKey, EntityId<SimpleUserProfile> profileId, BoostTarget target, PostBoostInteractionType interactionType)
        => _interactionKeyDecoder.DecodeInteractionKey(profileId, target, interactionKey)
            .And(source => _interactionTracker.RegisterInteraction(profileId, source, interactionType))
            .And(registerResult => registerResult == RegisterBoostedPostInteractionResult.Success)
            .Unwrap(false);
    
    • 0
      А EntityId свой не покажете? Хочется со своим сравнить.
      • +1
        gist.github.com/onyxmaster/9c9ee7dcab78205de441189e45b01134
        EntityId.FromString/ToString думаю можно не приводить.
        Когда-то был 128-битный, чтобы можно было генерировать без использования внешнего состояния (а-ля Snowflake), но потом от этой схемы отказались.
    • +4
      Мне кажется у вас спойлер коротковат. Думаю многие пользователи мобильного интернета не оценят три картинки на превью статьи.
      • –2
        Мне тоже жаль мобильных пользователей, но из песни слов не выкинешь. Если резать выше, то непонятно будет о чем статья.
      • +1
        Замечательная статья, Спасибо!
        С монадами не работал, но общая идея и примеры понравились (даже картинки тематические не поленились нарисовать :) ), написано по делу, несложно разобраться.
        Появилось желание разобраться в F# ;)
        • +2
          Получился эквивалент кода на исключениях.
          Даже проблемы с конвертацией ошибок на границах абстракций те же.
          В плюс идет проброс ошибки без раскрутки стека, в минус — не знаю как на F#, но на C# такой код умучаешься отлаживать.
          • 0
            В плюсы ещё список всех Failure (как с checked exceptions). Минусы масштабируемости как с checked exceptions:) Для валидация ИМХО такой подход хорошо работает. Для всего приложение — на любителя.
            • 0
              В плюсы ещё список всех Failure (как с checked exceptions).

              Вот это я как раз в плюсы занести не могу — слишком уж жесткий получается контракт, причем за счет фиксации на побочных путях, имеющих не самое прямое отношение к основной решаемой задаче.
              Кстати, на F# же вроде есть аналог do-нотации, почему бы не использовать его?

              • 0
                Кстати, на F# же вроде есть аналог do-нотации, почему бы не использовать его?

                Можно. Скотт пояснил, что не использовал их, чтобы «сработало» его маркетинговое обещание про то, что код с обработкой ошибок и без будет одинаковым:) Более того, Mark Seemann нечто подобное уже предложил уже в своем блоге. Правда там Either и Async. Идея немного другая, но сделано как раз с помощью computation expressions.
          • –2
            Кажется, автор переизобрел unix-пайпы и stderr.
            • +1
              Нет, это совсем другое кино
            • +1
              Ну вот, сначала обработку ошибок добавляют не меняя код, потом ассинхронность, потом транзакционность. Зачем лишать программиста удовольствия код поредактировать?
              • +1
                Возможно, потому что некоторые программисты не совсем эффективно «редактируют код»?:)
                • +2
                  Зачем лишать программиста удовольствия код поредактировать?

                  Ради еще более изысканного удовольствия код не трогать.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.