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
-
Скачать и распаковать babashka:
-
Для 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
-
для 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
-
-
Установка 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 или по ссылке.