Duct でデータベースを扱う
前回 に引き続き Duct について説明します。 Duct にある程度初期セットアップを入れた neumann-tokyo/duct-init をベースにして話します。
データベースの扱いと config.edn の動き
データベースコネクション
Duct は config.edn に各関数の依存関係を記述することによりシステム全体が動くようにします。 今回はデータベースを使うことに注目して config.edn の設定を見てみましょう。 次のファイルを見てください。
{:duct.profile/base
{:duct.core/project-ns duct-init
:duct-init.db/take-datasource {:db #ig/ref :duct.database.sql/hikaricp}
;; ... 後略
}
}
4 行目の設定がデータベースに関する設定です。
:duct-init.db/take-datasource
については後で説明するので先に :duct.database.sql/hikaricp
に注目してください。
ここでは duct-framework/database.sql.hikaricp というライブラリを使っています。 これは hikaricp によりデータベースのコネクションプールを作ることができるライブラリで
{:duct.database.sql/hikaricp {:jdbc-url "jdbc:sqlite:db/example.sqlite"}}
のような設定を config.edn に書くだけでデータベースに接続できます。 しかしこの設定は resources/duct_init/config.edn にありません。 dev/resources/dev.edn にあります。
{:duct.core/environment :development
:duct.database.sql/hikaricp
{:jdbc-url "jdbc:postgresql://localhost:5433/duct-init-development?user=postgres&password=password"}}
データベースの接続情報は環境によって変えたいので dev/resources/dev.edn に記述します。 このように duct では環境ごとに設定ファイルを分けることもできます。
話を :duct-init.db/take-datasource
に戻します。
データベースへの接続は duct-framework/database.sql.hikaricp で出来ているのですが、これの返す情報をそのまま使うのが少し不便だったので戻り値を少し修正するために :duct-init.db/take-datasource
というラッパーを用意しました。
これの実装は src/duct_init/db.clj です。
(ns duct-init.db
(:require [integrant.core :as ig]))
(defmethod ig/init-key ::take-datasource [_ {:keys [db]}]
(-> db :spec :datasource))
Duct の設定ファイルのキー名と同じ名前の keyword を defmethod ig/init-key
の引数に入れることで (go)
したときに呼び出す multimethod を定義することが出来ます。
この関数の引数の {:keys [db]}
には config.edn で設定した db
の値が渡ってきます。 config.edn は
:duct-init.db/take-datasource {:db #ig/ref :duct.database.sql/hikaricp}
ですので、db
には :duct.database.sql/hikaricp
の実行結果が入ります (#ig/ref
には指定したキーワードの multimethod を実行した結果を取得する効果があります)。
上記の結果として :duct-init.db/take-datasource
を利用することでデータベースの :datasource
情報を取得することが出来ます。
Boundary で SQL を発行する
データベースの :datasource
情報を seancorfield/next-jdbc の関数に渡せばデータベースに SQL を発行する事ができます。
この振る舞いも config.edn の中に定義してあります。
{:duct.profile/base
{:duct.core/project-ns duct-init
;; 中略
:duct-init.boundary.articles/create {:db #ig/ref :duct-init.db/take-datasource}
:duct-init.boundary.articles/update {:db #ig/ref :duct-init.db/take-datasource}
:duct-init.boundary.articles/delete {:db #ig/ref :duct-init.db/take-datasource}
:duct-init.boundary.articles/get-by-id {:db #ig/ref :duct-init.db/take-datasource}}
}
:duct-init.boundary.articles/
から始まるキーワードに対して db
という引数で duct-init.db/take-datasource
の結果を代入するように設定してあります。 :duct-init.boundary.articles/
たちの実装を見てみましょう。
これは src/duct_init/boundary/articles.clj にあります。
例えば :duct-init.boundary.articles/create
の場合、次の multimethod が呼ばれます。
(ns duct-init.boundary.articles
(:require [integrant.core :as ig]
[next.jdbc.sql :as sql]
[next.jdbc.date-time]
[java-time.api :as jt]
[duct-init.util :as util]))
(defmethod ig/init-key ::create [_ {:keys [db]}]
(fn [{:keys [title body]}]
(-> db
(sql/insert! :articles
{:title title
:body body})
util/structured)))
;; 後略
Duct の config.edn から db を受け取って、 articles
テーブルにデータを挿入する関数を返すようになっています。
sql/insert!
については next.jdbc のドキュメント を参照してください。
Boundary を Handler から利用
:duct-init.boundary.articles/create
も multimethod なので config.edn 上で他の multimethod にわたすことが出来ます。
この設定は config.edn 中では
:duct-init.handler.articles/create {:articles-create #ig/ref :duct-init.boundary.articles/create}
の定義になります。
:duct-init.handler.articles/create
という multimethod の引数で :articles-create
というキー名で :duct-init.boundary.articles/create
の実行結果(すなわち articles
テーブルにデータを挿入する関数)を入れてます。
少し Duct の動きに慣れてきたでしょうか?
:duct-init.handler.articles/create
の実装を見てみましょう。
これは src/duct_init/handler/articles.clj にあります。
(ns duct-init.handler.articles
(:require [integrant.core :as ig]))
;; 中略
(defmethod ig/init-key ::create [_ {:keys [articles-create]}]
(fn [{:keys [body-params]}]
(let [article (articles-create body-params)]
{:status 200
:body article})))
body-params
というキーから値を受け取って articles-create
にわたす実装になっています。
この関数は ring の Handler として動作する想定なので body-params
は HTTP リクエストの Body の JSON をパースしたものが入ります。
(この振る舞いは Clojure のルーティングライブラリである metosin/reitit により実装していますが、これの説明は次回行います)
ここで注目するべきは Handler である duct-init.handler.articles/create
は一切データベースに依存していないということです。
Duct では関数同士の依存関係を config.edn で記述することで設定し、コード中の実装では他の関数に依存しなくて良いようになっています。
これはテストしやすさに繋がります。
duct-init.handler.articles/create
をテストするとき articles-create
に第1引数を取って適当なオブジェクトを返すような関数 (つまり (fn [x] x)
みたいな関数) を渡してやればテストがかけることを示しています。
neumann-tokyo/duct-init では Handler でこのようなテストは書いてませんが、柔軟さは伝わると思います。
Boundary のテスト
テストの話が出てきたので Boundary のテストを見てみましょう。 test/duct_init/boundary/articles_test.clj は次のようになっています。
(ns duct-init.boundary.articles-test
(:require [clojure.test :as t]
[test-utils :as tu]))
(t/deftest boundary-articles-create-test
(t/testing "create an article"
(let [create (tu/ig-get :duct-init.boundary.articles/create)
article (create {:title "hello" :body "world"})]
(t/is (= (get-in article [:articles :title]) "hello"))
(t/is (= (get-in article [:articles :body]) "world")))))
;; 後略
Duct プロジェクトでテストを書くアプローチは 2 通りあります。
- config.edn などを全体的に動かしてテストする
defmethod ig/init-key
を呼び出してそれだけテストする
場合によってこの 2 つを使い分けて良いと思いますが、上記のテストは前者の方法で書いています。
はじめに [test-utils :as tu]
という便利機能を読み込んでいるのでこの実装を見てみましょう。
test/test_utils.clj
(ns test-utils
(:require [clojure.java.io :as io]
[duct.core :as duct]
[integrant.core :as ig]
[jsonista.core :as j]))
(duct/load-hierarchy)
(defonce system
(-> (duct/read-config (io/resource "duct_init/config.edn"))
(duct/prep-config [:duct.profile/test :duct.profile/local])
(ig/init)))
(defn ig-get [key]
(get system key))
;; 後略
defonce を使って system
という変数を定義しています。
この動きは次のようになります。
(duct/read-config (io/resource "duct_init/config.edn"))
により resources/duct_init/config.edn を設定ファイルとして読み込みます。(duct/prep-config [:duct.profile/test :duct.profile/local])
により:duct.profile/test
と:duct.profile/local
を有効にします。これにより dev/resources/test.edn も設定ファイルとして読み込みます。これによりテスト用のデータベースを使うことが出来ます。ig/init
関数により config.edn などの設定ファイルを結合した Hashmap を取得することが出来ます。 (次に定義してあるig-get
関数は単にsystem
から引数で指定されたキーのデータを取得するだけです。)
これにより REPL で (go)
したときとほぼ同じ状況を test 環境で再現できます。
test/duct_init/boundary/articles_test.clj に戻りましょう。
(ns duct-init.boundary.articles-test
(:require [clojure.test :as t]
[test-utils :as tu]))
(t/deftest boundary-articles-create-test
(t/testing "create an article"
(let [create (tu/ig-get :duct-init.boundary.articles/create) ;; ここに注目
article (create {:title "hello" :body "world"})]
(t/is (= (get-in article [:articles :title]) "hello"))
(t/is (= (get-in article [:articles :body]) "world")))))
;; 後略
上記のテストでは (tu/ig-get :duct-init.boundary.articles/create)
により articles テーブルにデータを挿入する関数を取得しています。
この関数 create
の振る舞いをテストすることで boundary のテストとしています。
テストの実行方法は前回説明しましたが
./runner test
です。通常の Duct プロジェクトでは REPL で (test)
関数を呼び出してテストを実行しますが、 neumann-tokyo/duct-init では上記の方法でテストを実行するので注意してください。
まとめ
今回はデータベースの利用方法に注目して config.edn の動きについても説明しました。 このあたりの振る舞いが頭の中でイメージできるようになると Duct を使いこなせるようになると思います。
次回はルーティングについて説明したいと思います。