README.md

State manager

State manager – библиотека управления состоянием компонентов для Clojure и Babashka.

Библиотека предоставляет следующие возможности:

  • Создание компонентов;

  • Управление конфигурацией компонентов;

  • Запуск и остановка компонентов;

  • Управление порядком запуска и останова компонентов;

  • Автоматическое разрешение зависимостей между компонентами при запуске и останове;

  • Управление контекстом системы как набором взаимосвязанных компонентов.

Проект создан на основе Шаблона библиотеки.

Перед началом работы с проектом см. раздел Установка зависимостей.

Подключение библиотеки

Для Clojure добавьте в файл CLI/deps.edn в секцию :deps {…} координаты библиотеки:

org.rssys/state-manager {:git/tag "v0.1.1" :git/sha "ddbfe2e" :git/url "https://gitflic.ru/project/red-stars-systems/state-manager.git"}

Для Babashka добавьте вышеуказанные координаты бибилотеки в файл bb.edn в секцию :deps {…}.

Основные термины

Компонент структура данных, хранящая изменяемое состояние. Например, в компоненте можно хранить: соединение с БД, экземпляр веб-сервера, соединение с шиной данных, кэш, пул соединений и т.д.

Система это набор взаимосвязанных компонентов с определённым порядком запуска и останова.

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

Введение

Вначале я сделаю несколько базовых утверждений:

  • Состояние системы предпочтительно держать в одном месте, чтобы управлять им консистентным образом.

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

  • Управление состоянием системы должно позволять строить многопользовательские (multi-tenant systems) окружения, с одинаковыми компонентами по типу, но разной реализацией. Контекст не должен быть жестко привязан к пространству имен. Идеально, если контекст это один из параметров для функций, выполняющих какую-то бизнес-логику.

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

  • Зависимости между компонентами системы должны быть указаны явно и разрешаться консистентным образом.

Структура компонента

{:id         :c1          ;; Идентификатор компонента
 :config     {}           ;; Конфигурационные параметры компонента
 :config-fn  nil          ;; Функция чтения конфигурационных параметров извне или их формирования
 :state      EmptyState   ;; Изменяемое состояние компонента
 :status     :stopped     ;; Статус компонента (запущен, остановлен, отключен)
 :start-fn   #object[]    ;; Функция запуска компонента и сохранения её результата в :state
 :stop-fn    #object[]    ;; Функция останова компонента и сохранения её результата в :state
 :start-deps [:db]        ;; Зависимости компонента (другие компоненты), которые должны быть запущены до
 :stop-deps  [:cache]     ;; Зависимости компонента (другие компоненты), которые должны быть остановлены до
                          ;; Поле `:stop-deps` формируется автоматически и не требует ручного управления.
}

Демо

Демонстрационный пример использования библиотеки.

Использование библиотеки

Для работы с библиотекой необходимо выполнить импорт.

