GraphQLを用いたAPI開発各言語での比較調査 Rails編

概要

ここに概要を書く、本文の内容を要約したものを目的から本文の内容を短い文章で書く

※ 最初に概要を書いて何をやるかを明確にしてから調査や考察をし、実際に調査をすると方向性や結果が予想とずれるので、その後書き直すとまとまりやすいでしょう

目次

目的

何のためにこれをするのか、xxxの開発効率を上げる。xxxのパフォーマンスが良くするなど。

対象読者

プロダクトオーナー向けなのか、アーキテクト・リードエンジニアなどアーキテクチャ設計を担当する向けかビジネスサイドの人向けなのかを明確にする。※読者を明確にすると文章の内容や構成をその人たち向けにかけるので独りよがりにならず良いです。

PR

UZUMAKIではアジャイル開発で新規事業の開発から、大規模Webアプリケーションのアーキテクチャ更新などの開発をしています。

お問い合わせはUZUMAKIのHPのお問合せフォームから

本文

従来方法

一旦Railsで書いてみようかな。

  • GraphQL検討で検討するべきところ

- [x] クエリの基本 DBから

- [ ] クエリの基本 N+1 User + links

- [ x ] ミューテーション基本 C(R)UD処理

-[ x ] 認証 JWTかな?

- [ ] 認可 マルチテナント GroupAに属している人しかGroupAのデータは見えない、GropuBの人がアクセスしたら確認できない

- [ ] ファイルアップロード

- [ ] ファイル複数アップロード

できれば ApolloClientで適当なフロントエンドも書いておきたい。DI的にサーバサイドの言語を切り替えられるようなの

クエリの基本操作

通常のRailsのModel, Controller, View以外のGraphQLで必要なファイルはなにか?

graphql-rubyは通常一つのコントローラGraphqlControllerのexecuteをエンドポイントとしており、コントローラ的処理の役割はgraphqlディレクトリ以下のファイルで行われる

ちなみにroute.rbでルーティングはこの様に至ってシンプル

post '/graphql', to: 'graphql#execute'

どのようなクエリがあるかを表す Types::QueryType

CRUD処理のRとそれ以外のCUDで定義ファイルが異なる。

RのRead、SQLで言うところのSelect可能なオペレーションを定義するTypes::QueryType

通常のRailsのコントローラと考えるとわかりやすい。

どのようなミューテーション(変更処理)があるか表すTypes::MutationType

CRUD処理のCUDを定義するのがこのクラス。

SQLでも同じCのCreate, UのUpdate, DのDeleteが可能なオペレーションを定義するTypes::MutationType

Typeクラス

既存のmodels/link.rbに対応するクラス。厳密には違うが、

GraphQLで表現するパラメタだけ記載している。そのためlinksテーブルにあるupdated_atやidは持っていない。概ねモデルの属性(nameとかid)のどれをgraphqlを通してクライアントに渡すか定義している。つまりpasswordフィールドなどをうっかり渡さないようにできる。

app/graphql/types/link_type.rb

 module Types
  class LinkType < BaseNode
    field :created_at, DateTimeType, null: false
    field :url, String, null: false
    field :description, String, null: false
    field :posted_by, UserType, null: false, method: :user
    field :votes, [Types::VoteType], null: false
  end
end

Rails的には冗長な気もするが、GraphQLのスキーマ定義でLinkのモデルを表すためにも必要

image

typesの課題

typesディレクトリに同じ階層にたくさんのファイルが置かれることになるので、扱うTypeが増えると取り扱いが大変になりそう

ある程度ドメインごとに改装をつく作ることは可能だろうか? user/profile user/category とか

QueryTypeクラスでリゾルバクラスを定義する場合と、シンプルにリゾルバクラスを定義せずメソッドを使うやり方があるので初見殺しだと思う

app/graphql/types/query_type.rb

module Types
  class QueryType < BaseObject
    add_field GraphQL::Types::Relay::NodeField
    add_field GraphQL::Types::Relay::NodesField

    # all_linksの指定があったら、基本的にプロダクションだとリゾルバを指定する書き方をする
    field :all_links, resolver: Resolvers::LinksSearch
   # 簡易な場合はフィールド名と同じメソッドを定義して第二引数に型を指定する
    field :_all_links_meta, QueryMetaType, null: false

    def _all_links_meta
      Link.count
    end
  end
end

クエリの例

query AllLinks{
  allLinks {
   id
   url
  }
}

レスポンス

{
  "data": {
    "allLinks": [
      {
        "id": "TGluay0z",
        "url": "https://yahoo.co.jp"
      },
      {
        "id": "TGluay0y",
        "url": "http://dev.apollodata.com/"
      }
    ]
  }
}

ミューテーション(変更操作)

