- 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 a
bind (>>=)
для композиции монадических функций в 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
Нажмите здесь для печати.