Erlang-like микросервисы в Clojure приложении: это просто

    Как известно в кругу Erlang разработчиков: только Erlang разработчики знают как "жить" правильно а все остальные "живут" — неправильно. Не пытаясь оспаривать этот факт, приведем пример Clojure приложения в стиле Erlang, используя библиотеку Otplike.


    Для понимая статьи читателю возможно понадобится знание основ Clojure (а разве еще есть люди, которые не знают Clojure?...) и базовые принципы Erlang/OTP (процессы, отправка сообщений, gen_server и supervisor behaviours). Для того, чтобы разобраться со всем остальным у среднестатистического Clojure разработчика есть все что нужно: код с примерами, REPL и "бубен".


    Почему Clojure?


    В действительности есть много ответов на вопрос "почему Clojure". Приведем наши любимые:


    №1. Clojure — очень эффективный язык для макетирования. По сравнению с Java, написать макет приложения на Clojure очень просто: очень легко разрабатывать модели данных и собирать их вместе.


    №2. В Clojure очень просто тестировать приложение: REPL + удобство макетирования тут решают. Каков бы ни был тестовый кейс в приложении, достаточно просто сконструировать контекст, в котором протестировать нужный кейс.


    Первые два пункта ускоряют разработку и поддержку приложения (попадающую под заданные условия) раза в 2. Но мы только начали перечислять…


    №3. Clojure полностью интероперабелен с Java/JVM. Это означает, в частности, что можно использовать классы в Clojure приложении и экспортировать Clojure приложение как классы (например, Интегрируем clojure-библиотеку в java-приложение). Так же это означает, что весь накопленный код человечества для JVM доступен для Clojure приложения. А значит язык Clojure идет не альтернативно и не вразрез развитию JVM, а как дополнение к JVM (очень важное дополнение, хочется сказать).


    Итак, мы упомянули, что через Clojure удобно добраться до любой части "наследия человечества" в JVM и удобно потестировать. Ну а теперь, почему все таки Clojure...


    №4. Clojure — язык, которые разрабатывался для того, чтобы сложные вещи сделать простыми и, на наш взгляд, у них получилось, благодаря опыту и гению Рича Хики, который сформулировал основные идеи языка (которые, в свою очередь, можно прочитать, например, тут: Почему стоит изучить Clojure?)


    Ну и персонально моя любимая причина....


    №5. Программировать на Clojure — это fun, т.е. "живо", интересно и без стресса. Просто загружаешь проект, просто читаешь код, просто думаешь, просто пишешь и просто отгружаешь единорогов выдаешь результат.


    Почему Erlang/OTP для Clojure?


    В прошлом разделе мы выяснили, что Clojure — это "серебряная пуля".


    Однако для промышленного программирования в эпоху многопоточных приложений, помимо удобного и прикольного языка, нужна практическая методология для разработки этих самих многопоточных приложений.


    Стандартное решение для многопоточных приложений на Clojure в данный момент — это библиотека core.async (например, Готовим многопоточность с core.async). Практический опыт однако показывает, что хотя сама библиотека — хороша, но для практики нужны "строительные блоки" более высокого уровня.


    И тут мы возвращаемся к счастливым Erlang разработчикам и к их "прелести". Erlang/OTP впитал в себя значительный опыт разработки многопоточных приложений, пожалуй, как никакой другой язык. Имея реализацию базовых идей Erlang/OTP с помощью библиотеки Otplike, мы получаем:


    • Erlang-like процессы, обмен сообщениями между процессами и линки между процессами;
    • реализацию OTP процесса типа supervisor (supervisor behaviour): процесса, который следит за другими процессами и перегружает их, в зависимости от настроек;
    • реализацию OTP процесса типа gen_server (gen_server behaviour): процесса, реализующего микросервис;
    • запуск процессов по таймеру (scheduling).

    И чтобы не быть голословным, приведем...


    Минималистичный пример приложения с микросервисом


    Сервис для списка задач TODO


    Мы хотим сделать микросервис для списка задач TODO, который принимает следующие команды:


    1. создать сущность todo: create-todo [params] -> [:ok todo] | [:error reason]
    2. вернуть todo по ID: find-todo-by-id [id] -> [:ok todo] | [:error reason]
    3. завершить todo: terminate-todo [id] -> [:ok updated_todo] | [:error reason]
    4. удалить todo: delete-todo [id] -> [:ok nil] | [:error reason]
    5. вернуть список активных todo: enumerate-active-todos [] -> [:ok todos] | [:error reason]

    Как ни странно, но из этого описания прямо следует публичное API для сервиса TODO (полный пример кода, можно посмотреть на GitHub тут):


    (defn create-todo [params]
      (call* [:create-todo params]))
    
    (defn find-todo-by-id [id]
      (call* [:find-todo-by-id id]))
    
    (defn terminate-todo [id]
      (call* [:terminate-todo id]))
    
    (defn delete-todo [id]
      (call* [:delete-todo id]))
    
    (defn enumerate-active-todos []
      (call* :enumerate-active-todos))

    Вызов call* означает просто отправление сообщения сервису.


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


    Для разработки Otplike gen_server сервиса доступны для реализации привычные для OTP сallbacks:


    1. обработчик инициализации state сервиса: init [args] -> [:ok initial_state]
    2. обработчик сообщений, которые должны вернуть результат клиентскому процессу (синхронных сообщений): handle-call [message from state] -> [:reply reply updated_state] | [:noreply updated_state]
    3. обработчик сообщений без результата клиентскому процессу (асинхронных сообщений): handle-cast [message state] -> [:noreply updated_state]
    4. обработчик системных сообщений: handle-info [message state] -> [:noreply updated_state]
    5. обработчик завершения сервиса: terminate [reason state] -> nil

    Продвинутый пример супервайзера для перегрузки сервисов в REPL


    Допустим мы реализовали TODO сервис, и из REPL запустили его в новом процессе. После чего мы внесли правки в код этого сервиса и теперь мы хотим запустить модифицированный код, чтобы его потестировать. Как нам это сделать?


    Одно из решений — это убить процесс со старым кодом и запустить новый код в новом процессе.
    Однако этих сервисов у нас может быть много и они могут зависеть один от другого. Кроме того, порядок запуска сервисов тоже может быть важен.


    Поэтому есть другое радикальное решение родом из OTP: нужно завести процесс-супервайзер, который будет следить за нашими сервисами и который будет уметь перегружать их.
    Кроме того, мы хотим уметь перегружать и самого супервайзера, чтобы мы могли из REPL приводить наше приложение к известному начальному состоянию.


    Для этих требований получился следующий код для Otplike:


    ;;;;;;;;;;;;;;;;;;;;;;;;; supervision-tree
    
    (defn- app-sup [_config]
      [:ok
       [{:strategy :one-for-one}
        [{:id :todo-server :start [todo-server/start-link [{}]]}]]])
    
    ;;;;;;;;;;;;;;;;;;;;;;;;; boot-proc
    
    (defn- start-app-sup-link [config]
      (supervisor/start-link :app-sup
                             app-sup
                             [config]))
    
    (defn- start-boot-sup-link [config]
      (supervisor/start-link :boot-sup
                             (fn [cfg]
                               [:ok
                                [{:strategy :one-for-all}
                                 [{:id :app-sup :start [start-app-sup-link [cfg]]}]]])
                             [config]))
    
    (defn start []
      (if-let [pid (process/whereis :boot-proc)]
    
        (log/info "already started" pid)
    
        (let [config (config/get-config)]
          (process/spawn-opt
            (process/proc-fn []
    
                             (match (start-boot-sup-link config)
    
                                    [:ok pid]
                                    (loop []
                                      (process/receive!
    
                                        :restart
                                        (do
                                          (log/info "------------------- RESTARTING -------------------")
                                          (supervisor/terminate-child pid :app-sup)
                                          (log/info "--------------------------------------------------")
                                          (supervisor/restart-child pid :app-sup)
                                          (recur))
    
                                        :stop
                                        (process/exit :normal)))
    
                                    [:error reason]
                                    (log/error "cannot start root supervisor: " {:reason reason})))
            {:register :boot-proc}))))
    
    (defn stop []
      (if-let [pid (process/whereis :boot-proc)]
        (process/! pid :stop)
        (log/info "already stopped")))
    
    (defn restart []
      (if-let [pid (process/whereis :boot-proc)]
        (process/! pid :restart)
        (start)))

    Полный код можно посмотреть на GitHub тут


    В функции app-sup мы перечисляем дочерние процессы для нашего главного супервайзера.
    А остальной код — это workaround для рестарта супервайзера.


    Ну и наконец...


    Тестирование


    Зайдем в REPL и посмотрим как работает наш TODO сервис и рестарт приложения.


    Стартуем REPL из консоли из корня проекта:


    lein repl

    Стартуем приложение:


    erl-like-app.server=> (erl-like-app.server/start)
    <proc1@1>
    18-05-11 14:29:24 INFO - todo server initialized

    Создадим пару TODO и отметим первое TODO как сделанное:


    erl-like-app.server=> (erl-like-app.todo.todo-server/create-todo {:title "task #1", :description "create task #2"})
    [:ok {:title "task #1", :description "create task #2", :id "1", :created 1526049427586, :updated 1526049427586, :status :active}]
    
    erl-like-app.server=> (erl-like-app.todo.todo-server/create-todo {:title "task #2"})
    [:ok {:title "task #2", :id "2", :created 1526049434985, :updated 1526049434985, :status :active}]
    
    erl-like-app.server=> (erl-like-app.todo.todo-server/terminate-todo "1")
    [:ok {:title "task #1", :description "create task #2", :id "1", :created 1526049427586, :updated 1526049443912, :status :terminated}]

    Какие остались активные TODO:


    erl-like-app.server=> (erl-like-app.todo.todo-server/enumerate-active-todos)
    [:ok ({:title "task #2", :id "2", :created 1526049434985, :updated 1526049434985, :status :active})]

    Какое значение state сервиса:


    erl-like-app.server=>  (erl-like-app.todo.todo-server/get-state)
    {:counter 2, :db {"1" {:title "task #1", :description "create task #2", :id "1", :created 1526049427586, :updated 1526049443912, :status :terminated}, "2" {:title "task #2", :id "2", :created 1526049434985, :updated 1526049434985, :status :active}}}

    Перегрузим приложение:


    erl-like-app.server=> (erl-like-app.server/restart)
    true
    18-05-11 14:30:28 INFO - ------------------- RESTARTING -------------------
    18-05-11 14:30:28 INFO - todo server stopped
    18-05-11 14:30:28 INFO - --------------------------------------------------
    18-05-11 14:30:28 INFO - todo server initialized

    Какое сейчас значение state сервиса:


    erl-like-app.server=> (erl-like-app.todo.todo-server/get-state)
    {:counter 0, :db {}}

    Итог


    А что в итоге:


    • есть библиотека Otplike, которая открывает в Clojure "дверку" в Erlang, и
    • есть пример приложения, которое открывает "дверку" в Otplike.

    Не могу знать, надо ли вам в эту "дверку", но из нашего опыта могу сказать, что за этой "дверкой" получается прикольный код (а значит и эффективный, и простой).


    Удачного кодинга!


    Поделиться публикацией

    Комментарии 19

    • НЛО прилетело и опубликовало эту надпись здесь
        0
        На мой субъективный взгляд, в качестве промышленного языка общего назначения лучше Clojure сейчас ничего нет, учитывая наличие готовых библиотек и средств разработки. А также возможности использования Clojure/ClojureScript для веб-приложений.
        А то, что Clojure — это LISP, говорит о том, что, наконец то, то было круто в теории, стало круто на практике :).
        +1

        Соглашусь с автором в том что Clojure отличный язык. Годовой опыт коммерческого использования не дал мне повода для сомнений а наоборот подтвердил это. Язык очень простой, писать и проверять код очень просто. Как следствие при меньших трудозатратах выход намного выше.
        Но. Касаемо использования данной библиотеки у меня сомнения, точнее не вижу вообще в ней смысла. Так же как не нашел смысла в core.async, 99% atom'ы и agent'ы решают задачи с успехом. По сути core.async поможет только если вы строите приложение внутренний через обмен сообщениями, но оправдано ли это?
        Если у кого-то есть вопросы сомнения по поводу Clojure в продакшене, можете задать вопросы кэтому комментарию, попытаюсь ответить.

          0
          А какую архитектуру / фреймворк вы используете для декомпозиции приложения на модули и поддержки состояния каждого модуля?

          Мы раньше использовали Component: github.com/stuartsierra/component
          Я видел так же, что люди используют Duct: github.com/duct-framework/duct

          Otplike эту задачу успешно решает, одновременно с заходом на многопоточность.
            0
            А какую архитектуру / фреймворк вы используете для декомпозиции приложения на модули
            Мы используем только стандартные средства, 1-н файл это один модуль (ns) стараемся чтобы он отвечал за обособленную часть логики.
            … поддержки состояния каждого модуля?
            По сути не так много мест где нужно состояние. Это некоторые инфраструктурные моменты, абстракции для работы БД(connection pool), конфиги, возможно ещё что-то и некоторые алгоритмы с бизнес логикой, которые требуют стэйта. У нас это отдельные модули.
            У нас очень мало стейта(условно переменных) в приложении, можно пересчитать по пальцам, только там где без этого не обойтись. В остальном мы стремимся чтобы каждая функция была чистой(т.е. без side effect), это значительно упрощает приложения и что уменьшает кол-во багов. В основном когда приходит запрос извне мы подготавливаем все необходимые данные в коллекцию и передаем их вместе с данными из запроса далле, а не запрашиваем в в каждой функции отдельно(в цепочке вычислений). Там где нужен стейт мы используем обычный def или atom, второе соответственно позволяет выполняет изменения стейта в runtime.
            Там где это сделать не возможно напрямую получаем нужный стэйт, проблем это не доставляет.
            Вообще нужно стремиться минимизировать количество стейтов, это сильно упрощает код, что уменьшает кол-во багов.
            Могли бы вы привести конкретную проблему которая заставила вас внедрять вышеописанные библиотеки? Нам достаточно описанного выше.
              0
              > стараемся чтобы он отвечал за обособленную часть логики
              Опыт показывает, что уже для приложений среднего размера — это практически невозможно. В некоторый момент роста приложения появится зависимость между модулями.
              Далее, как правило, появляются требования к серверу иметь стейты у модулей.
              Далее, у стейтов появляются жизненные циклы. И надо не забыть, что модули уже зависят друг от друга.
              И добавим к этому очевидное желание, чтобы сервер обрабатывал запросы параллельно.
              Erlang и Otplike дают простое решения для этой гремучей смеси :).
                0

                Касаемо описанных вами проблем, по сути, согласен что связанность в приложении растет, количество стейтов так же. Только регулярный рефакторинг уменьшает эту проблему.
                Но не понял как вы увязали параллельные запросы и модули. Модуль, в моем понимании это просто набор функций так или иначе связанных между собой, обособленных в файле или другой схожей сущности. Любую функцию или цепочку функций можно выполнить в отдельном потоке, вариантов реализации множество. ИМХО описанный выше подход вносит больше сложностей чем решает.

                  0
                  >Но не понял как вы увязали параллельные запросы и модули.
                  Стейт модуля — это часть модуля. Так или иначе, этот стейт нужно шарить между потоками в многопоточном приложении.
                  Атомы решают проблему одновременного доступа к стейту, но с учетом наличия жизненного цикла этого стейта и зависимостей между модуляли такой стейт становится сложно поддерживать.
                  Но я не все проблемы назвал, на самом деле :).
                  Далее, в приложении появляется необходимость иметь линки между стейтами и иметь событийную систему.
                  Через атомы (+ сore.async) это тоже можно решить видимо, но решения проще чем в Erlang/OTP я пока не видел :).
          0

          Давно хочу изучить clojure, но не знаю с чего начать, не подскажите?

            0
            По Сlojure сейчас есть много информации. В зависимости от вашего опыта вы можете выбрать лучший учебник на данном этапе:
            — Введение в Clojure: alexott.net/ru/clojure/clojure-intro
            — Introduction to Clojure: clojure-doc.org/articles/tutorials/introduction.html
            — Clojure By Example: kimh.github.io/clojure-by-example/#
            — Задачи по Clojure: www.4clojure.com
            — Книги по Clojure: rutracker.org/forum/tracker.php?nm=clojure

            Я бы вам советовал после вводной статьи по Clojure, прочитать книгу по Clojure. И одновременно с чтением книги пробовать писать код самостоятельно.
              0

              Опыт есть, на js пишу и стараюсь придерживаться фП. Спасибо большое за ссылки, буду разбираться

                0
                Для js можете заходить на ClojureScript ;)
                0

                А ещё, не подскажите какой текстовый редактор или иде используете?

              0

              Для начала поиграться с синтаксисом, к нему нужно просто привыкнуть, чтобы он легко читался, это неделя-две. 4clojure.com отличный сайт.
              Разобраться с основными структурами данных (если не знакомы), список и ассоциативный массив.
              Разобраться с основными функциями для работы со структурами в Clojure assoc, conj.
              Разобраться с основной функцией в FP — reduce, и её производными map, filter,…
              Ну и прочитать хорошую книгу, я рекомендую следующую:
              "Эмерик, Карпер, Гранд: Программирование в Clojure. Практика применения Lisp в мире Java"
              Если как рус так и анг вариант.

                0

                Синтаксис своеобразный, сильная вложенность смущает. Только радует совместимость с джавой.

                  0

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

                    0

                    "Для этих требований получился следующий код для Otplike:" ну я Посмотрел на этот кусок кода и мне стало чуть страшно) может это из-за того, что код "учебный"

                      0
                      Конкретно тот код — боевой, мы его используем в продакшене :).

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое