Конечно, в идеале лучше вообще Не писать лишнего кода. А если и писать, то, как известно, нужно хорошо продумывать кости системы архитектуру системы и реализовывать мясо системы логику системы. В данной заметке мы приведем рецепты для удобной реализации последнего.
Мы приведем примеры для языка Clojure, однако сам принцип можно применить и в других функциональных языках программирования (например, ровно эту же идею мы применяем в Erlang).
Идея
Идея сама по себе — проста и основывается на следующих утверждениях:
- любая логика всегда состоит из элементарных шагов;
- для каждого шага нужны определенные данные, к которым он применяет свою логику и выдает либо успешный, либо неуспешный результат.
На уровне псевдо-кода это можно представить так:
do-something-elementary(context) -> [:ok updated_context] | [:error reason]
Где:
do-something-elementary
— название функции;context
— аргумент функции, структура данных с начальным контекстом, из которого функция берет все необходимые данные;updated_context
— структура данных с обновленным контекстом, при успехе, куда функция складывает результат своего выполнения;reason
— структура данных, причина неудачи, при неуспехе.
Вот и вся идея. А дальше — дело техники. С 100500 миллионами деталей.
Пример: реализация пользователем покупки
Распишем детали на конкретном простом примере, который доступен на GitHub тут.
Допустим, что у нас есть пользователи, с деньгами, и лоты, которые стоят денег и которые пользователи могут купить. Мы хотим написать код, который будет проводить покупку лота:
buy-lot(user_id, lot_id) -> [:ok updated_user] | [:error reason]
Для простоты, количество денег и лоты пользователя мы будем хранить в самой структуре пользователя.
Для реализации нам потребуется несколько вспомогательных функций.
Функция until-first-error
В подавляющем числе случаев, бизнес логику можно представить как последовательность шагов, которые нужно сделать пока не возникло ошибки. Для этого мы заведем функцию:
until-first-error(fs, init_context) -> [:ok updated_context] | [:error reason]
Где:
fs
— последовательность функций (элементарных действий);init_context
— начальный контекст.
Реализацию этой функции можно посмотреть на GitHub тут.
Функция with-result-or-error
Очень часто элементарное действие состоит в том, что нужно просто выполнить какую-то функцию и, если она выполнилась успешно, добавить ее результат к контексту. Для этого заведем функцию:
with-result-or-error(f, key, context) -> [:ok updated_context] | [:error reason]
В целом, единственная цель этой функции — уменьшить размер кода.
Ну и, наконец, наша "красавица"...
Функция, реализующая покупку
1. (defn buy-lot [user_id lot_id]
2. (let [with-lot-fn (partial
3. util/with-result-or-error
4. #(lot-db/find-by-id lot_id)
5. :lot)
6.
7. buy-lot-fn (fn [{:keys [lot] :as ctx}]
8. (util/with-result-or-error
9. #(user-db/update-by-id!
10. user_id
11. (fn [user]
12. (let [wallet_v (get-in user [:wallet :value])
13. price_v (get-in lot [:price :value])]
14. (if (>= wallet_v price_v)
15. (let [updated_user (-> user
16. (update-in [:wallet :value]
17. -
18. price_v)
19. (update-in [:lots]
20. conj
21. {:lot_id lot_id
22. :price price_v}))]
23. [:ok updated_user])
24. [:error {:type :invalid_wallet_value
25. :details {:code :not_enough
26. :provided wallet_v
27. :required price_v}}]))))
28. :user
29. ctx))
30.
31. fs [with-lot-fn
32. buy-lot-fn]]
33.
34. (match (util/until-first-error fs {})
35.
36. [:ok {:user updated_user}]
37. [:ok updated_user]
38.
39. [:error reason]
40. [:error reason])))
Пройдемся по коду:
- стр. 34:
match
— это макрос для матчинга значения по шаблону из библиотекиclojure.core.match
; - стр. 34-40: мы применяем обещанную функцию
until-first-error
к элементарным шагамfs
, берем из контекста нужные нам данные и возвращаем их, или прокидываем ошибку наверх; - стр. 2-5: мы строим первое элементарное действие (к которому останется применить только текущий контекст), которое, просто добавляет данные по ключу
:lot
в текущий контекст; - стр. 7-29: здесь мы используем знакомую функцию
with-result-or-error
, но действие, которое оно обертывает получилось чуть более хитрым: в одной транзакции мы проверяем, что у пользователя имеется достаточно денег и в случае успеха проводим покупку (ибо, по умолчанию наше приложение — многопоточное(а кто где-нибудь в последний раз видел однопоточное приложение?)и мы к этому должны быть готовы).
И пару слов, про остальные функции, которые мы использовали:
lot-db/find-by-id(id)
— возвращает лот, поid
;user-db/update-by-id!(user_id, update-user-fn)
— применяет функциюupdate-user-fn
к пользователюuser_id
(в воображаемой базе данных).
А потестировать?...
Потестируем этот пример приложения из clojure REPL. Стартуем REPL из консоли из корня проекта:
lein repl
Какие у нас есть юзеры с финансами:
context-aware-app.core=> (context-aware-app.user.db/enumerate)
[:ok ({:id "1", :name "Vasya", :wallet {:value 100}, :lots []}
{:id "2", :name "Petya", :wallet {:value 100}, :lots []})]
Какие у нас есть лоты (товары):
context-aware-app.core=> (context-aware-app.lot.db/enumerate)
[:ok
({:id "1", :name "Apple", :price {:value 10}}
{:id "2", :name "Banana", :price {:value 20}}
{:id "3", :name "Nuts", :price {:value 80}})]
"Вася" покупает "яблоко":
context-aware-app.core=>(context-aware-app.processing/buy-lot "1" "1")
[:ok {:id "1", :name "Vasya", :wallet {:value 90}, :lots [{:lot_id "1", :price 10}]}]
И "банан:
context-aware-app.core=> (context-aware-app.processing/buy-lot "1" "2")
[:ok {:id "1", :name "Vasya", :wallet {:value 70}, :lots [{:lot_id "1", :price 10} {:lot_id "2", :price 20}]}]
И "орешки":
context-aware-app.core=> (context-aware-app.processing/buy-lot "1" "3")
[:error {:type :invalid_wallet_value, :details {:code :not_enough, :provided 70, :required 80}}]
На "орешки" денег не хватило.
Итого
В итоге, используя контекстное программирование, больше не будет огромных кусков кода (не влезающих в один экран), а также “длинных методов”, “больших классов” и “длинных списков параметров”. А это дает:
- экономию времени на чтение и понимание кода;
- упрощение тестирования кода;
- возможность переиспользовать код (в том числе и с помощью copy-paste + допиливание напильником);
- упрощение рефакторинга кода.
Т.е. все что, что мы любим и практикуем.
Комментарии (17)
mk2
09.06.2018 15:54Посмотрите на Clojure пристально. Там наверняка есть монады.
В ФП контекст обеспечивает монада State.ady1981 Автор
09.06.2018 15:58Что именно в программировании с монадами будет проще/лучше, чем в контекстном программировании?
Наверное, не все, что существует нужно применять потому, что это существует => хотелось бы узнать, чем программировании с монадами проще/лучше :)mk2
09.06.2018 16:09+1Конкретно в данном случае — вы используете монаду Either для ошибок и State для контекста. Чистая функция, которая может вернуть результат или ошибку — пишется именно с помощью Either. А until-first-error — это bind для Either (точнее, часть с матчингом) плюс костыль — обработка списка, которая решается функцией fold.
Проще говоря, вы не знаете, что монады уже умеют всё это делать, и поэтому городите велосипеды.
Плюс, если посмотреть на типичные монады Reader — Writer — State — они предназначены для ситуаций только читать(конфиги) — только писать(логи) — читать+писать(контекст). И чтобы использовать любую из них, достаточно завернуть функцию в монаду. А вам, чтобы сделать Reader+State, например, придется либо оставить конфиги изменяемыми и сделать их частью контекста(а потом искать ошибку, когда кто-то по ошибке их поменяет), либо городить еще больше велосипедов.ady1981 Автор
09.06.2018 18:58Сравнить контекстное программирование с программированием с монадами — действительно интересный и важный вопрос.
Любому человеку, незнакомому с контекстным программированием и монадным программированием из того, что вы написали понятно следующее:
— в контекстном программировании вводится понятие результата элементарного действия [:ok updated_context] | [:error reason] и, если это соответствует задаче, предлагается разбить логику на элементарные действия и использовать функцию `until-first-error`;
— в монадном программировании вводятся сущности: монада, типы монад (Maybe, Either, State, Reader, Writer и др.). И дальше предлагается изучить как это все готовить.
Возможно, что эта сложность монад, по мере роста масштаба приложения, в некоторый момент себя оправдает. Но совершенно очевидно, что для приложений среднего масштаба это сложность является излишней.
Ваш пример с Reader+State я пока не понял (в чем там killing feature у монад перед контекстным программированием), но еще подумаю.mk2
09.06.2018 21:46На самом деле большинство людей использует монады задолго до того, как слышит слово монада. Те же самые Maybe (т.е. вернуть значение или ничего) и Either (вернуть значение или ошибку) спокойно используются без каких-либо знаний о монадах.
И как раз-таки ваше «элементарное действие» это просто функция, принимающая дополнительным параметром Context и возвращающая Either Error Context. Даже монады знать не надо, чтобы так написать.
А потом, когда вы хотите скомбинировать несколько «действий» в одно, которое бы вернуло итоговый результат или первую ошибку — вы можете написать комбинирующую функцию вручную, а можете воспользоваться тем, что Either это монада и значит её можно комбинировать с другими Either — как раз так, как вам надо. И о монадах можете продолжать не знать ничего, кроме как что они комбинируются между собой.
Плюс заметьте, что мы совсем даже не использовали State — потому что возвращаем новый контекст в результате. А что если мы хотим вернуть какой-то осмысленный результат? Конечно, вы можете возвращать пару из Context и результата. Но как потом скомбинировать это действие с другим действием, которое принимает как аргументы ваш результат и Context(не как одну пару)? Ещё больше костылей. Или же вы прочитаете про монаду State и используете её.
TL DR: вы уже написали код с монадой Either, но не знаете об этом — и поэтому не пользуетесь преимуществами монад.ady1981 Автор
10.06.2018 22:58Новые инструменты есть смысл применять для решения конкретных проблем/сложностей.
Мы решали проблему декомпозиции кода, чтобы не было длинных методов (и проблем, которые с этим связаны). И метод контекстного программирования эту проблему полностью решает.
>И как раз-таки ваше «элементарное действие» это просто функция, принимающая дополнительным параметром Context и возвращающая Either Error Context.
Элементарное действие принимает контекст единственным параметром. И как правило, это не просто какая-то функция, а функция в который вызывается бизнес функция (как правило, это запрос к бизнес-сервису), результат которой сохраняется в текущий контекст. Цель элементарного действия: сделать бизнес вызов и обслужить контекст, с результатом этого вызова.
>А что если мы хотим вернуть какой-то осмысленный результат? Конечно, вы можете возвращать пару из Context и результата.
Результат нужно возвращать после применения элементарных действий над текущим контекстом. В этот момент контекст сделал свою функцию и возвращать его дальше никуда не нужно.
Поэтому про какие костыли тут речь, пока не очень понятно.
В бизнес-сервисах часто появляется State. Но, контекст — он шире State: при реализации логики State сервиса, как правило, включается в контекст, в нем обрабатывается и результирующий State в конце извлекается из контекста.
Является ли контекстное программирование заменой монадного программирование и решает ли он те же задачи, что и монады?
Конечно, не является и не решает. Я думаю, что мы даже и не ставили те же задачи, для которых были разработаны монады :).mk2
11.06.2018 01:40Может быть, решает, да. Но с моей точки зрения вы просто переизобрели велосипед — монаду Either.
> Элементарное действие принимает контекст единственным параметром.
Не вижу сложностей. Просто не делайте других параметров. И кстати, как вы тогда будете реализовывать действие «вернуть первые N записей»? Требовать, чтобы N было в вашем контексте под каким-то именем? Согласитесь, это же некрасиво. Особенно если это действие — первое в списке из нескольких. Потому что более никому ненужный параметр N так в контексте и останется.
> Результат нужно возвращать после применения элементарных действий над текущим контекстом.
Извиняюсь, не заметил, что промежуточные значения вы просто кладете в контекст. (Впрочем, until-first-error так и так возвращает updated-context, а не результат.) Только вот это порождает сразу несколько проблем. Как минимум, ломается изоляция — последующие функции видят результат всех предыдущих, если его явно не удалили из контекста. А удалять — значит это вы рассчитываете, что после функции a будет в цепочке функций всегда b, которая удалит результат a из контекста. Но тогда эти две функции разумно объединить.
>Является ли контекстное программирование заменой монадного программирование и решает ли он те же задачи, что и монады?
Не является. И не решает. Но все костыли вашего контекстного программирования заменяются использованием монады Either, даже не требуя глубокого понимания «что такое монады».
ady1981 Автор
10.06.2018 23:44>А потом, когда вы хотите скомбинировать несколько «действий» в одно, которое бы вернуло итоговый результат или первую ошибку — вы можете написать комбинирующую функцию вручную, а можете воспользоваться тем, что Either это монада и значит её можно комбинировать с другими Either — как раз так, как вам надо. И о монадах можете продолжать не знать ничего, кроме как что они комбинируются между собой.
Ага, тут чёрт в деталях. А деталей тут как минимум две:
1) Что вернет ваша комбинирующая функция, в случае exception? И что вам делать, если внутри действий вы начинаете некоторую транзакцию?
Насколько я понимаю, этот exception просто убежит за пределы этой комбинирующей функции и в этот момент мы потеряете значение контекста, который был до exception. Чтобы вам сделать rollback в этом случае, вам как раз придется придумывать велосипеды (обертывать ваши действия в логику, которая будет сохранять контекст в кастомном exception и доставать этот контекст в catch).
В нашей реализации есть опциональная параметр-функция `on-result-fn` для `until-first-error`, в которой вы можете сделать rollback без всякого оверхеда.
2) А что вам делать, когда вы захотите сделать retry для этой операции (причем не для всех операций, а только для неуспешной)? Поскольку у вас нет контекста, а есть просто комбинирование, то вам придется делать для этого очередной велосипед.mk2
11.06.2018 02:00Только вот вы эти проблемы тоже не решаете.
1) Вы передаёте в on-result-fn контекст, каким он был до выполнения кинувшей exception функции. Ни что это за функция, ни какой был exception — вы не узнаёте. Более того, вы даже не знаете, on-result-fn вызывается после успешного выполнения или из-за ошибки, если не впихнёте это в context.
Если вам достаточно такого странного — можно сделать свой instance монады и доопределить bind. Если нет, вам тоже придется придумывать тот же самый велосипед.
2) А вам что делать? У вас нет «продолжить отсюда». Вы можете только стартовать все операции с начала, и пропускать внутри те, которые уже были выполнены — проверяя внутри это контест. Так и я могу. Или же как-то допилить ваш велосипед, дополнив его еще парой костылей.
К тому же мы сделали rollback согласно пункту 1, то операции в любом случае нужно выполнять опять.ady1981 Автор
11.06.2018 23:16Через контекст эти задачи можно решать достаточно гибко, в зависимости от особенностей задачи.
>Ни что это за функция, ни какой был exception — вы не узнаёте.
Цель функции on-result-fn — обслужить завершение контекста, для логики, которая не зависит от результата. Например, если в элементарных действиях делается lock на ресурс, то ее реквизиты нужно сохранить в контексте и в функции on-result-fn этот lock нужно освободить.
При этом ничто не мешает определить успешность или неуспешность выполненных действий по содержимому контента.
>Более того, вы даже не знаете, on-result-fn вызывается после успешного выполнения или из-за ошибки, если не впихнёте это в context.
Если для какой-то логики для завершения контекста результат важен, то ее нужно добавлять после получения результата (результирующий контекст или описание ошибки).
>А вам что делать? У вас нет «продолжить отсюда». Вы можете только стартовать все операции с начала, и пропускать внутри те, которые уже были выполнены — проверяя внутри это контест. Так и я могу.
А как вы сможете это сделать только через комбинирование действий, если у вас нет начального контекста?mk2
11.06.2018 23:24>Например, если в элементарных действиях делается lock на ресурс, то ее реквизиты нужно сохранить в контексте и в функции on-result-fn этот lock нужно освободить.
Допустим, вы берете лок и потом кидаете exception (в одном элементарном действии). Вопрос: откуда вы узнаете, что был взят лок? Ведь у вас есть только контекст до начала действия?
>А как мы сможете это сделать только через комбинирование действий, если у вас нет начального контекста?
Начальный контекст это то, что мы передали как параметр первому(скомбинированному) действию. Этот параметр в вызывающей функции никуда не делся, и мы можем просто использовать его еще раз.ady1981 Автор
12.06.2018 12:01>Допустим, вы берете лок и потом кидаете exception (в одном элементарном действии). Вопрос: откуда вы узнаете, что был взят лок?
Элементарное действие для лока будет иметь вид:
(defn get-lock [{:keys [value_a ...] :as context}]
(util/with-result-or-error
#(lock-service/create-lock value_a ... )
:lock
context))
Если exception возник вlock-service/create-lock
, то значит лок не был создан и в context ничего сохранять не нужно, а еслиlock-service/create-lock
вернул успешный результат, то он сохранится в контекст (первый же тест это покажет и если он успешный, то сохранение лока в контекст всегда будет успешным).mk2
12.06.2018 12:04А, вот оно как. То есть если мы взяли лок, то в этом элементарном действии больше exception кидать нельзя.
dagrotar
Пишу сейчас процессинг. Море операций с файловой системой, базой данных, сторонними сервисами и программами. Пришел к похожей схеме с различием в структуре данных.
Для себя остановился на funcool/cats и монаде Either.
PS: Ваш блок
let
функцииbuy-lot
заставляет моё сердце кровоточить :)ady1981 Автор
Что именно заставляет «сердце кровоточить», интересно? :)
dagrotar
Многословность. В моем понимании в
let
блок должен быть максимально лаконичным.with-lot-fn
,buy-lot-fn
вынести наружу.buy-lot-fn
разбить для более легкого понимания.Лично меня начинает клинить если в функции большая вложенность.
Вложенные
let
блоки причиняют страдания.Всё это разумеется ИМХО
ady1981 Автор
В данном случае функции `with-lot-fn` и `buy-lot-fn` можно делать как приватными внутри функции `buy-lot` (в этом случае им доступны параметры функции `buy-lot` без прокидывания их через контекст), так и вынести их наружу (и тогда все данные нужно брать из контекста). Плюс, здесь нужно учитывать, что если функцию для каждого шага прописывать в неймспейсе, то у вас в этом неймспейсе получится адское количество таких функций, большинство которых будут использоватся только в одном месте.
Все остальное — дело вкуса, цвета и запаха :).