ClojureといえばREPL駆動開発(REPL-driven development)によるインタラクティブで高速な開発スタイルが大きな強みですが、その開発体験(DX; developer experience)をより良くするためにClojure開発環境の特性を反映した"reloaded workflow"というものが知られています。
このワークフローを実現するためのライブラリの組み合わせや設定のしかたは多様ですが、本記事では最もオーソドックスなtools.namespace + Integrant/Component/mountでの構成例をご紹介します。
サンプルコードlagenorhynque/reloaded-workflow-examplesは適宜参考に、ご自身の開発スタイルに合ったプロジェクト構成やエディタ設定をぜひ考えてみてください(お気に入りの構成が見つかったらプロジェクトテンプレート化しても良いかもしれません)。
reloaded workflowとは
Stuart Sierraによる有名な記事Clojure Workflow Reloadedでモチベーションと実現方法が解説されています。
一言でいえば、REPLを再起動する(JVMの起動とClojure本体/プロジェクトの初回ロードのオーバーヘッドが無視できない😇)ことなくソースコードの変更をリロードしアプリケーションを安全に再起動できる仕組みを整えることにより、高速かつ整合的にREPL駆動開発を継続できるようにするワークフローのことです。
Clojureの準標準ライブラリのひとつであるtools.namespaceの refresh
系関数を利用すると名前空間の依存関係に基づいてソースコードを賢くリロードすることができるわけですが、それに加えてIntegrant, Component, mountなどアプリケーションの状態/ライフサイクルを管理するライブラリを導入します。
この種のライフサイクル管理ライブラリでは、状態(state)を持つもの(「コンポーネント」と呼ばれる)に対する「起動」(リソースの初期化など)と「停止」(リソースの解放など)というライフサイクルを管理し、コンポーネントの組み合わせ(依存関係によるグラフ)によってアプリケーション(「システム」と呼ばれる)を構成します。
依存関係を整理し外部から注入するDI (dependency injection)の仕組みとして利用することもでき(ライフサイクル管理の対象であるコンポーネントの単位で差し替えが可能なため)、効果的に活用するとアプリケーションのアーキテクチャを疎結合に保つことにも繋がります。
例えば以下のようなコンポーネント(楕円)と依存関係(矢印)で成り立つアプリケーションがあったとすると、
システム全体を起動するには
- DB, external API
- app handler
- server
の順に初期化し、システム全体を停止するには
- server
- app handler
- DB, external API
の順にリソースを解放します(停止時に操作が不要なコンポーネントもあります)。
実践してみる
サンプルコードリポジトリ: lagenorhynque/reloaded-workflow-examples
1. tools.namespaceとライフサイクル管理ライブラリを導入する
ここではLeiningen管理のプロジェクトで例示しますが、Clojure CLI管理のプロジェクトでもほぼ同等の設定が可能なはずです。
このあと実装するサンプルアプリケーションのためにRing関連のライブラリもあらかじめdependenciesに追加しておきます。
Integrantの場合
- Integrant
- Integrant-REPL (tools.namespaceを内包している)
$ lein new app integrant-app
- Leiningenプロジェクトの設定
(defproject integrant-app "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
:url "https://www.eclipse.org/legal/epl-2.0/"}
:dependencies [[integrant "0.8.0"]
[org.clojure/clojure "1.10.3"]
[ring/ring-core "1.9.3"]
[ring/ring-jetty-adapter "1.9.3"]]
:main ^:skip-aot integrant-app.core
:target-path "target/%s"
:profiles {:uberjar {:aot :all
:jvm-opts ["-Dclojure.compiler.direct-linking=true"]}
:dev {:source-paths ["dev/src"]
:resource-paths ["dev/resources"]
:dependencies [[integrant/repl "0.3.2"]]}
:repl {:repl-options {:init-ns user}}})
-
user
名前空間の定義
(ns user)
(defn dev
"Load and switch to the 'dev' namespace."
[]
(require 'dev)
(in-ns 'dev)
:loaded)
-
dev
名前空間の定義
(ns dev
(:require
[clojure.java.io :as io]
[clojure.repl :refer :all]
[clojure.spec.alpha :as s]
[clojure.tools.namespace.repl :refer [refresh]]
[integrant.core :as ig]
[integrant.repl :refer [clear halt go init prep reset]]
[integrant.repl.state :refer [config system]]))
(clojure.tools.namespace.repl/set-refresh-dirs "dev/src" "src" "test")
Componentの場合
- Component
- component.repl (tools.namespaceを内包している)
$ lein new app component-app
- Leiningenプロジェクトの設定
(defproject component-app "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
:url "https://www.eclipse.org/legal/epl-2.0/"}
:dependencies [[com.stuartsierra/component "1.0.0"]
[org.clojure/clojure "1.10.3"]
[ring/ring-core "1.9.3"]
[ring/ring-jetty-adapter "1.9.3"]]
:main ^:skip-aot component-app.core
:target-path "target/%s"
:profiles {:uberjar {:aot :all
:jvm-opts ["-Dclojure.compiler.direct-linking=true"]}
:dev {:source-paths ["dev/src"]
:resource-paths ["dev/resources"]
:dependencies [[com.stuartsierra/component.repl "0.2.0"]]}
:repl {:repl-options {:init-ns user}}})
-
user
名前空間の定義
(ns user)
(defn dev
"Load and switch to the 'dev' namespace."
[]
(require 'dev)
(in-ns 'dev)
:loaded)
-
dev
名前空間の定義
(ns dev
(:require
[clojure.java.io :as io]
[clojure.repl :refer :all]
[clojure.spec.alpha :as s]
[clojure.tools.namespace.repl :refer [refresh]]
[com.stuartsierra.component :as component]
[com.stuartsierra.component.repl :refer [reset set-init start stop system]]))
(clojure.tools.namespace.repl/set-refresh-dirs "dev/src" "src" "test")
mountの場合
$ lein new app mount-app
- Leiningenプロジェクトの設定
(defproject mount-app "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
:url "https://www.eclipse.org/legal/epl-2.0/"}
:dependencies [[mount "0.1.16"]
[org.clojure/clojure "1.10.3"]
[ring/ring-core "1.9.3"]
[ring/ring-jetty-adapter "1.9.3"]]
:main ^:skip-aot mount-app.core
:target-path "target/%s"
:profiles {:uberjar {:aot :all
:jvm-opts ["-Dclojure.compiler.direct-linking=true"]}
:dev {:source-paths ["dev/src"]
:resource-paths ["dev/resources"]
:dependencies [[org.clojure/tools.namespace "1.1.0"]]}
:repl {:repl-options {:init-ns user}}})
-
user
名前空間の定義
(ns user)
(defn dev
"Load and switch to the 'dev' namespace."
[]
(require 'dev)
(in-ns 'dev)
:loaded)
-
dev
名前空間の定義
(ns dev
(:require
[clojure.java.io :as io]
[clojure.repl :refer :all]
[clojure.spec.alpha :as s]
[clojure.tools.namespace.repl :refer [refresh]]
[mount.core :as mount :refer [start stop]]))
(clojure.tools.namespace.repl/set-refresh-dirs "dev/src" "src" "test")
(defn reset []
(stop)
(refresh :after 'mount.core/start))
2. アプリケーションの基礎を構築する
ここではRingベースのWebサーバをミニマルに実装してみましょう。
"server"(Webサーバ)と"app"(アプリケーションの起点となるRingのハンドラ関数)という2つの「コンポーネント」による「システム」を考えます。
Integrantの場合
-
::app
コンポーネントの実装
(ns integrant-app.handler
(:require
[integrant.core :as ig]
[ring.util.response :as response]))
(defn hello-world [_]
(response/response "Hello, World!"))
(defmethod ig/init-key ::app [_ _]
hello-world)
-
::server
コンポーネントの実装
(ns integrant-app.server
(:require
[integrant.core :as ig]
[ring.adapter.jetty :as jetty]))
(defmethod ig/init-key ::server [_ {:keys [app options]}]
(jetty/run-jetty app options))
(defmethod ig/halt-key! ::server [_ server]
(.stop server))
- システムのもとになる設定マップと
-main
関数の定義
(ns integrant-app.core
(:gen-class)
(:require
[integrant-app.handler :as handler]
[integrant-app.server :as server]
[integrant.core :as ig]))
(def config
{::handler/app {}
::server/server {:app (ig/ref ::handler/app)
:options {:port 3000
:join? false}}})
(defn -main [& _]
(-> config
ig/prep
ig/init))
※ 設定マップ config
はここではマップとしてトップレベル定義しているが、ednファイルとして外部化して読み込ませることが多い
- REPLからシステムのライフサイクルを操作するための初期設定
@@ -4,8 +4,11 @@
[clojure.repl :refer :all]
[clojure.spec.alpha :as s]
[clojure.tools.namespace.repl :refer [refresh]]
+ [integrant-app.core]
[integrant.core :as ig]
[integrant.repl :refer [clear halt go init prep reset]]
[integrant.repl.state :refer [config system]]))
(clojure.tools.namespace.repl/set-refresh-dirs "dev/src" "src" "test")
+
+(integrant.repl/set-prep! (comp ig/prep (constantly integrant-app.core/config)))
Componentの場合
-
App
コンポーネントの実装
(ns component-app.handler
(:require
[com.stuartsierra.component :as component]
[ring.util.response :as response]))
(defn hello-world [_]
(response/response "Hello, World!"))
(defrecord App
[handler]
component/Lifecycle
(start [this]
(assoc this :handler hello-world))
(stop [this]
this))
-
Server
コンポーネントの実装
(ns component-app.server
(:require
[com.stuartsierra.component :as component]
[ring.adapter.jetty :as jetty]))
(defrecord Server
[instance app options]
component/Lifecycle
(start [this]
(cond-> this
(not instance) (assoc :instance (jetty/run-jetty (:handler app) options))))
(stop [this]
(when instance
(.stop instance))
(assoc this :instance nil)))
- システムマップを初期化する関数と
-main
関数の定義
(ns component-app.core
(:gen-class)
(:require
[com.stuartsierra.component :as component]
[component-app.handler :as handler]
[component-app.server :as server]))
(defn init-system [{:keys [server]}]
(component/system-map
:app (handler/map->App {})
:server (component/using (server/map->Server
(assoc server
:options {:port 3000
:join? false}))
[:app])))
(defn -main [& _]
(component/start (init-system nil)))
※ ここではシステムマップの構築時にサーバの設定(:options
キーの値)をハードコードして与えているが、実用的なアプリケーションでは外部から設定を注入することが多い
- REPLからシステムのライフサイクルを操作するための初期設定
@@ -5,6 +5,9 @@
[clojure.spec.alpha :as s]
[clojure.tools.namespace.repl :refer [refresh]]
[com.stuartsierra.component :as component]
- [com.stuartsierra.component.repl :refer [reset set-init start stop system]]))
+ [com.stuartsierra.component.repl :refer [reset set-init start stop system]]
+ [component-app.core]))
(clojure.tools.namespace.repl/set-refresh-dirs "dev/src" "src" "test")
+
+(set-init component-app.core/init-system)
mountの場合
-
app
コンポーネントの実装
(ns mount-app.handler
(:require
[mount.core :refer [defstate]]
[ring.util.response :as response]))
(defn hello-world [_]
(response/response "Hello, World!"))
(defstate app
:start hello-world)
-
env
コンポーネントの実装
(ns mount-app.config
(:require
[mount.core :refer [defstate]]))
(defstate env
:start {:server {:options {:port 3000
:join? false}}})
※ mount版ではサーバの設定を独立したコンポーネントとした(ここではマップとしてハードコードしているが、実用的なアプリケーションでは環境変数や設定ファイルから読み込んだデータで初期化することが多い)
-
server
コンポーネントの実装
(ns mount-app.server
(:require
[mount-app.config :as config]
[mount-app.handler :as handler]
[mount.core :refer [defstate]]
[ring.adapter.jetty :as jetty]))
(defstate server
:start (jetty/run-jetty handler/app (-> config/env :server :options))
:stop (.stop server))
-
-main
関数の定義
(ns mount-app.core
(:gen-class)
(:require
[mount-app.server]
[mount.core :as mount]))
(defn -main [& _]
(mount/start))
3. アプリケーションを素早くリロード & (再)起動する
ここまでの準備が上手くいっていれば、REPLを起動して (dev)
で dev
名前空間をロードして移動した後に (reset)
すると、ソースコードをリロードしてアプリケーションを(再)起動することができます。
user=> (dev)
2021-05-04 20:51:49.248:INFO::nREPL-session-13faeaf0-5180-4e36-afe6-613a14251abe: Logging initialized @7672ms to org.eclipse.jetty.util.log.StdErrLog
:loaded
dev=> (reset)
:reloading (integrant-app.server integrant-app.handler integrant-app.core integrant-app.core-test dev user)
2021-05-04 20:51:53.162:INFO:oejs.Server:nREPL-session-13faeaf0-5180-4e36-afe6-613a14251abe: jetty-9.4.40.v20210413; built: 2021-04-13T20:42:42.668Z; git: b881a572662e1943a14ae12e7e1207989f218b74; jvm 16.0.1+9
2021-05-04 20:51:53.201:INFO:oejs.AbstractConnector:nREPL-session-13faeaf0-5180-4e36-afe6-613a14251abe: Started ServerConnector@230c9323{HTTP/1.1, (http/1.1)}{0.0.0.0:3000}
2021-05-04 20:51:53.201:INFO:oejs.Server:nREPL-session-13faeaf0-5180-4e36-afe6-613a14251abe: Started @11625ms
:resumed
特に (reset)
操作は開発を進めるたびにリロード & (再)起動のために非常に高頻度で利用するので、以下のようにエディタ/IDEから簡単に実行できるようにしておくと便利です。
CIDER (Emacs, Spacemacs)
Miscellaneous Features :: CIDER Docs > Reloading Codeにあるように、 cider-ns-refresh
コマンド(≒ clojure.tools.namespaceの refresh
系関数呼び出し)の前後に任意の関数をフックすることができます。
(reset)
相当の振る舞い(Integrantでは suspend
-> refresh
-> resume
)になるように、プロジェクト固有の .dir-locals.el
に次のように設定しましょう。
ちなみにSpacemacs (Clojure layer)のEvilキーバインドでは , e n r
で cider-ns-refresh
が呼び出せます。
- Integrantの場合
((nil . ((cider-ns-refresh-before-fn . "integrant.repl/suspend")
(cider-ns-refresh-after-fn . "integrant.repl/resume"))))
- Componentの場合
((nil . ((cider-ns-refresh-before-fn . "com.stuartsierra.component.repl/stop")
(cider-ns-refresh-after-fn . "com.stuartsierra.component.repl/start"))))
- mountの場合
((nil . ((cider-ns-refresh-before-fn . "mount.core/stop")
(cider-ns-refresh-after-fn . "mount.core/start"))))
Cursive (IntelliJ IDEA)
Cursive: The REPL > REPL commandsを参考に、以下のようにREPL commandを設定しましょう。
キーボードショートカットも設定しておくと素早く繰り返し実行できます(一例として Option+R
に割り当て)。
Calva (VS Code)
Custom REPL Commands - Calva User Guideを参考に、以下のようにREPL commandを設定しましょう。
この例では Control+Option+Space R Enter
で実行することができます。
{
...,
"calva.customREPLCommandSnippets": [
{
"name": "Reset system",
"key": "r",
"snippet": "(reset)",
"ns": "dev",
"repl": "clj"
}
]
}
vim-iced (Vim)
vim-iced > 23.4. Reloaded workflowsにあるように、 IcedRefresh
コマンド(≒ clojure.tools.namespaceの refresh
系関数呼び出し)の前後に任意の関数をフックすることができます。
(reset)
相当の振る舞い(Integrantでは suspend
-> refresh
-> resume
)になるように、プロジェクト固有の .vimrc
に次のように設定しましょう。
- Integrantの場合
let g:iced#nrepl#ns#refresh_before_fn = 'integrant.repl/suspend'
let g:iced#nrepl#ns#refresh_after_fn = 'integrant.repl/resume'
- Componentの場合
let g:iced#nrepl#ns#refresh_before_fn = 'com.stuartsierra.component.repl/stop'
let g:iced#nrepl#ns#refresh_after_fn = 'com.stuartsierra.component.repl/start'
- mountの場合
let g:iced#nrepl#ns#refresh_before_fn = 'mount.core/stop'
let g:iced#nrepl#ns#refresh_after_fn = 'mount.core/start'
また、 (dev)
操作については IcedEval
で手軽に実行できるようにキーマップを設定しておくと便利かもしれません。
この例では <Leader>gd
で (dev)
できるようにしています。
aug MyClojureSetting
au!
au FileType clojure nnoremap <buffer> <Leader>gd :<C-u>IcedEval (user/dev)<CR>
aug END
Further Reading
Clojure開発環境
- Clojure/ClojureScript関連リンク集 - Qiita > エディタプラグイン
- Clojure開発環境での基本操作まとめ: Spacemacs, IntelliJ IDEA (Cursive), VS Code (Calva), Vim (vim-iced), rebel-readline - Qiita
- Clojure開発環境でのリンターclj-kondo, Joker設定まとめ: Spacemacs, IntelliJ IDEA (Cursive), VS Code (Calva), Vim (vim-iced) - Qiita
- Clojure開発環境でのフォーマッターcljstyle設定まとめ: Spacemacs, IntelliJ IDEA (Cursive), VS Code (Calva), Vim (vim-iced) - Qiita