app/graphql/types/mutation_type.rb

サンプル

queryと同様にmutationの場合もフィールドを定義して、mutationのキーワードに呼び出すresolverクラスMutations::CreateUserを指定する

module Types
  class MutationType < BaseObject
    field :create_user, mutation: Mutations::CreateUser
  end
end

app/graphql/mutations/create_link.rb

argumentを引数をここで定義して、resolveメソッドを実行する

module Mutations
  class CreateLink < BaseMutation
    argument :description, String, required: true
    argument :url, String, required: true

    type Types::LinkType

    def resolve(description: nil, url: nil)
      Link.create!(
        description: description,
        url: url,
        user: context[:current_user]
      )
    rescue ActiveRecord::RecordInvalid => e
      GraphQL::ExecutionError.new("Invalid input: #{e.record.errors.full_messages.join(', ')}")
    end
  end
end

こんなクエリで追加できる

mutation{
  createLink(description: "説明文", url:"https://www.yahoo.co.jp"){
    id
    description
  } 
}

Relayでのページネーション

graphql-rubyを使うとものすごく簡単に実装できます。

relayとはページネーションの仕組みの一つで、下記リンクのような定義がありますが、

ざっくりいうとカーソルベースのページネーションの仕組みで

21件目のデータから10件データを取ってくるではなく、

20件目のデータの後ろから20件データを取ってくるというような方式。

このRelayの仕組みで嬉しいのは、チャットなど投稿が多いシステムの場合です。20件までデータを表示している間に、1件データが挿入された場合、21件目から10件のデータを取得しようとしたら、21件目のデータには先程取得した20件目のデータを取得してしまいますよね。(サーバサイドの開発が硬めの古い会社だとこれ理解されなくて)

レスポンスの型を定義する箇所で [LinkType]の配列ではなくLinkType.connection_typeにしてあげると、

app/graphql/types/query_type.rb

module Types
  class QueryType < BaseObject
    field :simple_links, LinkType.connection_type, null: false

    def simple_links
      Link.all
    end
  end
end

クエリ

query SimpleLinks{ 
  simpleLinks(first: 2, after: "Mg"){
    edges {
      cursor
      node {
        description
        url
        postedBy{
          email
        }
      }
    }
    pageInfo{
      endCursor
      hasNextPage
      startCursor
      hasPreviousPage
    }
  }  
}

リゾルバクラスで定義する場合もfieldで記載した場合と同様にレスポンスのクラスをTypes::LinkType.connection_typeにしてあげる

type Types::LinkType.connection_type

レスポンス

{
  "data": {
    "allLinks": {
      "edges": [
        {
          "cursor": "MQ",
          "node": {
            "description": "Link 2 description",
            "url": "http://example2.com"
          }
        },
        {
          "cursor": "Mg",
          "node": {
            "description": "Link 1 description",
            "url": "http://example1.com"
          }
        }
      ]
    }
  }
}

クエリのN+1問題

GraphQL Batch を導入

app/graphql/graphql_tutorial_schema.rb

class GraphqlTutorialSchema < GraphQL::Schema
  query Types::QueryType
  mutation Types::MutationType
  # N+1対策
  use GraphQL::Batch

graphql-batchはActiveRecordに依存しているわけではないが、ActiveRecord用のローダーを設定することにより良い感じに動かせる

Although this library doesn't have a dependency on active record, the examples directory has record and association loaders for active record which handles edge cases like type casting ids and overriding GraphQL::Batch::Loader#cache_key to load associations on records with the same id.

LinkTypeクラスの変更点

def votes
  Loaders::AssociationLoader.for(Link, :votes).load(object)
end

module Types
  class LinkType < BaseNode
    # このvotesに対して
    field :votes, [Types::VoteType], null: false

		# 下記のように明示的にメソッドを追記してあげる
		# forの第一引数にモデルのクラス名、第二引数にアソシエーション名
		# つまりLinkクラスの has_manyで定義しているvotesを書く
    def votes
      Loaders::AssociationLoader.for(Link, :votes).load(object)
    end
  end
end

votesのところのSQLに注目すると、N+1が解消されている

Vote Load (1.6ms)  SELECT "votes".* FROM "votes" WHERE "votes"."link_id" IN (?, ?, ?, ?)  [["link_id", 1], ["link_id", 2], ["link_id", 4], ["link_id", 5]]
  ↳ app/graphql/loaders/association_loader.rb:41:in `preload_association'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/controllers/graphql_controller.rb:3:in `execute'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/controllers/graphql_controller.rb:3:in `execute'
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  ↳ app/controllers/graphql_controller.rb:3:in `execute'
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 6], ["LIMIT", 1]]
  ↳ app/controllers/graphql_controller.rb:3:in `execute'

