LoginSignup
21
8

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をはじめとした各種ライブラリを導入し、必要な設定ファイルを整えます。

project.clj
(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つのライブラリです。

これらのライブラリにより、Duct + Pedestal + Laciniaという構成で手軽にGraphQL API開発を始めることができます。

次にDuctの設定ファイルに必要なモジュール/コンポーネントの設定を追加します。

resources/aqoursql/config.edn
{: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 に書いておきます。

dev/resources/dev.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}}}
dev/resources/test.edn
{: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 テーブルで表現します。

aqoursql_db.png

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向けのユーティリティも整備しておきましょう。

dev/src/dev.clj
(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.png

GraphiQLでは、GraphQLのスキーマ定義によるドキュメントを確認することもできます(画面右上のDocsボタンを押すと開きます)。

graphiql_docs.png

左側にGraphQLのクエリを書き3、▶ボタンを押すと右側に実行結果が表示されます。

graphiql_member_by_id_null.png

現時点ではクエリ 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引数のマップから取り出すことができます。

ここでは id1 の場合だけオブジェクト Member に対応するマップデータを固定で返すように仮実装しました。

この関数 aqoursql.resolver.members/fetch-member-by-idattach-map でGraphQLスキーマに対応付けます。

src/aqoursql/graphql.clj
  (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 の結果が取得できることが確認できます。

graphiql_member_by_id_1.png

graphiql_member_by_id_100.png

※ ここまでのdiff (初回執筆時点)

7. DBアクセス層の実装

次は実際のDBからクエリ結果が取得できるように、DBアクセス層を実装します。

src/aqoursql/boundary/db/core.clj
(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.jdbcHoney 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]))))
src/aqoursql/boundary/db/organization.clj
(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
- (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 の結果として取得できることが確認できます。

graphiql_member_by_id_1_db.png

事前に用意したユーティリティ関数 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 の全件取得や

graphiql_members.png

dev> (q #:venia{:queries [[:members
                           [:name]]]})
{:data
 {:members
  ({:name "高海 千歌"}
   {:name "桜内 梨子"}
   {:name "松浦 果南"}
   {:name "黒澤 ダイヤ"}
   {:name "渡辺 曜"}
   {:name "津島 善子"}
   {:name "国木田 花丸"}
   {:name "小原 鞠莉"}
   {:name "黒澤 ルビィ"}
   {:name "鹿角 聖良"}
   {:name "鹿角 理亞"})}}

条件付きの絞り込み検索もできるようになります。

graphiql_members_args.png

dev> (q #:venia{:queries [[:members {:name "子"}
                           [:name]]]})
{:data {:members ({:name "桜内 梨子"} {:name "津島 善子"})}}

ここからさらにGraphQLスキーマを拡張し、

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によるアーティスト取得
  """
  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の良さがよく表れています。

graphiql_songs_artist_members.png

※ ここまでの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回

という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 が指定されていた場合に SongArtist のデータをSQL 1回でまとめて取得することができるように、DBアクセス関数を書き換え、

src/aqoursql/boundary/db/song.clj
     [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
  (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 の値を解決するリゾルバ関数は不要になり、 Songartist を選ぶと両者を一度に取得できるようになります。

同様の方法で Artistmembers フィールドの値も上位階層で効率良く一括取得できるように書き換えることができます。

以上のパフォーマンスチューニングを経て、改めて先ほどのような多階層のクエリを実行してみると、

aqoursql_songs.png

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発行、

aqoursql_songs2.png

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発行、

aqoursql_songs3.png

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

まずはテストコードを簡潔に書くためのヘルパー関数/マクロを用意します。

test/aqoursql/test_helper/core.clj
(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マクロは非常に便利🉐)。

test/aqoursql/test_helper/db.clj
(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)に対するテストは以下のように書くことができます。

test/aqoursql/resolver/songs_test.clj
(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
  (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)

  1. もちろん、kebab-caseで統一したい場合にはスキーマコンパイル前にキーワード名を変換する処理を入れることもできますが、Laciniaではあまり推奨されていないようです。

  2. clojure.specの関数に対するチェックを強化するライブラリOrchestraorchestra.spec.test/instrumentreset 時に実行するようにしています。

  3. GraphiQLの画面左側のクエリエディタでは補完が効き、Prettifyボタンで整形、Historyボタンで履歴呼出しも可能です。

  4. 開発合宿当日のAPI開発では、テストコード、CI、Slack連携、GitHub連携などを事前に準備していたため開発がとてもスムーズでした💪

21
8
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
21
8