О декомпозии кода замолвим слово: контекстное программирование
Конечно, в идеале лучше вообще Не писать лишнего кода. А если и писать, то, как известно, нужно хорошо продумывать кости системы архитектуру системы и реализовывать мясо системы логику системы. В данной заметке мы приведем рецепты для удобной реализации последнего.
Мы приведем примеры для языка 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 + допиливание напильником);
- упрощение рефакторинга кода.
Т.е. все что, что мы любим и практикуем.