しかしvoteにつながっているuserを取得するのにusersテーブルに2回SQLが発行されておりN+1が発生しているのがわかる。(これはbulletで検出されないタイプのN+1)

module Types
  class VoteType < BaseNode
    field :user, UserType, null: false

    def user
      Loaders::AssociationLoader.for(Vote, :user).load(object)
    end
  end
end

User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/models/auth_token.rb:14:in `user_from_token'
  Link Load (0.5ms)  SELECT "links".* FROM "links"
  ↳ app/controllers/graphql_controller.rb:3:in `execute'
  Vote Load (0.8ms)  SELECT "votes".* FROM "votes" WHERE "votes"."link_id" IN (?, ?, ?, ?)  [["link_id", 1], ["link_id", 2], ["link_id", 4], ["link_id", 5]]
  ↳ app/graphql/loaders/association_loader.rb:42:in `preload_association'
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?)  [["id", 3], ["id", 4], ["id", 6]]

Authはどうやってる?サンプルのコードにはありそう

このサンプルは簡易版で、参照したものでもJWTを使うことを推奨している

JWTの認証時にもちゃんと有効なユーザか(DB等で)チェックするしくみはもちろんいるだろう

InputTypeを作成する

app/graphql/types/auth_provider_credentials_input.rb

module Types
  class AuthProviderCredentialsInput < BaseInputObject
    graphql_name 'AUTH_PROVIDER_CREDENTIALS'

    argument :email, String, required: true
    argument :password, String, required: true
  end
end

ユーザ作成の方法で上記のInputTypeを使って少々トリッキーな書き方をしている

TODO 要動作確認、普通のeamilとpasswordにしたらどうなるか

module Mutations
  class CreateUser < BaseMutation
    # often we will need input types for specific mutation
    # in those cases we can define those input types in the mutation class itself
    class AuthProviderSignupData < Types::BaseInputObject
      argument :credentials, Types::AuthProviderCredentialsInput, required: false
    end

    argument :name, String, required: true
    argument :auth_provider, AuthProviderSignupData, required: false

    type Types::UserType

    def resolve(name: nil, auth_provider: nil)
      User.create!(
        name: name,
        email: auth_provider&.[](:credentials)&.[](:email),
        password: auth_provider&.[](:credentials)&.[](:password)
      )
    end
  end
end

ユーザ作成のクエリ

mutation {
  createUser(
    name: "Test User",
    authProvider: {
      credentials: {
        email: "email@example.com",
        password: "123456"
      }
  	}
  ) {
    id
    name
    email
  }
}

サインインするミューテーション

module Mutations
  class SignInUser < BaseMutation
    null true

    argument :credentials, Types::AuthProviderCredentialsInput, required: false

    field :token, String, null: true
    field :user, Types::UserType, null: true

    def resolve(credentials: nil)
      return unless credentials

      user = User.find_by email: credentials[:email]

      return unless user
      return unless user.authenticate(credentials[:password])

      token = AuthToken.token_for_user(user)
      # セッションに格納されているので、端末から常にTokenを送る必要はない
      context[:session][:token] = token

      { user: user, token: token }
    end
  end
end

サインインのクエリ

mutation signIn{
  signinUser(
    credentials: {
        email: "email@example.com",
        password: "123456"
  	}
  ){
    user {
      id
      name
    }
    token
  }
}

レスポンス

{
  "data": {
    "signinUser": {
      "user": {
        "id": "VXNlci0y",
        "name": "Test User"
      },
      "token": "X3vN2gbwy0rbgvSnvy6Ggu3W--uaQ8odux39wikia1--cD3faMbaH+7RYbqBVDI3Pw=="
    }
  }
}

サインインするデータをとってくる

GraphqlControllerを書き換えてリクエストからユーザ情報などを取得する。このへんは一旦おまじないとしておく
class GraphqlController < ApplicationController
  def execute
    result = GraphqlTutorialSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  rescue StandardError => e
    raise e unless Rails.env.development?

    handle_error_in_development e
  end

  private

  def query
    params[:query]
  end

  def variables
    ensure_hash params[:variables]
  end

  def operation_name
    params[:operationName]
  end

  def context
    {
      session: session,
      current_user: AuthToken.user_from_token(session[:token])
    }
  end

  def ensure_hash(ambiguous_param)
    case ambiguous_param
    when String
      if ambiguous_param.present?
        ensure_hash(JSON.parse(ambiguous_param))
      else
        {}
      end
    when Hash, ActionController::Parameters
      ambiguous_param
    when nil
      {}
    else
      raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
    end
  end

  def handle_error_in_development(error)
    logger.error error.message
    logger.error error.backtrace.join("\n")

    render json: { error: { message: error.message, backtrace: error.backtrace }, data: {} }, status: 500
  end
end

app/models/auth_token.rb 認証のおまじない
module AuthToken
  module_function

  PREFIX = 'user-id'.freeze

  def token_for_user(user)
    crypt.encrypt_and_sign("#{PREFIX}#{user.id}")
  end

  def user_from_token(token)
    return if token.blank?

    user_id = crypt.decrypt_and_verify(token).gsub(PREFIX, '').to_i
    User.find_by id: user_id
  rescue ActiveSupport::MessageVerifier::InvalidSignature
    nil
  end

  def crypt
    ActiveSupport::MessageEncryptor.new(
      Rails.application.secrets.secret_key_base.byteslice(0..31)
    )
  end
end

リンクを作るクエリ

mutation createLink {
  createLink(url: "https://yahoo.co.jp", description: "very popular sit in japan"){
    id
    url
    description
    postedBy{
      id
      name
    }
  }
}

レスポンス

{
  "data": {
    "createLink": {
      "id": "TGluay0z",
      "url": "https://yahoo.co.jp",
      "description": "very popular sit in japan",
      "postedBy": {
        "id": "VXNlci0y",
        "name": "Test User"
      }
    }
  }
}
graphql_ruby_session=4dD%2FnGCyLIHHlIhy1rq7LOc1l8%2B3qcq8%2F7%2F%2BFGuZF8lYQq0kZuU9qnQUVFwrUQExDzQZB4QM78gyZpHbteSWrwqHMCbwufJ4Z92Uq9hWDIUrDrARt2slEyQLyP4NBe1LmCieNiA8ocJVrz080M6NZXL5PoAoJWo%2FCXV0O57L9OjAUoo4SP1yz1rnQuxSui6bpRSQBcOLe%2Bqx6gf4%2BapRdMRvfCc5Fsg2%2B%2ByLy0%2F%2B6f7CoJU6t%2B%2BcZWGrBHwJjMZE8IE%2F%2BFTTt9BsglZIyKc3x4da8igUj8xIVZnHjzDbV1qLiVfCuQHEnbwAdlMkLT52o3SelB%2FUJ2Ed6hXxCDr1NRGJSC2iC%2FMwMK5m1Ire6Yoi%2FzLGAToBNwdtifqj%2FQJIYbqQRMyA%2BrJudmYHubFxxo7RDK6kkgX84nxy1nDZu9qmUtgw%2FA%3D%3D--qaMtekejI30BXxsm--dmz0noSqxU70r2mnuimedw%3D%3D

ログイン必須なクエリ、ミューテーションはどうやって実装するか?

BaseResolverクラスready?メソッドをオーバーライドすると

リゾルバのauthorized?メソッドをオーバーライドする

ログインが必須の場合はready? だけで良さそう

認可の場合の例がいまいちよくわかってない authorized? を使う場合

フロントエンドのサンプル

npx create-next-app --ts next-graphql-app

起動するポートが3000番でRailsとかぶるのでpackage.jsonを書き換えて3001に変更

"scripts": {
    "dev": "next dev -p 3001", ここ!
    "build": "next build",
    "start": "next start"
  },

Next.jsでapp

CSRF対応

RailsのCSRFのセキュリティ対応を簡略化するために外す。下記のコードを追加してCSRFのチェックをスキップする

class GraphqlController < ApplicationController
  skip_before_action :verify_authenticity_token <= 追加

CSRFをきちんとやる場合はこちらを参考にしてください

実験・調査・比較結果

内容によって、実験や、調査、比較するものがあれば観点をまとめて比較を書く

箇条書きでダラダラ書くのではなく、表やグラフを用いるべし

考察・提案

実験した結果や調査した内容から得られた知見をまとめる

例: こういうパターンにはAが適しており、次のパターンではBが適している

Githubに検証した際に作ったURLがあればここに貼る

まとめ

行なった内容のまとめ目的から本文の内容を要約する。概要とほぼ同じで良いが、もう少しフランクに感想なども付け加えて良い

参考リンク

ログイン用のメモ

mutation createUser{
  createUser(
    name: "Test User",
    authProvider: {
      credentials: {
        email: "email@example.com",
        password: "123456"
      }
  	}
  ) {
    id
    name
    email
  }
}

mutation signIn {
  signinUser(credentials:{
    email: "email@example.com"
    password: "123456"
  }) {
    token
  }
}

PR

XではUZUMAKIの新しい働き方や日常の様子を紹介!ぜひフォローをお願いします!

noteではUZUMAKIのメンバー・クライアントインタビュー、福利厚生を紹介!

UZUMAKIではRailsエンジニアを絶賛募集中です。

↓の記事を読んでご興味を持っていただいた方は、ぜひ応募よろしくお願いします!

是非応募宜しくおねがいします!