関数型言語とヨーロッパ言語が好きなClojurianのlagénorhynque (a.k.a. カマイルカ🐬)です(最近、トルコ語を学ぶのが楽しい)。
2019年は、Lisperサークルparen-holicを結成して技術書典6と技術書典7に出展したり、Clojurian仲間とPodcast (dosync radio)を始めたり、会社主催で始めた関数型言語勉強会Fun Fun Functionalでの発表"Simple Made Easy" Made Easyが全文書き起こし記事になったりと、個人的にもこれまでとはまた違った形でClojure/Lispコミュニティに関わることができたと感じる一年でした。
一方、仕事では昨年から開発を始めたREST APIに加えて今年の秋からGraphQL APIもClojure利用を決め、Clojurian Conquest(?)が着々と進んでいます。
以前にミニマリストのためのClojure REST API開発入門という記事で(マイクロ)フレームワークを利用することなく最小構成でREST APIを開発する例をご紹介しました。
そこでは敢えて1ファイル(1名前空間)にすべてのコードを収めていましたが、本記事では、より快適に本格的に開発しやすく、今後の拡張をスムーズにするためにリファクタリングを試みましょう。
構成を整理するにあたり、全体的にDuctフレームワークのスタイルを参考にしています。
サンプルコード:
- Leiningen版: minimal-api-lein
- Clojure CLI版: minimal-api-clj.core
※ 全体のdiff (初回執筆時点)
1. 依存ライブラリの最新化
※ コミット: lein & clj (初回執筆時点)
まずは、初期実装から時間も経過しているので、依存ライブラリの最新版を取り込んでみましょう。
Clojureは言語本体と標準ライブラリの後方互換性が非常に重視されていて、コミュニティ全体としても特によく使われる主要なライブラリは破壊的変更なく拡張されていくことが多いため、たいていの場合は素直に最新版に更新することができるでしょう(もちろん念のためCHANGELOGなどを確認しましょう)。
また、多機能の大きなライブラリ(フレームワーク)よりも単機能に特化した小さなライブラリを組み合わせることを好む傾向が強く、小さく安定したライブラリは良い意味で枯れているために数年以上の更新がないこともあります。
Leiningenの場合
lein-ancientというLeiningenプラグインを利用すると、更新可能な依存ライブラリを確認したり自動更新したりすることが簡単にできます。
ちなみに執筆時点のLeiningen最新版2.9.5では、JDK 15を利用するといくつか警告が出力されるようです😅
$ cd minimal-api-lein/
$ lein ancient :all
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by clojure.lang.InjectedInvoker/0x0000000800b45840 to method com.sun.xml.internal.stream.XMLInputFactoryImpl.createXMLStreamReader(java.io.Reader)
WARNING: Please consider reporting this to the maintainers of clojure.lang.InjectedInvoker/0x0000000800b45840
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release
[camel-snake-kebab "0.4.2"] is available but we use "0.4.0"
[org.clojure/clojure "1.10.1"] is available but we use "1.10.0" (use :check-clojure to upgrade)
[ring/ring-core "1.8.2"] is available but we use "1.7.1"
[ring/ring-jetty-adapter "1.8.2"] is available but we use "1.7.1"
[ring/ring-json "0.5.0"] is available but we use "0.4.0"
すべての依存ライブラリを最新化すると、以下のような出力に変わります。
$ lein ancient :all
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by clojure.lang.InjectedInvoker/0x0000000800b45840 to method com.sun.xml.internal.stream.XMLInputFactoryImpl.createXMLStreamReader(java.io.Reader)
WARNING: Please consider reporting this to the maintainers of clojure.lang.InjectedInvoker/0x0000000800b45840
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release
all artifacts are up-to-date.
Clojure CLIの場合
Depotというライブラリを利用することで依存ライブラリの最新版の確認や更新ができます。
ここでは、あらかじめUsageの手順通りに ~/.clojure/deps.edn
に outdated
というエイリアスを設定してあります。
$ cd minimal-api-clj.core/
$ clojure -M:outdated --every
Checking for old versions in: deps.edn
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.0"} -> {:mvn/version "0.4.2"}
org.clojure/clojure {:mvn/version "1.10.0"} -> {:mvn/version "1.10.1"}
ring/ring-core {:mvn/version "1.7.1"} -> {:mvn/version "1.8.2"}
ring/ring-jetty-adapter {:mvn/version "1.7.1"} -> {:mvn/version "1.8.2"}
ring/ring-json {:mvn/version "0.4.0"} -> {:mvn/version "0.5.0"}
すべての依存ライブラリを最新化すると、以下のような出力に変わります。
$ clojure -M:outdated --every
Checking for old versions in: deps.edn
All up to date!
2. Integrant-REPLの導入
Lisp族の末裔(?)たるClojureでは、REPLを開発時に最大限に活用した「REPL駆動開発」(REPL-driven development)が当然の前提というべき開発スタイルですが、開発しているアプリケーション内で状態を持つもののライフサイクルを管理し、安全に継続的にリロード可能(reloadable)なREPL環境を実現するためにIntegrant, Component, mountなどのライブラリを利用します(cf. My Clojure Workflow, Reloaded)。
前回の記事ですでにIntegrantを導入しているので、ここではIntegrant-REPLというライブラリを追加してREPLからより扱いやすい形に整えましょう。
依存ライブラリと設定の追加
Leiningenでは project.clj
に依存ライブラリとしてIntegrant-REPLを追加するとともに、開発時のみ利用するソースとリソースのためのディレクトリ dev/src
, dev/resources
をパスに追加してみました。
また、REPL起動時の名前空間を user
に変更しています。
@@ -13,4 +13,8 @@
[ring/ring-json "0.5.0"]]
:main ^:skip-aot minimal-api-lein.core
:target-path "target/%s"
- :profiles {:uberjar {:aot :all}})
+ :profiles {:uberjar {:aot :all}
+ :dev {:source-paths ["dev/src"]
+ :resource-paths ["dev/resources"]
+ :dependencies [[integrant/repl "0.3.2"]]}
+ :repl {:repl-options {:init-ns user}}})
Clojure CLIでは書き方に少し差がありますが、ほぼ同等の設定が可能です。
@@ -8,7 +8,9 @@
ring/ring-jetty-adapter {:mvn/version "1.8.2"}
ring/ring-json {:mvn/version "0.5.0"}}
:aliases
- {:test {:extra-paths ["test"]
+ {:dev {:extra-paths ["dev/resources" "dev/src"]
+ :extra-deps {integrant/repl {:mvn/version "0.3.2"}}}
+ :test {:extra-paths ["test"]
:extra-deps {org.clojure/test.check {:mvn/version "1.1.0"}}}
:runner
{:extra-deps {com.cognitect/test-runner
開発用名前空間の定義
REPL起動時の名前空間を user
としたので、その名前空間から開発時に便利な関数や変数にアクセスできるようにします。
user
名前空間に直接ユーティリティ関数などを定義していくと、対応するソース user.clj
のロード時にエラーが発生した場合にREPL自体の起動に失敗してしまうことがあります。
それを避けるため、REPL起動後に入る user
名前空間から別の開発用名前空間(ここでは dev
)に切り替える仕組みを用意します。
以下のように user
, dev
の各名前空間を定義します(前者はDuctテンプレートの user.clj
そのまま😅)。
(ns user)
(defn dev
"Load and switch to the 'dev' namespace."
[]
(require 'dev)
(in-ns 'dev)
:loaded)
(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]]
[minimal-api-lein.core]))
(integrant.repl/set-prep! (constantly minimal-api-lein.core/config))
このようにすると、 user
名前空間から dev
関数を呼び出すと dev
名前空間のロードと切り替えが行われ、 dev
名前空間を起点に様々な操作をすることができます。
dev
名前空間には必要に応じてrequireするlibを追加したりユーティリティ関数を定義したりすると良いでしょう。
ここでは、最終行で integrant.repl/set-prep!
関数によりこのアプリケーションの設定マップ minimal-api-lein.core/config
をIntegrant-REPLの仕組みに組み込んでいます(cf. Usage)。
動作確認
Leiningenでは lein repl
、Clojure CLIでは clj -M:dev
でREPLを起動してIntegrant-REPLによるAPIの起動/停止を確かめてみましょう。
$ lein repl
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
nREPL server started on port 54498 on host 127.0.0.1 - nrepl://127.0.0.1:54498
REPL-y 0.4.4, nREPL 0.8.3
Clojure 1.10.1
OpenJDK 64-Bit Server VM 15+36-1562
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)
で user
から dev
へと名前空間を切り替え、
user=> (dev)
2019-12-22 17:47:46.099:INFO::nRepl-session-68275480-c28b-4b45-9aeb-6f17bed10ddf: Logging initialized @22084ms to org.eclipse.jetty.util.log.StdErrLog
:loaded
dev=>
(reset)
とすると、ログ出力からも分かるようにAPIサーバが起動(または再起動)します。
ちなみにIntegrant-REPLの integrant.repl
名前空間では reset
のほかに clear
, halt
, go
, init
, prep
などREPLからシステムの状態を変える関数が提供されていますが、普段のワークフローではシステムの起動/再起動(+リロード)の reset
と停止の halt
という2つだけ覚えておけば実用上十分です。
dev=> (reset)
:reloading (minimal-api-lein.core minimal-api-lein.core-test dev user)
2019-12-22 17:48:25.597:INFO:oejs.Server:nRepl-session-68275480-c28b-4b45-9aeb-6f17bed10ddf: jetty-9.4.22.v20191022; built: 2019-10-22T13:37:13.455Z; git: b1e6b55512e008f7fbdf1cbea4ff8a6446d1073b; jvm 13.0.1+9
2019-12-22 17:48:25.624:INFO:oejs.AbstractConnector:nRepl-session-68275480-c28b-4b45-9aeb-6f17bed10ddf: Started ServerConnector@5920aca0{HTTP/1.1,[http/1.1]}{0.0.0.0:3000}
2019-12-22 17:48:25.625:INFO:oejs.Server:nRepl-session-68275480-c28b-4b45-9aeb-6f17bed10ddf: Started @61609ms
:resumed
integrant.repl.state/config
には設定マップが、 integrant.repl.state/system
にはシステムマップが自動的に束縛されます。
dev=> (clojure.pprint/pprint config)
#:minimal-api-lein.core{:routes {},
:app
{:routes {:key :minimal-api-lein.core/routes}},
:server
{:app {:key :minimal-api-lein.core/app},
:options {:port 3000, :join? false}}}
nil
dev=> (clojure.pprint/pprint system)
#:minimal-api-lein.core{:routes
["/"
{"todos"
{:get
#object[minimal_api_lein.core$list_todos 0x1137ebd3 "minimal_api_lein.core$list_todos@1137ebd3"],
:post
#object[minimal_api_lein.core$create_todo 0x2f6e91cd "minimal_api_lein.core$create_todo@2f6e91cd"]},
["todos/" :todo-id]
{:get
#object[minimal_api_lein.core$fetch_todo 0x49f1736a "minimal_api_lein.core$fetch_todo@49f1736a"],
:delete
#object[minimal_api_lein.core$delete_todo 0x7b40a452 "minimal_api_lein.core$delete_todo@7b40a452"],
:put
#object[minimal_api_lein.core$update_todo 0x65440c92 "minimal_api_lein.core$update_todo@65440c92"]}}],
:app
#object[ring.middleware.params$wrap_params$fn__5534 0x4c860f13 "ring.middleware.params$wrap_params$fn__5534@4c860f13"],
:server
#object[org.eclipse.jetty.server.Server 0x7959ea8b "Server@7959ea8b{STARTED}[9.4.22.v20191022]"]}
nil
curl
でアクセスしてみると、期待通りREST APIが動作していることが確認できます。
$ curl -s "http://localhost:3000/todos" -v | jq
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET /todos HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Sun, 22 Dec 2019 08:48:59 GMT
< Content-Type: application/json;charset=utf-8
< Transfer-Encoding: chunked
< Server: Jetty(9.4.22.v20191022)
<
{ [89 bytes data]
* Connection #0 to host localhost left intact
{
"todo1": {
"task": "build an API"
},
"todo2": {
"task": "?????"
},
"todo3": {
"task": "profit!"
}
}
そして (halt)
でシステムを停止すると、
dev=> (halt)
2019-12-22 17:49:09.178:INFO:oejs.AbstractConnector:nRepl-session-68275480-c28b-4b45-9aeb-6f17bed10ddf: Stopped ServerConnector@5920aca0{HTTP/1.1,[http/1.1]}{0.0.0.0:3000}
:halted
system
の値が nil
に変わり、
dev=> (clojure.pprint/pprint config)
#:minimal-api-lein.core{:routes {},
:app
{:routes {:key :minimal-api-lein.core/routes}},
:server
{:app {:key :minimal-api-lein.core/app},
:options {:port 3000, :join? false}}}
nil
dev=> (clojure.pprint/pprint system)
nil
nil
curl
の応答からAPIサーバが停止していることが確認できます。
$ curl -s "http://localhost:3000/todos" -v | jq
* Trying ::1...
* TCP_NODELAY set
* Connection failed
* connect to ::1 port 3000 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connection failed
* connect to 127.0.0.1 port 3000 failed: Connection refused
* Failed to connect to localhost port 3000: Connection refused
* Closing connection 0
(Emacs/Spacemacsユーザ向け) 便利な設定の追加
EmacsまたはSpacemacsでClojure開発環境としてCIDERを利用している場合、Miscellaneous Features :: CIDER Docs > Reloading Codeにあるように cider-ns-refresh
(= clojure.tools.namespaceの refresh
系関数呼び出し)の前後に任意の関数をフックすることができます。
Integrant-REPLの reset
関数がしているのは integrant.repl/suspend
, clojure.tools.namespace.repl/refresh
, integrant.repl/resume
を順に呼び出すことなので、以下のようにEmacsのローカル設定ファイル .dir-locals.el
を用意すると cider-ns-refresh
(SpacemacsのEvilキーバインドなら , e n r
)でREPL上での (reset)
呼び出しを代用することが可能になります。
((nil . ((cider-ns-refresh-before-fn . "integrant.repl/suspend")
(cider-ns-refresh-after-fn . "integrant.repl/resume"))))
3. テストの整備
テストコードのないコードベースに対して「リファクタリング」と称して危険な賭けを敢行するのは避けたいものです😅
本格的に既存コードに手を入れる前に、既存の振る舞いを保証するためのテストを整備しましょう。
ここでは、REST APIに対するE2E的なテストを用意し、静的解析ツールとともにCIで自動実行できるようにします。
依存ライブラリと設定の追加
Clojureで定番のHTTPクライアントclj-http、テスト結果の出力を見やすくするhumane-test-outputを開発時の依存ライブラリに追加し、またLeinigenプラグインとしてリンターのEastwood、テストカバレッジ計測のcloverage、イディオムチェッカーのkibitを追加しています。
また、コマンドラインから便利に使えるように test-coverage
、 lint
というエイリアスも設定してみました。
@@ -16,5 +16,21 @@
:profiles {:uberjar {:aot :all}
:dev {:source-paths ["dev/src"]
:resource-paths ["dev/resources"]
- :dependencies [[integrant/repl "0.3.2"]]}
+ :dependencies [[clj-http "3.11.0"]
+ [integrant/repl "0.3.2"]
+ [pjstadig/humane-test-output "0.10.0"]]
+ :plugins [[jonase/eastwood "0.3.12"]
+ [lein-cloverage "1.2.1"]
+ [lein-kibit "0.1.8"]]
+ :aliases {"test-coverage" ^{:doc "Execute cloverage."}
+ ["cloverage" "--ns-exclude-regex" "^(:?dev|user)$" "--codecov" "--junit"]
+ "lint" ^{:doc "Execute eastwood and kibit."}
+ ["do"
+ ["eastwood" "{:source-paths [\"src\"]
+ :test-paths []}"]
+ ["kibit"]]}
+ :injections [(require 'pjstadig.humane-test-output)
+ (pjstadig.humane-test-output/activate!)]}
:repl {:repl-options {:init-ns user}}})
Clojure CLIでは一部使えない(使うのが難しそう)なツールもありますが、機能的に近い設定を以下のように書いてみました。
@@ -9,7 +9,8 @@
ring/ring-json {:mvn/version "0.5.0"}}
:aliases
{:dev {:extra-paths ["dev/resources" "dev/src"]
- :extra-deps {integrant/repl {:mvn/version "0.3.2"}}}
+ :extra-deps {clj-http {:mvn/version "3.11.0"}
+ integrant/repl {:mvn/version "0.3.2"}}}
:test {:extra-paths ["test"]
:extra-deps {org.clojure/test.check {:mvn/version "1.1.0"}}}
:runner
@@ -17,4 +18,15 @@
"-d" "test"]}
:uberjar {:extra-deps {seancorfield/depstar {:mvn/version "2.0.161"}}
:main-opts ["-m" "hf.depstar.uberjar" "minimal-api-clj.core.jar"
- "-C" "-m" "minimal-api-clj.core"]}}}
+ "-C" "-m" "minimal-api-clj.core"]}
+ :cloverage
+ {:extra-deps {cloverage {:mvn/version "1.2.1"}}
+ :main-opts ["-m" "cloverage.coverage"
+ "--src-ns-path" "src" "--test-ns-path" "test" "--codecov" "--junit"]}
+ :eastwood
+ {:extra-deps {jonase/eastwood {:mvn/version "0.3.12"}}
+ :main-opts ["-m" "eastwood.lint" {:source-paths ["src"]
+ :test-paths []}]}}}
さらに、リンターのclj-kondoとJoker、フォーマッターのcljstyleの設定ファイルを追加し、 make
でコマンドラインから手軽に実行できるようにしてみました。
これらの静的解析ツールはエディタ/IDEで自動実行するように設定しておくと非常に便利です。
{:lint-as {clojure.test.check.clojure-test/defspec clojure.test/deftest
clojure.test.check.properties/for-all clojure.core/for
minimal-api-lein.test-helper/with-system clojure.core/with-open}
:linters {:unresolved-namespace {:exclude []}}}
{:ignored-file-regexes [#"dev/.+"
#"profiles.clj"]
:ignored-unused-namespaces []
:known-macros [clojure.spec.alpha/fdef
clojure.test.check.clojure-test/defspec
clojure.test.check.properties/for-all
minimal-api-lein.test-helper/with-system]
:known-tags []}
{:files {:ignore #{}}
:rules {:blank-lines {:max-consecutive 1
:padding-lines 1}
:functions {:enabled? false}
:indentation {:indents {defspec [[:inner 0]]
fdef [[:inner 0]]
for-all [[:inner 0]]}
:list-indent 1}
:namespaces {:indent-size 1}
:types {:enabled? false}}}
.PHONY: cljstyle-check
cljstyle-check:
@cljstyle check
.PHONY: cljstyle-fix
cljstyle-fix:
@cljstyle fix
.PHONY: clj-kondo-lint
clj-kondo-lint:
@clj-kondo --lint src test
.PHONY: joker-lint
joker-lint:
@joker --lint --working-dir .
.PHONY: lint
lint:
@make cljstyle-check clj-kondo-lint joker-lint
テスト支援ユーティリティの実装
必要なライブラリと設定は追加したので早速テストコードを書いていきたいところですが、あらかじめ今回書こうとするテストコードでよくあるパターンを関数やマクロとして定義しておくと便利です。
Integrantでライフサイクル管理されたシステムをテスト用の設定で起動/停止できるようにするマクロ with-system
、テスト時の(インメモリ)DBの初期データを投入し元に戻すマクロ with-db-data
、HTTPアクセスを楽にする http-*
関数を用意してみました。
ここでは簡単さを優先してテスト用の設定マップを minimal-api-lein.core/config
から加工する方法を採用しました(cf. test-config
関数)が、テスト時固有の設定マップを差分だけ別途定義しておき、Meta-Mergeのようなライブラリで賢くマージさせるような方法も考えられます。
(ns minimal-api-lein.test-helper
(:require
[clj-http.client :as client]
[integrant.core :as ig]
[minimal-api-lein.core :as core]))
(defn test-config []
(assoc-in core/config [::core/server :options :port] 3001))
(defn test-system []
(ig/prep (test-config)))
(def test-url-prefix "http://localhost:3001")
;;; macros for testing context
(defmacro with-system [[bound-sym binding-expr] & body]
`(let [~bound-sym (ig/init ~binding-expr)]
(try
~@body
(finally (ig/halt! ~bound-sym)))))
(defmacro with-db-data [db-data-map & body]
`(let [old-val# @core/todos]
(try
(reset! core/todos ~db-data-map)
~@body
(finally (reset! core/todos old-val#)))))
;;; HTTP client
(defn http-get [path]
(client/get (str test-url-prefix path)
{:accept :json
:as :json
:coerce :always
:throw-exceptions? false}))
(defn http-post [path body]
(client/post (str test-url-prefix path)
{:form-params body
:content-type :json
:accept :json
:as :json
:coerce :always
:throw-exceptions? false}))
(defn http-put [path body]
(client/put (str test-url-prefix path)
{:form-params body
:content-type :json
:accept :json
:as :json
:coerce :always
:throw-exceptions? false}))
(defn http-delete [path]
(client/delete (str test-url-prefix path)
{:accept :json
:as :json
:coerce :always
:throw-exceptions? false}))
テストケースの実装
テスト用のユーティリティが整備されていれば、例えば以下のように簡潔にREST APIに対するテストを記述することができます。
(ns minimal-api-lein.core-test
(:require
[clojure.test :as t]
[minimal-api-lein.test-helper :as helper :refer [with-db-data with-system]]))
(t/deftest test-api
(with-system [sys (helper/test-system)]
(with-db-data {"todo1" {"task" "build an API"}
"todo2" {"task" "?????"}
"todo3" {"task" "profit!"}}
(t/testing "TODOリストの一覧が取得できる"
(let [{:keys [status body]} (helper/http-get "/todos")]
(t/is (= 200 status))
(t/is (= {:todo1 {:task "build an API"}
:todo2 {:task "?????"}
:todo3 {:task "profit!"}}
body))))
(t/testing "指定したIDのTODOが取得できる"
(let [{:keys [status body]} (helper/http-get "/todos/todo3")]
(t/is (= 200 status))
(t/is (= {:task "profit!"}
body))))
(t/testing "指定したIDのTODOが削除できる"
(let [{:keys [status body]} (helper/http-delete "/todos/todo2")]
(t/is (= 204 status))
(t/is (nil? body))))
(t/testing "TODOが追加できる"
(let [{:keys [status body]} (helper/http-post "/todos" {:task "something new"})]
(t/is (= 201 status))
(t/is (= {:task "something new"}
body))))
(t/testing "指定したIDのTODOが更新できる"
(let [{:keys [status body]} (helper/http-put "/todos/todo3" {:task "something different"})]
(t/is (= 201 status))
(t/is (= {:task "something different"}
body))))
(t/testing "更新内容を反映したTODOリストの一覧が取得できる"
(let [{:keys [status body]} (helper/http-get "/todos")]
(t/is (= 200 status))
(t/is (= {:todo1 {:task "build an API"}
:todo3 {:task "something different"}
:todo4 {:task "something new"}}
body))))
(t/testing "存在しないIDのTODOを取得しようとするとエラー"
(let [{:keys [status body]} (helper/http-get "/todos/todo10")]
(t/is (= 404 status))
(t/is (= {:message "Todo todo10 doesn't exist"}
body))))
(t/testing "存在しないIDのTODOを削除しようとするとエラー"
(let [{:keys [status body]} (helper/http-delete "/todos/todo10")]
(t/is (= 404 status))
(t/is (= {:message "Todo todo10 doesn't exist"}
body))))
(t/testing "存在しないIDのTODOを更新しようとすると作成される"
(let [{:keys [status body]} (helper/http-put "/todos/todo10" {:task "another one"})]
(t/is (= 201 status))
(t/is (= {:task "another one"}
body))
(let [{:keys [status body]} (helper/http-get "/todos")]
(t/is (= 200 status))
(t/is (= {:todo1 {:task "build an API"}
:todo3 {:task "something different"}
:todo4 {:task "something new"}
:todo10 {:task "another one"}}
body))))))))
テストの実行
コマンドラインからは lein test
(Clojure CLIでは clojure -M:dev:test:runner
)で単純なテスト実行、 lein test-coverage
(Clojure CLIでは clojure -M:dev:test:cloverage
)でカバレッジ計測付きのテスト実行が可能です。
$ lein test
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
2019-12-25 20:14:33.681:INFO::main: Logging initialized @7290ms to org.eclipse.jetty.util.log.StdErrLog
lein test minimal-api-lein.core-test
2019-12-25 20:14:37.915:INFO:oejs.Server:main: jetty-9.4.22.v20191022; built: 2019-10-22T13:37:13.455Z; git: b1e6b55512e008f7fbdf1cbea4ff8a6446d1073b; jvm 13.0.1+9
2019-12-25 20:14:37.959:INFO:oejs.AbstractConnector:main: Started ServerConnector@350ede1{HTTP/1.1,[http/1.1]}{0.0.0.0:3001}
2019-12-25 20:14:37.959:INFO:oejs.Server:main: Started @11569ms
2019-12-25 20:14:38.201:INFO:oejs.AbstractConnector:main: Stopped ServerConnector@350ede1{HTTP/1.1,[http/1.1]}{0.0.0.0:3001}
lein test minimal-api-lein.test-helper
Ran 1 tests containing 20 assertions.
0 failures, 0 errors.
$ lein test-coverage
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
Retrieving org/clojure/tools.reader/1.1.2/tools.reader-1.1.2.pom from central
Retrieving org/clojure/tools.reader/1.1.2/tools.reader-1.1.2.jar from central
WARNING: reader-conditional already refers to: #'clojure.core/reader-conditional in namespace: clojure.tools.reader.impl.utils, being replaced by: #'clojure.tools.reader.impl.utils/reader-conditional
WARNING: tagged-literal already refers to: #'clojure.core/tagged-literal in namespace: clojure.tools.reader.impl.utils, being replaced by: #'clojure.tools.reader.impl.utils/tagged-literal
Loading namespaces: (minimal-api-lein.core)
Test namespaces: (minimal-api-lein.test-helper minimal-api-lein.core-test)
2019-12-25 20:17:17.866:INFO::main: Logging initialized @5909ms to org.eclipse.jetty.util.log.StdErrLog
Loaded minimal-api-lein.core .
Instrumented namespaces.
2019-12-25 20:17:20.806:INFO:oejs.Server:main: jetty-9.4.22.v20191022; built: 2019-10-22T13:37:13.455Z; git: b1e6b55512e008f7fbdf1cbea4ff8a6446d1073b; jvm 13.0.1+9
2019-12-25 20:17:20.876:INFO:oejs.AbstractConnector:main: Started ServerConnector@10bf2726{HTTP/1.1,[http/1.1]}{0.0.0.0:3001}
2019-12-25 20:17:20.876:INFO:oejs.Server:main: Started @8920ms
2019-12-25 20:17:21.217:INFO:oejs.AbstractConnector:main: Stopped ServerConnector@10bf2726{HTTP/1.1,[http/1.1]}{0.0.0.0:3001}
Ran tests.
Writing HTML report to: /Users/lagenorhynque/programming/clojure-rest-api-quickstart/minimal-api-lein/target/coverage/index.html
Writing codecov.io report to: /Users/lagenorhynque/programming/clojure-rest-api-quickstart/minimal-api-lein/target/coverage/codecov.json
|-----------------------+---------+---------|
| Namespace | % Forms | % Lines |
|-----------------------+---------+---------|
| minimal-api-lein.core | 98.82 | 98.31 |
|-----------------------+---------+---------|
| ALL FILES | 98.82 | 98.31 |
|-----------------------+---------+---------|
また、LeiningenやClojure CLIの起動のオーバーヘッドを考えると、開発時に起動中のREPLから素早くテストが実行できると非常に捗ります。
エディタ/IDEのプラグインが提供する機能でキーバインドで手軽にREPLからテストを実行できることが多いですが、開発用名前空間 dev
に例えば以下のような test
関数を用意してみても良いかもしれません(さらにEftestのようなサードパーティのテストランナーを導入する案もあります)。
@@ -1,7 +1,9 @@
(ns dev
+ (:refer-clojure :exclude [test])
(:require
[clojure.java.io :as io]
[clojure.repl :refer :all]
[clojure.spec.alpha :as s]
+ [clojure.test :as test]
[clojure.tools.namespace.repl :refer [refresh]]
[integrant.core :as ig]
[integrant.repl :refer [clear halt go init prep reset]]
@@ -9,3 +11,7 @@
[minimal-api-lein.core]))
(integrant.repl/set-prep! (constantly minimal-api-lein.core/config))
+
+(defn test
+ ([] (test/run-all-tests #"minimal-api-lein\..+-test"))
+ ([ns-sym] (test/run-tests ns-sym)))
CIの設定
最後にテストと静的解析ツールをCIで自動実行させるための設定を追加します。
例えばCircleCI向けに以下のように設定することができるでしょう。
version: 2.1
jobs:
build:
working_directory: ~/clojure-rest-api-quickstart
docker:
- image: circleci/clojure:openjdk-11-lein-2.9.5
environment:
TZ: Asia/Tokyo
steps:
- checkout
- run:
name: Install dependencies
command: |
curl -O https://download.clojure.org/install/linux-install-1.10.1.763.sh
chmod +x linux-install-1.10.1.763.sh
sudo ./linux-install-1.10.1.763.sh
- restore_cache:
key: minimal-api-lein-{{ checksum "minimal-api-lein/project.clj" }}
- restore_cache:
key: minimal-api-clj-{{ checksum "minimal-api-clj.core/deps.edn" }}
- run: cd minimal-api-lein; lein deps
- run: cd minimal-api-lein; lein test-coverage
- run: cd minimal-api-clj.core; clojure -M:dev:test:cloverage
- run: cd minimal-api-lein; lein lint
- run: cd minimal-api-clj.core; clojure -M:eastwood
- run:
name: Install static code analysis tools
environment:
CLJSTYLE_VERSION: 0.14.0
CLJ_KONDO_VERSION: 2020.12.12
JOKER_VERSION: 0.15.7
command: |
# cljstyle
wget https://github.com/greglook/cljstyle/releases/download/${CLJSTYLE_VERSION}/cljstyle_${CLJSTYLE_VERSION}_linux.tar.gz
tar -xzf cljstyle_${CLJSTYLE_VERSION}_linux.tar.gz
sudo mv -f cljstyle /usr/local/bin/
# clj-kondo
curl -sLO https://raw.githubusercontent.com/borkdude/clj-kondo/master/script/install-clj-kondo
chmod +x install-clj-kondo
sudo ./install-clj-kondo --version ${CLJ_KONDO_VERSION}
# Joker
wget https://github.com/candid82/joker/releases/download/v${JOKER_VERSION}/joker-${JOKER_VERSION}-linux-amd64.zip
unzip -qq joker-${JOKER_VERSION}-linux-amd64.zip
sudo mv -f joker /usr/local/bin/
- run: cd minimal-api-lein; make lint
- run: cd minimal-api-clj.core; make lint
- run: bash <(curl -s https://codecov.io/bash) -f '!*.txt'
- save_cache:
key: minimal-api-lein-{{ checksum "minimal-api-lein/project.clj" }}
paths:
- ~/.lein
- ~/.m2
- save_cache:
key: minimal-api-clj-{{ checksum "minimal-api-clj.core/deps.edn" }}
paths:
- ~/.cpcache
- ~/.m2
- run:
name: Save test results
command: |
mkdir -p ~/test-results/lein-test
if [ -f minimal-api-lein/target/coverage/junit.xml ]; then
cp minimal-api-lein/target/coverage/junit.xml ~/test-results/lein-test/
fi
mkdir -p ~/test-results/clj-test
if [ -f minimal-api-clj.core/target/coverage/junit.xml ]; then
cp minimal-api-clj.core/target/coverage/junit.xml ~/test-results/clj-test/
fi
when: always
- store_test_results:
path: ~/test-results
4. 名前空間の分割と設定の外部化
テストケースとCI環境を用意して高いテストカバレッジも確認できたので、いよいよ本格的なリファクタリングに取り組んでみましょう。
前回の記事における初期実装では1名前空間にすべてを定義していましたが、アプリケーション内での責務に合う形で名前空間を分割し、Integrantの設定マップもこのタイミングでednファイルとして外部化します。
個々の独立した名前空間(モジュール)に分けることで単純にコードの見通しが良くなるのはもちろん、それぞれの名前空間を凝集性高く疎結合に構成していくことで変更の影響を局所化しながら拡張しやすいクリーンなアーキテクチャの実現にも繋がります。
名前空間の定義と紐付け
もとの名前空間 minimal-api-lein.core
はアプリケーションのエントリーポイントとしての責務に特化させ、残る責務に対応する形で以下のように名前空間を分割してみます。
-
minimal-api-lein.db
: (インメモリ)DBの定義 -
minimal-api-lein.handler
: HTTPのリクエストを受け取ってレスポンスを返すハンドラ関数の定義 -
minimal-api-lein.middleware
: ハンドラ関数に共通の事前/事後処理を行うミドルウェアの定義 -
minimal-api-lein.routes
: APIのルーティングの定義 -
minimal-api-lein.server
: APIサーバの定義
(ns minimal-api-lein.db)
(def todos
(atom {"todo1" {"task" "build an API"}
"todo2" {"task" "?????"}
"todo3" {"task" "profit!"}}))
(ns minimal-api-lein.handler
(:require
[clojure.string :as str]
[minimal-api-lein.db :refer [todos]]
[ring.util.http-response :as response]))
(defn list-todos [_]
(response/ok @todos))
(defn create-todo [{:keys [params]}]
(let [id (->> (keys @todos)
(map #(-> %
(str/replace-first "todo" "")
Long/parseLong))
(apply max)
inc)
todo-id (str "todo" id)]
(swap! todos assoc todo-id {"task" (:task params)})
(response/created (str "/todos/" todo-id) (get @todos todo-id))))
(defn fetch-todo [{:keys [params]}]
(if-let [todo (get @todos (:todo-id params))]
(response/ok todo)
(response/not-found {:message (str "Todo " (:todo-id params) " doesn't exist")})))
(defn delete-todo [{:keys [params]}]
(if (get @todos (:todo-id params))
(do (swap! todos dissoc (:todo-id params))
(response/no-content))
(response/not-found {:message (str "Todo " (:todo-id params) " doesn't exist")})))
(defn update-todo [{:keys [params]}]
(let [task {"task" (:task params)}]
(swap! todos assoc (:todo-id params) task)
(response/created (str "/todos/" (:todo-id params)) task)))
(ns minimal-api-lein.middleware
(:require
[camel-snake-kebab.core :refer [->kebab-case ->snake_case]]
[camel-snake-kebab.extras :refer [transform-keys]]))
(defn wrap-kebab-case-keys [handler]
(fn [request]
(let [response (-> request
(update :params (partial transform-keys #(->kebab-case % :separator \_)))
handler)]
(transform-keys #(->snake_case % :separator \-) response))))
(ns minimal-api-lein.routes
(:require
[integrant.core :as ig]
[minimal-api-lein.handler :as handler]))
(defmethod ig/init-key ::routes [_ _]
["/" {"todos" {:get handler/list-todos
:post handler/create-todo}
["todos/" :todo-id] {:get handler/fetch-todo
:delete handler/delete-todo
:put handler/update-todo}}])
(ns minimal-api-lein.server
(:require
[bidi.ring :refer [make-handler]]
[integrant.core :as ig]
[minimal-api-lein.middleware :refer [wrap-kebab-case-keys]]
[ring.adapter.jetty :as jetty]
[ring.middleware.json :refer [wrap-json-params wrap-json-response]]
[ring.middleware.keyword-params :refer [wrap-keyword-params]]
[ring.middleware.params :refer [wrap-params]]))
(defmethod ig/init-key ::app [_ {:keys [routes]}]
(-> (make-handler routes)
wrap-kebab-case-keys
wrap-keyword-params
wrap-json-params
wrap-json-response
wrap-params))
(defmethod ig/init-key ::server [_ {:keys [app options]}]
(jetty/run-jetty app options))
(defmethod ig/halt-key! ::server [_ server]
(.stop server))
また、 minimal-api-lein.core
名前空間に定義されていた設定マップ config
は resources/config.edn
として外部ファイル化します。
名前空間分割により設定マップのキー名(= Integrantのコンポーネント名)が変わることに注意が必要です(Integrantの名前空間自動ロード機能を活かすには、コンポーネントを定義した名前空間に対応するキー名を採用するのがオススメです)。
ednファイルでは integrant.core/ref
関数の代わりに #ig/ref
タグを利用します。
{:minimal-api-lein.routes/routes {}
:minimal-api-lein.server/app {:routes #ig/ref :minimal-api-lein.routes/routes}
:minimal-api-lein.server/server {:app #ig/ref :minimal-api-lein.server/app
:options {:port 3000
:join? false}}}
システム起動方法の変更
システムの設定マップをednファイルに抽出したことにより、Integrantを介したシステムの起動方法も自ずと変更することになります。
システムを起動するのは、本番アプリケーションのためのメインエントリーポイント、開発時のREPL、テスト時のテスト支援ユーティリティの3箇所なので、それぞれに修正が必要です。
メインエントリーポイント
名前空間分割により minimal-api-lein.core
名前空間には -main
関数だけが残ることになります。
設定マップ config
の代わりにリソースから config.edn
の内容を integrant.core/read-string
で読み込み、 integrant.core/load-namespaces
で必要な名前空間を自動ロードさせてから integrant.core/prep
、 integrant.core/init
すれば良いでしょう。
@@ -1,97 +1,12 @@
(ns minimal-api-lein.core
(:gen-class)
(:require
- [bidi.ring :refer [make-handler]]
- [camel-snake-kebab.core :refer [->kebab-case ->snake_case]]
- [camel-snake-kebab.extras :refer [transform-keys]]
- [clojure.string :as str]
- [integrant.core :as ig]
- [ring.adapter.jetty :as jetty]
- [ring.middleware.json :refer [wrap-json-params wrap-json-response]]
- [ring.middleware.keyword-params :refer [wrap-keyword-params]]
- [ring.middleware.params :refer [wrap-params]]
- [ring.util.http-response :as response]))
-
-;;; handlers
-
-(def todos
- (atom {"todo1" {"task" "build an API"}
- "todo2" {"task" "?????"}
- "todo3" {"task" "profit!"}}))
-
-(defn list-todos [_]
- (response/ok @todos))
-
-(defn create-todo [{:keys [params]}]
- (let [id (->> (keys @todos)
- (map #(-> %
- (str/replace-first "todo" "")
- Long/parseLong))
- (apply max)
- inc)
- todo-id (str "todo" id)]
- (swap! todos assoc todo-id {"task" (:task params)})
- (response/created (str "/todos/" todo-id) (get @todos todo-id))))
-
-(defn fetch-todo [{:keys [params]}]
- (if-let [todo (get @todos (:todo-id params))]
- (response/ok todo)
- (response/not-found {:message (str "Todo " (:todo-id params) " doesn't exist")})))
-
-(defn delete-todo [{:keys [params]}]
- (if (get @todos (:todo-id params))
- (do (swap! todos dissoc (:todo-id params))
- (response/no-content))
- (response/not-found {:message (str "Todo " (:todo-id params) " doesn't exist")})))
-
-(defn update-todo [{:keys [params]}]
- (let [task {"task" (:task params)}]
- (swap! todos assoc (:todo-id params) task)
- (response/created (str "/todos/" (:todo-id params)) task)))
-
-;;; routes
-
-(defmethod ig/init-key ::routes [_ _]
- ["/" {"todos" {:get list-todos
- :post create-todo}
- ["todos/" :todo-id] {:get fetch-todo
- :delete delete-todo
- :put update-todo}}])
-
-;;; middleware
-
-(defn wrap-kebab-case-keys [handler]
- (fn [request]
- (let [response (-> request
- (update :params (partial transform-keys #(->kebab-case % :separator \_)))
- handler)]
- (transform-keys #(->snake_case % :separator \-) response))))
-
-(defmethod ig/init-key ::app [_ {:keys [routes]}]
- (-> (make-handler routes)
- wrap-kebab-case-keys
- wrap-keyword-params
- wrap-json-params
- wrap-json-response
- wrap-params))
-
-;;; API server
-
-(defmethod ig/init-key ::server [_ {:keys [app options]}]
- (jetty/run-jetty app options))
-
-(defmethod ig/halt-key! ::server [_ server]
- (.stop server))
-
-;;; system configuration
-
-(def config
- {::routes {}
- ::app {:routes (ig/ref ::routes)}
- ::server {:app (ig/ref ::app)
- :options {:port 3000
- :join? false}}})
-
-;;; main entry point
-
-(defn -main [& args]
- (ig/init config))
+ [clojure.java.io :as io]
+ [integrant.core :as ig]))
+
+(defn -main [& _]
+ (-> (io/resource "config.edn")
+ slurp
+ ig/read-string
+ (doto ig/load-namespaces)
+ ig/prep
+ ig/init))
開発時
REPLから reset
と halt
で(再)起動/停止できるようにするには、設定マップ config
の代わりに config.edn
から読み取ったマップを(名前空間自動ロードさせてから) Integrant-REPLの仕組みに組み込めば良いので、例えば以下のように dev
名前空間に設定します。
@@ -7,10 +7,15 @@
[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]]
- [minimal-api-lein.core]))
+ [integrant.repl.state :refer [config system]]))
-(integrant.repl/set-prep! (constantly minimal-api-lein.core/config))
+(defn read-config []
+ (-> (io/resource "config.edn")
+ slurp
+ ig/read-string
+ (doto ig/load-namespaces)))
+
+(integrant.repl/set-prep! (comp ig/prep read-config))
(defn test
([] (test/run-all-tests #"minimal-api-lein\..+-test"))
テスト時
開発時の dev
名前空間と同様に config.edn
から読み込んだ内容をシステムの設定マップとして利用します。
@@ -1,10 +1,15 @@
(ns minimal-api-lein.test-helper
(:require
[clj-http.client :as client]
+ [clojure.java.io :as io]
[integrant.core :as ig]
- [minimal-api-lein.core :as core]))
+ [minimal-api-lein.db :as db]))
(defn test-config []
- (assoc-in core/config [::core/server :options :port] 3001))
+ (-> (io/resource "config.edn")
+ slurp
+ ig/read-string
+ (assoc-in [:minimal-api-lein.server/server :options :port] 3001)
+ (doto ig/load-namespaces)))
(defn test-system []
(ig/prep (test-config)))
@@ -20,11 +25,11 @@
(finally (ig/halt! ~bound-sym)))))
(defmacro with-db-data [db-data-map & body]
- `(let [old-val# @core/todos]
+ `(let [old-val# @db/todos]
(try
- (reset! core/todos ~db-data-map)
+ (reset! db/todos ~db-data-map)
~@body
- (finally (reset! core/todos old-val#)))))
+ (finally (reset! db/todos old-val#)))))
;;; HTTP client
以上の対応により、これまでと変わらずREPLから reset
/halt
したり、テストを実行したり、 lein run -m minimal-api-lein.core
(Clojure CLIでは clojure -M -m minimal-api-clj.core
)でAPIをコマンドライン起動したりすることができます。
5. "boundary"プロトコルによる疎結合化
名前空間分割により生まれた minimal-api-lein.handler
名前空間と minimal-api-lein.db
名前空間に注目してみると、HTTPリクエスト/レスポンスを扱う各種ハンドラ関数が(インメモリ)DBの実装の詳細(今回は簡単に atom
で可変状態を実現していること、内部に保持されているマップの構造など)を知りすぎている極めて密結合な状態であることに気づきます。
レイヤーの異なる名前空間(モジュール)同士の疎結合性を高める(言い換えれば「依存関係逆転の原則」(dependency inversion principle; DIP)に従う)ため、Clojureのプロトコル(protocol)を活用してDBアクセスのインターフェースと実装を分離しましょう。
Ductフレームワークではこの種の外部サービス/リソースとの境界を定義するようなプロトコルを"boundary"(バウンダリ)と呼んでいるため、ここでもその命名を踏襲します。
インメモリDBアクセス用のユーティリティの実装
外部リソースとしてのインメモリDBに対するアクセスに関する汎用的なユーティリティのために、 minimal-api-lein.boundary.db.core
という名前空間を新たに用意しましょう。
この名前空間には、 :data
というひとつのフィールドを持つレコード Boundary
を定義し、新規のIntegrantコンポーネント ::db
(= :minimal-api-lein.boundary.db.core/db
)がインメモリDBのデータを :data
フィールドに持つ Boundary
として初期化されるようにします(ちなみに ->Boundary
は Boundary
という名前でレコードを定義すると自動生成されるコンストラクタ関数です)。
また、インメモリDBのマップ(Boundary
の :data
フィールド)はTODOデータ以外も管理できるようにTODOデータは :todo
キーに持たせるように変更しています。
そしてインメモリDBに対する典型的なアクセスパターンをユーティリティ関数 select-all
, select-by-key
, insert!
, update-by-key!
, delete-by-key!
として定義しておきます。
(ns minimal-api-lein.boundary.db.core
(:require
[clojure.string :as str]
[integrant.core :as ig]))
(defrecord Boundary [data])
(defmethod ig/init-key ::db [_ _]
(->Boundary (atom {:todo {"todo1" {"task" "build an API"}
"todo2" {"task" "?????"}
"todo3" {"task" "profit!"}}})))
(defn select-all [{:keys [data]} table]
(get @data table))
(defn selet-by-key [{:keys [data]} table k]
(get-in @data [table k]))
(defn insert! [{:keys [data]} table key-prefix v]
(let [id (->> (keys (get @data table))
(map #(-> %
(str/replace-first key-prefix "")
Long/parseLong))
(apply max)
inc)
k (str key-prefix id)]
(swap! data assoc-in [table k] v)
k))
(defn update-by-key! [{:keys [data]} table k v]
(swap! data assoc-in [table k] v)
nil)
(defn delete-by-key! [{:keys [data]} table k]
(swap! data update table dissoc k)
nil)
TODOデータにアクセスするためのプロトコル(DBバウンダリ)の定義と実装
汎用的なインメモリDBアクセスユーティリティが用意できたら、TODOデータにアクセスするためのインターフェースをプロトコルとして定義し、 minimal_api_lein.boundary.db.core.Boundary
の場合の実装を与えます。
これは、例えば関数 minimal-api-lein.boundary.db.todo/find-todos
の第1引数に minimal_api_lein.boundary.db.core.Boundary
型の値を与えると、 (db/select-all db :todo)
という実装が呼び出されることを意味します。
(ns minimal-api-lein.boundary.db.todo
(:require
[minimal-api-lein.boundary.db.core :as db]))
(defprotocol Todo
(find-todos [db])
(find-todo-by-id [db id])
(create-todo! [db todo])
(update-todo! [db id todo])
(delete-todo! [db id]))
(extend-protocol Todo
minimal_api_lein.boundary.db.core.Boundary
(find-todos [db]
(db/select-all db :todo))
(find-todo-by-id [db id]
(db/selet-by-key db :todo id))
(create-todo! [db todo]
(db/insert! db :todo "todo" todo))
(update-todo! [db id todo]
(db/update-by-key! db :todo id todo))
(delete-todo! [db id]
(db/delete-by-key! db :todo id)))
DB情報のリクエストマップへの注入(DI)
インメモリDBからTODOデータにアクセスする関数は実装できたので、あとはハンドラ関数から呼び出せるようにする必要があります。
ハンドラ関数に minimal_api_lein.boundary.db.core.Boundary
(= :minimal-api-lein.boundary.db.core/db
コンポーネント) の値を渡すことができれば良いので、ここではミドルウェアを利用してハンドラ関数の入力であるリクエストマップに注入することにします。
まず、設定マップでは :minimal-api-lein.server/app
コンポーネントに :minimal-api-lein.boundary.db.core/db
コンポーネントを参照させます。
@@ -1,5 +1,7 @@
-{:minimal-api-lein.routes/routes {}
- :minimal-api-lein.server/app {:routes #ig/ref :minimal-api-lein.routes/routes}
+{:minimal-api-lein.boundary.db.core/db {}
+ :minimal-api-lein.routes/routes {}
+ :minimal-api-lein.server/app {:routes #ig/ref :minimal-api-lein.routes/routes
+ :db #ig/ref :minimal-api-lein.boundary.db.core/db}
:minimal-api-lein.server/server {:app #ig/ref :minimal-api-lein.server/app
:options {:port 3000
:join? false}}}
ミドルウェア wrap-db
は第2引数 db
の値を :db
というキーでリクエストマップに追加します。
@@ -8,3 +8,7 @@
(update :params (partial transform-keys #(->kebab-case % :separator \_)))
handler)]
(transform-keys #(->snake_case % :separator \-) response))))
+
+(defn wrap-db [handler db]
+ (fn [request]
+ (handler (assoc request :db db))))
:minimal-api-lein.server/app
コンポーネントは :minimal-api-lein.boundary.db.core/db
コンポーネントの値を受け取れるので、それをミドルウェア minimal-api-lein.middleware/wrap-db
経由でリクエストマップに差し込みます。
@@ -1,19 +1,20 @@
(ns minimal-api-lein.server
(:require
[bidi.ring :refer [make-handler]]
[integrant.core :as ig]
- [minimal-api-lein.middleware :refer [wrap-kebab-case-keys]]
+ [minimal-api-lein.middleware :refer [wrap-db wrap-kebab-case-keys]]
[ring.adapter.jetty :as jetty]
[ring.middleware.json :refer [wrap-json-params wrap-json-response]]
[ring.middleware.keyword-params :refer [wrap-keyword-params]]
[ring.middleware.params :refer [wrap-params]]))
-(defmethod ig/init-key ::app [_ {:keys [routes]}]
+(defmethod ig/init-key ::app [_ {:keys [routes db]}]
(-> (make-handler routes)
wrap-kebab-case-keys
wrap-keyword-params
wrap-json-params
wrap-json-response
- wrap-params))
+ wrap-params
+ (wrap-db db)))
(defmethod ig/init-key ::server [_ {:keys [app options]}]
(jetty/run-jetty app options))
DBバウンダリ関数の動作確認
ここで minimal-api-lein.boundary.db.todo
に定義した Todo
プロトコル関数(DBバウンダリ関数)の動作を確認しておきましょう。
以下のようなユーティリティ関数を開発用名前空間 dev
に用意しておくと便利かもしれません。
@@ -20,3 +20,9 @@
(defn test
([] (test/run-all-tests #"minimal-api-lein\..+-test"))
([ns-sym] (test/run-tests ns-sym)))
+
+(defn db []
+ (:minimal-api-lein.boundary.db.core/db system))
+
+(defn db-run [f & args]
+ (apply f (db) args))
(reset)
でシステムを起動してから db-run
関数を利用してDBバウンダリ関数を試してみると、期待通りに動作していることが確認できます。
dev=> (db-run minimal-api-lein.boundary.db.todo/find-todos)
{"todo1" {"task" "build an API"}, "todo2" {"task" "?????"}, "todo3" {"task" "profit!"}}
dev=> (db-run minimal-api-lein.boundary.db.todo/find-todo-by-id "todo2")
{"task" "?????"}
dev=> (db-run minimal-api-lein.boundary.db.todo/create-todo! {"task" "something new"})
"todo4"
dev=> (db-run minimal-api-lein.boundary.db.todo/update-todo! "todo2" {"task" "something different"})
nil
dev=> (db-run minimal-api-lein.boundary.db.todo/delete-todo! "todo3")
nil
dev=> (db-run minimal-api-lein.boundary.db.todo/find-todos)
{"todo1" {"task" "build an API"}, "todo2" {"task" "something different"}, "todo4" {"task" "something new"}}
DBバウンダリ関数を利用したハンドラ関数の改修
ハンドラ関数がリクエストマップから minimal_api_lein.boundary.db.core.Boundary
の値を取り出せるようになったので、あとは各ハンドラ関数から minimal-api-lein.boundary.db.todo
にある Todo
プロトコルの関数を利用するだけです。
これにより、 minimal-api-lein.handler
名前空間のハンドラ関数は Todo
プロトコルというインターフェースに依存するのみでインメモリDBという実装の詳細に対する依存がなくなります。
ハンドラ関数は、TODOデータがインメモリDBにあるのか、RDBにあるのか、ファイルにあるのか、外部APIに由来するのかさえ知らない状態にすることができたわけです。
@@ -1,34 +1,27 @@
(ns minimal-api-lein.handler
(:require
- [clojure.string :as str]
- [minimal-api-lein.db :refer [todos]]
+ [minimal-api-lein.boundary.db.todo :as db.todo]
[ring.util.http-response :as response]))
-(defn list-todos [_]
- (response/ok @todos))
+(defn list-todos [{:keys [db]}]
+ (response/ok (db.todo/find-todos db)))
-(defn create-todo [{:keys [params]}]
- (let [id (->> (keys @todos)
- (map #(-> %
- (str/replace-first "todo" "")
- Long/parseLong))
- (apply max)
- inc)
- todo-id (str "todo" id)]
- (swap! todos assoc todo-id {"task" (:task params)})
- (response/created (str "/todos/" todo-id) (get @todos todo-id))))
+(defn create-todo [{:keys [params db]}]
+ (let [id (db.todo/create-todo! db {"task" (:task params)})
+ todo (db.todo/find-todo-by-id db id)]
+ (response/created (str "/todos/" id) todo)))
-(defn fetch-todo [{:keys [params]}]
- (if-let [todo (get @todos (:todo-id params))]
+(defn fetch-todo [{:keys [params db]}]
+ (if-let [todo (db.todo/find-todo-by-id db (:todo-id params))]
(response/ok todo)
(response/not-found {:message (str "Todo " (:todo-id params) " doesn't exist")})))
-(defn delete-todo [{:keys [params]}]
- (if (get @todos (:todo-id params))
- (do (swap! todos dissoc (:todo-id params))
+(defn delete-todo [{:keys [params db]}]
+ (if (db.todo/find-todo-by-id db (:todo-id params))
+ (do (db.todo/delete-todo! db (:todo-id params))
(response/no-content))
(response/not-found {:message (str "Todo " (:todo-id params) " doesn't exist")})))
-(defn update-todo [{:keys [params]}]
+(defn update-todo [{:keys [params db]}]
(let [task {"task" (:task params)}]
- (swap! todos assoc (:todo-id params) task)
+ (db.todo/update-todo! db (:todo-id params) task)
(response/created (str "/todos/" (:todo-id params)) task)))
テストコードの改修
最後に、インメモリDBの管理方法が変わったのでテスト時の初期データ投入のためのマクロ wit-db-data
を改修します。
@@ -1,8 +1,7 @@
(ns minimal-api-lein.test-helper
(:require
[clj-http.client :as client]
[clojure.java.io :as io]
- [integrant.core :as ig]
- [minimal-api-lein.db :as db]))
+ [integrant.core :as ig]))
(defn test-config []
(-> (io/resource "config.edn")
@@ -24,12 +23,13 @@
~@body
(finally (ig/halt! ~bound-sym)))))
-(defmacro with-db-data [db-data-map & body]
- `(let [old-val# @db/todos]
+(defmacro with-db-data [[system db-data-map] & body]
+ `(let [db# (:minimal-api-lein.boundary.db.core/db ~system)
+ old-val# @(:data db#)]
(try
- (reset! db/todos ~db-data-map)
+ (reset! (:data db#) ~db-data-map)
~@body
- (finally (reset! db/todos old-val#)))))
+ (finally (reset! (:data db#) old-val#)))))
;;; HTTP client
テストケース側でも with-db-data
マクロとインメモリDBのデータ構造の変更を取り込めば、今まで通りすべてのテストがパスするはずです。
@@ -5,9 +5,9 @@
(t/deftest test-api
(with-system [sys (helper/test-system)]
- (with-db-data {"todo1" {"task" "build an API"}
- "todo2" {"task" "?????"}
- "todo3" {"task" "profit!"}}
+ (with-db-data [sys {:todo {"todo1" {"task" "build an API"}
+ "todo2" {"task" "?????"}
+ "todo3" {"task" "profit!"}}}]
(t/testing "TODOリストの一覧が取得できる"
(let [{:keys [status body]} (helper/http-get "/todos")]
(t/is (= 200 status))
6. clojure.specの導入
今回のAPIでは(インメモリ)DBに対するアクセスがビジネスロジックとして非常に重要ですが、DBアクセス関数の入出力データがどのようなものであるか必ずしも明らかでないという問題が残っています。
開発時やテスト時に想定しない入出力に対して分かりやすい形でfail-fastになるように、clojure.specでDBアクセス関数を保護することにしましょう。
specの定義
まず minimal-api-lein.boundary.db.core
名前空間では ::db
(= :minimal-api-lein.boundary.db.core/db
) とは :data
フィールドを持つマップであるとspec定義します。
@@ -1,7 +1,11 @@
(ns minimal-api-lein.boundary.db.core
(:require
+ [clojure.spec.alpha :as s]
[clojure.string :as str]
[integrant.core :as ig]))
+(s/def ::data any?)
+(s/def ::db (s/keys :req-un [::data]))
+
(defrecord Boundary [data])
(defmethod ig/init-key ::db [_ _]
すると、例えば minimal-api-lein.boundary.db.todo/find-todos
は :minimal-api-lein.boundary.db.core/db
を受け取って ::id
をキー、 ::todo
を値とするマップを返す関数として仕様を記述することができます。
また、 minimal-api-lein.boundary.db.todo/find-todo-by-id
は minimal-api-lein.boundary.db.core/db
と ::id
を受け取って ::todo
または nil
を返す関数として仕様を表現できるでしょう。
@@ -1,5 +1,35 @@
(ns minimal-api-lein.boundary.db.todo
(:require
+ [clojure.spec.alpha :as s]
[minimal-api-lein.boundary.db.core :as db]))
+
+(s/def ::id string?)
+(s/def ::todo (s/map-of string? string?))
+(s/def ::todos (s/map-of ::id ::todo))
+
+(s/fdef find-todos
+ :args (s/cat :db ::db/db)
+ :ret ::todos)
+
+(s/fdef find-todo-by-id
+ :args (s/cat :db ::db/db
+ :id ::id)
+ :ret (s/nilable ::todo))
+
+(s/fdef create-todo!
+ :args (s/cat :db ::db/db
+ :todo ::todo)
+ :ret ::id)
+
+(s/fdef update-todo!
+ :args (s/cat :db ::db/db
+ :id ::id
+ :todo ::todo)
+ :ret any?)
+
+(s/fdef delete-todo!
+ :args (s/cat :db ::db/db
+ :id ::id)
+ :ret any?)
(defprotocol Todo
(find-todos [db])
このように Todo
プロトコル関数の入出力の仕様をclojure.specで定義することができます。
開発環境への組み込みと動作確認
specが定義できたので、開発環境でREPLからspecのチェックが動作することを確かめてみましょう。
ここで、Clojure標準ライブラリの clojure.spec.test.alpha/instrument
関数は関数のspecのうち引数に対するspec (:args
spec)のみチェックを有効化するようになっている(cf. spec Guide > Instrumentation)ので、今回は戻り値に対するspec (:ret
spec)も有効化できるサードパーティライブラリOrchestraの orchestra.spec.test/instrument
関数を利用します。
@@ -18,6 +18,7 @@
:resource-paths ["dev/resources"]
:dependencies [[clj-http "3.11.0"]
[integrant/repl "0.3.2"]
+ [orchestra "2020.09.18-1"]
[pjstadig/humane-test-output "0.10.0"]]
:plugins [[jonase/eastwood "0.3.12"]
[lein-cloverage "1.2.1"]
@@ -10,7 +10,8 @@
:aliases
{:dev {:extra-paths ["dev/resources" "dev/src"]
:extra-deps {clj-http {:mvn/version "3.11.0"}
- integrant/repl {:mvn/version "0.3.2"}}}
+ integrant/repl {:mvn/version "0.3.2"}
+ orchestra {:mvn/version "2020.09.18-1"}}}
:test {:extra-paths ["test"]
:extra-deps {org.clojure/test.check {:mvn/version "1.1.0"}}}
:runner
関数のspecの組み込みはREPLでの reset
(システム(再)起動 + リロード)のたびに実行できると便利なので、以下のように dev
名前空間で integrant.repl/reset
の直後に orchestra.spec.test/instrument
を呼び出す同名の関数 reset
を定義してみました。
また、(.gitignore
でバージョン管理対象外にする想定の) local.clj
という名前のファイルがあれば load
する機能を追加しています。
これにより、他の開発者に影響を与えることなく dev
名前空間に自分だけの独自ユーティリティを追加することができます(Ductテンプレートの dev.clj
でも使われている工夫です)。
@@ -6,8 +6,9 @@
[clojure.test :as test]
[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]]))
+ [integrant.repl :refer [clear halt go init prep]]
+ [integrant.repl.state :refer [config system]]
+ [orchestra.spec.test :as stest]))
(defn read-config []
(-> (io/resource "config.edn")
@@ -15,6 +16,11 @@
ig/read-string
(doto ig/load-namespaces)))
+(defn reset []
+ (let [result (integrant.repl/reset)]
+ (with-out-str (stest/instrument))
+ result))
+
(integrant.repl/set-prep! (comp ig/prep read-config))
(defn test
@@ -26,3 +32,6 @@
(defn db-run [f & args]
(apply f (db) args))
+
+(when (io/resource "local.clj")
+ (load "local"))
Emacs/Spacemacsユーザで前述の cider-ns-refresh
を利用している場合には、 dev/reset
関数と同等の振る舞いになるように cider-ns-refresh
の後に呼び出される関数を差し替えておくと良いでしょう。
早速 local.clj
に次のような integrant.repl/resume
の後に orchestra.spec.test/instrument
する関数を local.clj
ファイルに定義し、 .dir-locals.el
の cider-ns-refresh-after-fn
に登録します。
(defn resume-instrument []
(let [result (integrant.repl/resume)]
(with-out-str (stest/instrument))
result))
((nil . ((cider-ns-refresh-before-fn . "integrant.repl/suspend")
(cider-ns-refresh-after-fn . "dev/resume-instrument"))))
REPLで (reset)
してから再び db-run
関数でDBバウンダリ関数を動作確認してみると、想定外の引数に対しては関数の本体処理が実行されることなくspecエラーになることが分かります(ここでは確認しませんが、Orchestraの instrument
を利用しているので戻り値が想定外の場合にもspecエラーになるでしょう)。
dev=> (db-run minimal-api-lein.boundary.db.todo/find-todo-by-id "todo2")
{"task" "?????"}
dev=> (db-run minimal-api-lein.boundary.db.todo/find-todo-by-id 2)
Execution error - invalid arguments to orchestra.spec.test/spec-checking-fn$conform! at (test.cljc:115).
2 - failed: string? at: [:args :id] spec: :minimal-api-lein.boundary.db.todo/id
dev=> (db-run minimal-api-lein.boundary.db.todo/create-todo! {"task" "something new"})
"todo4"
dev=> (db-run minimal-api-lein.boundary.db.todo/create-todo! ["task" "something new"])
Execution error - invalid arguments to orchestra.spec.test/spec-checking-fn$conform! at (test.cljc:115).
["task" "something new"] - failed: map? at: [:args :todo] spec: :minimal-api-lein.boundary.db.todo/todo
テスト環境への組み込み
ローカル開発環境でspecのチェックが効くようになったので、最後にテスト時にも同様のチェックが有効になるようにしましょう。
今回は clojure.test/use-fixtures
を利用して、全テストケースの前に1回だけ orchestra.spec.test/instrument
を呼び出すようにしてみました。
@@ -1,7 +1,8 @@
(ns minimal-api-lein.test-helper
(:require
[clj-http.client :as client]
[clojure.java.io :as io]
- [integrant.core :as ig]))
+ [integrant.core :as ig]
+ [orchestra.spec.test :as stest]))
(defn test-config []
(-> (io/resource "config.edn")
@@ -15,6 +16,12 @@
(def test-url-prefix "http://localhost:3001")
+;;; fixtures
+
+(defn instrument-specs [f]
+ (stest/instrument)
+ (f))
+
;;; macros for testing context
(defmacro with-system [[bound-sym binding-expr] & body]
@@ -3,6 +3,10 @@
[clojure.test :as t]
[minimal-api-lein.test-helper :as helper :refer [with-db-data with-system]]))
+(t/use-fixtures
+ :once
+ helper/instrument-specs)
+
(t/deftest test-api
(with-system [sys (helper/test-system)]
(with-db-data [sys {:todo {"todo1" {"task" "build an API"}
まとめ
本記事では、1ファイルによるミニマルなREST APIを本格的な開発にも十分に耐えられる構成に段階的にリファクタリングしました。
- 依存ライブラリの更新を確認する
- Integrant-REPLでIntegrantベースのシステムのREPL開発フローを快適にする
- REST APIに対するテストとCIを整備する
- 名前空間を責務に合わせて分割し設定を外部化する
- "boundary"プロトコルで外部サービスアクセスを抽象化する
- clojure.specで関数の入出力の仕様を表現する
Clojureで開発するシステムを大規模化や複雑化に応えられるアーキテクチャに改善していくためのアプローチとして参考になれば幸いです。
REPLによるフィードバックをこまめに受け取りながら大きく複雑な問題にも立ち向かえるClojureでのプログラミングを楽しみましょう>ω</