Elixir で GraphQL サーバといえば Absinthe 一択ですが、日本語・英語を問わず、そのまま動作するサンプルがあまり世の中にありません。使ってみた+α の情報はだんだんと増えてきましたが、プロダクトに導入してみようとなると途端に、どうやればいいのか、また調べなければならないことが急に増えます。
そこで実案件での事前調査もかねて、実際に使いそうな機能を使いそうな形で実装したプロトタイプを作成しました。
特徴と機能
- Docker Compose で MySQL と Phoenix 環境を用意してあるため、コンテナをビルドすればすぐに動かしてみることができます。
- だいたい最新のライブラリで作られています。Absinthe 1.5 が一ヶ月以内にはリリースされそうな気がしますが、出たら 1.5 に書き換える予定です。
- OTP 21, Elixir 1.7.4
- Phoenix 1.4, Ecto 3.0
- Absinthe 1.4.13
- dataloaderで N+1 問題を解決
- 認証まわりを扱う middleware を用意
- LIMIT/OFFSET の指定の有無に関わらず、条件にマッチする最大レコード数を返すフィールドを用意([1][2][3]みたいなページングを実現する時に、とても便利)
- いろんな query/mutation を使える
- mutation
- メアドとパスワードでログイン。アクセス権限は二種類
- 一般ユーザ
- 管理者
- ユーザアカウントの作成
- 管理者アカウントのみ作成可能
- 非ログイン・一般ユーザアカウントの場合、エラーを返す
- メアドとパスワードでログイン。アクセス権限は二種類
- query
- 最大レコード数を含む、各種 query
- mutation
- query の入れ子に対応
{
questions {
users {
}
}
}
{
me {
users {
}
questions {
}
answers {
}
}
}
{
questions {
answers {
users {
}
}
}
}
}
- ER図
- N+1問題がついて回る、Q&A サイトで良くあるやつです
動作させるには?
- Docker Compose でコンテナをビルド
MYUID=${UID} docker-compose up -d --build
- 下記のような環境で動きます
- コンテナ
- showcase コンテナ:Phoenix 環境
- showcase_mysql コンテナ:MySQL 環境
- 各ポートの接続先
- 5002 → MySQLコンテナの3306
- 5001 → Phoenixコンテナの4000
- volume のマウント
- ローカルの
showcase/src
→ Phoenixコンテナの/showcase/src
- ローカルの
- コンテナ
- Phoenix のコンテナに接続して、初期設定
docker exec -it showcase /bin/bash
mix deps.get
mix deps.compile
mix ecto.setup
- ブラウザから
http://localhost:5001/graphiql
に接続- テスト用 データは
lib/showcase/dev/support/seeds.ex
で登録しています -
mix test
の接続先は/api
です
- テスト用 データは
ポート番号や環境などは docker-compose.yml
もしくは引数渡すなり、自身の環境に適当にあわせてください。
- 使える query などその他の情報は、GraphiQL に接続してスキーマのドキュメントを読むか、リポジトリの説明を参照ください
実装的な話
ログイン
- アカウントのパスワードは
plain_password
という名前で平文で登録しています。基本誰でも comeonin とか使うと思いますが、別の方法で実装したいときに逆に面倒なのと、テストデータのパスワードが何だったか調べるのに楽なので、あえて平文にしています。 - ログイン成功時のトークンは
lib/showcase_web/graphql/authentication.ex
でPhoenix.Token.sign/3
で生成しています。Guardian とかを使う場合はそこを差し替えればいいでしょう。
def sign(data) do
Phoenix.Token.sign(ShowcaseWeb.Endpoint, @user_salt, data)
end
def verify(token) do
Phoenix.Token.verify(ShowcaseWeb.Endpoint, @user_salt, token, max_age: 365 * 24 * 3600)
end
認証
- アカウント作成のとき(admin権限が必要としています)など、適切なアカウント権限を持ってるかどうかの確認は middleware で実装しています
- 権限をチェックする時に必要となる(ログイン中の)ユーザ情報は、Plug の中で
Absinthe.Plug.put_options/2
を呼び出すことで Asbinstheのコンテキストに設定しています - 適切な権限があるときは
res
をそのまま、そうでない場合はAbsinthe.Resolution.put_result/1
でエラーメッセージを追加します。- 権限は
:normal_user
,:admin
,:any
で、:any
は権限は何でもいいけどログインが必要という権限です(自身の情報を取得する me query で使用)
- 権限は
- 認証のトークン(login mutation から取得したもの)は、
authorization
ヘッダにBearer *トークン*
の形で渡します- UnitTest の中では (conn_case.exの中で
auth_user/2
というヘルパ用関数を用意してヘッダをセットしています - GraphiQL では
Headers
のOAuth 2 Bearer Token
から設定できます
- UnitTest の中では (conn_case.exの中で
- 権限をチェックする時に必要となる(ログイン中の)ユーザ情報は、Plug の中で
with %{current_user: current_user} <- res.context,
true <- correct_permission?(current_user, permission) do
res
else
_ ->
res
|> Absinthe.Resolution.put_result({:error, "unauthorized"})
end
- middleware は schema の object の中で直接指定して呼び出しています。また、リターン値が「エラー」「追加したアカウントの情報」の両方のケースがあるため、他の object とは違い
user_with_priv_result
という object でuser_with_priv
とinput_error
の両方の object をラップしています。
object :create_user_mutations do
@desc """
create user account
"""
field :create_user, :user_with_priv_result do
arg(:email, non_null(:string))
arg(:nickname, non_null(:string))
arg(:password, non_null(:string))
arg(:permission, non_null(:permission))
middleware(Middleware.Authorize, :admin)
resolve(&Resolvers.Accounts.create_user/3)
end
end
最大レコード数を返す
- totalCount が指定されている場合、queryable に下記操作を行い
Repo.all/1
を呼び出すことで、query の結果とは別にレコード数を取得しています-
Query.exclude/2
で LIMIT と OFFSET を削除 -
select/3
で count を追加
-
- また totalCount を格納する場所として、各モデル(コンテキストが導入されてからはモデルという名前じゃないんでしたっけ?)の virtual として
total_count
を追加しています
schema "questions" do
field(:total_count, :integer, virtual: true)
field(:body, :string)
field(:title, :string)
belongs_to(:user, User)
has_many(:answers, Answer)
timestamps()
end
dataloader
dataloader を使う時は、下記のように(Phoenixの)コンテキストで data/1
を定義すると思いますが、ここに処理を追加してあげることで、queryable に WHERE, ORDER, LIMIT, OFFSET などの条件を追加することができます。
def data() do
Dataloader.Ecto.new(Repo, query: &query/2)
end
query/2
の第一引数は queryable で、第二引数には GraphQL の query で指定された引数が Map で渡ってきます(例:%{limit: 5}
)。LIMIT/OFFSET が未指定の時は LIMIT=5, OFFSET=0 が指定されたことにするために LIMIT/OFFSET のデフォルト値のセットと、queryable への追加を行っています。
def query(Answer, params) do
# LIMIT/OFFSET のデフォルト値をセット
new_params =
:answers
|> Resolvers.QA.get_default_params()
|> Resolvers.QA.set_default_params(params)
# queryable に limit/offset を追加
# リターン値は追加後の queryable
ContextHelper.limit_offset(:dataloader, Answer, new_params)
end
dataloader の結果をいじる
- 例えば下記のような N+1 問題が発生する query では dataloader を使いたくなりますが、単に
dataloader/2
を呼んでいるだけだと結果をいじる場所がありません。そのため下記のような子 query で totalCount が指定された時の処理では、dataloader/2
の中身を展開してコンテキストに持っていき、その中で任意の値(今回の場合はtotalCount
)を追加しています。
questions(id:5) {
id
title
answers {
id
totalCount
}
}
def answers_for_question(source, key) do
fn parent, args, %{context: %{loader: loader}} ->
[total_count] =
from(
q in Answer,
select: count(q.id),
where: q.question_id == ^parent.id
)
|> Repo.all()
loader
|> Dataloader.load(source, {key, args}, parent)
|> on_load(fn loader ->
result =
Dataloader.get(loader, source, {key, args}, parent)
|> Enum.map(fn x ->
Map.put(x, :total_count, Integer.to_string(total_count))
end)
{:ok, result}
end)
end
end
- ちなみに展開前の dataloader の dataloader/1 の実装は https://github.com/absinthe-graphql/absinthe/blob/master/lib/absinthe/resolution/helpers.ex にあります。
まとめ
なんかもうちょっと色々実装していたような気がしますが時間切れなので、思い出したらまたちょっと追記するかもしれません。
Absinthe の作者の Ben Wilson さんは普通に毎日 Slack で Q&A に答えてるので、absinthe の疑問点は Slack で聞くと速攻解決したりしますのでオススメです。