Clojure Camp

一覧に戻る

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 を使いこなせるようになると思います。

次回はルーティングについて説明したいと思います。

一覧に戻る