LoginSignup
25
13

More than 1 year has passed since last update.

Clojureで快適なREPL駆動開発のために"reloaded workflow"を実践しよう

Last updated at Posted at 2021-05-05

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.namespacerefresh 系関数を利用すると名前空間の依存関係に基づいてソースコードを賢くリロードすることができるわけですが、それに加えてIntegrant, Component, mountなどアプリケーションの状態/ライフサイクルを管理するライブラリを導入します。

この種のライフサイクル管理ライブラリでは、状態(state)を持つもの(「コンポーネント」と呼ばれる)に対する「起動」(リソースの初期化など)と「停止」(リソースの解放など)というライフサイクルを管理し、コンポーネントの組み合わせ(依存関係によるグラフ)によってアプリケーション(「システム」と呼ばれる)を構成します。

依存関係を整理し外部から注入するDI (dependency injection)の仕組みとして利用することもでき(ライフサイクル管理の対象であるコンポーネントの単位で差し替えが可能なため)、効果的に活用するとアプリケーションのアーキテクチャを疎結合に保つことにも繋がります。

例えば以下のようなコンポーネント(楕円)と依存関係(矢印)で成り立つアプリケーションがあったとすると、

components.png

システム全体を起動するには

  1. DB, external API
  2. app handler
  3. server

の順に初期化し、システム全体を停止するには

  1. server
  2. app handler
  3. DB, external API

の順にリソースを解放します(停止時に操作が不要なコンポーネントもあります)。

実践してみる

サンプルコードリポジトリ: lagenorhynque/reloaded-workflow-examples

1. tools.namespaceとライフサイクル管理ライブラリを導入する

ここではLeiningen管理のプロジェクトで例示しますが、Clojure CLI管理のプロジェクトでもほぼ同等の設定が可能なはずです。

このあと実装するサンプルアプリケーションのためにRing関連のライブラリもあらかじめdependenciesに追加しておきます。

Integrantの場合

$ lein new app integrant-app
  • Leiningenプロジェクトの設定
integrant-app/project.clj
(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 名前空間の定義
integrant-app/dev/src/user.clj
(ns user)

(defn dev
  "Load and switch to the 'dev' namespace."
  []
  (require 'dev)
  (in-ns 'dev)
  :loaded)
  • dev 名前空間の定義
integrant-app/dev/src/dev.clj
(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の場合

$ lein new app component-app
  • Leiningenプロジェクトの設定
component-app/project.clj
(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 名前空間の定義
component-app/dev/src/user.clj
(ns user)

(defn dev
  "Load and switch to the 'dev' namespace."
  []
  (require 'dev)
  (in-ns 'dev)
  :loaded)
  • dev 名前空間の定義
component-app/dev/src/dev.clj
(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プロジェクトの設定
mount-app/project.clj
(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 名前空間の定義
mount-app/dev/src/user.clj
(ns user)

(defn dev
  "Load and switch to the 'dev' namespace."
  []
  (require 'dev)
  (in-ns 'dev)
  :loaded)
  • dev 名前空間の定義
mount-app/dev/src/dev.clj
(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つの「コンポーネント」による「システム」を考えます。

minimal-components.png

Integrantの場合

  • ::app コンポーネントの実装
integrant-app/src/integrant_app/handler.clj
(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 コンポーネントの実装
integrant-app/src/integrant_app/server.clj
(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 関数の定義
integrant-app/src/integrant_app/core.clj
(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からシステムのライフサイクルを操作するための初期設定
integrant-app/dev/src/dev.clj
@@ -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 コンポーネントの実装
component-app/src/component_app/handler.clj
(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 コンポーネントの実装
component-app/src/component_app/server.clj
(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 関数の定義
component-app/src/component_app/core.clj
(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からシステムのライフサイクルを操作するための初期設定
component-app/dev/src/dev.clj
@@ -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 コンポーネントの実装
mount-app/src/mount_app/handler.clj
(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 コンポーネントの実装
mount-app/src/mount_app/config.clj
(ns mount-app.config
  (:require
   [mount.core :refer [defstate]]))

(defstate env
  :start {:server {:options {:port 3000
                             :join? false}}})

※ mount版ではサーバの設定を独立したコンポーネントとした(ここではマップとしてハードコードしているが、実用的なアプリケーションでは環境変数や設定ファイルから読み込んだデータで初期化することが多い)

  • server コンポーネントの実装
mount-app/src/mount_app/server.clj
(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 関数の定義
mount-app/src/mount_app/core.clj
(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 rcider-ns-refresh が呼び出せます。

  • Integrantの場合
integrant-app/.dir-locals.el
((nil . ((cider-ns-refresh-before-fn . "integrant.repl/suspend")
         (cider-ns-refresh-after-fn  . "integrant.repl/resume"))))
  • Componentの場合
component-app/.dir-locals.el
((nil . ((cider-ns-refresh-before-fn . "com.stuartsierra.component.repl/stop")
         (cider-ns-refresh-after-fn  . "com.stuartsierra.component.repl/start"))))
  • mountの場合
mount-app/.dir-locals.el
((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を設定しましょう。

image.png

キーボードショートカットも設定しておくと素早く繰り返し実行できます(一例として Option+R に割り当て)。

image.png

Calva (VS Code)

Custom REPL Commands - Calva User Guideを参考に、以下のようにREPL commandを設定しましょう。

この例では Control+Option+Space R Enter で実行することができます。

settings.json
{
  ...,
  "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の場合
integrant-app/.vimrc
let g:iced#nrepl#ns#refresh_before_fn = 'integrant.repl/suspend'
let g:iced#nrepl#ns#refresh_after_fn = 'integrant.repl/resume'
  • Componentの場合
component-app/.vimrc
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の場合
mount-app/.vimrc
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) できるようにしています。

~/.vimrc
aug MyClojureSetting
  au!
  au FileType clojure nnoremap <buffer> <Leader>gd :<C-u>IcedEval (user/dev)<CR>
aug END

Further Reading

Clojure開発環境

Integrantの拡張としてのDuct

25
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
13