Clojure
ClojureScript
Reagent
GraphQL
Lacinia
ClojureDay 17

ClojureでGraphQLサーバを立てる

これは Clojure Advent Calendar 2017 17 日目の記事です。
GraphQL サーバの Clojure 実装 Lacinia を紹介します。

GraphQL

GraphQL はデータアクセス用のクエリ言語です。スキーマの定義仕様がセットになっているため、型に守られた API を定義することができます。今年も一気にとは行きませんでしたが、じわじわと普及してきました。

Lacinia

GraphQL は仕様であるため様々な言語による実装が存在します。その中の Clojure 実装が Walmart Labs (!) が開発する Lacinia です。Lacinia を使うとスキーマは EDN として定義でき、データアクセスは Clojure で実装出来るため Clojure アプリに柔軟に組み込むことができます。

とりあえず使ってみる

今回使う各ライブラリのバージョンは下記サンプルアプリの依存を参照して下さい。
https://github.com/223kazuki/lacinia-app/blob/master/project.clj

スキーマ定義

Lacinia を使うためにはまずスキーマを定義します。スキーマ定義は EDN で記述します。

schema.edn
{:objects
 {:BoardGame
  {:fields
   {:id {:type (non-null ID)}
    :name {:type (non-null String)}
    :summary
    {:type String
     :description "A one-line summary of the game."}
    :description
    {:type String
     :description "A long-form description of the game."}
    :designers
    {:type (non-null (list (non-null :Designer)))
     :description "Designers who contributed to the game."
     :resolve :BoardGame/designers}
    :min_players
    {:type Int
     :description "The minimum number of players the game supports."}
    :max_players
    {:type Int
     :description "The maximum number of players the game supports."}
    :play_time
    {:type Int
     :description "Play time in minutes for a typical game."}}
   :description "A physical or virtual board game."}
  :Designer
  {:fields
   {:id {:type (non-null ID)}
    :name {:type (non-null String)}
    :url
    {:type String
     :description "Home page URL if known."}
    :games
    {:type (non-null (list (non-null :BoardGame)))
     :description "Games designed by this designer."
     :resolve :Designer/games}}
   :description
   "A person who may have contributed to a board game design."}}
 :queries
 {:game_by_id
  {:type :BoardGame
   :description "Access a BoardGame by its unique id if it exists."
   :args {:id {:type ID}}
   :resolve :query/game-by-id}}}

GraphQL スキーマを見たことがある人なら対応が分かると思いますが、:objects でオブジェクトの型を定義しており、:queries でクエリを定義 (引数と返り値型) しています。それぞれの内容に :resolve が存在していますが、これは後述の Resolver に紐づきます。

Resolver 実装

Resolver はデータアクセスを実装する Clojure 関数です。Resolver は下記の引数を受け取り、スキーマ定義中の型スペックを満たすデータを返すよう実装します。

  • context: 実行時コンテクスト
  • args: クエリから抽出された引数マップ
  • value: 親オブジェクト(例:BoardGame/designers の field resolver の場合 BoardGame エンティティが渡る)

作成した各 Resolver はスキーマ定義内の :resolve で指定されたキーワードをキーにマップ化します。

;; Resolver 実装
(defn resolve-game-by-id
  [context args _]
  ;; args から id を抽出して BoardGame を返す
  ;; Ex. args {:id "1237"} 
  ;; 略
  )

(defn resolve-board-game-designers
  [context args board-game]
  ;; 第三引数には親オブジェクトが渡る
  ;; 略
  )

(defn resolve-designer-games
  [context args designer]
  ;; 略
  )

;; :resolve キーに対しマッピング
(def resolver-map
  {:query/game-by-id resolve-game-by-id
   :BoardGame/designers resolve-board-game-designers
   :Designer/games resolve-designer-games})

スキーマコンパイル

スキーマ定義 (EDN) と Resolver マップを基にスキーマをコンパイルします。