(require '[org.rssys.state-manager.core :as sm])

Быстрый старт

Соберем систему из двух компонентов: базы данных и веб-сервера и запустим её.

(let [*ctx (sm/new-context
             [{:id        :db
               :config    {:host "127.0.0.1" :port 5432 :user "dba" :password nil}
               :config-fn (fn [config] (assoc config :password (.toString (random-uuid))))
               :start-fn  (fn [config state] (println "Запуск БД" config) :db-state)
               :stop-fn   (fn [config state] (println "Остановка БД" state) nil)}

              {:id         :web
               :config     {:port 8080}
               :start-deps [:db]
               :start-fn   (fn [config state] (println "Запуск веб-сервера" config) :web-state)
               :stop-fn    (fn [config state] (println "Остановка веб-сервера" state) nil)}])]


  (sm/start-all! *ctx)
  (println "\nСистема запущена.Текущее состояние компонентов:")
  (println (sm/state-map *ctx) "\n") ;; Контекст можно передать в виде state-map в поток обработки данных
  (sm/stop-all! *ctx))
;; => [:db :web]

Вывод на консоль:

Запуск БД {:host 127.0.0.1, :port 5432, :user dba, :password 578484fd-6c31-438d-a938-438c5caea4d1}
Запуск веб-сервера {:port 8080}

Система запущена.Текущее состояние компонентов:
{:db :db-state, :web :web-state}

Остановка веб-сервера :web-state
Остановка БД :db-state

В данном примере в компоненте базы данных используется функция :config-fn для чтения пароля и обогащения структуры :config.
Порядок запуска компонентов определяется зависимостями, прописываемыми в :start-deps. Порядок останова компонентов формируется в контексте автоматически.

Создание компонента

Для создания простейшего компонента необходим словарь из 2-х атрибутов: уникального идентификатора компонента и его конфигурационных параметров.

(def c1 (sm/create-component {:id :c1 :config {}}))

Только что мы создали минимальный компонент, готовый к работе. То есть его можно запускать и вызывать над ним функции работы с компонентами.
Правда он ничего не делает, т.к. функции-заглушки :start-fn и :stop-fn используемые по-умолчанию – тривиальны.

Создание контекста

Для управления компонентами как системой необходимо их собрать в контекст. Именно в контексте происходит разрешение зависимостей, старт и останов компонентов, управление их конфигурацией.

Создадим контекст.

(def *ctx (sm/new-context))

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

(def *ctx (sm/new-context [{:id :c1 :config {}}]))

Добавление и удаление компонентов

Для добавления компонента в контекст:

(def c2 (sm/create-component {:id :c2 :config {}}))
(sm/add! *ctx c2)
;;=> :c2

Функция возвращает идентификатор добавленного компонента.

Для добавления группы компонентов в контекст:

(def c3 (sm/create-component {:id :c3 :config {}}))
(def c4 (sm/create-component {:id :c4 :config {}}))
(sm/add-components! *ctx [c3 c4])
;;=> [:c3 :c4]

Функция возвращает вектор идентификаторов добавленных компонентов.

Для удаления компонента из контекста:

(sm/remove! *ctx :c2)
;; => :c2

Функция возвращает идентификатор удаленного компонента.

Для удаления группы компонентов из контекста:

(sm/remove-components! *ctx [:c3 :c4])
;;=> [:c3 :c4]

Функция возвращает вектор идентификаторов удаленных компонентов.

Конфигурация компонентов

Настройка параметров работы компонентов осуществляется двумя атрибутами компонента:

  • Словарь :config;

  • Функция формирования параметров :config-fn.

Порядок использования при старте компонента таков: (:config-fn :config) → новое значение :config. Если :config-fn равно nil или не задано, то используется значение :config.

Словарь :config может содержать любые данные, необходимые компоненту. При создании компонента :config всегда передается явно. Если нечего передавать, то передается пустой словарь {}. Например:

(def *ctx (sm/new-context [{:id        :db
                            :config    {:host "127.0.0.1"
                                        :port 5432
                                        :user "dba"
                                        :password nil}}]))

В данном примере мы явно передали параметры подключения к базе, без указания пароля. При этом мы не указали :config-fn, что явно говорит, что значение :config итоговое для компонента.

Функция формирования параметров :config-fn предназначена для программного построения конфигурации компонента, чтении секретов и настроек извне. Данная функция принимает на вход текущее значение :config и должна возвратить новое значение :config, которое будет записано в соответствующий атрибут компонента. Например:

(def *ctx (sm/new-context [{:id        :db
                            :config    {:host "127.0.0.1"
                                        :port 5432
                                        :user "dba"
                                        :password nil}
                            :config-fn  (fn [config] (assoc config :password (.toString (random-uuid))))}]))

В данном примере, при старте компонента сначала будет вызвана функция :config-fn и в качестве аргумента будет передано текущее значение :config. Далее будет выработан пароль и записан в структуру :config в качестве возвращаемого значения функции :config-fn. Полученное значение с паролем, будет итоговым вариантом конфигурации для функции старта компонента.

Если :config-fn не задана или её значение nil, то в качестве итогового значения конфигурации принимается текущее значение :config.

Для выключения функции :config-fn в её значение можно записать nil.

(sm/update-config-fn! *ctx :db nil)

Это может быть полезно сделать после старта компонента, когда мы хотим с помощью функции :config-fn однократно сходить во внешний мир, сформировать конфигурацию и далее при старте/останове компонента использовать полученную конфигурацию компонента.

Запуск и остановка компонента

Для запуска и останова компонента разработчику необходимо определить функции :start-fn и :stop-fn соответственно. Если эти функции не заданы для компонента, то будут использоваться функции заглушки, предоставляемые по умолчанию.

Данные функции принимают два аргумента: конфигурация, текущее состояние компонента. Функции :start-fn и :stop-fn должны вернуть в качестве результата состояние компонента – объект любого типа.

После останова компонента, при следующем старте функция :start-fn может использовать предыдущее состояние компонента по своему усмотрению.

Для запуска и останова компонентов в контексте могут использоваться функции:

  • sm/start! – для запуска одиночного компонента и его зависимостей;

  • sm/stop! – для останова одиночного компонента и его зависимостей;

  • sm/start-some! – для запуска выборочных компонентов и их зависимостей;

  • sm/stop-some! – для останова выборочных компонентов и их зависимостей;

  • sm/start-all! – для запуска всех компонентов в контексте;

  • sm/stop-all! – для останова всех компонентов в контексте.

При этом, порядок запуска и останова зависимостей компонентов контекст вычисляет автоматически.

Для примера создадим, запустим и остановим компонент.

(defn open-socket [{:keys [port]} _]
  (let [s (java.net.ServerSocket. ^int port)]
    (println "Сокет открыт. Порт:" port)
    s))

(defn close-socket [_ ^java.net.ServerSocket state]
  (.close state)
  (println "Сокет закрыт:"))

(def *ctx (sm/new-context [{:id       :socket
                            :config   {:port 10080}
                            :start-fn open-socket
                            :stop-fn  close-socket}]))

(sm/start! *ctx :socket)
;; Сокет открыт. Порт: 10080
;;=> [:socket]

(sm/stop! *ctx :socket)
;; Сокет закрыт:
;;=> [:socket]

В качестве успешного результата функции запуска и останова всегда возвращают вектор.

В векторе возврата находятся идентификаторы компонентов, которые запустились или остановились соответственно.

Во время запуска компонента происходит вычисление вектора :stop-deps. Этот вектор будет содержать идентификаторы зависимостей, которые должны быть остановлены, в случае остановки компонента.

Обработка ошибок

Если при старте или останове компонента произошла ошибка или возникло исключение, то оно будет перехвачено и в качестве ошибки функции вернут словарь, в котором будут указаны подробности.

Этот же словарь можно прочитать из контекста используя функцию sm/get-error.

Например, попытаемся создать сокет с недопустимым значением сетевого порта: -1.

(defn open-socket [{:keys [port]} _]
  (let [s (java.net.ServerSocket. ^int port)]
    (println "Сокет открыт. Порт:" port)
    s))

(def *ctx (sm/new-context [{:id       :socket
                            :config   {:port -1}
                            :start-fn open-socket}]))

(sm/start! *ctx :socket)
;;=>
;; {:msg "Во время запуска компонента произошло исключение",
;; :component-id :socket,
;; :ex-data nil,
;; :ex-msg "Port value out of range: -1",
;; :ex-cause nil,
;; :context/type :error}

(sm/get-error *ctx)
;; => ...

Для очистки ошибки из контекста нужно использовать функцию sm/clean-error!.

Возврат специальных значений

В процессе запуска компонента функция :start-fn может попасть в ситуацию, когда компонент не может быть запущен. Для этого функция :start-fn может вернуть специальное значение из переменной sm/error-kw. Возврат этого значения буквально означает, что компонент не запустился. Тогда статус компонента останется в состоянии :stopped и его состояние останется неизменным, т.е. результат :start-fn не будет учтён.

Аналогично, в процессе останова компонента функция :stop-fn может определить, что компонент нельзя остановить и нужно передать информацию в контекст об этой ситуации. Для этого функция :stop-fn может вернуть специальное значение из переменной sm/error-kw. Тогда статус компонента останется в состоянии :started и его состояние останется неизменным, т.е. результат :stop-fn не будет учтён.

Управление зависимостями

Зависимости – компоненты, которые должны быть запущены раньше чем данный компонент. Для перечисления зависимостей необходимо использовать вектор :start-deps, где должны быть перечислены идентификаторы других компонентов.

(def *ctx (sm/new-context [{:id :c1 :config {} :start-deps [:c2]}
                           {:id :c2 :config {} :start-deps [:c3]}
                           {:id :c3 :config {} :start-deps []}]))
;;=> #'user/*ctx
(sm/start! *ctx :c1)
;;=> [:c1 :c2 :c3]

Обратный порядок останова будет вычислен автоматически.

Циклические зависимости

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

(def *ctx (sm/new-context [{:id :c1 :config {} :start-deps [:c2]}
                           {:id :c2 :config {} :start-deps [:c3]}
                           {:id :c3 :config {} :start-deps [:c1]}]))

(sm/start-all! *ctx)
;; =>
;;{:msg "Во время запуска компонента произошло исключение",
;; :component-id :c1,
;; :ex-data {:component-id :c1, :call-trace-list [:c1 :c2 :c3]},
;; :ex-msg "В зависимостях обнаружена петля",
;; :ex-cause nil,
;; :context/type :error}

Отключение компонента

В некоторых случаях может понадобиться временное отключение компонента. В этом случае в конфигурации компонента достаточно добавить ключ со значением из переменной sm/component-disabled и значение true. Тогда при запуске компонента он не будет запущен и его статус будет иметь значение :disabled. При этом, его зависимости могут быть запущены.

(def *ctx (sm/new-context [{:id :c1 :config {} :start-deps []}
                           {:id :c2 :config {sm/component-disabled true} :start-deps [:c3]}
                           {:id :c3 :config {} :start-deps []}]))
;;=> #'user/*ctx
(sm/start-all! *ctx)
;;=> [:c1 :c3]

Другие функции

Некоторые дополнительные функции при работе с контекстом и компонентами.

(def *ctx (sm/new-context [{:id :c1 :config {} :start-deps []}
                           {:id :c2 :config {sm/component-disabled true} :start-deps [:c3]}
                           {:id :c3 :config {} :start-deps []}]))
;;=> #'user/*ctx
(sm/start-all! *ctx)
;;=> [:c1 :c3]
(sm/list-ids *ctx)       ;; Выдать перечень всех идентификаторов
;;=> [:c1 :c2 :c3]
(sm/started-ids *ctx)    ;; Выдать перечень запущенных компонентов
;;=> [:c1 :c3]
(sm/stopped-ids *ctx)    ;; Выдать перечень остановленных компонентов
;;=> []
(sm/disabled-ids *ctx)   ;; Выдать перечень выключенных компонентов
;;=> [:c2]

(sm/state-map *ctx)      ;; Выдать словарь в виде структуры: идентификатор – состояние компонента.
;;=> {:c1 :started-state,
;;    :c2 EmptyState,
;;    :c3 :started-state}

(sm/component-exist? *ctx :abc) ;; Проверить наличие компонента в контексте
;;=> false
(sm/component-exist? *ctx :c1)
;;=> true

(sm/get-config *ctx :c1)       ;; Получить конфигурацию компонента
;;=> {}
(sm/get-state *ctx :c1)        ;; Получить состояние компонента
;;=> :started-state
(sm/get-component *ctx :c1)    ;; Получить компонент из контекста
;;=>
;;{:stop-deps [],
;; :config-fn nil,
;; :config {},
;; :stop-fn #object[],
;; :state :started-state,
;; :status :started,
;; :id :c1,
;; :start-deps [],
;; :start-fn #object[]}
;;

Раздел для разработчиков

Цели проекта

Для настройки целей проекта используйте файлы bb.edn и build.clj.

Для вывода списка целей проекта запустите команду bb tasks:

clean        Очистить содержимое папки target
build        Собрать дистрибутив библиотеки в виде jar файла
install      Установить локально jar файл (требуется pom.xml файл)
deploy       Опубликовать jar файл в публичный репозиторий
release      Сделать выпуск: присвоить тэг выпуска, сделать сборку, опубликовать в репозиторий
test         Запустить тесты
repl         Запустить Clojure REPL
outdated     Проверить устаревшие зависимости
outdated:fix Проверить устаревшие зависимости и обновить
format       Форматировать исходный код
lint         Проверить исходный код линтером
requirements Установить зависимости необходимые проекту

Установка зависимостей

Для работы над проектом необходимо однократно выполнить установку зависимостей в среде Alt Linux p10.

Получение прав sudo

Установка разрешения на запуск sudo для текущего пользователя:

SUDO_USER=`whoami`; su -c "echo '$SUDO_USER ALL=(ALL:ALL) ALL' > /etc/sudoers.d/$SUDO_USER"

Установка Babashka

  1. Скачать и распаковать babashka:

    1. Для aarch64

      curl -O https://cdn01.rssys.org/babashka/babashka-1.3.186-linux-aarch64-static.tar.gz
      tar xvf babashka-1.3.186-linux-aarch64-static.tar.gz
      
    2. для amd64/x86_64:

      curl -O https://cdn01.rssys.org/babashka/babashka-1.3.186-linux-amd64-static.tar.gz
      tar xvf babashka-1.3.186-linux-amd64-static.tar.gz
      
  2. Установка babashka:

    sudo mv ./bb /usr/local/bin/
    rm -f bb babashka*
    

Установка JDK

Скрипт установки автоматизирует установку OpenJDK 21 в ОС Alt Linux.

jdk21-install.sh

bb <(curl -s https://cdn01.rssys.org/bin/install-jdk.bb)

Установка bb как утилиты clojure

Скрипт установки автоматизирует установку babashka в качестве утилиты clojure и clj

install-deps.sh

bb <(curl -s https://cdn01.rssys.org/bin/install-clj.bb)

Установка инструмента управления шаблонами

Установка инструмента работы с шаблонами Leiningen/Boot/clj-template deps-new

clojure -Ttools install io.github.seancorfield/deps-new '{:git/tag "v0.5.2"}' :as new

Данный инструмент будет установлен в каталог ~/.gitlibs/libs/

Лицензия

© 2023 Михаил Ананьев.

Данный проект распространяется под Открытой лицензией на программное обеспечение РЭД СТАРС СИСТЕМС 1.0
Текст лицензии находится в файле LICENSE или по ссылке.

Описание

Библиотека управления состоянием компонентов

Конвейеры
0 успешных
0 с ошибкой