- PVSM.RU - https://www.pvsm.ru -

Как пользователь я хочу изменить ФИО и 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 в системе
И увидеть сообщение об ошибке, если что-то пойдет не так.

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]. С их помощью можно моделировать несколько возможных состояний в рамках одного типа. У функции остается одно возвращаемое значение, но теперь оно принимает одно из четырех возможных значений: успех или тип ошибки. Осталось только обобщить данных подход. Объявим тип Result [2], состоящий из двух возможных значений Success и Failure и добавим generic-аргумент с данными.
type Result<'TEntity> =
| Success of 'TEntity
| Failure of string

Success и Failure

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

Любой поклонник Haskell заметит, что описанный мной подход является монадой Either, специализрованной типом списка ошибок для «левого» (Left) случая. В Haskell мы могли бы записать так:
type Result a b = Either [a] (b,[a])
Конечно-же я не пытаюсь выдать себя за изобретателя данного подхода, хотя и претендую на авторство глупой аналогии с железной дорогой. Так почему же я не использовал стандартную терминологию Haskell? Во-первых, это не очередное руководство по монадам. Вместо этого основной фокус смещен на решение конкретной проблемы обработки ошибок. Большинство людей, начинающих изучение F# не знакомы с монадами, поэтому я предпочитаю менее пугающий, более визуальный и интуитивный для многих подход.
Во-вторых, я убежден, что подход от частного к общему более эфективен: гораздо проще взбираться на следующий уровень абстракции, когда хорошо разобрался в текущем. Я был бы не прав, если бы назвал свой «двухколейный» подход монадой. Монады – сложнее и я не хочу вдаваться в монадические законы [4]в этом материале.
В-третьих, Either – слишком общая концепция. Я хотел бы представить рецепт, а не инструмент. Рецепт приготовления хлеба, в котором написано «просто воспользуйтесь мукой и духовкой» не слишком полезен. Абсолютно также бесполезно руководство по обработке ошибок в стиле «просто воспользуйтесь bind и Either». Поэтому я предлагаю комплексный подход, включающий в себя целый набор техник:
Either String abind (>>=) для композиции монадических функций в pipeline>=>) для композиции монадических функцийmap и fmap для интеграции немонадических функций в pipelinetee для интеграции функций, возвращающих unit (аналог void в F#)&&& для комбинирования монадических функций в параллельной обработке (например, для валидации)Надеюсь, что это вам понравится больше, чем просто «воспользуйтесь монадой Either».

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

Теперь есть «одноколейные» и «двухколейные» функции. По отдельности и те, и другие компонуются, но они не компонуются друг с другом. Для этого нам потребуется небольшой «адаптер». В случае успеха, вызываем функцию и передаем ей значение, а в случае ошибки просто передаем значение ошибки дальше без изменений. В ФП такая функция называется 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 [5] в 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 и объединяем их с помощью композиции. Двигаемся по зеленой колее пока все хорошо или сворачиваем на красную в случае ошибки.

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 [6]” — даже Йода не рекомендует использовать исключения для control flow [7]. Много интересного на эту тему в докладе Exceptional Exceptions [8] Адама Ситника (на английском языке).

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

Мы объединили функции Validate, Canonicalize, UpdateDb и SendEmail. Осталась одна проблема. Браузер не понимает «двухколейной модели». Теперь необходимо снова вернуться к «одноколейной» модели. Добавляем функцию returnMessage. Возвращаем http-код 200 и JSON случае успеха или BadRequest и сообщение, в случае ошибки.
let executeUseCase =
receiveRequest
>> validateRequest
>> updateDbFromRequest
>> sendEmail
>> returnMessage
Итак, я и обещал, что код без обработки ошибок будет идентичен коду с обработкой ошибок. Признаюсь, я немного схитрил и объявил новые функции в другом пространстве имен, оборачивающие функции слева в bind.
Я хочу особо отметить, что обработка ошибок входит в состав требований к ПО. Мы концентрируемся только на успешных сценариях. Нужно уровнять успешные сценарии и ошибки в правах.
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. Стоит отметить, что они не взлетели [9].
Если вы практикуете 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. Резюмируя можно сказать, что такой подход дает следующие преимущества:

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

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

Result. Классический Either еще более абстрактный и содержит свойства Left и Right. Мой тип Result лишь более специализирован.Исходный код с примером доступен на github [11].
Автор: Максим Аршинов
Источник [12]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/265153
Ссылки в тексте:
[1] типы-объединения: https://fsharpforfunandprofit.com/posts/discriminated-unions/
[2] Result: https://habrahabr.ru/post/267231/
[3] оригинальный доклад: http://fsharpforfunandprofit.com/rop/
[4] монадические законы : https://wiki.haskell.org/Monad_laws
[5] SelectMany: https://ericlippert.com/2013/03/25/monads-part-ten/
[6] Do or do not, there is no try: https://www.youtube.com/watch?v=BQ4yd2W50No
[7] для control flow: https://stackoverflow.com/questions/729379/why-not-use-exceptions-as-regular-flow-of-control
[8] Exceptional Exceptions: https://www.youtube.com/watch?v=U92Ts53win4
[9] не взлетели: https://stackoverflow.com/questions/613954/the-case-against-checked-exceptions
[10] моноид без слез: http://fsharpforfunandprofit.com/posts/monoids-without-tears/
[11] доступен на github: https://github.com/swlaschin/Railway-Oriented-Programming-Example
[12] Источник: https://habrahabr.ru/post/339606/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.