Clojureの状態管理まとめ

状態管理, ライフサイクル管理, システム管理およびそのライブラリ.

主な目的は, システムの中に散らばる📝Clojure: 状態(State)を各namespaceでバラバラに宣言せずに一括で宣言しましょうということ. これによりシステム全体のstart/stopが依存関係を考慮して一意にできるようになる.

すると, たとえばデバッグ時にシステムを変更箇所だけ部分的再起動みたいなことも可能になる. これをReloaded Workflowという.

また, システムといわず, あるコンポーネント単位のstart/stopも可能. 🤔状態管理はシステム管理とライフサイクル管理に分解できる.

Integrantはシステム全体を静的に定義するような設計思想, ComponentはOOPのようにモジュール単位でのstart/stopをするような設計思想.

状態管理ライブラリ

🔧Clojure: Component

🔧weavejester/integrant

データ駆動設計によるアプリケーションを作成するためのマイクロフレームワーク.

Dependency Ingection をClojureで実現.

設定データに対する初期化関数を定義でき, 設定データの定義から実体を生成.

Ductは内部でintegrantをつかっているのでIntegrantの情報はductで検索してもいい.

Basic Usages

  • integrant
    • configuration map をもとに生成されるmicro-serviceの1つの単位.
  • configuration map
    • keyの定義をまずする. これは具体的な実装へと初期化される入力情報.
    • Clojureのmapとして表現するかEDNファイルとして外部ファイルに定義する.
    • configuration map同士は ig/ref で 参照することができる.
  • ig/init-key で integrant serviceの初期化におけるデータと関数を定義.
  • ig/halt-key!でintegrant serviceのinit-keyの定義を破棄する関数を定義.
  • ig/initにconfiguration mapを渡すことで, 依存関係に従って integrant を初期化.
  • init/halt!で 破棄.
(defmethod ig/init-key :handler/greet [_ {:keys [name]}]
  (fn [_] (resp/response (str "Hello " name))))
  • {:keys [name]}はClojure 分配束縛の記法.
  • keyに ::hogeみたいな 2つのコロンをみかける. ::hogeは :(namespace)/hogeの意味.
    • REPLで評価するとわかる, reader syntax.

Usage: Integrant suspend/resume

https://github.com/weavejester/integrant#suspending-and-resuming

Integrantはinitとhalt, つまりシステムの開始と終了の機能を提供する.

suspend/resumeは主に 開発用 である. そして使いこなすにはatomとdelayをつかうというひと工夫を加える.

Integrantの考え方としてnamespaceにatomをbindingしない. その代わりに init-keyの中で atomを宣言して返り値のmapにbindingする.

🔧Clojure: Integrant-REPL

IntegrantでReloaded Workflowをするための補助ライブラリ. Integrantと同じweavejesterさんが作成.

https://github.com/weavejester/integrant-repl

コード自体は小さくシンプルなのでなにをやっているのかはソース見るのが早いかも.

💡systemをreplからみる

個人的に強いとおもうTips. integrant.replはintegrantで初期化したsystemを alter-var-root 変数にbindしている. すると, このsystemの変数を覗いてしまえば動作しているシステムの中身がスケスケのまるみえ.

integrantが要はアプリのなかでatomなどの状態をバラバラに宣言するのではなくて一つのrootとそのツリーにまとめましょうという設計なので, この中をみればシステムの全てが見れる.

