ClojureによるWeb開発のサーバサイドで注目度の高い(マイクロ)フレームワークとしてDuctがあります。
ちょうど1年前の2017年12月に私自身初めてDuctによる簡単なREST APIを試作してみて、その過程をAdvent Calendar記事にしたことがありました。
今回は主にClojure言語のことを知っていてWebアプリ開発に興味のある方向けに、英語も含めてドキュメントのとても少ないDuctというフレームワークがどのようなものなのか、技術的な詳細に踏み込んだ解説を試みます。
ちなみに、執筆時点で参照/動作確認した各種ライブラリのバージョンは、サンプルプロジェクト"hello-duct"のproject.cljの通りです(Duct最新安定版0.12.0のテンプレートのまま)。
Ductとは
アプリケーション状態/ライフサイクル管理ライブラリのひとつIntegrantを基礎としたサーバサイド(マイクロ)フレームワークです1。
GitHubのduct-frameworkを見ると、主に次のような要素で構成されていることが分かります。
- プロジェクト生成ユーティリティ
-
duct
-
duct/lein-template: Leiningenテンプレート(コマンド
lein new duct ...
) -
duct/lein-duct: Leiningenプラグイン(コマンド
lein duct ...
)
-
duct/lein-template: Leiningenテンプレート(コマンド
-
duct
- コアライブラリ
- duct/core: コア機能を提供するライブラリ
- Ductモジュール
- duct/module.web: Web/API関連機能を導入するDuctモジュール
- duct/module.ataraxy: ルーティングライブラリAtaraxyを導入するDuctモジュール
- duct/module.sql: SQL/DBアクセス関連機能を導入するDuctモジュール
- duct/module.cljs: ClojureScriptフロントエンド関連機能を導入するDuctモジュール
- duct/module.logging: ログ出力関連機能を導入するDuctモジュール
以下では、Ductというフレームワークがどのような機能を提供しているのか、また、どのように拡張可能なのか、順に見ていくことにしましょう。
duct/lein-template
ductリポジトリ配下にあるプロジェクト"duct/lein-template"は名前からも分かるようにLeiningenのテンプレートです。
一般にLeiningenのテンプレート機能はプロジェクトの初期のディレクトリ構成や設定ファイル類を素早く自動生成するためのものであり、本格的にアプリケーションを開発していく際には構成や設定を見直して目的に合わせて適宜手を加えていくことになると思いますが、スタート時点で基本形を構築するのに非常に便利です。
ちなみに、テンプレートから生成された project.clj
では依存ライブラリが最新版でなかったり、推移的な依存ライブラリが整理されていなかったりすることがあるので、 lein ancient :all
(cf. lein-ancient), lein deps :tree
などで確認して必要に応じて更新しておくのがオススメです。
執筆時点の最新安定版0.12.0のsrc/leiningen/new/duct.cljを見てみると、
(defn duct
"Create a new Duct web application.
Accepts the following profile hints:
+api - adds API middleware and handlers
+ataraxy - adds the Ataraxy router
+cljs - adds in ClojureScript compilation and hot-loading
+example - adds an example handler
+heroku - adds configuration for deploying to Heroku
+postgres - adds a PostgreSQL dependency and database component
+site - adds site middleware, a favicon, webjars and more
+sqlite - adds a SQLite dependency and database component"
[name & hints]
(when (.startsWith name "+")
(main/abort "Failed to create project: no project name specified."))
(main/info (str "Generating a new Duct project named " name "..."))
(generate-project (project-template name hints))
(main/info "Run 'lein duct setup' in the project directory to create local config files."))
という関数 duct
が定義されていて、 lein new duct <プロジェクト名> <プロファイル>*
という形式でDuctのプロジェクトのscaffoldingを生成することができます2。
例えば
$ lein new duct hello-duct +api +ataraxy +example
Generating a new Duct project named hello-duct...
Run 'lein duct setup' in the project directory to create local config files.
とすると、API関連、ルーティングライブラリAtaraxy、サンプルコード付きでプロジェクトが生成されます。
$ tree hello-duct
hello-duct
├── README.md
├── dev
│ ├── resources
│ │ └── dev.edn
│ └── src
│ ├── dev.clj
│ └── user.clj
├── project.clj
├── resources
│ └── hello_duct
│ ├── config.edn
│ └── public
├── src
│ ├── duct_hierarchy.edn
│ └── hello_duct
│ ├── handler
│ │ └── example.clj
│ └── main.clj
└── test
└── hello_duct
└── handler
└── example_test.clj
12 directories, 10 files
ここからの具体的なWeb API開発の流れについてはClojureのDuctでWeb API開発してみたなどの例も参考にしてみてください。
ちなみに、Duct最新安定版はテンプレート名が duct
ですが、alpha版は duct-alpha
、beta版は duct-beta
のように命名されているので、例えば lein new duct-beta ...
で執筆時点ではbeta版0.11.0-beta4のテンプレートでプロジェクトが生成されます。
Leiningenのテンプレート機能について詳しくは公式ドキュメントleiningen/TEMPLATES.mdやソースコードleiningen/new.cljが参考になるでしょう。
duct/lein-duct
duct/lein-templateと並んでductリポジトリ配下にある"duct/lein-duct"はLeiningenのプラグインです。
Leiningenのプラグイン機能はコマンドラインから任意のタスクを実行可能にするものですが、執筆時点の最新安定版0.12.0のsrc/leiningen/duct.cljを見てみると以下のような関数 duct
が定義されています。
(defn duct
"Tasks for managing a Duct project."
{:subtasks [#'setup]}
[project subtask & args]
(case subtask
"setup" (apply setup project args)
(main/abort "Unknown duct subtask:" subtask)))
現状ではサブタスク setup
のみが提供されていることが分かります3。
先ほど lein new duct
で生成した新規プロジェクトhello-ductにはすでにプラグインとしてlein-ductが設定されているので、早速 lein duct setup
を実行してみましょう。
$ cd hello-duct/
$ cat project.clj | grep lein-duct
:plugins [[duct/lein-duct "0.12.0"]]
:middleware [lein-duct.plugin/middleware]
$ lein duct setup
Created profiles.clj
Created .dir-locals.el
Created dev/resources/local.edn
Created dev/src/local.clj
ここで生成される4つのファイルはいずれもデフォルトで.gitignoreによりGitの管理対象から外れているため、個々の開発者がローカル環境で独自の設定を適用したい場合などに活用できます。
-
profiles.clj
: Leiningenプロジェクト設定project.cljのプロファイルを上書きする -
.dir-locals.el
: Emacsのプロジェクト固有の設定をする(e.g. CIDERの挙動のカスタマイズ) -
dev/resources/local.edn
: 開発環境のIntegrantコンポーネントの設定dev/resources/dev.ednを上書きする -
dev/src/local.clj
: REPLでの開発時の名前空間dev
(dev/src/dev.clj)の定義を上書きする
これらの設定ファイルはDuctでの開発で必須のものではありませんが、それぞれの役割を把握しておくと有用なこともあるでしょう(Ductプロジェクト以外でも設定手法として参考になります)。
また、duct/lein-ductにはsrc/lein_duct/plugin.cljに以下のような関数 middleware
があります。
(defn middleware [project]
(assoc-in project [:uberjar-merge-with "duct_hierarchy.edn"] `hierarchy-merger))
これはLeingenプラグインの"project middleware"と呼ばれるもので、ここでは project.clj
のプロジェクトマップに :uberjar-merge-with
の設定を追加する機能を果たしています。
Leinigenのプラグイン機能、プロファイル機能について詳しくは公式ドキュメントleiningen/PLUGINS.md, leiningen/PROFILES.mdが参考になります。
duct/core
Ductフレームワークのまさにコアとなっているのが"duct/core"です。
Webアプリのサーバサイド開発の基盤として使いやすく拡張しやすいように、Integrantをベースに便利な機能が提供されています。
先ほど用意したDuctプロジェクトhello-ductの内容を手がかりに、duct/coreがどのような役割を果たしているのか探ってみましょう。
開発環境でREPLから
README.mdの説明を参考に、REPLからシステムを操作してみます。
開発用名前空間への移動: dev
$ lein repl
nREPL server started on port 55259 on host 127.0.0.1 - nrepl://127.0.0.1:55259
REPL-y 0.4.3, nREPL 0.5.3
Clojure 1.10.0
Java HotSpot(TM) 64-Bit Server VM 11+28
Docs: (doc function-name-here)
(find-doc "part-of-name-here")
Source: (source function-name-here)
Javadoc: (javadoc java-object-or-class-here)
Exit: Control+D or (exit) or (quit)
Results: Stored in vars *1, *2, *3, an exception in *e
user=> (dev)
:loaded
dev=>
lein repl
でREPLを起動して入ったデフォルトの名前空間 user
(dev/src/user.clj)で定義されている関数 dev
を呼び出すと、開発用の名前空間 dev
(dev/src/dev.clj)がロードされて現在の名前空間に切り替わります4。
システムの起動: go
(= prep
+ init
)
名前空間 dev
から関数 go
でシステムを起動することができます。
dev=> (go)
:duct.server.http.jetty/starting-server {:port 3000}
:initiated
http://localhost:3000/example
にアクセスしてみると、確かにAPIサーバが動作していることが確認できます。
$ curl http://localhost:3000/example
{"example":"data"}
この関数は integrant.repl/go
で、IntegrantのREPL向けユーティリティライブラリintegrant/repl由来の関数のひとつです。
ソースを確認してみると、
dev=> (source go)
(defn go []
(prep)
(init))
nil
-
integrant.repl/prep
: Integrantの設定マップを準備(prepare)する -
integrant.repl/init
(内部的にはintegrant.core/init
): 設定マップに基づいてシステムを初期化/起動(initialize)する
関数であることが分かります。
ここまでを見てみると、一見して特にduct/coreが登場することなくintegrant/replを介してIntegrantのシステム起動プロセスが呼び出されているだけのように見えます。
しかし実際にはDuct特有の処理が介在しています。
改めて現在の名前空間 dev
に対応するソースコードdev/src/dev.cljを見てみましょう。
(ns dev
(:refer-clojure :exclude [test])
(:require [clojure.repl :refer :all]
[fipp.edn :refer [pprint]]
[clojure.tools.namespace.repl :refer [refresh]]
[clojure.java.io :as io]
[duct.core :as duct]
[duct.core.repl :as duct-repl]
[eftest.runner :as eftest]
[integrant.core :as ig]
[integrant.repl :refer [clear halt go init prep reset]]
[integrant.repl.state :refer [config system]]))
(duct/load-hierarchy)
(defn read-config []
(duct/read-config (io/resource "hello_duct/config.edn")))
(defn test []
(eftest/run-tests (eftest/find-tests "test")))
(def profiles
[:duct.profile/dev :duct.profile/local])
(clojure.tools.namespace.repl/set-refresh-dirs "dev/src" "src" "test")
(when (io/resource "local.clj")
(load "local"))
(integrant.repl/set-prep! #(duct/prep-config (read-config) profiles))
注目すべきはduct.coreの関数を利用しているトップレベルフォーム (duct/load-hierarchy)
と (integrant.repl/set-prep! #(duct/prep-config (read-config) profiles))
です。
コンポーネントのキー(キーワード)の階層関係の自動構築: duct.core/load-hierarchy
関数 duct.core/load-hierarchy
は、クラスパス内の duct_hierarchy.edn
というファイルを読み込み、マップデータをもとに derive
でキーワード間の階層(親子)関係を構築します。
(defn load-hierarchy
"Search the base classpath for files named `duct_hierarchy.edn`, and use them
to extend the global `derive` hierarchy. This allows a hierarchy to be
constructed without needing to load every namespace.
The `duct_hierarchy.edn` file should be an edn map that maps child keywords
to vectors of parents. For example:
{:example/child [:example/father :example/mother]}
This is equivalent to writing:
(derive :example/child :example/father)
(derive :example/child :example/mother)
This function should be called once when the application is started."
[]
(doseq [url (hierarchy-urls)]
(let [hierarchy (edn/read-string (slurp url))]
(doseq [[tag parents] hierarchy, parent parents]
(derive tag parent)))))
Integrantではコンポーネントを識別するキーとしてキーワードを多用しますが、キーワードに階層関係を定義することによって派生した下位のキーワードを派生元の上位のキーワードで一般化する仕組みが活用されています。
そしてDuctでは、ednの設定ファイルでキーワードの関係を宣言的に記述して一括で自動ロードできるようになっています5。
サンプルプロジェクトの名前空間 dev
ではトップレベルで呼び出されているため、ロードされるたびに実行されます。
次に (integrant.repl/set-prep! #(duct/prep-config (read-config) profiles))
を見てみます。
独自拡張されたリーダによる設定ファイルの読み込み: duct.core/read-config
read-config
は、設定ファイルresources/hello_duct/config.ednを引数として duct.core/read-config
を呼び出します。
(defn read-config []
(duct/read-config (io/resource "hello_duct/config.edn")))
関数 duct.core/read-config
は、受け取ったファイルを integrant.core/read-string
で読み取った結果を返します。
integrant.core/read-string
はednファイルのタグ #ig/ref
を 関数 integrant.core/ref
の適用に、タグ #ig/refset
を関数 integrant.core/refset
の適用に読み替える機能を持っていますが、 duct.core/read-config
ではそれに加えて
-
#duct/env
: 環境変数の値への解決 -
#duct/include
: 設定ファイルの組み込み -
#duct/resource
: 関数clojure.java.io/resource
の適用
という追加のタグがサポートされています。
(defn read-config
"Read an edn configuration from a slurpable source. An optional map of data
readers may be supplied. By default the following five readers are supported:
#duct/env
: an environment variable, see [[duct.core.env/env]]
#duct/include
: substitute for a configuration on the classpath
#duct/resource
: a resource path string, see [[resource]]
#ig/ref
: an Integrant reference to another key
#ig/refset
: an Integrant reference to a set of keys"
([source]
(read-config source {}))
([source readers]
(some->> source slurp (ig/read-string {:readers (merge-default-readers readers)}))))
この関数は省略可能な第2引数としてデータリーダのマップを受け取ることができるため、必要に応じてフレームワーク利用者側で既存の実装を参考に独自のタグをサポートするようにさらに拡張するのも容易です。
設定マップに対する独自の下処理: duct.core/prep-config
#(duct/prep-config (read-config) profiles)
で上述の read-config
の結果を入力としている関数が duct.core/prep-config
です。
この関数は、設定ファイルから読み込まれてednのタグが処理された設定マップに対するさらなる下処理を進めます。
(defn prep-config
"Load, build and prep a configuration of modules into an Integrant
configuration that's ready to be initiated. This function loads in relevant
namespaces based on key names, so is side-effectful (though idempotent)."
([config]
(prep-config config :all))
([config profiles]
(-> config
(doto ig/load-namespaces)
(build-config profiles)
(doto ig/load-namespaces)
(ig/prep))))
具体的には
-
integrant.core/load-namespaces
で キーワードに対応する名前空間をロードする -
duct.core/build-config
でモジュール(:duct/module
から派生したコンポーネント)とプロファイル(:duct/profile
から派生したコンポーネント)から設定マップを組み立てる6-
integrant.core/prep
でモジュールの事前準備を行う -
integrant.core/init
で引数profiles
で指定された(指定なしの場合はすべての)プロファイルの設定マップをduct.core/merge-configs
によって賢くマージする7 -
duct.core/fold-modules
でモジュールによる設定マップの変換を適用する
-
-
integrant.core/load-namespaces
でキーワードに対応する名前空間をさらにロードする8 -
integrant.core/prep
でコンポーネントの事前準備を行う
サンプルプロジェクトの開発用名前空間 dev
では、以上の read-config
と duct.core/prep-config
を組み合わせた #(duct/prep-config (read-config) profiles)
が integrant.repl/set-prep!
によって integrant.repl/prep
時に呼び出される関数として設定されています9。
REPLから go
関数を実行すると、Integrantの機能に上乗せする形でこれだけの処理が走ってシステムが起動していたのです。
実はこれがDuctフレームワークが提供しているコア機能のほぼすべてです。
システムの停止: halt
起動しているシステムを停止するには関数 halt
を利用します。
dev=> (halt)
:duct.server.http.jetty/stopping-server
:halted
システムが停止した状態でAPIエンドポイントにアクセスしてみると、
$ curl http://localhost:3000/example
curl: (7) Failed to connect to localhost port 3000: Connection refused
確かにAPIサーバが停止していることが分かります。
この関数は integrant.repl/halt
(内部的には integrant.core/halt!
) で、システムのコンポーネントを停止します。
Duct特有の機能はありません。
システムの再起動: reset
(= suspend
+ refresh
+ resume
)
また、システムを再起動するには関数 reset
を利用します10。
dev=> (reset)
:reloading (hello-duct.main dev hello-duct.handler.example hello-duct.handler.example-test user)
:duct.server.http.jetty/starting-server {:port 3000}
:resumed
関数 integrant.repl/reset
は、ソースを確認すると次のように実装されていて、
dev=> (source reset)
(defn reset []
(suspend)
(repl/refresh :after 'integrant.repl/resume))
nil
-
integrant.repl/suspend
(内部的にはintegrant.core/suspend!
): システムを一時停止する -
clojure.tools.namespace.repl/refresh
: ソースコードの変更を検出してリロードする -
integrant.repl/resume
(内部的にはintegrant.repl/prep
+integrant.core/resume
またはintegrant.core/init
): 設定マップを構築してシステムを再開/起動する
という流れで動作します。
関数 integrant.repl/go
と同様に、 integrant.repl/prep
が設定マップ構築のためにDuct独自の処理を実行しますが、それ以外はIntegrantの機能そのものです。
config
と system
integrant.repl/prep
で構築された設定マップはREPLで config
という変数で確認することができます。
dev=> (pprint config)
{:duct.handler.static/method-not-allowed {:body {:error :method-not-allowed}},
:duct.logger/timbre {:level :debug,
:appenders {:duct.logger.timbre/spit #ig/ref :duct.logger.timbre/spit,
:duct.logger.timbre/brief #ig/ref :duct.logger.timbre/brief}},
:duct.logger.timbre/brief {:min-level :report},
:duct.middleware.web/format {},
:duct.router/ataraxy {:routes {[:get "/example"] [:hello-duct.handler/example]},
:handlers {:ataraxy.error/unmatched-path #ig/ref :duct.handler.static/not-found,
:ataraxy.error/unmatched-method #ig/ref :duct.handler.static/method-not-allowed,
:ataraxy.error/missing-params #ig/ref :duct.handler.static/bad-request,
:ataraxy.error/missing-destruct #ig/ref :duct.handler.static/bad-request,
:ataraxy.error/failed-coercions #ig/ref :duct.handler.static/bad-request,
:ataraxy.error/failed-spec #ig/ref :duct.handler.static/bad-request,
:hello-duct.handler/example #ig/ref :hello-duct.handler/example}},
:duct.middleware.web/log-requests {:logger #ig/ref :duct/logger},
:duct.middleware.web/defaults {:params {:urlencoded true,
:keywordize true},
:responses {:not-modified-responses true,
:absolute-redirects true,
:content-types true,
:default-charset "utf-8"}},
:duct.server.http/jetty {:port 3000,
:handler #ig/ref :duct.handler/root,
:logger #ig/ref :duct/logger},
:duct.handler.static/internal-server-error {:headers {"Content-Type" "application/json"},
:body #duct/resource "duct/module/web/errors/500.json"},
:duct.middleware.web/hide-errors {:error-handler #ig/ref :duct.handler.static/internal-server-error},
:duct.logger.timbre/spit {:fname "logs/dev.log"},
:duct.middleware.web/log-errors {:logger #ig/ref :duct/logger},
:duct.handler/root {:router #ig/ref :duct/router,
:middleware [#ig/ref :duct.middleware.web/not-found
#ig/ref :duct.middleware.web/format
#ig/ref :duct.middleware.web/defaults
#ig/ref :duct.middleware.web/log-requests
#ig/ref :duct.middleware.web/log-errors
#ig/ref :duct.middleware.web/stacktrace]},
:duct.handler.static/not-found {:body {:error :not-found}},
:duct.middleware.web/not-found {:error-handler #ig/ref :duct.handler.static/not-found},
:duct.core/environment :development,
:duct.middleware.web/stacktrace {},
:duct.handler.static/bad-request {:body {:error :bad-request}},
:duct.core/project-ns hello-duct,
:hello-duct.handler/example {}}
nil
また、起動したシステムのマップはREPLで system
という変数で確認できます。
dev=> (pprint system)
{:duct.handler.static/method-not-allowed #object[duct.handler.static$make_handler$fn__5997
"0x5e604f76"
"duct.handler.static$make_handler$fn__5997@5e604f76"],
:duct.logger/timbre #duct.logger.timbre.TimbreLogger{:config {:level :debug,
:appenders {:duct.logger.timbre/spit {:enabled? true,
:async? false,
:min-level nil,
:rate-limit nil,
:output-fn :inherit,
:fn #object[taoensso.timbre.appenders.core$spit_appender$self__13513
"0x706607cb"
"taoensso.timbre.appenders.core$spit_appender$self__13513@706607cb"]},
:duct.logger.timbre/brief {:enabled? true,
:async? false,
:min-level :report,
:rate-limit nil,
:output-fn #object[duct.logger.timbre$brief_output_fn
"0x36ce3d31"
"duct.logger.timbre$brief_output_fn@36ce3d31"],
:fn #object[taoensso.timbre.appenders.core$println_appender$fn__13503
"0x6a1ea958"
"taoensso.timbre.appenders.core$println_appender$fn__13503@6a1ea958"]}}}},
:duct.logger.timbre/brief {:enabled? true,
:async? false,
:min-level :report,
:rate-limit nil,
:output-fn #object[duct.logger.timbre$brief_output_fn
"0x36ce3d31"
"duct.logger.timbre$brief_output_fn@36ce3d31"],
:fn #object[taoensso.timbre.appenders.core$println_appender$fn__13503
"0x6a1ea958"
"taoensso.timbre.appenders.core$println_appender$fn__13503@6a1ea958"]},
:duct.middleware.web/format #object[duct.middleware.web$eval10207$fn__10208$fn__10209
"0x1e0fa86f"
"duct.middleware.web$eval10207$fn__10208$fn__10209@1e0fa86f"],
:duct.router/ataraxy #object[ataraxy.core$handler$fn__10874
"0x3a930081"
"ataraxy.core$handler$fn__10874@3a930081"],
:duct.middleware.web/log-requests #object[duct.middleware.web$eval10139$fn__10141$fn__10143
"0x1a897506"
"duct.middleware.web$eval10139$fn__10141$fn__10143@1a897506"],
:duct.middleware.web/defaults #object[duct.middleware.web$eval10182$fn__10183$fn__10184
"0x464e82de"
"duct.middleware.web$eval10182$fn__10183$fn__10184@464e82de"],
:duct.server.http/jetty {:handler #object[clojure.lang.Atom
"0x7d48d8d"
{:status :ready,
:val #object[clojure.core$promise$reify__8486
"0x35e4dad4"
{:status :ready,
:val #object[ring.middleware.stacktrace$wrap_stacktrace_web$fn__10063
"0x6aac0375"
"ring.middleware.stacktrace$wrap_stacktrace_web$fn__10063@6aac0375"]}]}],
:logger #object[clojure.lang.Atom
"0x5727d431"
{:status :ready,
:val #duct.logger.timbre.TimbreLogger{:config {:level :debug,
:appenders {:duct.logger.timbre/spit {:enabled? true,
:async? false,
:min-level nil,
:rate-limit nil,
:output-fn :inherit,
:fn #object[taoensso.timbre.appenders.core$spit_appender$self__13513
"0x706607cb"
"taoensso.timbre.appenders.core$spit_appender$self__13513@706607cb"]},
:duct.logger.timbre/brief {:enabled? true,
:async? false,
:min-level :report,
:rate-limit nil,
:output-fn #object[duct.logger.timbre$brief_output_fn
"0x36ce3d31"
"duct.logger.timbre$brief_output_fn@36ce3d31"],
:fn #object[taoensso.timbre.appenders.core$println_appender$fn__13503
"0x6a1ea958"
"taoensso.timbre.appenders.core$println_appender$fn__13503@6a1ea958"]}}}}}],
:server #object[org.eclipse.jetty.server.Server
"0x75fcc3ff"
"org.eclipse.jetty.server.Server@75fcc3ff"]},
:duct.handler.static/internal-server-error #object[duct.handler.static$make_handler$fn__5997
"0x5624e934"
"duct.handler.static$make_handler$fn__5997@5624e934"],
:duct.middleware.web/hide-errors #object[duct.middleware.web$eval10157$fn__10159$fn__10161
"0x644ed95c"
"duct.middleware.web$eval10157$fn__10159$fn__10161@644ed95c"],
:duct.logger.timbre/spit {:enabled? true,
:async? false,
:min-level nil,
:rate-limit nil,
:output-fn :inherit,
:fn #object[taoensso.timbre.appenders.core$spit_appender$self__13513
"0x706607cb"
"taoensso.timbre.appenders.core$spit_appender$self__13513@706607cb"]},
:duct.middleware.web/log-errors #object[duct.middleware.web$eval10148$fn__10150$fn__10152
"0x49b7472b"
"duct.middleware.web$eval10148$fn__10150$fn__10152@49b7472b"],
:duct.handler/root #object[ring.middleware.stacktrace$wrap_stacktrace_web$fn__10063
"0x6aac0375"
"ring.middleware.stacktrace$wrap_stacktrace_web$fn__10063@6aac0375"],
:duct.handler.static/not-found #object[duct.handler.static$make_handler$fn__5997
"0x1b302a5d"
"duct.handler.static$make_handler$fn__5997@1b302a5d"],
:duct.middleware.web/not-found #object[duct.middleware.web$eval10166$fn__10168$fn__10170
"0x926845e"
"duct.middleware.web$eval10166$fn__10168$fn__10170@926845e"],
:duct.core/environment :development,
:duct.middleware.web/stacktrace #object[duct.middleware.web$eval10198$fn__10199$fn__10200
"0x7adb144e"
"duct.middleware.web$eval10198$fn__10199$fn__10200@7adb144e"],
:duct.handler.static/bad-request #object[duct.handler.static$make_handler$fn__5997
"0x2a2ff159"
"duct.handler.static$make_handler$fn__5997@2a2ff159"],
:duct.core/project-ns hello-duct,
:hello-duct.handler/example #object[hello_duct.handler.example$eval14220$fn__14221$fn__14223
"0x43c02e71"
"hello_duct.handler.example$eval14220$fn__14221$fn__14223@43c02e71"]}
nil
lein run/uberjarで -main
関数から
ここまでは開発環境でREPLからシステムを操作した場合の挙動を見てきましたが、 lein run
や lein uberjar
で生成したjarで -main
関数からシステムを起動する場合にはどのように動作するでしょうか。
サンプルプロジェクト"hello-duct"のsrc/hello_duct/main.cljは以下のようになっています。
(ns hello-duct.main
(:gen-class)
(:require [duct.core :as duct]))
(duct/load-hierarchy)
(defn -main [& args]
(let [keys (or (duct/parse-keys args) [:duct/daemon])
profiles [:duct.profile/prod]]
(-> (duct/resource "hello_duct/config.edn")
(duct/read-config)
(duct/exec-config profiles keys))))
dev/src/dev.clj
と比べた大きな違いは、
-
duct.core/parse-keys
でコマンドライン引数を処理し、キーとしてduct.core/exec-config
の第3引数に与えている -
profiles
が異なる(開発環境とローカル環境独自の[:duct.profile/dev :duct.profile/local]
ではなくプロダクション環境のみの[:duct.profile/prod]
) -
integrant.core/init
ではなくduct.core/exec-config
でシステムを起動している
の3点です。
コマンドライン引数のキーワード化: duct.core/parse-keys
キーワード形式の文字列シーケンスをキーワードシーケンスに変換するシンプルな関数です。
(defn parse-keys
"Parse config keys from a sequence of command line arguments."
[args]
(seq (filter keyword? (map edn/read-string args))))
例えばこのように動作します。
dev=> (duct/parse-keys [":foo" ":bar-baz"])
(:foo :bar-baz)
したがって、
(let [keys (or (duct/parse-keys args) [:duct/daemon])
profiles [:duct.profile/prod]]
(-> ,,,
(duct/exec-config profiles keys)))
というコードは、コマンドライン引数が与えられていればそのキーワード(シーケンス)、なければ [:duct/daemon]
を第3引数として duct.core/exec-config
を呼び出すことになります。
スタンドアローン実行用に機能追加された integrant.core/init
: duct.core/exec-config
duct.core/prep-config
で設定マップを準備し、 integrant.core/init
でシステムを起動することに加えて、 await-daemons
でキー :duct/daemon
から派生したコンポーネントがある場合にシャットダウンフックでシステムが正しく停止するようにする機能を持つ、-main
関数での利用を想定した関数です。
(defn exec-config
"Build, prep and initiate a configuration of modules, then block the thread
(see [[await-daemons]]). By default it only runs profiles derived from
`:duct.profile/prod` and keys derived from `:duct/daemon`.
This function is designed to be called from `-main` when standalone operation
is required."
([config]
(exec-config config [:duct.profile/prod]))
([config profiles]
(exec-config config profiles [:duct/daemon]))
([config profiles keys]
(-> config (prep-config profiles) (ig/init keys) (await-daemons))))
以上のことから、 -main
関数を実行するとデフォルトではシステム全体のうち :duct/daemon
キーワードから派生したコンポーネント(とそれが依存するコンポーネント)が起動し、シャットダウン時に停止するように振る舞うことが分かります。
Ductモジュール
最後に、Ductフレームワークの機能拡張/再利用メカニズムである「モジュール」について探ってみましょう。
Ductの「モジュール」とは
Ductにおけるモジュールについてはduct/coreのREADMEにもセクションがあります。
Ductモジュールとは、キーワード :duct/module
から派生したキーのIntegrantコンポーネントであり、
-
integrant.core/prep-key
の実装(省略可能): モジュール適用の前提条件として要求するコンポーネントのキーを:duct.core/requires
としてオプションマップに追加する(モジュールの他コンポーネントへの依存関係を定義することで適用順序が調整される) -
integrant.core/init-key
の実装: 設定マップを受け取って設定マップを返す関数を返す(duct.core/prep-config
実行時にコンポーネントや設定の追加など、設定マップを変換する任意の処理が実行できる)
を持つもののことです。
例えばduct/coreのREADMEに示されている例では、仮に設定ファイルに :duct.module/example {:port 8080}
という設定があれば :duct.server/http
キーが存在する設定マップに :duct.server/http {:port 8080}
という設定を追加するように動作するモジュール :duct.module/example
を定義しています。
(require '[duct.core.merge :as merge])
(defmethod ig/prep-key :duct.module/example [_ opts]
(assoc opts :duct.core/requires (ig/ref :duct.server/http)))
(defmethod ig/init-key :duct.module/example [_ {:keys [port]}]
(fn [config]
(duct/merge-configs
config
{:duct.server/http {:port (merge/displace port)}})))
コンポーネントのキーを :duct/module
から派生させるには、すでに触れた関数 duct.core/load-hierarchy
が自動的にロードして derive
に展開してくれる設定ファイル duct_hierarchy.edn
に定義しておくと便利です。
上の例では、以下の内容の duct_hierarchy.edn
をクラスパス上に配置します。
{:duct.module/example [:duct/module]}
標準モジュール
モジュールは duct.core/prep-config
時に設定マップに対して任意の処理を実行するという非常に単純な仕組みなので様々な応用が考えられそうですが、最も一般的なユースケースは再利用可能なコンポーネントを選択的に追加することでフレームワークとしての機能を拡張するというものです。
実際にDuctフレームワークでは以下のようなモジュールが独立したライブラリとして提供されています。
project.cljに依存ライブラリとして追加し、設定ファイルにモジュールやモジュールを介して追加されるコンポーネントに対する設定を書き足すだけで、Webアプリのサーバ機能やDBアクセス機能、ClojureScriptフロントエンド開発機能などを使い始めることができます1112。
サードパーティモジュール
公式による標準提供のもの以外にもサードパーティによるDuctモジュールライブラリが開発され、公開されています。
cf. Modules · duct-framework/duct Wiki
私自身も、最近までにDuctとPedestalを組み合わせたWeb APIを開発する機会が仕事と趣味で何度かあり、標準モジュールduct/module.webの代わりにPedestalによるAPI開発機能の初期設定をコンポーネントとして組み込めるようにするモジュールduct.module.pedestal、標準モジュールduct/module.loggingの代わりにCambiumによるJSON形式でのログ出力を可能にするモジュールduct.module.cambiumを書いて公開しました。
興味のある方は具体的な利用例として以下のサンプルプロジェクトも参考にしてみてください。
- lagenorhynque/aqoursql: Duct + Pedestal + LaciniaのGraphQL APIサンプル実装
- lagenorhynque/clj-rest-api: lagenorhynque/aqoursqlと同一のDBに対するDuct + PedestalのREST APIサンプル実装
- lagenorhynque/route-guide: Duct + Pedestal + ProtojureのgRPC APIサンプル実装
Further Reading
-
Duct
-
duct-framework/duct Wiki: 貴重な公式ドキュメント
- Boundaries: Duct固有の機能ではないため本記事では触れなかった、"boundary"プロトコルについて(DBなど外部サービスとの境界をプロトコルで疎結合にする設計パターンはDuctに限らず非常に有用)
- duct/UPGRADING.md: 最新版に移行する場合のアップグレードガイド
-
duct-framework/duct Wiki: 貴重な公式ドキュメント
- duct/core: READMEに少ないながら重要な情報がまとまっている
- Integrant
- Clojureで快適なREPL駆動開発のために"reloaded workflow"を実践しよう
- ClojureのDuctとClojureScriptのre-frameによるREST API + SPA開発入門
- Clojure/ClojureScript関連リンク集 > Webサーバサイド (Clojure)
-
したがってIntegrantの基本的な機能と扱い方を知っていると理解がスムーズです。ちなみにIntegrantや同種のライブラリ(Component, mount, etc.)が登場した背景についてはClojure Workflow Reloadedが参考になります。 ↩
-
Ductテンプレートに限りませんが
lein new duct :show
のようにしてテンプレートのドキュメントを確認することもできます。 ↩ -
Ductプラグインに限りませんが
lein help duct
のようにすることでプラグインのヘルプも確認できます。 ↩ -
このような仕組みを整えるのはREPLの起動失敗を防止するための工夫として一般的ですね。 ↩
-
この機能は特に後述するDuctモジュールで活かされています。 ↩
-
Ductのモジュールという特殊なコンポーネントの挙動については後述します。 ↩
-
マージ戦略については名前空間
duct.core.merge
参照。メタデータを適切に設定することで設定ファイル間の優先度を調整できます。 ↩ -
設定マップのキー(キーワード)の名前に基づいて名前空間が自動的にロードされることから、コンポーネントのキーを適切に命名し適切な名前空間に配置すると、コンポーネントを定義した名前空間を明示的にロードする必要がなくなります。 ↩
-
duct.core/prep-config
の第2引数にプロファイルとして[:duct.profile/dev :duct.profile/local]
を指定しているので、開発環境とローカル環境独自の設定マップが組み込まれることになります。 ↩ -
reset
はhalt
とgo
に相当する機能を内包していて、システムをまだ一度も起動していない状態でも動作するように実装されているため、REPL駆動開発のワークフローでは起動も再起動もreset
だけで足ります。 ↩ -
もちろん、効果的に活用するには個々のDuctモジュールがどのようなコンポーネントや設定を追加してくれるのか、どのようにカスタマイズできるのかを把握する必要があります。 ↩
-
標準モジュールについては、duct/lein-templateでプロジェクト生成時にプロファイルを指定すると対応するモジュールの初期設定がproject.cljと設定ファイルに自動追加されます。 ↩