Clojurianでラブライブ!ファンのlagénorhynque (a.k.a. カマイルカ🐬)です。
昨年のAdvent CalendarではClojureフレームワークDuctを用いたREST API開発について書きましたが、今回はちょうど先月に会社のイベント開発合宿2018 in 三浦海岸で取り組んだClojureでのGraphQL API開発についてご紹介します。
利用するのは、ClojureによるGraphQL実装"Lacinia"です。
最終的なサンプルAPIはこちら: lagenorhynque/aqoursql
API開発環境の構築
LaciniaはあくまでGraphQL実装であり任意のサーバに載せて利用することが可能ですが、Lacinia-Pedestalを使うとPedestalのエンドポイントが手軽に整えられて便利です。
1. プロジェクトの生成
まずはLeiningenを利用してプロジェクトを生成します。
ここでは、サーバサイドのClojure開発をスムーズにするためにDuctを利用しています。
DBアクセス関連のデフォルト設定以外に特に必要なものはないので(このあと実際に利用するのはMariaDBですが😅) +postgres
だけ付けて生成してみました。
$ lein new duct aqoursql +postgres
※ ここまでのdiff (初回執筆時点)
2. 各種ライブラリの導入と設定
Laciniaをはじめとした各種ライブラリを導入し、必要な設定ファイルを整えます。
(defproject aqoursql "0.1.0"
:description "AqoursQL, an example GraphQL API"
:url "https://github.com/lagenorhynque/aqoursql"
:min-lein-version "2.8.1"
:dependencies [[camel-snake-kebab "0.4.3"]
[clojure.java-time "1.4.2"]
[com.github.seancorfield/honeysql "2.5.1103"]
[com.github.seancorfield/next.jdbc "1.3.909"]
[com.walmartlabs/lacinia-pedestal "1.2" :exclusions [org.ow2.asm/asm]]
[duct.module.cambium "1.3.1" :exclusions [cheshire
org.clojure/tools.logging]]
[duct.module.pedestal "2.2.0"]
[duct/core "0.8.1"]
[duct/module.sql "0.6.1"]
[integrant "0.8.0"]
[io.pedestal/pedestal.jetty "0.6.3"]
[io.pedestal/pedestal.service "0.6.3"]
[metosin/malli "0.14.0"]
[org.clojure/clojure "1.11.1"]
[org.mariadb.jdbc/mariadb-java-client "3.3.2"]
[org.slf4j/slf4j-api "2.0.11"]]
:plugins [[duct/lein-duct "0.12.3"]]
:middleware [lein-duct.plugin/middleware]
:main ^:skip-aot aqoursql.main
:resource-paths ["resources" "target/resources"]
:prep-tasks ["javac" "compile" ["run" ":duct/compiler"]]
:profiles
{:repl {:prep-tasks ^:replace ["javac" "compile"]
:repl-options {:init-ns user}}
:dev [:shared :project/dev :profiles/dev]
:test [:shared :project/dev :project/test :profiles/test]
:uberjar [:shared :project/uberjar]
:shared {}
:project/dev {:source-paths ["dev/src"]
:resource-paths ["dev/resources"]
:dependencies [[clj-http "3.12.3" :exclusions [commons-io]]
[com.bhauman/rebel-readline "0.1.4"]
[com.gearswithingears/shrubbery "0.4.1"]
[eftest "0.6.0" :exclusions [org.clojure/tools.logging
org.clojure/tools.namespace]]
[fipp "0.6.26"]
[hawk "0.2.11"]
[integrant/repl "0.3.3" :exclusions [integrant]]
[orchestra "2021.01.01-1"]
[pjstadig/humane-test-output "0.11.0"]
[vincit/venia "0.2.5"]]
:plugins [[jonase/eastwood "1.4.2"]
[lein-ancient "0.7.0"]
[lein-cloverage "1.2.4"]
[lein-codox "0.10.8"]
[lein-kibit "0.1.8"]]
:aliases {"rebel" ^{:doc "Run REPL with rebel-readline."}
["trampoline" "run" "-m" "rebel-readline.main"]
"test-coverage" ^{:doc "Execute cloverage."}
["cloverage" "--ns-exclude-regex" "^(:?dev|user)$" "--codecov" "--junit"]
"lint" ^{:doc "Execute eastwood and kibit."}
["do"
["eastwood" "{:config-files [\"dev/resources/eastwood_config.clj\"]
:source-paths [\"src\"]
:test-paths []}"]
["kibit"]]}
:injections [(require 'pjstadig.humane-test-output)
(pjstadig.humane-test-output/activate!)]
:codox {:output-path "target/codox"
:source-uri "https://github.com/lagenorhynque/aqoursql/blob/master/{filepath}#L{line}"
:metadata {:doc/format :markdown}}}
:project/test {}
:project/uberjar {:aot :all
:uberjar-name "aqoursql.jar"}
:profiles/dev {}
:profiles/test {}})
テストやCIなどを見据えて開発支援ツール類をいろいろ設定していますが、重要なのは :dependencies
の以下3つのライブラリです。
- com.walmartlabs/lacinia: Lacinia本体
- com.walmartlabs/lacinia-pedestal: LaciniaをPedestalのサービスとして組み込むライブラリ
- duct.module.pedestal: PedestalのサーバをDuctモジュールとして組み込むライブラリ
これらのライブラリにより、Duct + Pedestal + Laciniaという構成で手軽にGraphQL API開発を始めることができます。
次にDuctの設定ファイルに必要なモジュール/コンポーネントの設定を追加します。
{:duct.profile/base
{:duct.core/project-ns aqoursql
:duct.server/pedestal
{:base-service #ig/ref :aqoursql.graphql/service
:service #:io.pedestal.http{:type :jetty
:join? true
:host #duct/env "SERVER_HOST"
:port #duct/env ["SERVER_PORT" Int :or 8888]}}
:aqoursql.graphql/schema
{:path "aqoursql/schema.graphql"}
:aqoursql.graphql/service
{:schema #ig/ref :aqoursql.graphql/schema
:options {:api-path "/graphql"
:subscriptions-path "/graphql-ws"
:ide-path "/"
:asset-path "/assets/graphiql"
:app-context {:db #ig/ref :duct.database/sql}
:env :prod}}}
:duct.profile/dev #duct/include "dev"
:duct.profile/test #duct/include "test"
:duct.profile/local #duct/include "local"
:duct.profile/prod {}
:duct.module/cambium {}
:duct.module/sql
{:database-url #duct/env "DATABASE_URL"}
:duct.module/pedestal {}}
今回の場合、duct.module.pedestalでPedestalサーバを利用するために :duct.module/pedestal
, :duct.server/pedestal
を追加する必要があります(詳しくはREADME参照)。
また、LaciniaのGraphQLスキーマをコンパイルし、Pedestalのサービスマップを構築するために :aqoursql.graphql/schema
, :aqoursql.graphql/service
というコンポーネントを追加しています(後ほど実装します)。
開発環境、テスト環境固有の設定については dev.edn
, test.edn
に書いておきます。
{:duct.database.sql/hikaricp
{:jdbc-url "jdbc:mariadb://localhost:3316/aqoursql?user=aqoursql_dev&password=password123"}
:duct.server/pedestal
{:service #:io.pedestal.http{:join? false}}
:aqoursql.graphql/service
{:options {:env :dev}}}
{:duct.database.sql/hikaricp
{:jdbc-url #duct/env ["TEST_DATABASE_URL" :or "jdbc:mariadb://localhost:3317/aqoursql_test?user=aqoursql_dev&password=password123"]}
:duct.server/pedestal
{:service #:io.pedestal.http{:port 8881}}}
最後に、ローカル開発/テスト環境用のDBを用意しておきます(docker-compose
便利🉐)。
※ ここまでのdiff (初回執筆時点)
3. DBとテストデータの準備
今回は既存のRDBのデータにアクセスするためのインターフェースをGraphQLベースのAPIで構築するという前提で、特にDBマイグレーションツール(e.g. ragtime)は利用せず、DDLのダンプからローカル開発環境のDBを用意しました。
ここでは、サンプルAPIのために音楽の「楽曲」(song
)、「アーティスト」(artist
)、アーティストを構成する「メンバー」(member
)、メンバーの所属する「組織」(organization
)を管理するためのテーブル構成にしてみました。
アーティストとメンバーの多対多の関係は artist_member
テーブルで表現します。
DockerでDBを起動し、用意したSQLをインポートすればDBの準備は完了です。
# ローカルDBを起動
$ docker compose up -d
# DBスキーマをインポート
$ docker compose exec -T mariadb mariadb -uroot -proot aqoursql < sql/ddl/aqoursql.sql
$ docker compose exec -T mariadb-test mariadb -uroot -proot aqoursql_test < sql/ddl/aqoursql.sql
# テストデータを投入
$ docker compose exec -T mariadb mariadb -uroot -proot aqoursql < sql/dml/seed.sql
※ ここまでのdiff (初回執筆時点)
APIの開発
開発環境の事前準備を終えて、いよいよGraphQL APIを開発していきます。
4. GraphQLスキーマの定義
まずはGraphQLのスキーマを定義します。
LaciniaではClojureのデータとして扱いやすいedn形式でGraphQLスキーマを表現することもできますが、今回はGraphQL標準のSDL (Schema Definition Language)を利用する(cf. GraphQL SDL Parsing)方針を採用しています。
初期段階では以下のようなスキーマを定義してみました。
- resources/aqoursql/schema.graphql (※ リンク先は最終形)
### schema
schema {
query: Query
}
### types
"""
アーティスト
"""
type Artist {
id: Int!
type: Int!
name: String!
members: [Member!]!
}
"""
メンバー
"""
type Member {
"メンバーID"
id: Int!
"メンバー名"
name: String!
"所属組織ID"
organization_id: Int!
"所属組織名"
organization_name: String!
}
type Query {
"""
IDによるメンバー取得
"""
member_by_id(
"メンバーID"
id: Int!
): Member
}
"""
楽曲
"""
type Song {
"楽曲ID"
id: Int!
"楽曲名"
name: String!
"アーティストID"
artist_id: Int!
"アーティスト"
artist: Artist!
"リリース日 (YYYY-MM-DD)"
release_date: String!
}
オブジェクト(type)として Artist
, Member
, Song
、クエリ(Query型のフィールド)として Member
をID指定で取得する member_by_id
を定義しています。
5. Lacinia-Pedestalの基礎構築
GraphQLスキーマを定義したら、次はそれをコンパイルしてAPIとして必要最低限の動作をするようにします。
resources/aqoursql/config.edn
に追加していたコンポーネント :aqoursql.graphql/schema
, :aqoursql.graphql/service
に実装を与える必要があります。
- src/aqoursql/graphql.clj (※ リンク先は最終形)
(ns aqoursql.graphql
(:require
[clojure.java.io :as io]
[com.walmartlabs.lacinia.parser.schema :as parser.schema]
[com.walmartlabs.lacinia.pedestal.subscriptions :as subscriptions]
[com.walmartlabs.lacinia.pedestal2 :as lacinia.pedestal2]
[com.walmartlabs.lacinia.schema :as schema]
[integrant.core :as ig]
[io.pedestal.http :as http]
[io.pedestal.http.jetty.websockets :as websockets]))
(def attach-map
{:resolvers {;; TODO: implement list-artist-members resolver
:Artist {:members (constantly [])}
;; TODO: implement fetch-member-by-id resolver
:Query {:member_by_id (constantly nil)}
;; TODO: implement fetch-artist-by-id resolver
:Song {:artist (constantly nil)}}})
(defmethod ig/init-key ::schema
[_ {:keys [path]}]
(-> (io/resource path)
slurp
(parser.schema/parse-schema attach-map)))
(defn routes [interceptors {:keys [api-path ide-path asset-path]
:as options}]
(into #{[api-path :post interceptors
:route-name ::graphql-api]
[ide-path :get (lacinia.pedestal2/graphiql-ide-handler options)
:route-name ::graphiql-ide]}
(lacinia.pedestal2/graphiql-asset-routes asset-path)))
(defmethod ig/init-key ::service
[_ {:keys [schema options]}]
(let [compiled-schema (schema/compile schema)
interceptors (lacinia.pedestal2/default-interceptors compiled-schema (:app-context options))]
(lacinia.pedestal2/enable-graphiql
{:env (:env options)
::http/routes (routes interceptors options)
::http/allowed-origins (constantly true)
::http/container-options
{:context-configurator
(fn [context]
(websockets/add-ws-endpoints context
{(:subscriptions-path options) nil}
{:listener-fn
(subscriptions/listener-fn-factory compiled-schema options)}))}})))
マルチメソッド integrant.core/init-key
により、 :aqoursql.graphql/schema
は起動時に resources
配下のGraphQLスキーマファイル(resources/aqoursql/schema.graphql
)を読み込み、リゾルバ関数を埋め込んでGraphQLスキーマデータになります。
ここで変数 attach-map
の :resolvers
配下でキーに対応する値は、フィールドの値を解決する関数(リゾルバ関数; resolver function)であり、Laciniaによるスキーマのコンパイル時に関数が埋め込まれます。
ちなみにClojureでは通常、キーワード名をkebab-caseにするのが一般的ですが、LaciniaではGraphQL標準のスタイルに倣ってsnake_caseで書くことが推奨されています1。
また、 :aqoursql.graphql/service
は起動時に schema
(= :aqoursql.graphql/schema
)とその他の設定値 options
をもとにPedestalのサービスマップを構築します。
以上でGraphQLスキーマを利用してLacinia-PedestalのGraphQL APIサーバが起動する仕組みが整備できました。
このタイミングでついでに開発環境のREPL向けのユーティリティも整備しておきましょう。
(ns dev
(:refer-clojure :exclude [test])
(:require
[clojure.java.io :as io]
[clojure.repl :refer :all]
[clojure.spec.alpha :as s]
[clojure.tools.namespace.repl :refer [refresh]]
[com.walmartlabs.lacinia :as lacinia]
[com.walmartlabs.lacinia.schema :as schema]
[duct.core :as duct]
[duct.core.repl :as duct-repl]
[eftest.runner :as eftest]
[fipp.edn :refer [pprint]]
[integrant.core :as ig]
[integrant.repl :refer [clear halt go init prep]]
[integrant.repl.state :refer [config system]]
[orchestra.spec.test :as stest]
[venia.core :as venia]))
(duct/load-hierarchy)
(defn read-config []
(duct/read-config (io/resource "aqoursql/config.edn")))
(defn reset []
(let [result (integrant.repl/reset)]
(with-out-str (stest/instrument))
result))
;;; unit testing
(defn test
([]
(eftest/run-tests (eftest/find-tests "test")
{:multithread? false}))
([sym]
(eftest/run-tests (eftest/find-tests sym)
{:multithread? false})))
;;; DB access
(defn db-run [f & args]
(apply f (:duct.database.sql/hikaricp system) args))
;;; GraphQL
(defn q
([query] (q query nil))
([query variables]
(lacinia/execute (schema/compile (:aqoursql.graphql/schema system))
(venia/graphql-query query)
variables
{:db (:duct.database.sql/hikaricp system)})))
;;; namespace settings
(clojure.tools.namespace.repl/set-refresh-dirs "dev/src" "src" "test")
(when (io/resource "local.clj")
(load "local"))
(def profiles
[:duct.profile/dev :duct.profile/local])
(integrant.repl/set-prep! #(duct/prep-config (read-config) profiles))
ユニットテスト、DBアクセス、GraphQLクエリ実行のためのユーティリティ関数を用意するとともに、システムの(再)起動とリロードに利用する integrant.repl/reset
をラップした関数 reset
2を定義してみました。
ここまでできたら、REPLに接続してシステム(GraphQL APIサーバ)を起動してみましょう。
user> (dev)
:loaded
dev> (reset)
:reloading (aqoursql.main dev aqoursql.graphql user)
INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
Creating your [DEV] server...
INFO org.eclipse.jetty.util.log - Logging initialized @38206ms to org.eclipse.jetty.util.log.Slf4jLog
INFO org.eclipse.jetty.server.Server - jetty-9.4.10.v20180503; built: 2018-05-03T15:56:21.710Z; git: daa59876e6f384329b122929e70a80934569428c; jvm 10.0.2+13
INFO o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@146ad897{/,null,AVAILABLE}
INFO o.e.jetty.server.AbstractConnector - Started ServerConnector@282cc43f{HTTP/1.1,[http/1.1, h2c]}{localhost:8888}
INFO org.eclipse.jetty.server.Server - Started @38515ms
:resumed
http://localhost:8888 にアクセスするとGraphiQLの画面が開きます。
GraphiQLでは、GraphQLのスキーマ定義によるドキュメントを確認することもできます(画面右上のDocsボタンを押すと開きます)。
左側にGraphQLのクエリを書き3、▶ボタンを押すと右側に実行結果が表示されます。
現時点ではクエリ member_by_id
は常に null
(Clojureコードでは nil
) を返すように実装してあるので、GraphQL APIサーバは期待通りに動作しているようです。
※ ここまでのdiff (初回執筆時点)
6. リゾルバの実装1
クエリ member_by_id
が要求する値を解決するリゾルバ関数をまずは仮実装します。
Laciniaにおける「(フィールド)リゾルバ関数」((field) resolver function)はコンテキストマップ context
、引数マップ args
、フィールドが属する解決済みの値のマップ value
の3引数関数なので、例えば member_by_id
のリゾルバ関数 fetch-member-by-id
は以下のように実装できます。
- src/aqoursql/resolver/members.clj (※ リンク先は初回執筆時点)
(ns aqoursql.resolver.members)
(defn fetch-member-by-id [{:keys [db]} {:keys [id]} _]
(get {1 {:id 1
:name "高海 千歌"
:organization_id 1
:organization_name "浦の星女学院"}}
id))
第1引数のコンテキストマップにはキー :db
でDBコネクションデータが含まれるようにresoures/aqoursql/config.ednで設定してあるのでここで取り出しています(実際に利用するのはDBアクセス関数実装後です)。
また、クエリ member_by_id
は引数 id
があるので第2引数のマップから取り出すことができます。
ここでは id
が 1
の場合だけオブジェクト Member
に対応するマップデータを固定で返すように仮実装しました。
この関数 aqoursql.resolver.members/fetch-member-by-id
を attach-map
でGraphQLスキーマに対応付けます。
- src/aqoursql/graphql.clj (diff) (※ リンク先は初回執筆時点)
(ns aqoursql.graphql
(:require
+ [aqoursql.resolver.members :as members]
[clojure.java.io :as io]
[com.walmartlabs.lacinia.parser.schema :as parser.schema]
[com.walmartlabs.lacinia.pedestal.subscriptions :as subscriptions]
[com.walmartlabs.lacinia.pedestal2 :as lacinia.pedestal2]
[com.walmartlabs.lacinia.schema :as schema]
[integrant.core :as ig]
[io.pedestal.http :as http]
[io.pedestal.http.jetty.websockets :as websockets]))
(def attach-map
{:resolvers {;; TODO: implement list-artist-members resolver
:Artist {:members (constantly [])}
- ;; TODO: implement fetch-member-by-id resolver
- :Query {:member_by_id (constantly nil)}
+ :Query {:member_by_id members/fetch-member-by-id}
;; TODO: implement fetch-artist-by-id resolver
:Song {:artist (constantly nil)}}})
この状態で (reset)
でシステムを再起動して先ほどのGraphQLクエリを実行してみると、期待通りに member_by_id
の結果が取得できることが確認できます。
※ ここまでのdiff (初回執筆時点)
7. DBアクセス層の実装
次は実際のDBからクエリ結果が取得できるように、DBアクセス層を実装します。
(ns aqoursql.boundary.db.core
(:require
[clojure.spec.alpha :as s]
[clojure.string :as str]
[duct.database.sql]
[honey.sql :as sql]
[next.jdbc]
[next.jdbc.quoted]
[next.jdbc.result-set]
[next.jdbc.sql]))
;;; DB access utilities
(s/def ::spec any?)
(s/def ::db (s/keys :req-un [::spec]))
(s/def ::sql-map (s/map-of keyword? any?))
(s/def ::table keyword?)
(s/def ::row-map (s/map-of keyword? any?))
(s/def ::row-count (s/and integer? (complement neg?)))
(s/def ::row-id (s/and integer? pos?))
(def sql-format-opts
{:dialect :mysql})
(s/fdef escape-like-param
:args (s/cat :s string?)
:ret string?)
(defn escape-like-param [s]
(str/replace s #"[\\_%]" "\\\\$0"))
(s/fdef select
:args (s/cat :db ::db
:sql-map ::sql-map)
:ret (s/coll-of ::row-map))
(defn select [{{:keys [datasource]} :spec} sql-map]
(next.jdbc.sql/query datasource (sql/format sql-map sql-format-opts)
{:builder-fn next.jdbc.result-set/as-unqualified-maps}))
(s/fdef select-first
:args (s/cat :db ::db
:sql-map ::sql-map)
:ret (s/nilable ::row-map))
(defn select-first [db sql-map]
(first (select db sql-map)))
(s/fdef select-count
:args (s/cat :db ::db
:sql-map ::sql-map)
:ret ::row-count)
(defn select-count [db sql-map]
(:row-count (select-first db (assoc sql-map
:select [[:%count.* :row-count]]))))
(s/fdef insert!
:args (s/cat :db ::db
:table ::table
:row-map ::row-map)
:ret ::row-id)
(defn insert! [{{:keys [datasource]} :spec} table row-map]
(-> datasource
(next.jdbc.sql/insert! table
row-map
{:table-fn (next.jdbc.quoted/schema next.jdbc.quoted/mysql)
:column-fn next.jdbc.quoted/mysql
:builder-fn next.jdbc.result-set/as-unqualified-maps})
:insert_id))
(s/fdef insert-multi!
:args (s/cat :db ::db
:table ::table
:row-maps (s/coll-of ::row-map :min-count 1))
:ret ::row-count)
(defn insert-multi! [{{:keys [datasource]} :spec} table row-maps]
(-> datasource
(next.jdbc/execute-one! (sql/format {:insert-into table
:values row-maps}
sql-format-opts))
:next.jdbc/update-count))
DBアクセスにはnext.jdbcとHoney SQLを利用しています。
典型的なアクセスパターンをユーティリティ関数として定義し、clojure.specで想定する入出力データの形式を明示するようにしてみました。
具体的なDBアクセス関数は境界(boundary)としてのプロトコルによってインターフェースと実装を分離し、疎結合にします。
例えば member
テーブルを id
指定で検索する関数 find-member-by-id
は以下のように実装できます。
ここでもclojure.specで関数のインターフェースを記述しておくと、開発/テスト時に関数がfail-fastになりとても便利です。
- src/aqoursql/boundary/db/member.clj (※ リンク先は初回執筆時点)
(ns aqoursql.boundary.db.member
(:require
[aqoursql.boundary.db.core :as db]
[aqoursql.boundary.db.organization :as organization]
[clojure.spec.alpha :as s]
[duct.database.sql]
[honey.sql.helpers :refer [where]]))
(s/def ::id nat-int?)
(s/def ::name string?)
(s/def ::organization_id ::organization/id)
(s/def ::organization_name ::organization/name)
(s/def ::member
(s/keys :req-un [::id
::name
::organization_id]
:opt-un [::organization_name]))
(s/fdef find-member-by-id
:args (s/cat :db ::db/db
:id ::id)
:ret (s/nilable ::member))
(defprotocol Member
(find-member-by-id [db id]))
(def sql-member-with-organization
{:select [:m.*
[:o.name :organization_name]]
:from [[:member :m]]
:join [[:organization :o]
[:= :m.organization_id :o.id]]})
(extend-protocol Member
duct.database.sql.Boundary
(find-member-by-id [db id]
(db/select-first db (where sql-member-with-organization [:= :m.id id]))))
(ns aqoursql.boundary.db.organization
(:require
[clojure.spec.alpha :as s]))
(s/def ::id nat-int?)
(s/def ::name string?)
あらかじめ用意しておいたREPL用ユーティリティ db-run
を利用して動作確認してみると、期待通りにDBから member
テーブルのデータが取得できることが分かります。
dev> (db-run aqoursql.boundary.db.member/find-member-by-id 1)
{:id 1, :name "高海 千歌", :organization_id 1, :organization_name "浦の星女学院"}
dev> (db-run aqoursql.boundary.db.member/find-member-by-id 100)
nil
※ ここまでのdiff (初回執筆時点)
8. リゾルバの実装2
DBアクセス関数が用意できたので、仮実装の代わりにリゾルバ関数から実行するように書き換えましょう。
- src/aqoursql/resolver/members.clj (diff) (※ リンク先は初回執筆時点)
- (ns aqoursql.resolver.members)
+ (ns aqoursql.resolver.members
+ (:require
+ [aqoursql.boundary.db.member :as db.member]))
(defn fetch-member-by-id [{:keys [db]} {:keys [id]} _]
- (get {1 {:id 1
- :name "高海 千歌"
- :organization_id 1
- :organization_name "浦の星女学院"}}
- id))
+ (db.member/find-member-by-id db id))
変更を反映してGraphiQLで試してみると、実際のDBの内容がクエリ member_by_id
の結果として取得できることが確認できます。
事前に用意したユーティリティ関数 q
でREPLからGraphQLクエリ生成ライブラリveniaによるクエリを実行して同等の挙動を確認することもできます。
dev> (q #:venia{:queries [[:member_by_id {:id 1}
[:id
:name
:organization_id
:organization_name]]]})
{:data
{:member_by_id
{:id 1, :name "高海 千歌", :organization_id 1, :organization_name "浦の星女学院"}}}
※ ここまでのdiff (初回執筆時点)
同様にしてメンバーの一覧取得クエリ members
を実装すると、無条件での Member
の全件取得や
dev> (q #:venia{:queries [[:members
[:name]]]})
{:data
{:members
({:name "高海 千歌"}
{:name "桜内 梨子"}
{:name "松浦 果南"}
{:name "黒澤 ダイヤ"}
{:name "渡辺 曜"}
{:name "津島 善子"}
{:name "国木田 花丸"}
{:name "小原 鞠莉"}
{:name "黒澤 ルビィ"}
{:name "鹿角 聖良"}
{:name "鹿角 理亞"})}}
条件付きの絞り込み検索もできるようになります。
dev> (q #:venia{:queries [[:members {:name "子"}
[:name]]]})
{:data {:members ({:name "桜内 梨子"} {:name "津島 善子"})}}
ここからさらにGraphQLスキーマを拡張し、
### schema
schema {
query: Query
}
### types
"""
アーティスト
"""
type Artist {
id: Int!
type: Int!
name: String!
members: [Member!]!
}
"""
メンバー
"""
type Member {
"メンバーID"
id: Int!
"メンバー名"
name: String!
"所属組織ID"
organization_id: Int!
"所属組織名"
organization_name: String!
}
type Query {
"""
IDによるアーティスト取得
"""
artist_by_id(
"アーティストID"
id: Int!
): Artist
"""
アーティスト一覧取得
"""
artists(
"アーティストタイプ (1: グループ, 2: ソロ)"
type: Int
"アーティスト名 (部分一致, 1文字以上)"
name: String
): [Artist!]!
"""
IDによるメンバー取得
"""
member_by_id(
"メンバーID"
id: Int!
): Member
"""
メンバー一覧取得
"""
members(
"メンバー名 (部分一致, 1文字以上)"
name: String
"所属組織名 (部分一致, 1文字以上)"
organization_name: String
): [Member!]!
"""
IDによる楽曲取得
"""
song_by_id(
"楽曲ID"
id: Int!
): Song
"""
楽曲一覧取得
"""
songs(
"楽曲名 (部分一致, 1文字以上)"
name: String
): [Song!]!
}
"""
楽曲
"""
type Song {
"楽曲ID"
id: Int!
"楽曲名"
name: String!
"アーティストID"
artist_id: Int!
"アーティスト"
artist: Artist!
"リリース日 (YYYY-MM-DD)"
release_date: String!
}
アーティスト artist_by_id
, artists
、楽曲 song_by_id
, songs
、楽曲のアーティスト Song > artist
、アーティストのメンバー Artist > members
も取得できるようにリゾルバの実装を進めると、例えば以下のように楽曲(song)のアーティスト(artist)のメンバー名(member.name)のように多階層のデータをたどって取得することができるようになります。
ユーザ側でほしい項目をクエリとして柔軟に指定できるGraphQLの良さがよく表れています。
※ ここまでのdiff (初回執筆時点)
APIの改善
9. N+1問題の回避
先ほど実行したGraphQLクエリ
{
songs {
name
artist {
name
members {
name
}
}
}
}
について logs/dev.log
に出力されているDBアクセスログを確認してみると、以下のように合計27のSQL文が発行されていることが分かります。
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `s`.* FROM `song` `s` ORDER BY `s`.`id` ASC"], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 1], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 1], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 1], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 1], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 1], :elapsed 6}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 1], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 2], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 2], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 2], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 2], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 3], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 3], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 3], :elapsed 3}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 3], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 4], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 4], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 4], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 4], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 5], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 5], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 5], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 5], :elapsed 2}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 5], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 5], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `a`.* FROM `artist` `a` WHERE `a`.`id` = ?" 6], :elapsed 1}
18-12-24 09:31:42 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE `am`.`artist_id` = ? ORDER BY `m`.`id` ASC" 6], :elapsed 1}
これは
- クエリ
songs
の第1階層に対する無条件検索: 1回- → 13レコード
-
artist
解決のためのID指定検索: 1回- → 1レコード
-
members
解決のためのID指定検索: 1回
-
- → 1レコード
-
- → 13レコード
という3階層分の検索が繰り返されることにより、 1 + 13 * (1 + 1 * 1) = 27
回SQLが発行されるためです。
ここではテストデータが少量のため大きな問題にはなりませんが、上位階層の検索結果件数に比例して下位階層のデータを取得するためのSQL発行数が増加するため、データ量が多くなるとパフォーマンス問題に直面する可能性が高い状態にあると言えます。
いわゆる「N+1問題」です。
Lacinia公式ドキュメントのNested Selectionsを見ると、このようなネストしたクエリでの非効率なデータ取得を回避するための機能について説明があります。
com.walmartlabs.lacinia.executor/selects-field?
は、リゾルバ関数において下位階層で特定のフィールドが選択されているかどうかを判定できる関数です。
この関数を活用すると、下の階層で要求されるデータを上の階層であらかじめまとめて取得しておくというパフォーマンス改善のための最適化を賢く実装することができます。
例えば、クエリ song_by_id
, songs
の解決時に artist
が指定されていた場合に Song
と Artist
のデータをSQL 1回でまとめて取得することができるように、DBアクセス関数を書き換え、
- src/aqoursql/boundary/db/song.clj (diff) (※ リンク先は初回執筆時点)
[clojure.spec.alpha :as s]
[duct.database.sql]
- [honey.sql.helpers :refer [order-by where]]))
+ [honey.sql.helpers :refer [join order-by select where]]))
(s/def ::id nat-int?)
(s/def ::name string?)
(s/def ::artist_id ::artist/id)
(s/def ::release_date #(instance? java.util.Date %))
+ (s/def ::with-artist? boolean?)
+ (s/def ::artist_type ::artist/type)
+ (s/def ::artist_name ::artist/name)
+
(s/def ::song
(s/keys :req-un [::id
::name
::artist_id
- ::release_date]))
+ ::release_date]
+ :opt-un [::artist_type
+ ::artist_name]))
(s/fdef find-song-by-id
:args (s/cat :db ::db/db
- :id ::id)
+ :tx-data (s/keys :req-un [::id]
+ :opt-un [::with-artist?]))
:ret (s/nilable ::song))
(s/fdef find-songs
:args (s/cat :db ::db/db
- :tx-data (s/nilable (s/keys :opt-un [::name]))))
+ :tx-data (s/nilable (s/keys :opt-un [::name
+ ::with-artist?]))))
(defprotocol Song
- (find-song-by-id [db id])
+ (find-song-by-id [db tx-data])
(find-songs [db tx-data]))
(def sql-song
(sql/build
:select :s.*
:from [[:song :s]]))
+ (defn select-artist [sql]
+ (-> sql
+ (select [:a.type :artist_type]
+ [:a.name :artist_name])
+ (join [:artist :a]
+ [:= :s.artist_id :a.id])))
+
(extend-protocol Song
duct.database.sql.Boundary
- (find-song-by-id [db id]
- (db/select-first db (where sql-song [:= :s.id id])))
- (find-songs [db {:keys [name]}]
+ (find-song-by-id [db {:keys [id with-artist?]}]
+ (db/select-first db (cond-> sql-song
+ with-artist? select-artist
+ id (where [:= :s.id id]))))
+ (find-songs [db {:keys [name with-artist?]}]
(db/select db (cond-> sql-song
+ with-artist? select-artist
name (where [:like :s.name (str \% name \%)])
true (order-by [:s.id :asc])))))
関数 com.walmartlabs.lacinia.executor/selects-field?
を利用して artist
指定時のみ一括取得するようにリゾルバ関数を書き換えます。
- src/aqoursql/resolver/songs.clj (diff) (※ リンク先は初回執筆時点)
(ns aqoursql.resolver.songs
(:require
- [aqoursql.boundary.db.song :as db.song]))
+ [aqoursql.boundary.db.song :as db.song]
+ [clojure.set :as set]
+ [com.walmartlabs.lacinia.executor :as executor]))
- (defn fetch-song-by-id [{:keys [db]} {:keys [id]} _]
- (db.song/find-song-by-id db id))
+ (defn- song-with-artist [song]
+ (let [artist (-> song
+ (select-keys [:artist_id :artist_type :artist_name])
+ (set/rename-keys {:artist_id :id
+ :artist_type :type
+ :artist_name :name}))]
+ (assoc song :artist artist)))
- (defn list-songs [{:keys [db]} args _]
- (db.song/find-songs db args))
+ (defn fetch-song-by-id [{:keys [db] :as context} args _]
+ (if (executor/selects-field? context :Song/artist)
+ (song-with-artist (db.song/find-song-by-id db (assoc args :with-artist? true)))
+ (db.song/find-song-by-id db args)))
+
+ (defn list-songs [{:keys [db] :as context} args _]
+ (if (executor/selects-field? context :Song/artist)
+ (map song-with-artist
+ (db.song/find-songs db (assoc args :with-artist? true)))
+ (db.song/find-songs db args)))
すると、個々の Song
オブジェクトのフィールド artist
の値を解決するリゾルバ関数は不要になり、 Song
の artist
を選ぶと両者を一度に取得できるようになります。
同様の方法で Artist
の members
フィールドの値も上位階層で効率良く一括取得できるように書き換えることができます。
以上のパフォーマンスチューニングを経て、改めて先ほどのような多階層のクエリを実行してみると、
18-12-24 11:40:30 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `s`.*, `a`.`type` AS `artist_type`, `a`.`name` AS `artist_name` FROM `song` `s` INNER JOIN `artist` `a` ON `s`.`artist_id` = `a`.`id` WHERE (`s`.`name` like ?) ORDER BY `s`.`id` ASC" "%aquarium%"], :elapsed 2}
18-12-24 11:40:30 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name`, `am`.`artist_id` AS `artist_id` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE (`am`.`artist_id` in (?)) ORDER BY `m`.`id` ASC" 1], :elapsed 3}
songs
クエリで artist
, members
を含めた条件付きの絞り込み検索(1レコード取得)で2回のSQL発行、
18-12-24 11:41:10 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `s`.*, `a`.`type` AS `artist_type`, `a`.`name` AS `artist_name` FROM `song` `s` INNER JOIN `artist` `a` ON `s`.`artist_id` = `a`.`id` ORDER BY `s`.`id` ASC"], :elapsed 2}
18-12-24 11:41:10 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `m`.*, `o`.`name` AS `organization_name`, `am`.`artist_id` AS `artist_id` FROM `member` `m` INNER JOIN `organization` `o` ON `m`.`organization_id` = `o`.`id` INNER JOIN `artist_member` `am` ON `m`.`id` = `am`.`member_id` WHERE (`am`.`artist_id` in (?, ?, ?, ?, ?, ?)) ORDER BY `m`.`id` ASC" 1 2 3 4 5 6], :elapsed 2}
また、 songs
クエリで artist
, members
を含めた無条件検索(13レコード取得)でも2回のSQL発行、
18-12-24 11:44:34 lagenorhynque-imac INFO [duct.database.sql.hikaricp:30] - :duct.database.sql/query {:query ["SELECT `s`.* FROM `song` `s` ORDER BY `s`.`id` ASC"], :elapsed 1}
さらに songs
クエリのみの無条件検索(13レコード取得)では1回のSQL発行に抑えることができています。
ユーザ側で自由にクエリを指定できるGraphQLでは非効率で高負荷なデータ取得も容易に発生しうるため、N+1問題の回避以外にもクエリの深さ制限やクエリ実行のタイムアウトなど、パフォーマンス/システム負荷の問題を軽減/回避するための考慮が実用上重要になりそうです。
※ ここまでのdiff (初回執筆時点)
10. テスト
最後に(本当はもっと早い段階で整えておいたほうが効果的ですが)、継続的な改善をスムーズにするためにテストコードを整備しましょう4。
まずはテストコードを簡潔に書くためのヘルパー関数/マクロを用意します。
(ns aqoursql.test-helper.core
(:require
[aqoursql.test-helper.db :refer [insert-db-data! truncate-all-tables!]]
[cheshire.core :as cheshire]
[clj-http.client :as client]
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
[duct.core :as duct]
[integrant.core :as ig]
[orchestra.spec.test :as stest]
[venia.core :as venia]))
(duct/load-hierarchy)
;;; fixtures
(defn instrument-specs [f]
(stest/instrument)
(f))
;;; macros for testing context
(defn test-system []
(-> (io/resource "aqoursql/config.edn")
duct/read-config
(duct/prep-config [:duct.profile/dev :duct.profile/test])))
(s/fdef with-system
:args (s/cat :binding (s/coll-of any?
:kind vector?
:count 2)
:body (s/* any?)))
(defmacro with-system [[bound-var binding-expr] & body]
`(let [~bound-var (ig/init ~binding-expr)]
(try
~@body
(finally (ig/halt! ~bound-var)))))
(s/fdef with-db-data
:args (s/cat :binding (s/coll-of any?
:kind vector?
:count 2)
:body (s/* any?)))
(defmacro with-db-data [[system db-data-map] & body]
`(let [db# (:duct.database.sql/hikaricp ~system)]
(try
(insert-db-data! db# ~db-data-map)
~@body
(finally (truncate-all-tables! db#)))))
;;; HTTP client
(def ^:private url-prefix "http://localhost:")
(defn- server-port [system]
(get-in system [:duct.server/pedestal :io.pedestal.http/port]))
(defn http-post [system url body & {:as options}]
(client/post (str url-prefix (server-port system) url)
(merge {:body body
:content-type :json
:accept :json
:throw-exceptions? false} options)))
;;; GraphQL utilities
(defn- query-json [q]
(-> q
(update :query venia/graphql-query)
cheshire/generate-string))
(s/def ::query (s/map-of keyword? any?))
(s/def ::variables (s/map-of keyword? any?))
(s/def ::q
(s/keys :req-un [::query]
:opt-un [::variables]))
(s/fdef run-query
:args (s/cat :system any?
:q ::q))
(defn run-query [system q]
(http-post system "/graphql" (query-json q)))
;;; JSON conversion
(defn ->json [obj]
(cheshire/generate-string obj))
(defn <-json [str]
(cheshire/parse-string str true))
;;; misc.
(defn nth-errors [json index]
(-> json <-json :errors (nth index)))
with-system
はテストケースの前後にテスト環境のシステム(GraphQL APIサーバ)を起動/停止し、 with-db-data
はテストケースの前後にテスト環境のDBにテストデータをまとめてINSERT/TRUNCATEするマクロです(このような用途でLispマクロは非常に便利🉐)。
(ns aqoursql.test-helper.db
(:require
[aqoursql.boundary.db.core :as db]
[clojure.spec.alpha :as s]
[next.jdbc]))
(s/def ::name string?)
(s/def ::table (s/keys :req-un [::name]))
(s/def ::db-data-map
(s/map-of keyword?
(s/coll-of ::db/row-map :min-count 1)))
(s/fdef select-tables
:args (s/cat :db ::db/db)
:ret (s/coll-of ::table))
(defn select-tables [db]
(db/select db {:select [[[:concat :table_schema "." :table_name] :name]]
:from :information_schema.tables
:where [:= :table_type "BASE TABLE"]}))
(s/fdef truncate-table!
:args (s/cat :db ::db/db
:table ::table)
:ret any?)
(defn truncate-table! [{{:keys [datasource]} :spec} table]
(next.jdbc/execute! datasource [(str "truncate table " (:name table))]))
(s/fdef set-foreign-key-checks!
:args (s/cat :db ::db/db
:enabled? boolean?)
:ret any?)
(defn set-foreign-key-checks! [{{:keys [datasource]} :spec} enabled?]
(next.jdbc/execute! datasource [(str "set @@session.foreign_key_checks = "
(if enabled? 1 0))]))
(s/fdef insert-db-data!
:args (s/cat :db ::db/db
:db-data-map ::db-data-map)
:ret any?)
(defn insert-db-data! [db db-data-map]
(set-foreign-key-checks! db false)
(doseq [[table records] db-data-map]
(db/insert-multi! db table records))
(set-foreign-key-checks! db true))
(s/fdef truncate-all-tables!
:args (s/cat :db ::db/db)
:ret any?)
(defn truncate-all-tables! [db]
(set-foreign-key-checks! db false)
(doseq [table (select-tables db)]
(truncate-table! db table))
(set-foreign-key-checks! db true))
こちらは aqoursql.test-helper.core/with-db-data
マクロを実現するためのDB一括INSERT/TRUNCATEを実行する関数群です(本質的ではない実装の詳細ですが、改善の余地がある気も🤔)。
テスト用のユーティリティが整ったら、今回はE2Eテスト的にGraphQL APIエンドポイントに対するテストを書いてみます。
例えばクエリ song_by_id
(リゾルバ関数 fetch-song-by-id
)に対するテストは以下のように書くことができます。
(ns aqoursql.resolver.songs-test
(:require
[aqoursql.test-helper.core :as helper :refer [with-db-data with-system]]
[aqoursql.test-helper.db-data :as db-data]
[aqoursql.util.const :as const]
[clojure.string :as str]
[clojure.test :as t]))
(t/use-fixtures
:once
helper/instrument-specs)
(t/deftest test-fetch-song-by-id
(with-system [sys (helper/test-system)]
(with-db-data [sys {:artist db-data/artist
:artist_member db-data/artist_member
:member db-data/member
:organization db-data/organization
:song db-data/song}]
(let [query #:venia{:operation #:operation{:type :query
:name "SongByIdWithArtistAndMembers"}
:queries [[:song_by_id {:id :$id}
[:id
:name
:artist_id
[:artist
[:id
:type
:name
[:members
[:id
:name
:organization_id
:organization_name]]]]
:release_date]]]
:variables [#:variable{:name "id"
:type :Int!}]}]
(t/testing "アーティストが取得できる"
(let [{:keys [status body]}
(helper/run-query sys {:query query
:variables {:id 1}})]
(t/is (= 200 status))
(t/is (= {:data {:song_by_id {:id 1
:name "君のこころは輝いてるかい?"
:artist_id 1
:artist {:id 1
:type 1
:name "Aqours"
:members [{:id 1
:name "黒澤 ダイヤ"
:organization_id 1
:organization_name "浦の星女学院"}
{:id 2
:name "渡辺 曜"
:organization_id 1
:organization_name "浦の星女学院"}
{:id 3
:name "津島 善子"
:organization_id 1
:organization_name "浦の星女学院"}]}
:release_date "2015-10-07"}}}
(-> body helper/<-json))))
(let [{:keys [status body]}
(helper/run-query sys {:query query
:variables {:id 100}})]
(t/is (= 200 status))
(t/is (= {:data {:song_by_id nil}}
(-> body helper/<-json)))))
(t/testing "members選択なし"
(let [query #:venia{:operation #:operation{:type :query
:name "SongByIdWithArtist"}
:queries [[:song_by_id {:id :$id}
[:id
:name
:artist_id
[:artist
[:id
:type
:name]]
:release_date]]]
:variables [#:variable{:name "id"
:type :Int!}]}]
(let [{:keys [status body]}
(helper/run-query sys {:query query
:variables {:id 1}})]
(t/is (= 200 status))
(t/is (= {:data {:song_by_id {:id 1
:name "君のこころは輝いてるかい?"
:artist_id 1
:artist {:id 1
:type 1
:name "Aqours"}
:release_date "2015-10-07"}}}
(-> body helper/<-json))))
(let [{:keys [status body]}
(helper/run-query sys {:query query
:variables {:id 100}})]
(t/is (= 200 status))
(t/is (= {:data {:song_by_id nil}}
(-> body helper/<-json))))))
(t/testing "artistもmembersも選択なし"
(let [query #:venia{:operation #:operation{:type :query
:name "SongByIdWithoutArtistAndMembers"}
:queries [[:song_by_id {:id :$id}
[:id
:name
:artist_id
:release_date]]]
:variables [#:variable{:name "id"
:type :Int!}]}]
(let [{:keys [status body]}
(helper/run-query sys {:query query
:variables {:id 1}})]
(t/is (= 200 status))
(t/is (= {:data {:song_by_id {:id 1
:name "君のこころは輝いてるかい?"
:artist_id 1
:release_date "2015-10-07"}}}
(-> body helper/<-json))))
(let [{:keys [status body]}
(helper/run-query sys {:query query
:variables {:id 100}})]
(t/is (= 200 status))
(t/is (= {:data {:song_by_id nil}}
(-> body helper/<-json))))))))))
GraphQLのクエリ構築には、Clojureのデータで表現できるveniaというライブラリを利用しています。
テストユーティリティを整備したことで見通し良くテストを書くことができました。
テストの過程で見つかったプロダクトコードの不具合や考慮漏れなどは適宜修正します。
- src/aqoursql/resolver/songs.clj (diff) (※ リンク先は初回執筆時点)
(defn fetch-song-by-id [{:keys [db] :as context} args _]
(cond
(executor/selects-field? context :Artist/members)
- (let [song (db.song/find-song-by-id db (assoc args :with-artist? true))
- members (db.member/find-members db {:artist_id (:artist_id song)})]
- (-> song
- song-with-artist
- (assoc-in [:artist :members] members)))
+ (when-let [song (db.song/find-song-by-id db (assoc args :with-artist? true))]
+ (let [members (db.member/find-members db {:artist_id (:artist_id song)})]
+ (-> song
+ song-with-artist
+ (assoc-in [:artist :members] members))))
(executor/selects-field? context :Song/artist)
- (song-with-artist (db.song/find-song-by-id db (assoc args :with-artist? true)))
+ (some-> (db.song/find-song-by-id db (assoc args :with-artist? true)) song-with-artist)
:else
(db.song/find-song-by-id db args)))
このようにAPIの振る舞いを保証するテストを書き、CIで自動実行し、テストカバレッジを高い水準に保つことで、コードベースを継続的に改善するための基盤が手に入るはずです。
※ ここまでのdiff (初回執筆時点)
まとめ
- Duct, Pedestal, Laciniaという組み合わせで比較的手軽に本格的なGraphQL APIを開発することができた
- Clojureライブラリのデータ駆動のアプローチとREPL駆動開発の生産性の高さを再認識した
- GraphQLの自由度の高さゆえにパフォーマンスやシステム負荷には注意が必要
- やはりClojureは超楽しい>ω</
Further Reading
Clojure/ClojureScript関連リンク集 > Webサーバサイド (Clojure)
-
もちろん、kebab-caseで統一したい場合にはスキーマコンパイル前にキーワード名を変換する処理を入れることもできますが、Laciniaではあまり推奨されていないようです。 ↩
-
clojure.specの関数に対するチェックを強化するライブラリOrchestraの
orchestra.spec.test/instrument
をreset
時に実行するようにしています。 ↩ -
GraphiQLの画面左側のクエリエディタでは補完が効き、Prettifyボタンで整形、Historyボタンで履歴呼出しも可能です。 ↩
-
開発合宿当日のAPI開発では、テストコード、CI、Slack連携、GitHub連携などを事前に準備していたため開発がとてもスムーズでした💪 ↩