概要
ここに概要を書く、本文の内容を要約したものを目的から本文の内容を短い文章で書く
※ 最初に概要を書いて何をやるかを明確にしてから調査や考察をし、実際に調査をすると方向性や結果が予想とずれるので、その後書き直すとまとまりやすいでしょう
目次
- 概要
- 目次
- 目的
- 対象読者
- PR
- 本文
- 従来方法
- クエリの基本操作
- 通常のRailsのModel, Controller, View以外のGraphQLで必要なファイルはなにか?
- どのようなクエリがあるかを表す Types::QueryType
- どのようなミューテーション(変更処理)があるか表すTypes::MutationType
- Typeクラス
- typesの課題
- ミューテーション(変更操作)
- Relayでのページネーション
- クエリのN+1問題
- GraphQL Batch を導入
- Authはどうやってる?サンプルのコードにはありそう
- サインインするデータをとってくる
- ログイン必須なクエリ、ミューテーションはどうやって実装するか?
- フロントエンドのサンプル
- 実験・調査・比較結果
- 考察・提案
- まとめ
- 参考リンク
- PR
目的
何のためにこれをするのか、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のモデルを表すためにも必要
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.
https://github.com/Shopify/graphql-batch/blob/master/examples/association_loader.rb
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=="
}
}
}
サインインするデータをとってくる
リンクを作るクエリ
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?メソッドをオーバーライドする
https://graphql-ruby.org/mutations/mutation_authorization.html
https://qiita.com/ham0215/items/fa4ce59b5cbedfb1fb7d#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
https://dev.to/ivanms1/next-js-graphql-typescript-setup-5bog
CSRF対応
RailsのCSRFのセキュリティ対応を簡略化するために外す。下記のコードを追加してCSRFのチェックをスキップする
class GraphqlController < ApplicationController
skip_before_action :verify_authenticity_token <= 追加
CSRFをきちんとやる場合はこちらを参考にしてください
https://zenn.dev/leaner_tech/articles/20210930-rails-api-spa-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エンジニアを絶賛募集中です。
↓の記事を読んでご興味を持っていただいた方は、ぜひ応募よろしくお願いします!
是非応募宜しくおねがいします!