(require '[clojure.java.io :as io]
         '[clojure.edn :as edn]
         '[com.walmartlabs.lacinia.util :as util]
         '[com.walmartlabs.lacinia.schema :as schema])

(def schema
  (-> (io/resource "schema.edn")
      slurp
      edn/read-string                       ;; スキーマ定義読み込み
      (util/attach-resolvers resolver-map)  ;; Resolver をアタッチ
      schema/compile))                      ;; コンパイル

クエリ実行

コンパイル済みのスキーマはシステムで一つ管理し、それとクエリを引数に execute 関数を実行することでデータアクセスできます。execute は Clojure データを返してくれるので、サーバ内でクエリ実行する場合はそのまま利用できます。

(require '[com.walmartlabs.lacinia :as lacinia])

(lacinia/execute schema "query{game_by_id(id:\"1237\"){id,name}}" nil nil)
;; => {:data #ordered/map ([:game_by_id #ordered/map ([:id 1237] [:name 7 Wonders: Duel])])}

HTTP サーバーに乗せる

HTTP サーバへの乗せ方は自由ですが lacinia-pedestal を使うことで簡単に pedestal に組み込めます。必要なのはコンパイル済みスキーマのみで、スキーマをサービスにマッピングしてサーバを生成し、起動すると 8888 ポートから GraphQL サーバエンドポイントにアクセス可能になります。

(require '[com.walmartlabs.lacinia.pedestal :as pedestal]
         '[io.pedestal.http :as http])

(-> (pedestal/service-map schema {}) ;; スキーマをサービスにマッピング
    http/create-server               ;; サーバ作成
    http/start)                      ;; 起動

curl で実行。

curl -H "Content-type: application/graphql" -XPOST http://localhost:8888/graphql -d "query{game_by_id(id:\"1237\"){id,name}}"
# => {"data":{"game_by_id":{"id":"1237","name":"7 Wonders: Duel"}}}

GraphiQL

また、lacinia-pedestal では option 一つで GraphiQL エンドポイントも追加可能です。名前が紛らわしいですが GraphiQL は GraphQL の実行クライアントで、GraphQL のスキーマ情報を使うことでドキュメントが参照できたり、クエリの補完が効いたりします。これにより GraphQL のスキーマの力を存分に味わうことが出来るようになります。

(require '[com.walmartlabs.lacinia.pedestal :as pedestal]
         '[io.pedestal.http :as http])

(-> (pedestal/service-map schema {:graphiql true}) ;; options
    http/create-server
    http/start)

http://localhost:8888/ にアクセス。左部のウィンドウにクエリを入力し、Ctrl + Enter または Cmd + Enter でクエリを実行出来ます。

ezgif.com-video-to-gif (1).gif

補完がサクサク効いて楽しいです。また、Boardgame -> Designer -> BoardGame という循環参照も扱えています。

CORS 対応

後述の Reagent からの呼び出しを試そうとしたら CORS 対応入ってなくて躓きました。pedestal なので interceptors(ring でいう middleware)、route をいじって対応しました。allow-origin はとりあえず全許可(some?)です。

(require '[com.walmartlabs.lacinia.pedestal :as pedestal]
         '[io.pedestal.http :as http]
         '[io.pedestal.http.route :as route]
         '[io.pedestal.http.cors :refer [allow-origin]])

(let [options {:graphiql true}
      ;; preflight route
      preflight-route ["/graphql" :options (fn [_] {:status 200}) :route-name ::preflight]
      ;; allow-origin interceptor, preflight route 追加
      routes (as-> (pedestal/graphql-routes schema options) $
               (conj $ preflight-route)
               (route/expand-routes $)
               (map (fn [route] (update-in route [:interceptors] (partial cons (allow-origin {:allowed-origins some?})))) $))]
  (-> (pedestal/service-map schema (assoc options :routes routes))
      http/create-server
      http/start))

これでクロスドメイン呼び出し可能な GraphQL サーバが立ちました。

スキーマ定義を汎用化する

EDN によるスキーマ定義は Clojurian にとっては扱いやすいですが可視性には欠けます。また、定義上のエンティティに対し Spec を定義する場合、二重管理になってしまいます。

こういった問題を解決してくれるのが umlaut です。umlaut は Clojure をターゲットにしたスキーマ定義言語で、定義ファイル (.umlaut) を Lacinia スキーマ、Clojure Spec、Graphviz ファイル (.dot) などへコンパイルしてくれます。

定義ファイルは、大体 GraphQL のスキーマ定義ファイルと同形式で、各コンパイルターゲットの独自属性は @lang/[target] アノテーションにより指定ます。

schema.umlaut
@doc "A physical or virtual board game."
type BoardGame {
  id: ID
  name: String
  summary: String? {
    @doc "A one-line summary of the game."
  }
  description: String? {
    @doc "A long-form description of the game."
  }
  designers: Designer[0..n] {
    @doc "Designers who contributed to the game."
    @lang/lacinia resolver BoardGame_designers
  }
  min_players: Integer? {
    @doc "The minimum number of players the game supports."
  }
  max_players: Integer? {
    @doc "The maximum number of players the game supports."
  }
  play_time: Integer? {
    @doc "Play time, in minutes, for a typical game."
  }
}

@doc "A person who may have contributed to a board game design."
type Designer {
  id: ID
  name: String
  url: String? {
    @doc "Home page URL, if known."
    @lang/spec validator url?
  }
  games: BoardGame[0..n] {
    @doc "Games designed by this designer."
    @lang/lacinia resolver Designer_games
  }
}

@lang/lacinia identifier query
type QueryRoot {
  game_by_id(id: ID?): BoardGame? {
    @doc "Access a BoardGame by its unique id, if it exists."
    @lang/lacinia resolver query_game-by-id
  }
}

上記の場合、@lang/lacinia resolver Designer_games が Lacinia スキーマ定義ファイルの :resolve にコンパイルされます。

umlaut 実行方法

leiningen pulugin が提供されているので、leiningen から実行します。

lein umlaut [target] args...

Graphviz 画像生成

Graphviz を対象にする際は、umlaut が画像生成までやってくれるため dot コマンドのインストールが必要です。

lein umlaut dot umlaut/ graphviz/

02.png

Lacinia スキーマ定義生成

lein umlaut lacinia umlaut/ resources/schema.edn

上記で定義していたスキーマ定義と同様の定義が出力されます。進行中の issue ですが、アノテーション内で "/" が使えないため Resolver 名に名前付きキーワードが使用できません。

resources/schema.edn
{:objects
 {:BoardGame
  {:fields
   {:id {:type (non-null ID), :isDeprecated false},
    :name {:type (non-null String), :isDeprecated false},
    :summary
    {:type String,
     :description "A one-line summary of the game.",
     :isDeprecated false},
    :description
    {:type String,
     :description "A long-form description of the game.",
     :isDeprecated false},
    :designers
    {:type (non-null (list (non-null :Designer))),
     :description "Designers who contributed to the game.",
     :isDeprecated false,
     :resolve :BoardGame_designers},
    :min_players
    {:type Int,
     :description "The minimum number of players the game supports.",
     :isDeprecated false},
    :max_players
    {:type Int,
     :description "The maximum number of players the game supports.",
     :isDeprecated false},
    :play_time
    {:type Int,
     :description "Play time, in minutes, for a typical game.",
     :isDeprecated false}},
   :implements [],
   :description "A physical or virtual board game."},
  :Designer
  {:fields
   {:id {:type (non-null ID), :isDeprecated false},
    :name {:type (non-null String), :isDeprecated false},
    :url
    {:type String,
     :description "Home page URL, if known.",
     :isDeprecated false},
    :games
    {:type (non-null (list (non-null :BoardGame))),
     :description "Games designed by this designer.",
     :isDeprecated false,
     :resolve :Designer_games}},
   :implements [],
   :description
   "A person who may have contributed to a board game design."}},
 :enums {},
 :interfaces {},
 :mutations {},
 :queries
 {:game_by_id
  {:type :BoardGame,
   :description "Access a BoardGame by its unique id, if it exists.",
   :isDeprecated false,
   :args {:id {:type ID}},
   :resolve :query_game-by-id}}}

Clojure.spec 生成

lein umlaut spec umlaut/ spec/ lacinia.spec lacinia-app.validators lacinia-app

エンティティ単位で spec ファイルが生成されます。こちらも現状問題があり、上記の定義で出力すると Designer と BoardGame の spec ファイルが循環参照してしまい読み込めませんでした。

spec/designer.clj
; AUTO-GENERATED: Do NOT edit this file
(ns
 lacinia-app.spec.designer
 (:require
  [clojure.spec :as s]
  [lacinia-app.validators :refer :all]
  [lacinia-app.spec.board-game :refer :all])) ;; 循環参照

(s/def :lacinia-app/id string?)

(s/def :designer/name string?)

(s/def :designer/url (s/and (s/nilable string?) url?))

(s/def :designer/games (s/coll-of :board-game/board-game :min-count 0))

(s/def
 :designer/designer
 (s/keys
  :req
  [:lacinia-app/id :designer/name :designer/games]
  :opt
  [:designer/url]))

そもそも Lacinia の強みとして、スキーマを EDN として定義出来るという点を挙げていたので、それを更に別の形式で定義するのは微妙ですが、spec も積極的に利用していくならありかと思います。

duct に乗せる

色々とやってることがごちゃごちゃしてきたので分かりやすいように(?) duct に乗せてみました。Mutation なども追加しています。

https://github.com/223kazuki/lacinia-app

config はこんな感じ。

resources/config.edn
{:duct.core/project-ns  lacinia-app
 :duct.core/environment :production

 :lacinia-app/db
 {:initial-data-path "lacinia_app/data.edn"}

 :lacinia-app/resolvers
 {:db #ig/ref :lacinia-app/db}

 :lacinia-app/schema
 {:schema-path "lacinia_app/schema.edn"
  :resolvers #ig/ref :lacinia-app/resolvers}

 :lacinia-app/pedestal
 {:schema #ig/ref :lacinia-app/schema}}
dev/resources/dev.edn
{:duct.core/environment :development
 :duct.core/include ["lacinia_app/config"]

 :lacinia-app.module/umlaut
 {:umlaut-files-folder "schema"
  :lacinia {:output-file "resources/lacinia_app/schema.edn"}
  :dot {:output-folder "graphviz"}
  :spec {:output-folder "spec"
         :spec-package "lacinia-app.spec"
         :custom-validators-filepath "lacinia-app.validators"
         :id-namespace "lacinia-app"}}

 :lacinia-app/schema
 {:umlaut #ig/ref :lacinia-app.module/umlaut}

 :lacinia-app/pedestal
 {:graphiql true}}

開発プロファイルのシステムマップを図にするとこんな感じ。

図1.png

開発プロファイルで起動するためには repl から起動してください。

$ lein repl
nREPL server started on port 50066 on host 127.0.0.1 - nrepl://127.0.0.1:50066
REPL-y 0.3.7, nREPL 0.2.12
Clojure 1.8.0
Java HotSpot(TM) 64-Bit Server VM 1.8.0_151-b12
    Docs: (doc function-name-here)
          (find-doc "part-of-name-here")
  Source: (source function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
    Exit: Control+D or (exit) or (quit)
 Results: Stored in vars *1, *2, *3, an exception in *e

user=> (dev)(go)
:loaded
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
Saved graphviz/all.png
Saved  graphviz/all.dot
Saved resources/lacinia_app/schema.edn
Remember!
You need to have a namespace: lacinia-app.validators that defines your custom validators.
Saved  spec/board_game.clj
Saved  spec/designer.clj
Saved  spec/query_root.clj
:initiated
dev=>

起動後、http://localhost:8888 を開くと GraphiQL を試せます。umlaut も毎回 leiningen で実行するのはつらいので module として実装してみました。開発プロファイルでは初期化時に下記の処理が順次実行されます。

  1. umlaut により lacinia スキーマ定義ファイル、Graphviz イメージ、spec ファイル作成 (:lacinia-app.module/umlaut)
  2. EDN ファイルから初期データ読み込んで疑似 DB 作成 (:lacinia-app/db)
  3. 疑似 DB を使って Resolver マップ生成 (:lacinia-app/resolvers)
  4. スキーマ定義ファイル、Resolver マップからスキーマコンパイル (:lacinia-app/schema)
  5. スキーマから lacinia-pedestal サーバ起動 (:lacinia-app/pedestal)

この構成では、スキーマ定義(.umlaut)を書き換えながら Graphviz のイメージ確認、GraphiQL からのクエリ実行をすぐに試せるので捗ります。

reagent/re-frame から呼び出す

最後にせっかくなので reagent/re-frame から呼び出してみます。

https://github.com/223kazuki/lacinia-reagent

クエリを呼び出すだけならクエリ文字列を POST すればよいだけなのですが、扱いやすいように GraphQL クエリを Clojure データから生成する vinia を使ってみます。

api.cljs
(require '[ajax.core :refer [POST]]
         '[venia.core :as v]))

(defn request [query options]
  (POST "http://localhost:8888/graphql"
        (-> options
            (select-keys [:handler :error-handler :params])
            (assoc :headers {"Content-type" "application/graphql"}
                   :response-format :json
                   :keywords? true
                   :body (str "query" (v/graphql-query query))))))

(request {:venia/queries [[:games [:id :name]]]}
         {:handler #(console.log (pr-str %))})
;; => {:data ([:game_by_id ([:id 1237] [:name 7 Wonders: Duel])])}

クエリをクライアント側で動的に組み立てられるため、より柔軟な呼び出しが可能です。サンプルでは BoardGame に対して取得する属性を動的に指定できる様にしています。

おわりに

今年 Clojure/conj 2017 に参加してきました。Lacinia についても セッション があり、そこで興味を持ったというのがそもそもこの記事を書いたきっかけです。

GraphQL の重要度は増しており、今後サーバ側を実装するという機会もあるのではないかと思います。GraphQL には今回紹介してない機能仕様も多数あり、特にデータ変更をクライアントに通知する Subscriptions などは魅力的な機能です。Lacinia は GraphQL の Full implementation を謳っており、当然 Subscriptions もサポートしているので是非試してみて下さい。

参考