...

суббота, 9 июня 2018 г.

О декомпозии кода замолвим слово: контекстное программирование

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

Мы приведем примеры для языка 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 + допиливание напильником);
  • упрощение рефакторинга кода.

Т.е. все что, что мы любим и практикуем.


Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий