LoginSignup
23
11

More than 1 year has passed since last update.

ミニマリストのためのClojure REST API開発入門2 〜リファクタリング編〜

Last updated at Posted at 2019-12-25

関数型言語とヨーロッパ言語が好きな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フレームワークのスタイルを参考にしています。

サンプルコード:

※ 全体の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.ednoutdated というエイリアスを設定してあります。

$ 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の導入

※ コミット: lein, clj (初回執筆時点)

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 に変更しています。

minimal-api-lein/project.clj
@@ -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では書き方に少し差がありますが、ほぼ同等の設定が可能です。

minimal-api-clj.core/deps.edn
@@ -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 そのまま😅)。

minimal-api-lein/dev/src/user.clj
(ns user)

(defn dev
  "Load and switch to the 'dev' namespace."
  []
  (require 'dev)
  (in-ns 'dev)
  :loaded)
minimal-api-lein/dev/src/dev.clj
(ns dev
  (:require
   [clojure.java.io :as io]
   [clojure.repl :refer :all]
   [clojure.spec.alpha :as s]
   [clojure.tools.namespace.repl :refer [refresh]]
   [integrant.core :as ig]
   [integrant.repl :refer [clear halt go init prep reset]]
   [integrant.repl.state :refer [config system]]
   [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.namespacerefresh 系関数呼び出し)の前後に任意の関数をフックすることができます。

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) 呼び出しを代用することが可能になります。

minimal-api-lein/.dir-locals.el
((nil . ((cider-ns-refresh-before-fn . "integrant.repl/suspend")
         (cider-ns-refresh-after-fn  . "integrant.repl/resume"))))

3. テストの整備

※ コミット: lein, clj (初回執筆時点)

テストコードのないコードベースに対して「リファクタリング」と称して危険な賭けを敢行するのは避けたいものです😅

本格的に既存コードに手を入れる前に、既存の振る舞いを保証するためのテストを整備しましょう。

ここでは、REST APIに対するE2E的なテストを用意し、静的解析ツールとともにCIで自動実行できるようにします。

依存ライブラリと設定の追加

Clojureで定番のHTTPクライアントclj-http、テスト結果の出力を見やすくするhumane-test-outputを開発時の依存ライブラリに追加し、またLeinigenプラグインとしてリンターのEastwood、テストカバレッジ計測のcloverage、イディオムチェッカーのkibitを追加しています。

また、コマンドラインから便利に使えるように test-coveragelint というエイリアスも設定してみました。

minimal-api-lein/project.clj
@@ -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では一部使えない(使うのが難しそう)なツールもありますが、機能的に近い設定を以下のように書いてみました。

minimal-api-clj.core/deps.edn
@@ -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-kondoJoker、フォーマッターのcljstyleの設定ファイルを追加し、 make でコマンドラインから手軽に実行できるようにしてみました。

これらの静的解析ツールはエディタ/IDEで自動実行するように設定しておくと非常に便利です。

minimal-api-lein/.clj-kondo/config.edn
{: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 []}}}
minimal-api-lein/.joker
{: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 []}
minimal-api-lein/.cljstyle
{: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}}}
minimal-api-lein/Makefile
.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のようなライブラリで賢くマージさせるような方法も考えられます。

minimal-api-lein/test/minimal_api_lein/test_helper.clj
(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に対するテストを記述することができます。

minimal-api-lein/test/minimal_api_lein/core_test.clj
(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のようなサードパーティのテストランナーを導入する案もあります)。

minimal-api-lein/dev/src/dev.clj
@@ -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向けに以下のように設定することができるでしょう。

.circleci/config.yml
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. 名前空間の分割と設定の外部化

※ コミット: lein, clj (初回執筆時点)

テストケースと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サーバの定義
minimal-api-lein/src/minimal_api_lein/db.clj
(ns minimal-api-lein.db)

(def todos
  (atom {"todo1" {"task" "build an API"}
         "todo2" {"task" "?????"}
         "todo3" {"task" "profit!"}}))
minimal-api-lein/src/minimal_api_lein/handler.clj
(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)))
minimal-api-lein/src/minimal_api_lein/middleware.clj
(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))))
minimal-api-lein/src/minimal_api_lein/routes.clj
(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}}])
minimal-api-lein/src/minimal_api_lein/server.clj
(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 名前空間に定義されていた設定マップ configresources/config.edn として外部ファイル化します。

名前空間分割により設定マップのキー名(= Integrantのコンポーネント名)が変わることに注意が必要です(Integrantの名前空間自動ロード機能を活かすには、コンポーネントを定義した名前空間に対応するキー名を採用するのがオススメです)。

ednファイルでは integrant.core/ref 関数の代わりに #ig/ref タグを利用します。

minimal-api-lein/resources/config.edn
{: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/prepintegrant.core/init すれば良いでしょう。

minimal-api-lein/src/minimal_api_lein/core.clj
@@ -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から resethalt で(再)起動/停止できるようにするには、設定マップ config の代わりに config.edn から読み取ったマップを(名前空間自動ロードさせてから) Integrant-REPLの仕組みに組み込めば良いので、例えば以下のように dev 名前空間に設定します。

minimal-api-lein/dev/src/dev.clj
@@ -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 から読み込んだ内容をシステムの設定マップとして利用します。

minimal-api-lein/test/minimal_api_lein/test_helper.clj
@@ -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"プロトコルによる疎結合化

※ コミット: lein, clj (初回執筆時点)

名前空間分割により生まれた 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 として初期化されるようにします(ちなみに ->BoundaryBoundary という名前でレコードを定義すると自動生成されるコンストラクタ関数です)。

また、インメモリDBのマップ(Boundary:data フィールド)はTODOデータ以外も管理できるようにTODOデータは :todo キーに持たせるように変更しています。

そしてインメモリDBに対する典型的なアクセスパターンをユーティリティ関数 select-all, select-by-key, insert!, update-by-key!, delete-by-key! として定義しておきます。

minimal-api-lein/src/minimal_api_lein/boundary/db/core.clj
(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) という実装が呼び出されることを意味します。

minimal-api-lein/src/minimal_api_lein/boundary/db/todo.clj
(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 コンポーネントを参照させます。

minimal-api-lein/resources/config.edn
@@ -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 というキーでリクエストマップに追加します。

minimal-api-lein/src/minimal_api_lein/middleware.clj
@@ -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 経由でリクエストマップに差し込みます。

minimal-api-lein/src/minimal_api_lein/server.clj
@@ -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 に用意しておくと便利かもしれません。

minimal-api-lein/dev/src/dev.clj
@@ -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に由来するのかさえ知らない状態にすることができたわけです。

minimal-api-lein/src/minimal_api_lein/handler.clj
@@ -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 を改修します。

minimal-api-lein/test/minimal_api_lein/test_helper.clj
@@ -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のデータ構造の変更を取り込めば、今まで通りすべてのテストがパスするはずです。

minimal-api-lein/test/minimal_api_lein/core_test.clj
@@ -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の導入

※ コミット: lein, clj (初回執筆時点)

今回の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定義します。

minimal-api-lein/src/minimal_api_lein/boundary/db/core.clj
@@ -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-idminimal-api-lein.boundary.db.core/db::id を受け取って ::todo または nil を返す関数として仕様を表現できるでしょう。

minimal-api-lein/src/minimal_api_lein/boundary/db/todo.clj
@@ -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)も有効化できるサードパーティライブラリOrchestraorchestra.spec.test/instrument 関数を利用します。

minimal-api-lein/project.clj
@@ -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"]
minimal-api-clj.core/deps.edn
@@ -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 でも使われている工夫です)。

minimal-api-lein/dev/src/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.elcider-ns-refresh-after-fn に登録します。

minimal-api-lein/dev/src/local.clj
(defn resume-instrument []
  (let [result (integrant.repl/resume)]
    (with-out-str (stest/instrument))
    result))
minimal-api-lein/.dir-locals.el
((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 を呼び出すようにしてみました。

minimal-api-lein/test/minimal_api_lein/test_helper.clj
@@ -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]
minimal-api-lein/test/minimal_api_lein/core_test.clj
@@ -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でのプログラミングを楽しみましょう>ω</

Further Reading

23
11
0

Register as a new user and use Qiita more conveniently

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