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


Мы приведем примеры для языка 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)


  1. dagrotar
    09.06.2018 15:53

    Пишу сейчас процессинг. Море операций с файловой системой, базой данных, сторонними сервисами и программами. Пришел к похожей схеме с различием в структуре данных.
    Для себя остановился на funcool/cats и монаде Either.
    PS: Ваш блок let функции buy-lot заставляет моё сердце кровоточить :)


    1. ady1981 Автор
      09.06.2018 15:54

      Что именно заставляет «сердце кровоточить», интересно? :)


      1. dagrotar
        09.06.2018 16:07

        Многословность. В моем понимании в let блок должен быть максимально лаконичным.
        with-lot-fn, buy-lot-fn вынести наружу. buy-lot-fn разбить для более легкого понимания.
        Лично меня начинает клинить если в функции большая вложенность.
        Вложенные let блоки причиняют страдания.
        Всё это разумеется ИМХО


        1. ady1981 Автор
          09.06.2018 18:22

          В данном случае функции `with-lot-fn` и `buy-lot-fn` можно делать как приватными внутри функции `buy-lot` (в этом случае им доступны параметры функции `buy-lot` без прокидывания их через контекст), так и вынести их наружу (и тогда все данные нужно брать из контекста). Плюс, здесь нужно учитывать, что если функцию для каждого шага прописывать в неймспейсе, то у вас в этом неймспейсе получится адское количество таких функций, большинство которых будут использоватся только в одном месте.
          Все остальное — дело вкуса, цвета и запаха :).


  1. mk2
    09.06.2018 15:54

    Посмотрите на Clojure пристально. Там наверняка есть монады.
    В ФП контекст обеспечивает монада State.


    1. ady1981 Автор
      09.06.2018 15:58

      Что именно в программировании с монадами будет проще/лучше, чем в контекстном программировании?
      Наверное, не все, что существует нужно применять потому, что это существует => хотелось бы узнать, чем программировании с монадами проще/лучше :)


      1. mk2
        09.06.2018 16:09
        +1

        Конкретно в данном случае — вы используете монаду Either для ошибок и State для контекста. Чистая функция, которая может вернуть результат или ошибку — пишется именно с помощью Either. А until-first-error — это bind для Either (точнее, часть с матчингом) плюс костыль — обработка списка, которая решается функцией fold.
        Проще говоря, вы не знаете, что монады уже умеют всё это делать, и поэтому городите велосипеды.

        Плюс, если посмотреть на типичные монады Reader — Writer — State — они предназначены для ситуаций только читать(конфиги) — только писать(логи) — читать+писать(контекст). И чтобы использовать любую из них, достаточно завернуть функцию в монаду. А вам, чтобы сделать Reader+State, например, придется либо оставить конфиги изменяемыми и сделать их частью контекста(а потом искать ошибку, когда кто-то по ошибке их поменяет), либо городить еще больше велосипедов.


        1. ady1981 Автор
          09.06.2018 18:58

          Сравнить контекстное программирование с программированием с монадами — действительно интересный и важный вопрос.
          Любому человеку, незнакомому с контекстным программированием и монадным программированием из того, что вы написали понятно следующее:
          — в контекстном программировании вводится понятие результата элементарного действия [:ok updated_context] | [:error reason] и, если это соответствует задаче, предлагается разбить логику на элементарные действия и использовать функцию `until-first-error`;
          — в монадном программировании вводятся сущности: монада, типы монад (Maybe, Either, State, Reader, Writer и др.). И дальше предлагается изучить как это все готовить.
          Возможно, что эта сложность монад, по мере роста масштаба приложения, в некоторый момент себя оправдает. Но совершенно очевидно, что для приложений среднего масштаба это сложность является излишней.
          Ваш пример с Reader+State я пока не понял (в чем там killing feature у монад перед контекстным программированием), но еще подумаю.


          1. mk2
            09.06.2018 21:46

            На самом деле большинство людей использует монады задолго до того, как слышит слово монада. Те же самые Maybe (т.е. вернуть значение или ничего) и Either (вернуть значение или ошибку) спокойно используются без каких-либо знаний о монадах.
            И как раз-таки ваше «элементарное действие» это просто функция, принимающая дополнительным параметром Context и возвращающая Either Error Context. Даже монады знать не надо, чтобы так написать.
            А потом, когда вы хотите скомбинировать несколько «действий» в одно, которое бы вернуло итоговый результат или первую ошибку — вы можете написать комбинирующую функцию вручную, а можете воспользоваться тем, что Either это монада и значит её можно комбинировать с другими Either — как раз так, как вам надо. И о монадах можете продолжать не знать ничего, кроме как что они комбинируются между собой.
            Плюс заметьте, что мы совсем даже не использовали State — потому что возвращаем новый контекст в результате. А что если мы хотим вернуть какой-то осмысленный результат? Конечно, вы можете возвращать пару из Context и результата. Но как потом скомбинировать это действие с другим действием, которое принимает как аргументы ваш результат и Context(не как одну пару)? Ещё больше костылей. Или же вы прочитаете про монаду State и используете её.
            TL DR: вы уже написали код с монадой Either, но не знаете об этом — и поэтому не пользуетесь преимуществами монад.


            1. ady1981 Автор
              10.06.2018 22:58

              Новые инструменты есть смысл применять для решения конкретных проблем/сложностей.
              Мы решали проблему декомпозиции кода, чтобы не было длинных методов (и проблем, которые с этим связаны). И метод контекстного программирования эту проблему полностью решает.

              >И как раз-таки ваше «элементарное действие» это просто функция, принимающая дополнительным параметром Context и возвращающая Either Error Context.

              Элементарное действие принимает контекст единственным параметром. И как правило, это не просто какая-то функция, а функция в который вызывается бизнес функция (как правило, это запрос к бизнес-сервису), результат которой сохраняется в текущий контекст. Цель элементарного действия: сделать бизнес вызов и обслужить контекст, с результатом этого вызова.

              >А что если мы хотим вернуть какой-то осмысленный результат? Конечно, вы можете возвращать пару из Context и результата.

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

              Является ли контекстное программирование заменой монадного программирование и решает ли он те же задачи, что и монады?
              Конечно, не является и не решает. Я думаю, что мы даже и не ставили те же задачи, для которых были разработаны монады :).


              1. mk2
                11.06.2018 01:40

                Может быть, решает, да. Но с моей точки зрения вы просто переизобрели велосипед — монаду Either.

                > Элементарное действие принимает контекст единственным параметром.
                Не вижу сложностей. Просто не делайте других параметров. И кстати, как вы тогда будете реализовывать действие «вернуть первые N записей»? Требовать, чтобы N было в вашем контексте под каким-то именем? Согласитесь, это же некрасиво. Особенно если это действие — первое в списке из нескольких. Потому что более никому ненужный параметр N так в контексте и останется.

                > Результат нужно возвращать после применения элементарных действий над текущим контекстом.
                Извиняюсь, не заметил, что промежуточные значения вы просто кладете в контекст. (Впрочем, until-first-error так и так возвращает updated-context, а не результат.) Только вот это порождает сразу несколько проблем. Как минимум, ломается изоляция — последующие функции видят результат всех предыдущих, если его явно не удалили из контекста. А удалять — значит это вы рассчитываете, что после функции a будет в цепочке функций всегда b, которая удалит результат a из контекста. Но тогда эти две функции разумно объединить.

                >Является ли контекстное программирование заменой монадного программирование и решает ли он те же задачи, что и монады?
                Не является. И не решает. Но все костыли вашего контекстного программирования заменяются использованием монады Either, даже не требуя глубокого понимания «что такое монады».


            1. ady1981 Автор
              10.06.2018 23:44

              >А потом, когда вы хотите скомбинировать несколько «действий» в одно, которое бы вернуло итоговый результат или первую ошибку — вы можете написать комбинирующую функцию вручную, а можете воспользоваться тем, что Either это монада и значит её можно комбинировать с другими Either — как раз так, как вам надо. И о монадах можете продолжать не знать ничего, кроме как что они комбинируются между собой.

              Ага, тут чёрт в деталях. А деталей тут как минимум две:
              1) Что вернет ваша комбинирующая функция, в случае exception? И что вам делать, если внутри действий вы начинаете некоторую транзакцию?
              Насколько я понимаю, этот exception просто убежит за пределы этой комбинирующей функции и в этот момент мы потеряете значение контекста, который был до exception. Чтобы вам сделать rollback в этом случае, вам как раз придется придумывать велосипеды (обертывать ваши действия в логику, которая будет сохранять контекст в кастомном exception и доставать этот контекст в catch).
              В нашей реализации есть опциональная параметр-функция `on-result-fn` для `until-first-error`, в которой вы можете сделать rollback без всякого оверхеда.
              2) А что вам делать, когда вы захотите сделать retry для этой операции (причем не для всех операций, а только для неуспешной)? Поскольку у вас нет контекста, а есть просто комбинирование, то вам придется делать для этого очередной велосипед.


              1. mk2
                11.06.2018 02:00

                Только вот вы эти проблемы тоже не решаете.
                1) Вы передаёте в on-result-fn контекст, каким он был до выполнения кинувшей exception функции. Ни что это за функция, ни какой был exception — вы не узнаёте. Более того, вы даже не знаете, on-result-fn вызывается после успешного выполнения или из-за ошибки, если не впихнёте это в context.
                Если вам достаточно такого странного — можно сделать свой instance монады и доопределить bind. Если нет, вам тоже придется придумывать тот же самый велосипед.
                2) А вам что делать? У вас нет «продолжить отсюда». Вы можете только стартовать все операции с начала, и пропускать внутри те, которые уже были выполнены — проверяя внутри это контест. Так и я могу. Или же как-то допилить ваш велосипед, дополнив его еще парой костылей.
                К тому же мы сделали rollback согласно пункту 1, то операции в любом случае нужно выполнять опять.


                1. ady1981 Автор
                  11.06.2018 23:16

                  Через контекст эти задачи можно решать достаточно гибко, в зависимости от особенностей задачи.

                  >Ни что это за функция, ни какой был exception — вы не узнаёте.

                  Цель функции on-result-fn — обслужить завершение контекста, для логики, которая не зависит от результата. Например, если в элементарных действиях делается lock на ресурс, то ее реквизиты нужно сохранить в контексте и в функции on-result-fn этот lock нужно освободить.
                  При этом ничто не мешает определить успешность или неуспешность выполненных действий по содержимому контента.

                  >Более того, вы даже не знаете, on-result-fn вызывается после успешного выполнения или из-за ошибки, если не впихнёте это в context.

                  Если для какой-то логики для завершения контекста результат важен, то ее нужно добавлять после получения результата (результирующий контекст или описание ошибки).

                  >А вам что делать? У вас нет «продолжить отсюда». Вы можете только стартовать все операции с начала, и пропускать внутри те, которые уже были выполнены — проверяя внутри это контест. Так и я могу.

                  А как вы сможете это сделать только через комбинирование действий, если у вас нет начального контекста?


                  1. mk2
                    11.06.2018 23:24

                    >Например, если в элементарных действиях делается lock на ресурс, то ее реквизиты нужно сохранить в контексте и в функции on-result-fn этот lock нужно освободить.

                    Допустим, вы берете лок и потом кидаете exception (в одном элементарном действии). Вопрос: откуда вы узнаете, что был взят лок? Ведь у вас есть только контекст до начала действия?

                    >А как мы сможете это сделать только через комбинирование действий, если у вас нет начального контекста?

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


                    1. 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 вернул успешный результат, то он сохранится в контекст (первый же тест это покажет и если он успешный, то сохранение лока в контекст всегда будет успешным).


                      1. mk2
                        12.06.2018 12:04

                        А, вот оно как. То есть если мы взяли лок, то в этом элементарном действии больше exception кидать нельзя.