(require '[integrant.repl.state :refer [config system]])

✅CIDER連携

Emacs CIDER で M-x cider-ns-refreshの前後にsuspendとresumeをhookさせると気軽にシステムをreset.

.dir-locals.elに以下を記載.

((clojure-mode . ((cider-ns-refresh-before-fn . "integrant.repl/suspend")
                  (cider-ns-refresh-after-fn . "integrant.repl/resume"))))

通常, cider-ns-refreshはM-x C-c M-n rにbindingされている. これは変更があったnamespaceのみをrefreshするすべてのnamespaceをrefreshしたい場合は, C-u C-c M-n rを実施する必要がある.

ref. Miscellaneous Features :: CIDER Docs

💡cider-ns-refresh-show-logのワナ

落とし穴なのがよくわからないが, cider-ns-refreshで実行すると前後のintegrantのinit/haltの標準出力がREPLに表示されなかった. cider-ns-refresh-show-log-bufferにtを設定すると cider-ns-refresh-log というbufferが表示されてここに出力されていることがわかる.

(setq cider-ns-refresh-show-log-buffer t)

ドキュメントをみると outとbufferの両方に同じ出力がでると読めたのだが… 自分の環境ではでない. これがnreplの影響なのかは不明. とりあえず出力が確認できれば深追いはしないことにした.

Workaroundとしてprintlnを諦めてlogライブラリを使うのも手.

⚖Integrant vs component

💡ComponentとIntegrantの決定的違いはOOP vs FP

ComponentやMountを使ったことがないので以下はリンク先からの理解.

Integrant: an alternative to Component and Mount : Clojure

ComponentやMountは状態をグローバルに参照することができるので, 関数の引数としてもらう必要がない.

Integrantは状態がライブラリの中に隠されていて自由に参照できない. そのためその状態に対する操作は関数の引数としてもらって変化した値を返すように書く. またはhandlerの定義として状態とそれに対する操作を1つにbindingする.

Webフレームワークならたくさんのサンプルを見ながら自然とこの初期化で状態と関数をhandlerとしてbindingするパターンに従えばいいものの, webとは関係なく単にintegrantを使おうとしたとき, ベストプラクティスがないので自分の流儀で実装しがち, 本質を考えよう.

初期化時に自前でnamespaceにatomに保存しておく方法はそもそもフレームワークで状態を管理する考えに反するアンチパターン.

Integrantは関数型プログラミング(FP)の考えに近い. 一方Componentの考えはクラスやOOPに近い.

とくにFPでシステムを構築すると冪等性を獲得することができ, これが開発時にとくに役に立つ(cf. Reloaded Workflow). Componentは自分が初期化済みかどうかはComponent自身しかわからない.


以下, ChatGPT.

設計の違い: Integrantは、コンポーネントをシンプルなマップとして表現し、コンポーネントのステートは変更可能な状態を持つことができます。一方、Componentは、コンポーネントをレコードとして表現し、コンポーネントのステートはイミュータブルな状態を持つことができます。

💡考察: Integrant Rationale cf. Component

Clojure Component の代替を意識して, とくにComponentが依存関係をプログラム内(Clojure Source Code)で管理するが, IntegrantはEDNで管理するところがこだわりポイント.

すなわちIntegrantはClojure MapでもEDNでもどちらでも構成定義できるが, 設計動機からいえばEDNつかえよ!ということかな?

💡Topics

✅Reloaded Workflowでinit/haltに時間がかかる処理をスキップ

Reloaded Workflowでシステムやそのサブシステムの起動と終了をしたくないとき. 言い換えると, 開発中はスタンバイ状態でなにもしてほしくないときの方法.

atom, delay, realizedをうまくつかう.

(defmethod ig/init-key ::app [_ opts]
    (atom (delay (start opts))))
 
(defmethod ig/halt-key! ::app [_ app]
  (when (realized? @app)
    (.close @@app)))

スタンバイ状態になるので外部関数から起動.

(defn run []
  (let [bot (:hoge.bot/app system)]
    (when (not (realized? @bot))
      (force @bot))
    :running))

自分で考察した方法なのでベストプラクティスなのかはわからない. delayの代わりにpromise/deliverをつかっても同じことはできるはず.

see more. 🎓評価の遅延マクロパターン

💡System Storage

Clojureの世界では, 普通は状態をatomで管理する.

Integrantを導入することで, 各namespaceに散らばるatomで宣言された状態をsystemというひとつの状態に紐づけてまとめることができる. そしてこのツリー構造で状態を管理するからこそシステムの停止や再起動が用意にできる.

逆に言うと, Integrantを利用するということは, namespaceでatomを宣言しないということ.

System StorageまたはGlobal storage といわれる. (ref. Systems in Clojure).


Integrant: how to store and access the running system? : Clojure

Systems in Integrant are intended to be autonomous.

Anyway, my point is that it seems to me Integrant still requires a whole app buy-in in the sense that, unlike with Mount, you have to thread all your state through a single entry-point.

systemはthreadで動作する再帰プロセス. しかしこれはIntegrantと言うよりも関数型プログラミングのイディオム. 定数のみが参照可能であり状態は隠されているという考え(debug除く).

Only constants should be global.

✅System Storageの宣言はdefonceとalter-var-root

Systems in Clojureより. 💡System Storageのベストプラクティス的なもの.

namespaceでdeconceを定義する. そしてalter-var-rootをつかってdefonceの定義を書き換える.

(defonce ^:private system nil)
 
(def alter-system (partial alter-var-root #'system))
 
(defn system-init [config]
  (alter-system (constantly (make-system config))))
 
(defn system-start []
  (alter-system component/start))
 
(defn system-stop []
  (alter-system component/stop))

システムのエントリポイントから呼び出す.

(defn -main [& args]
  (let [config (load-config "config.edn")]
    (system-init config)
    (system-start)))

see also 💡def macro / alter-var-rootによるvarの再定義

💡Integrant Topics

✅integrantで状態を保持したいならdef/alter-var-root

ref. 💡考察: Component/MountとIntegrantの決定的違いはOOP vs FP

それでもオブジェクト指向的にintegrantをつかうならば def macro / alter-var-rootによるvarの再定義のパターンをつかう.

; https://github.com/dimovich/roll/blob/master/src/clj/roll/sente.clj
 
(defonce sente-fns nil)
 
(defn send-msg [& args]
  (some-> (:chsk-send! sente-fns)
          (apply args)))
 
(defmethod ig/init-key :roll/sente [_ opts]
  (alter-var-root #'sente-fns (constantly (start-sente opts))))
 
(defmethod ig/halt-key! :roll/sente [_ {:keys [stop-fn]}]
  (info "stopping roll/sente...")
  (alter-var-root #'sente-fns (constantly nil))
  (when stop-fn
    (stop-fn)))

tools.namespace/refreshはdefonceしても強制的にリセットするのでdef/defonceはあまり関係ないかな?

💡JVM上のClojure IntegrantはLinux VM上のミニDocker

ClojureにおけるIntegrantの役割は📝Dockerのアナロジーを使っている. Linux VM上のDockerのように, JVM上のコンポーネントは振る舞う.

Integrant is like Docker for Clojure · Aaron Announces

わたしもこのアナロジーに賛同する.

✨Integrant Insights

🤔Java Command Patternからのアナロジー

クラスというものを単なる抽象データ構造と捉えると, クラスには属性としての値と関数値の集合であり, オブジェクトとはそれをメモリ上に領域確保した状態.

ig/init-keyでやっていることは値とその初期化関数のpairのbindingであり, ig/init-keyで定義したpairの集合をig/initでまとめて初期化している.

そうすると, ig/init-keyで初期化したそれぞれのオブジェクトを1つのオブジェクトに bindingして管理しているようにもみえる. 管理ということで, suspend, resume, haltはオブジェクトを Command Pattern で扱うようなものとして捉えれば納得がいく.

(アナロジーとして類推しただけで実装を読んではない…後で読む).

🤔IntegrantとRing Handlerの2つのパターン

2つのパターンがある.

  • すべてのRingハンドラーをコンポーネントとして扱う.
  • ルーター部分までをコンポーネントにして、Ringハンドラーはただの関数として扱う.

Component/MountとIntegrantの決定的違いはOOP vs FP の議論に似ている.

🤔なぜintegrantをつかうのか?

以下のサイトには3点の理由か述べられている.

ref. Why bother with Integrant? - quanttype

  • Correct start-up and shut-down orde.
  • Starting a partial system.
  • Good for REPL-driven and test-driven workflows.

個人的にはIntegrant-REPLのコンボによるReloaded Workflowの部分が大きいが, 若干記法を覚えないといけない煩わしさがあるのが嫌かもしれない.

思想的にあまりatomを多用するような感じに書けない(部分的には書いてるがこれでいいのかなと違和感がある…). システムとしてGlobal Storage的なものにバラバラに散らばる状態を全部を閉じ込めてrestartできればそれが理想なのだ. 現実は面倒.

🤔ig/init-keyの第一引数はなに?

いつも _ となって省略されているやつ. ここにはkeyが入ってくる. debug printするとinit-keyの隣にある ::xxx が入ってくる.

たいていの場合は _ で構わない.

integrantのREADME.md,ではresume-keyの中で利用されている. resume-keyの中でさらにintegrantの関数を呼び出すときにつかう.

🤔ig/prepは外部からの引数を元にシステムを初期化することが目的

ig/prepはig/initを実行する前にconfig mapを書き換える.

これはOOPの喩えでいえば, システムを初期入力パラメータで初期化するようなコンストラクタにみえる. make-xxxx, create-yyy, initialize-zzzみたいな名前がつく類のものだ.

話がややこしいのは, ig/initはシステムにおけるstartの役割であり, ig/prepがinitializeに近い. 一方, integrantのinitに相当するのがComponentのstartであり, こちらのほうがわかりやすい.

created: <2023-02-23 Thu 17:30>

🤔initとhalt!の2つのifの提供と単一エントリポイントとしてのsystemを提供する設計

ig/initの戻り値はig/halt!で利用するので保存が必要.

integrantというフレームワークでコンポーネントを作成することで, 起動と停止の2つの操作, そしてその実行対象という単一データの3つを意識するということで, システムの複雑さがシンプルになる.

この設計に反することは, 起動と終了以外にわちゃわちゃ外部へのIFをnamespaceに作成してしまうこと. integrantを使うならばなるべく依存関係や呼び出しを廃してシンプルにしたい.

created: <2023-02-23 Thu 19:31>

🤔システムがカオスになればなるほどintegrantが生きる

システムの状態をひとつにあつめて起動と終了の機能を与える.

ちょっとしたアプリだといらないかもしれない. しかし, だんだん機能追加をしていって状態が増えていきカオスになればなるほど, 状態管理に対する秩序がintegrantによってもたらされることを感じる今日この頃(📓2022-w44).

🤔状態管理はシステム管理とライフサイクル管理に分解できる

長い間勘違いをしていた. integrantはシステムの起動と停止を行うものでシステムの開始に呼び出すものだと思っていた.

ClojureでOOPのインスタンスのようにstart/stopをやるのをどうやればいいのかなと悩んで, RecordにProtocolを生やせばいいのかと悩んでいたら, Componentというモジュールを再発見した. これにはライフサイクル管理という名前がついていた.

つまり, 状態管理というのはシステム管理とライフサイクル管理に分解できる. システムというと大きな単一のエントリポイントのようだが, もう少し細かい粒度での起動と終了を定義することもできるということだ.

created: <2023-02-23 Thu 08:06>

🔗References

Integrant

References