LoginSignup
12
5

More than 5 years have passed since last update.

Absinthe (のおよそ主要機能)を使って GraphQL サーバのプロトタイプを作った

Posted at

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
  • query の入れ子に対応
{
  questions {
    users {
    }
  }
}
{
  me {
    users {
    }
    questions {
    }
    answers {
    }
  }
}

{
  questions {
    answers {
      users {
      }
    }
   }
  }
}
  • ER図
    • N+1問題がついて回る、Q&A サイトで良くあるやつです

er.png

動作させるには?

  • 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.exPhoenix.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 では HeadersOAuth 2 Bearer Token から設定できます

スクリーンショット 2018-12-04 15.59.52.png
スクリーンショット 2018-12-04 16.00.27.png

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_privinput_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

まとめ

なんかもうちょっと色々実装していたような気がしますが時間切れなので、思い出したらまたちょっと追記するかもしれません。

Absinthe の作者の Ben Wilson さんは普通に毎日 Slack で Q&A に答えてるので、absinthe の疑問点は Slack で聞くと速攻解決したりしますのでオススメです。

12
5
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
12
5