Skip to content

Instantly share code, notes, and snippets.

@shinseitaro
Forked from lagenorhynque/duct-guide.md
Created November 10, 2022 11:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shinseitaro/19548d15625ab32a8d07c13926b5ed40 to your computer and use it in GitHub Desktop.
Save shinseitaro/19548d15625ab32a8d07c13926b5ed40 to your computer and use it in GitHub Desktop.

ClojureによるWeb開発のサーバサイドで注目度の高い(マイクロ)フレームワークとしてDuctがあります。

ちょうど1年前の2017年12月に私自身初めてDuctによる簡単なREST APIを試作してみて、その過程をAdvent Calendar記事にしたことがありました。

今回は主にClojure言語のことを知っていてWebアプリ開発に興味のある方向けに、英語も含めてドキュメントのとても少ないDuctというフレームワークがどのようなものなのか、技術的な詳細に踏み込んだ解説を試みます。

ちなみに、執筆時点で参照/動作確認した各種ライブラリのバージョンは、サンプルプロジェクト"hello-duct"のproject.cljの通りです(Duct最新安定版0.11.2のテンプレートのまま)。

Ductとは

アプリケーション状態/ライフサイクル管理ライブラリのひとつIntegrantを基礎としたサーバサイド(マイクロ)フレームワークです1

GitHubのduct-frameworkを見ると、主に次のような要素で構成されていることが分かります。

  • プロジェクト生成ユーティリティ
  • コアライブラリ
    • duct/core: コア機能を提供するライブラリ
  • Ductモジュール

以下では、Ductというフレームワークがどのような機能を提供しているのか、また、どのように拡張可能なのか、順に見ていくことにしましょう。

ductリポジトリ配下にあるプロジェクト"duct/lein-template"は名前からも分かるようにLeiningenのテンプレートです。

一般にLeiningenのテンプレート機能はプロジェクトの初期のディレクトリ構成や設定ファイル類を素早く自動生成するためのものであり、本格的にアプリケーションを開発していく際には構成や設定を見直して目的に合わせて適宜手を加えていくことになると思いますが、スタート時点で基本形を構築するのに非常に便利です。

ちなみに、テンプレートから生成された project.clj では依存ライブラリが最新版でなかったり、推移的な依存ライブラリが整理されていなかったりすることがあるので、 lein ancient :all(cf. lein-ancient), lein deps :tree などで確認して必要に応じて更新しておくのがオススメです。

執筆時点の最新安定版0.11.2の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 "..."))
  (let [mods  (cons :base (profiles hints))
        data  (reduce into {} (map #(profile-data % name) mods))
        files (reduce into [] (map #(profile-files % data) mods))]
    (apply ->files data files))
  (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-templateと並んでductリポジトリ配下にある"duct/lein-duct"はLeiningenのプラグインです。

Leiningenのプラグイン機能はコマンドラインから任意のタスクを実行可能にするものですが、執筆時点の最新安定版0.11.2の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.11.2"]]
  :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フレームワークのまさにコアとなっているのが"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
  1. integrant.repl/prep: Integrantの設定マップを準備(prepare)する
  2. 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))))

具体的には

  1. integrant.core/load-namespaces で キーワードに対応する名前空間をロードする

  2. duct.core/build-config でモジュール(:duct/module から派生したコンポーネント)とプロファイル(:duct/profile から派生したコンポーネント)から設定マップを組み立てる6

    1. integrant.core/prep でモジュールの事前準備を行う
    2. integrant.core/init で引数 profiles で指定された(指定なしの場合はすべての)プロファイルの設定マップを duct.core/merge-configs によって賢くマージする7
    3. duct.core/fold-modules でモジュールによる設定マップの変換を適用する
  3. integrant.core/load-namespaces でキーワードに対応する名前空間をさらにロードする8

  4. integrant.core/prep でコンポーネントの事前準備を行う

サンプルプロジェクトの開発用名前空間 dev では、以上の read-configduct.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
  1. integrant.repl/suspend(内部的には integrant.core/suspend!): システムを一時停止する
  2. clojure.tools.namespace.repl/refresh: ソースコードの変更を検出してリロードする
  3. integrant.repl/resume(内部的には integrant.repl/prep + integrant.core/resume または integrant.core/init): 設定マップを構築してシステムを再開/起動する

という流れで動作します。

関数 integrant.repl/go と同様に、 integrant.repl/prep が設定マップ構築のためにDuct独自の処理を実行しますが、それ以外はIntegrantの機能そのものです。

configsystem

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 runlein 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を書いて公開しました。

興味のある方は具体的な利用例として以下のサンプルプロジェクトも参考にしてみてください。

Further Reading

Footnotes

  1. したがってIntegrantの基本的な機能と扱い方を知っていると理解がスムーズです。

  2. Ductテンプレートに限りませんが lein new duct :show のようにしてテンプレートのドキュメントを確認することもできます。

  3. Ductプラグインに限りませんが lein help duct のようにすることでプラグインのヘルプも確認できます。

  4. このような仕組みを整えるのはREPLの起動失敗を防止するための工夫として一般的ですね。

  5. この機能は特に後述するDuctモジュールで活かされています。

  6. Ductのモジュールという特殊なコンポーネントの挙動については後述します。

  7. マージ戦略については名前空間 duct.core.merge 参照。メタデータを適切に設定することで設定ファイル間の優先度を調整できます。

  8. 設定マップのキー(キーワード)の名前に基づいて名前空間が自動的にロードされることから、コンポーネントのキーを適切に命名し適切な名前空間に配置すると、コンポーネントを定義した名前空間を明示的にロードする必要がなくなります。

  9. duct.core/prep-config の第2引数にプロファイルとして [:duct.profile/dev :duct.profile/local] を指定しているので、開発環境とローカル環境独自の設定マップが組み込まれることになります。

  10. resethaltgo に相当する機能を内包していて、システムをまだ一度も起動していない状態でも動作するように実装されているため、REPL駆動開発のワークフローでは起動も再起動も reset だけで足ります。

  11. もちろん、効果的に活用するには個々のDuctモジュールがどのようなコンポーネントや設定を追加してくれるのか、どのようにカスタマイズできるのかを把握する必要があります。

  12. 公式モジュールについては、duct/lein-templateでプロジェクト生成時にプロファイルを指定すると対応するモジュールの初期設定がproject.cljと設定ファイルに自動追加されます。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment