ClojureのLaciniaでGraphQL API開発してみた

Clojurianでラブライブ!ファンの[lagénorhynque](https://twitter.com/lagenorhynque) (a.k.a. カマイルカ🐬)です。

昨年のAdvent Calendarでは[ClojureフレームワークDuctを用いたREST API開発](https://qiita.com/lagenorhynque/items/57d5aa086c4a080a1c54)について書きましたが、今回はちょうど先月に会社のイベント[開発合宿2018 in 三浦海岸](https://tech-magazine.opt.ne.jp/entry/2018/12/10/170000)で取り組んだClojureでのGraphQL API開発についてご紹介します。

利用するのは、ClojureによるGraphQL実装["Lacinia"](https://github.com/walmartlabs/lacinia)です。

最終的なサンプルAPIはこちら: [lagenorhynque/aqoursql](https://github.com/lagenorhynque/aqoursql)

# API開発環境の構築

Laciniaはあくまで[GraphQL](https://graphql.org/)実装であり任意のサーバに載せて利用することが可能ですが、[Lacinia-Pedestal](https://github.com/walmartlabs/lacinia-pedestal)を使うと[Pedestal](http://pedestal.io/)のエンドポイントが手軽に整えられて便利です。

## 1. プロジェクトの生成

まずは[Leiningen](https://leiningen.org/)を利用してプロジェクトを生成します。

ここでは、サーバサイドのClojure開発をスムーズにするために[Duct](https://github.com/duct-framework/duct)を利用しています。

DBアクセス関連のデフォルト設定以外に特に必要なものはないので(このあと実際に利用するのはMariaDBですが😅) `+postgres` だけ付けて生成してみました。

```sh
$ lein new duct aqoursql +postgres
```

**※ ここまでの[diff](https://github.com/lagenorhynque/aqoursql/commit/62b4a5b9e40306f2050fba6cb5a50ff1c78823d4)** (初回執筆時点)

## 2. 各種ライブラリの導入と設定

Laciniaをはじめとした各種ライブラリを導入し、必要な設定ファイルを整えます。

- [project.clj](https://github.com/lagenorhynque/aqoursql/blob/master/project.clj)

```clj: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つのライブラリです。

- [com.walmartlabs/lacinia](https://github.com/walmartlabs/lacinia): Lacinia本体
- [com.walmartlabs/lacinia-pedestal](https://github.com/walmartlabs/lacinia-pedestal): LaciniaをPedestalのサービスとして組み込むライブラリ
- [duct.module.pedestal](https://github.com/lagenorhynque/duct.module.pedestal): PedestalのサーバをDuctモジュールとして組み込むライブラリ

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

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

- [resources/aqoursql/config.edn](https://github.com/lagenorhynque/aqoursql/blob/master/resources/aqoursql/config.edn)

```clj: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](https://github.com/lagenorhynque/duct.module.pedestal/blob/master/README.md)参照)。

また、LaciniaのGraphQLスキーマをコンパイルし、Pedestalのサービスマップを構築するために `:aqoursql.graphql/schema`, `:aqoursql.graphql/service` というコンポーネントを追加しています(後ほど実装します)。

開発環境、テスト環境固有の設定については `dev.edn`, `test.edn` に書いておきます。

- [dev/resources/dev.edn](https://github.com/lagenorhynque/aqoursql/blob/master/dev/resources/dev.edn)

```clj: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](https://github.com/lagenorhynque/aqoursql/blob/master/dev/resources/test.edn)

```clj: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}}}
```

- [docker-compose.yml](https://github.com/lagenorhynque/aqoursql/blob/master/docker-compose.yml)

最後に、ローカル開発/テスト環境用のDBを用意しておきます(`docker-compose` 便利🉐)。

**※ ここまでの[diff](https://github.com/lagenorhynque/aqoursql/commit/db451444e576848eb83479f2172d3f1d92ac6fd6)** (初回執筆時点)

## 3. DBとテストデータの準備

今回は既存のRDBのデータにアクセスするためのインターフェースをGraphQLベースのAPIで構築するという前提で、特にDBマイグレーションツール(e.g. [ragtime](https://github.com/weavejester/ragtime))は利用せず、DDLのダンプからローカル開発環境のDBを用意しました。

ここでは、サンプルAPIのために音楽の「楽曲」(`song`)、「アーティスト」(`artist`)、アーティストを構成する「メンバー」(`member`)、メンバーの所属する「組織」(`organization`)を管理するためのテーブル構成にしてみました。

アーティストとメンバーの多対多の関係は `artist_member` テーブルで表現します。

![aqoursql_db.png](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/4bfc9062752036a36e5303a45a5b350fffaeeb48/aqoursql_db.png)

DockerでDBを起動し、用意したSQLをインポートすればDBの準備は完了です。

```sh
# ローカル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](https://github.com/lagenorhynque/aqoursql/commit/4ecfc1991b1ee088743590314d89f4390ff1a7bd)** (初回執筆時点)

# APIの開発

開発環境の事前準備を終えて、いよいよGraphQL APIを開発していきます。

## 4. GraphQLスキーマの定義

まずはGraphQLのスキーマを定義します。

LaciniaではClojureのデータとして扱いやすい[edn](https://github.com/edn-format/edn)形式でGraphQLスキーマを表現することもできますが、今回はGraphQL標準のSDL (Schema Definition Language)を利用する(cf. [GraphQL SDL Parsing](https://lacinia.readthedocs.io/en/latest/schema/parsing.html))方針を採用しています。

初期段階では以下のようなスキーマを定義してみました。

- [resources/aqoursql/schema.graphql](https://github.com/lagenorhynque/aqoursql/blob/master/resources/aqoursql/schema.graphql) (※ リンク先は最終形)

```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によるメンバー取得
  """
  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](https://github.com/lagenorhynque/aqoursql/blob/master/src/aqoursql/graphql.clj) (※ リンク先は最終形)

```clj: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](https://github.com/lagenorhynque/aqoursql/blob/master/dev/src/dev.clj)

```clj: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サーバ)を起動してみましょう。

```clj
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](https://github.com/graphql/graphiql)の画面が開きます。

![graphiql.png](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/f78be789f1d703047d3dc1c3b04922a2c4019956/graphiql.png)

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

![graphiql_docs.png](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/f78be789f1d703047d3dc1c3b04922a2c4019956/graphiql_docs.png)

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

![graphiql_member_by_id_null.png](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/f78be789f1d703047d3dc1c3b04922a2c4019956/graphiql_member_by_id_null.png)

現時点ではクエリ `member_by_id` は常に `null`(Clojureコードでは `nil`) を返すように実装してあるので、GraphQL APIサーバは期待通りに動作しているようです。

**※ ここまでの[diff](https://github.com/lagenorhynque/aqoursql/commit/5e62c3475c33eade6b25996feca150eb9f7d7172)** (初回執筆時点)

## 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](https://github.com/lagenorhynque/aqoursql/blob/0ea6029920f3a2c39e9d3de9f443d219a03c9965/src/aqoursql/resolver/members.clj) (※ リンク先は初回執筆時点)

```clj: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](https://github.com/lagenorhynque/aqoursql/blob/master/resources/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)](https://github.com/lagenorhynque/aqoursql/commit/0ea6029920f3a2c39e9d3de9f443d219a03c9965#diff-5d54371b5f98df69bc32be1dcb7dc2b5) (※ リンク先は初回執筆時点)

```diff: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](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/f78be789f1d703047d3dc1c3b04922a2c4019956/graphiql_member_by_id_1.png)

![graphiql_member_by_id_100.png](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/f78be789f1d703047d3dc1c3b04922a2c4019956/graphiql_member_by_id_100.png)

**※ ここまでの[diff](https://github.com/lagenorhynque/aqoursql/commit/0ea6029920f3a2c39e9d3de9f443d219a03c9965)** (初回執筆時点)

## 7. DBアクセス層の実装

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

- [src/aqoursql/boundary/db/core.clj](https://github.com/lagenorhynque/aqoursql/blob/master/src/aqoursql/boundary/db/core.clj)

```clj: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.jdbc](https://github.com/seancorfield/next-jdbc)と[Honey SQL](https://github.com/seancorfield/honeysql)を利用しています。

典型的なアクセスパターンをユーティリティ関数として定義し、clojure.specで想定する入出力データの形式を明示するようにしてみました。

具体的なDBアクセス関数は境界(boundary)としてのプロトコルによってインターフェースと実装を分離し、疎結合にします。

例えば `member` テーブルを `id` 指定で検索する関数 `find-member-by-id` は以下のように実装できます。

ここでもclojure.specで関数のインターフェースを記述しておくと、開発/テスト時に関数がfail-fastになりとても便利です。

- [src/aqoursql/boundary/db/member.clj](https://github.com/lagenorhynque/aqoursql/blob/f1631c7ac9975d0b8503b3a4158866569ef90f43/src/aqoursql/boundary/db/member.clj) (※ リンク先は初回執筆時点)

```clj: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](https://github.com/lagenorhynque/aqoursql/blob/master/src/aqoursql/boundary/db/organization.clj)

```clj: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` テーブルのデータが取得できることが分かります。

```clj
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](https://github.com/lagenorhynque/aqoursql/commit/f1631c7ac9975d0b8503b3a4158866569ef90f43)** (初回執筆時点)

## 8. リゾルバの実装2

DBアクセス関数が用意できたので、仮実装の代わりにリゾルバ関数から実行するように書き換えましょう。

- [src/aqoursql/resolver/members.clj (diff)](https://github.com/lagenorhynque/aqoursql/commit/5a40e5127f7008085285e34554a13f011429a13b#diff-5d54371b5f98df69bc32be1dcb7dc2b5) (※ リンク先は初回執筆時点)

```diff: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](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/f78be789f1d703047d3dc1c3b04922a2c4019956/graphiql_member_by_id_1_db.png)

事前に用意したユーティリティ関数 `q` でREPLからGraphQLクエリ生成ライブラリ[venia](https://github.com/Vincit/venia)によるクエリを実行して同等の挙動を確認することもできます。

```clj
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](https://github.com/lagenorhynque/aqoursql/commit/5a40e5127f7008085285e34554a13f011429a13b)** (初回執筆時点)

同様にしてメンバーの一覧取得クエリ `members` を実装すると、無条件での `Member` の全件取得や

![graphiql_members.png](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/f78be789f1d703047d3dc1c3b04922a2c4019956/graphiql_members.png)

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

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

![graphiql_members_args.png](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/f78be789f1d703047d3dc1c3b04922a2c4019956/graphiql_members_args.png)

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

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

- [resources/aqoursql/schema.graphql](https://github.com/lagenorhynque/aqoursql/blob/master/resources/aqoursql/schema.graphql)

```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](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/088115c7e87726d81588bf70112ab2bc324a2e5f/graphiql_songs_artist_members.png)

**※ ここまでの[diff](https://github.com/lagenorhynque/aqoursql/compare/5a40e5127f7008085285e34554a13f011429a13b...00f0c0b428cff0c85c511fd487057edc059431fe)** (初回執筆時点)

# APIの改善

## 9. N+1問題の回避

先ほど実行したGraphQLクエリ

```graphql
{
  songs {
    name
    artist {
      name
      members {
        name
      }
    }
  }
}
```

について `logs/dev.log` に出力されているDBアクセスログを確認してみると、以下のように合計27のSQL文が発行されていることが分かります。

```sh
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](https://lacinia.readthedocs.io/en/latest/resolve/selections.html)を見ると、このようなネストしたクエリでの非効率なデータ取得を回避するための機能について説明があります。

[`com.walmartlabs.lacinia.executor/selects-field?`](https://github.com/walmartlabs/lacinia/blob/v0.30.0/src/com/walmartlabs/lacinia/executor.clj#L544-L575) は、リゾルバ関数において下位階層で特定のフィールドが選択されているかどうかを判定できる関数です。

この関数を活用すると、下の階層で要求されるデータを上の階層であらかじめまとめて取得しておくというパフォーマンス改善のための最適化を賢く実装することができます。

例えば、クエリ `song_by_id`, `songs` の解決時に `artist` が指定されていた場合に `Song` と `Artist` のデータをSQL 1回でまとめて取得することができるように、DBアクセス関数を書き換え、

- [src/aqoursql/boundary/db/song.clj (diff)](https://github.com/lagenorhynque/aqoursql/commit/06c82c0582cece080cfe524c28f6a87a07ca4cf8#diff-199c3dd4fc76205578e5ff0626d81db9) (※ リンク先は初回執筆時点)

```diff: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 (diff)](https://github.com/lagenorhynque/aqoursql/commit/06c82c0582cece080cfe524c28f6a87a07ca4cf8#diff-c51d85a46d8a22b62992954e3df09a82) (※ リンク先は初回執筆時点)

```diff: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` の値を解決するリゾルバ関数は不要になり、 `Song` の `artist` を選ぶと両者を一度に取得できるようになります。

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

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

![aqoursql_songs.png](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/196cdde82a94100e001f38fb80d24b8e97ea68e9/aqoursql_songs.png)

```sh
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](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/088115c7e87726d81588bf70112ab2bc324a2e5f/aqoursql_songs2.png)

```sh
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](https://gist.githubusercontent.com/lagenorhynque/8eaa1eda988c0cc865ff604fa4ca7467/raw/088115c7e87726d81588bf70112ab2bc324a2e5f/aqoursql_songs3.png)

```sh
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](https://github.com/lagenorhynque/aqoursql/compare/00f0c0b428cff0c85c511fd487057edc059431fe...89f5095b9a6dba2cd20f1316d39d68211902d94c)** (初回執筆時点)

## 10. テスト

最後に(本当はもっと早い段階で整えておいたほうが効果的ですが)、継続的な改善をスムーズにするためにテストコードを整備しましょう[^4]。

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

- [test/aqoursql/test_helper/core.clj](https://github.com/lagenorhynque/aqoursql/blob/master/test/aqoursql/test_helper/core.clj)

```clj: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](https://github.com/lagenorhynque/aqoursql/blob/master/test/aqoursql/test_helper/db.clj)

```clj: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](https://github.com/lagenorhynque/aqoursql/blob/master/test/aqoursql/resolver/songs_test.clj)

```clj: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](https://github.com/Vincit/venia)というライブラリを利用しています。

テストユーティリティを整備したことで見通し良くテストを書くことができました。

テストの過程で見つかったプロダクトコードの不具合や考慮漏れなどは適宜修正します。

- [src/aqoursql/resolver/songs.clj (diff)](https://github.com/lagenorhynque/aqoursql/commit/6953458325ced50a07518a1deeed8e7ff87e3503#diff-c51d85a46d8a22b62992954e3df09a82) (※ リンク先は初回執筆時点)

```diff: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](https://github.com/lagenorhynque/aqoursql/compare/39e7d9f45a0280acfbae4816d3c631c1d738e461...66c98f265f37eb83a45ee576ef3a91c9199fd479)** (初回執筆時点)

# まとめ

- Duct, Pedestal, Laciniaという組み合わせで比較的手軽に本格的なGraphQL APIを開発することができた
- Clojureライブラリのデータ駆動のアプローチとREPL駆動開発の生産性の高さを再認識した
- GraphQLの自由度の高さゆえにパフォーマンスやシステム負荷には注意が必要
- やはりClojureは超楽しい＞ω＜/

# Further Reading

[Clojure/ClojureScript関連リンク集 > Webサーバサイド (Clojure)](https://qiita.com/lagenorhynque/items/68c314c288b75a9492ba#web%E3%82%B5%E3%83%BC%E3%83%90%E3%82%B5%E3%82%A4%E3%83%89-clojure)

[^1]: もちろん、kebab-caseで統一したい場合にはスキーマコンパイル前にキーワード名を変換する処理を入れることもできますが、[Laciniaではあまり推奨されていない](https://lacinia.readthedocs.io/en/latest/tutorial/database-1.html#primary-keys)ようです。
[^2]: clojure.specの関数に対するチェックを強化するライブラリ[Orchestra](https://github.com/jeaye/orchestra)の `orchestra.spec.test/instrument` を `reset` 時に実行するようにしています。
[^3]: GraphiQLの画面左側のクエリエディタでは補完が効き、Prettifyボタンで整形、Historyボタンで履歴呼出しも可能です。
[^4]: 開発合宿当日のAPI開発では、テストコード、CI、Slack連携、GitHub連携などを事前に準備していたため開発がとてもスムーズでした💪