概要
サーバサイドをRails、フロントエンドをNex.jsとしGraphQLを使ったシンプルサービスの構築手順を説明します。 この記事で構築したシステムを元に、次回のスキーマの変更に強いCIを構築する方法を説明します。
目次
- 概要
- 目次
- 目的
- 対象読者
- PR
- 本文
- 環境構築
- GraphQLの導入
- RailsにGraphQLを導入する手順
- Mutation
- ネストしたクエリを作成する
- 外部からのアクセスできるセキュリティ設定
- CORS対応
- GraphqlControllerコントローラの設定
- Next.js側の設定
- Next.jsでのコンポーネントテストとApollo Clientのテスト方法
- まとめ
- バックエンド Rails
- ここから先は別記事
- GraphQLの定義ファイルを出力する
- Github ActionsでGraphQLの定義ファイルを自動生成してコミットする
- フロントエンド Next.js
- apollo clientの準備
- テスティングフレームワーク jestの導入
- 実験・調査・比較結果
- 考察・提案
- まとめ
- 参考リンク
- PR
目的
何のためにこれをするのか、xxxの開発効率を上げる。xxxのパフォーマンスが良くするなど。
対象読者
プロダクトオーナー向けなのか、アーキテクト・リードエンジニアなどアーキテクチャ設計を担当する向けかビジネスサイドの人向けなのかを明確にする。※読者を明確にすると文章の内容や構成をその人たち向けにかけるので独りよがりにならず良いです。
PR
UZUMAKIではアジャイル開発で新規事業の開発から、大規模Webアプリケーションのアーキテクチャ更新などの開発をしています。
お問い合わせはUZUMAKIのHPのお問合せフォームから
本文
環境構築
必要な技術要素
本記事を読むにあたって必要な技術要素は以下の通りです。
- Ruby 3.1
- Rails7.0
- Node.js 18
- npm
RailsとNext.jsのインストールはそれぞれ省略します。
GraphQLの導入
RailsにGraphQLを導入する手順
まずは、GraphQLを導入するために必要なGemである graphql
をインストールします。Gemfile
に以下の行を追加し、bundle install
コマンドでインストールしてください。
gem 'graphql'
次に、GraphQLのスキーマを定義します。スキーマは、GraphQLのリクエストに対して、どのようなレスポンスを返すかを定義するものです。例えば、以下のようなスキーマを定義することができます。
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
field :hello, String, null: false, description: 'A simple hello world'
def hello
'Hello World!'
end
end
end
これで、helloというクエリを定義し、その結果として Hello World!
を返すことができるようになりました。また、リゾルバーと呼ばれるクラスを作成することで、GraphQLのクエリに対して具体的な動作を返すことができます。例えば、以下のようなリゾルバーを定義することができます。
# app/graphql/resolvers/hello_resolver.rb
class Resolvers::HelloResolver < GraphQL::Schema::Resolver
def resolve
'Hello World!'
end
end
そして、スキーマにリゾルバーを関連付けることで、リゾルバーがクエリに対して実行されるようになります。例えば、以下のようなスキーマとリゾルバーを定義することができます。
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
field :hello, String, null: false, description: 'A simple hello world'
def hello
'Hello World!'
end
field :hello_resolver, resolver: Resolvers::HelloResolver
end
end
このようにすることで、hello_resolverというクエリを定義し、その結果として Hello World!
を返すことができるようになります。
Gemfile に GraphQLを利用するのに必要なgemを追加
# graphql機能
gem 'graphiql-rails'
gem 'graphql'
GraphQL gem 初期化コマンド 実行しベースの設定やファイルを生成する
rails generate graphql:install
routes.rbにGraphQL向けのルーティング設定が追加されていることを確認する(下記は自動生成されたものからリファクタいしている)
Rails.application.routes.draw do
mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: '/graphql' if Rails.env.development?
post '/graphql', to: 'graphql#execute'
#省略...
end
Typeクラスの生成
rails g graphql:object Post
rails g graphql:object Comment
module Types
class PostType < Types::BaseObject
field :id, ID
field :name, String, null: false
field :created_at, GraphQL::Types::ISO8601DateTime
field :updated_at, GraphQL::Types::ISO8601DateTime
field :comments, [CommentType]
def comments
Loaders::AssociationLoader.for(Post, :comments).load(object)
end
end
end
graphql/type/query_type.rb
を以下のように追記します。
class Types::QueryType < Types::BaseObject
# Add `node(id: ID!) and `nodes(ids: [ID!]!)`
include GraphQL::Types::Relay::HasNodeField
include GraphQL::Types::Relay::HasNodesField
# 追記部分
field :posts, [Types::PostType], null: false
def posts
Post.all
end
end
Mutation
データの取得だけでなく、データを追加・変更・削除をするMutationの簡単なサンプルも追加します。
Postを作成するMutationを自動生成します
rails g graphql:mutation CreatePost
生成されたファイルの中身を下記のように修正します。
app/graphql/mutations/create_post.rb
module Mutations
class CreatePost < BaseMutation
field :post, Types::PostType, null: true
argument :name, String, required: true
def resolve(**args)
post = Post.create!(args)
{
post:
}
end
end
end
app/graphql/types/mutation_type.rb にcreate_postのfieldが追加されていることを確認してください。
module Types
class MutationType < Types::BaseObject
field :create_post, mutation: Mutations::CreatePost
end
end
同様にコメント追加用のMutationを作成します。
Mutationの自動生成コマンドを実行し
rails g graphql:mutation CreateComment
app/graphql/mutations/create_comment.rb
ファイルを修正
module Mutations
class CreateComment < BaseMutation
field :comment, Types::CommentType, null: false
argument :name, String, required: true
argument :post_id, Integer, required: true
def resolve(**args)
comment = Comment.create!(args)
{
comment:
}
end
end
end
動作確認
ここまでできたらGraphQLでPostとCommentを追加できます。
ネストしたクエリを作成する
module Types
class PostType < Types::BaseObject
# 中略、下記の一行を追加
field :comments, [CommentType], null: true
end
end
N+1対策に、graphql-batchを追加する
graphql-rubyにはN+1対策にGraphQL::Dataloader
が組み込まれていますが、よりシンプルにかけるgraphql-batchを採用します。
Gemfileに書きを追記
gem 'graphql-batch’
graphql-batchのexampleに従い1:1の場合のカスタムローダーを作成
app/graphql/loaders/record_loader.rb
module Loaders
class RecordLoader < GraphQL::Batch::Loader
def initialize(model)
@model = model
end
def perform(ids)
@model.where(id: ids).each { |record| fulfill(record.id, record) }
ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
end
end
end
1:Nの場合のカスタムローダはこちらのexampleを作成する
app/graphql/loaders/association_loader.rb
module Loaders
class AssociationLoader < GraphQL::Batch::Loader
def self.validate(model, association_name)
new(model, association_name)
nil
end
def initialize(model, association_name)
super()
@model = model
@association_name = association_name
validate
end
def load(record)
raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
return Promise.resolve(read_association(record)) if association_loaded?(record)
super
end
# We want to load the associations on all records, even if they have the same id
def cache_key(record)
record.object_id
end
def perform(records)
preload_association(records)
records.each { |record| fulfill(record, read_association(record)) }
end
private
def validate
unless @model.reflect_on_association(@association_name)
raise ArgumentError, "No association #{@association_name} on #{@model}"
end
end
def preload_association(records)
::ActiveRecord::Associations::Preloader.new(records: records, associations: @association_name).call
end
def read_association(record)
record.public_send(@association_name)
end
def association_loaded?(record)
record.association(@association_name).loaded?
end
end
end
app/graphql/base_rails_schema.rb
GraphQL::dataloaderをGraphQL::Batchに置き換える
# 置き換え
# use GraphQL::dataloader
use GraphQL::Batch
app/graphql/types/post_type.rb
にcommentsフィールドにN+1対応するコードを追加し、null不許可の設定を調整
module Types
class PostType < Types::BaseObject
field :id, ID
field :name, String, null: false
field :created_at, GraphQL::Types::ISO8601DateTime
field :updated_at, GraphQL::Types::ISO8601DateTime
field :comments, [CommentType]
def comments
Loaders::AssociationLoader.for(Post, :comments).load(object)
end
end
end
合わせて app/graphql/types/comment_type.rb
のnull不許可の設定を調整
module Types
class CommentType < Types::BaseObject
field :id, ID
field :name, String, null: false
field :post_id, Integer, null: false
field :created_at, GraphQL::Types::ISO8601DateTime
field :updated_at, GraphQL::Types::ISO8601DateTime
end
end
動作確認
{
posts {
id
name
comments {
id
name
}
}
}
N+1問題が発生していないことを確認
Processing by GraphqlController#execute as */*
Parameters: {"query"=>"{\n posts {\n id\n name\n comments {\n id\n name\n }\n }\n}", "graphql"=>{"query"=>"{\n posts {\n id\n name\n comments {\n id\n name\n }\n }\n}"}}
Post Load (2.7ms) SELECT `posts`.* FROM `posts`
↳ app/controllers/graphql_controller.rb:17:in `execute'
Comment Load (0.9ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` IN (1, 2, 3, 4)
↳ app/graphql/loaders/association_loader.rb:40:in `preload_association'
Completed 200 OK in 74ms (Views: 0.4ms | ActiveRecord: 3.6ms | Allocations: 3454)
外部からのアクセスできるセキュリティ設定
CORS対応
Gemfileにrack-cors
gem 'rack-cors'
config/initializers/cors.rb
に設定を追加
環境変数 FRONTEND_URL
からはアクセス可能のするよう設定。developmentやtestでは任意でアクセス可能
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
if Rails.env.production?
origins ENV.fetch('FRONTEND_URL', '')
else
origins '*'
end
resource '*', headers: :any, methods: %i[get post patch put delete]
end
end
GraphqlControllerコントローラの設定
自動生成されてコメントに従い、外からのAPIアクセスできるようにコメントを外す
class GraphqlController < ApplicationController
# If accessing from outside this domain, nullify the session
# This allows for outside API access while preventing CSRF attacks,
# but you'll have to authenticate your user separately
protect_from_forgery with: :null_session # ここのコメントを外す
Next.js側の設定
Next.jsにApollo Clientを導入し、GraphQLを利用する手順
次に、Next.jsでGraphQLを利用するためにApollo Clientを導入します。Apollo ClientはGraphQLのクエリを実行するためのクライアントライブラリであり、Next.jsでも利用することができます。
まずは、必要なパッケージをインストールします。以下のコマンドを実行して、必要なパッケージをインストールしてください。
$ npm install --save @apollo/client graphql
次に、Apollo Clientを初期化します。_app.js
ファイルに以下のようなコードを追加してください。
import { ApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: '/graphql',
cache: new InMemoryCache()
});
export default function App({ Component, pageProps }) {
return (
<ApolloProvider client={client}>
<Component {...pageProps} />
</ApolloProvider>
);
}
このようにすることで、ApolloProvider
コンポーネントの下に定義したコンポーネントで、GraphQLのクエリを実行することができるようになります。例えば、以下のようなコードを書くことで、クエリを実行することができます。
import { useQuery, gql } from '@apollo/client';
const GET_HELLO = gql`
query {
hello
}
`;
function HelloWorld() {
const { loading, error, data } = useQuery(GET_HELLO);
if (loading) return 'Loading...';
if (error) return `Error! ${error.message}`;
return (
<p>{data.hello}</p>
);
}
このようにすることで、GET_HELLO
というクエリを実行し、その結果として Hello World!
を表示することができます。
クエリとミューテーションの作成方法
GraphQLでは、クエリとミューテーションを使用してデータの取得や更新を行います。クエリはデータを取得するために、ミューテーションはデータを更新するために使用します。
クエリを作成する場合は、以下のような形式でクエリを定義します。
const GET_DATA = gql`
query {
data {
id
name
}
}
`;
このように定義することで、data
という名前のクエリを定義し、その結果として id
と name
のフィールドを返すようになります。
ミューテーションを作成する場合は、以下のような形式でミューテーションを定義します。
const ADD_DATA = gql`
mutation AddData($name: String!) {
addData(name: $name) {
id
name
}
}
`;
このように定義することで、addData
という名前のミューテーションを定義し、その結果として id
と name
のフィールドを返すようになります。また、$name
という変数を定義して、name
の値を受け取ることができるようになります。
Next.jsでのコンポーネントテストとApollo Clientのテスト方法
Next.jsでは、コンポーネントのテストに Jest
を使用することができます。また、Apollo Clientのテストには、 @apollo/client/testing
を使用することができます。
まずは、必要なパッケージをインストールします。以下のコマンドを実行して、必要なパッケージをインストールしてください。
$ npm install --save-dev jest @testing-library/react @apollo/client/testing
次に、以下のようなコードを書くことで、コンポーネントのテストを行うことができます。
import { render, screen } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import HelloWorld, { GET_HELLO } from './HelloWorld';
describe('HelloWorld', () => {
it('should render Hello World!', async () => {
const mocks = [
{
request: {
query: GET_HELLO
},
result: {
data: {
hello: 'Hello World!'
}
}
}
];
render(
<MockedProvider mocks={mocks}>
<HelloWorld />
</MockedProvider>
);
await screen.findByText('Hello World!');
});
});
このようにすることで、HelloWorld
コンポーネントをテストし、クエリに対して Hello World!
を返すように設定した結果を表示することができます。
また、以下のようなコードを書くことで、Apollo Clientのテストを行うことができます。
import { MockedProvider } from '@apollo/client/testing';
import { GET_HELLO } from './HelloWorld';
describe('HelloWorld', () => {
it('should render Hello World!', async () => {
const mocks = [
{
request: {
query: GET_HELLO
},
result: {
data: {
hello: 'Hello World!'
}
}
}
];
const { getByText } = render(
<MockedProvider mocks={mocks} addTypename={false}>
<HelloWorld />
</MockedProvider>
);
await new Promise(resolve => setTimeout(resolve, 0));
expect(getByText('Hello World!')).toBeInTheDocument();
});
});
このようにすることで、Apollo Clientを使用してクエリを実行し、その結果として Hello World!
を表示することができます。
まとめ
本記事では、RailsとNext.jsを使用してGraphQL通信の環境構築を行う方法を解説しました。必要な技術要素や環境構築方法、GraphQLの導入方法、クエリやミューテーションの作成方法、テスト方法について説明しました。
これから学習を進める場合には、公式ドキュメントやオンラインのチュートリアルを参考にすることをおすすめします。また、以下のようなリソースも参考にすることができます。
・GraphQL公式ドキュメント(https://graphql.org/) ・Rails on GraphQL(https://graphql-ruby.org/) ・Next.js with Apollo Client(https://www.apollographql.com/docs/react/get-started/)
GraphQLを利用することで、フロントエンドとバックエンドの間の通信を効率的に行うことができます。ぜひ、今回の記事を参考にして、GraphQLを導入してみてください。
バックエンド Rails
ここから先は別記事
GraphQLの定義ファイルを出力する
Rakeファイルを作成する
lib/tasks/graphql.rake
require 'graphql/rake_task'
GraphQL::RakeTask.new(schema_name: 'BaseRailsSchema', directory: 'docs')
動作確認
rails graphql:schema:dump
docs以下に`schema.graphql
` とschema.json
が生成されることを確認する
Github ActionsでGraphQLの定義ファイルを自動生成してコミットする
ここで、自動生成することでコミットミスを防ぐ
.github/workflows/gerenate_grphql_schema.yml
name: GraphQL
on: [pull_request]
jobs:
graphql:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2.1'
bundler-cache: true
- name: Generate GraphQL Schema
run: |
bundle exec rails graphql:schema:dump
- name: Commit GraphQL Schema files
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Re-Generate GraphQL Schema
いくつか細かい問題が発生した修正も入っているので詳しくはこちらのPRの差分を確認してください
フロントエンド Next.js
github cilのダウンロードしてghコマンドを使えるようにする
gh auth login
必要なライブラリを追加する
npm i -S graphql ~/d/u/graphql_next graphql_base
npm i -D typescript ts-node @graphql-codegen/cli @graphql-codegen/client-preset
apollo clientも追加する
npm i -D @apollo/client
package.json
"scripts": {
# 追記
"codegen": "scripts/codegen.sh",
"codegen-watch": "npx graphql-codegen --watch"
},
scripts/codegen.sh にschema.graphqlをgithubにあるところから取得して、コードを自動生成する
SCHEMA_FILE=/repos/konyu/graphql_rails/contents/docs/schema.graphql
if [ -e schema.graphql ]; then
rm schema.graphql
fi
gh api $SCHEMA_FILE -H "Accept: application/vnd.github.raw" > schema.graphql
npx graphql-codegen
設定ファイルの作成
import { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "schema.graphql",
documents: ["src/**/*.tsx"],
ignoreNoDocuments: true, // for better experience with the watcher
generates: {
"./src/gql/": {
preset: "client",
},
},
};
export default config;
自動生成をしてみる
npx graphql-codegen
src/ggl 下に定義ファイルが自動生成されることを確認する
apollo clientの準備
src/pages/_app.tsx
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client";
const cache = new InMemoryCache();
const client = new ApolloClient({
uri: `http://localhost:3000/graphql`,
cache,
});
export default function App({ Component, pageProps }: AppProps) {
return (
<ApolloProvider client={client}>
<Component {...pageProps} />
</ApolloProvider>
);
}
src/pages/index.tsx
import Head from "next/head";
import styles from "@/styles/Home.module.css";
import { useQuery } from "@apollo/client";
import { graphql } from "../gql";
import { Post, Comment } from "../gql/graphql";
const allPostsQueryDocument = graphql(/* GraphQL */ `
query allPosts {
posts {
id
name
comments {
id
postId
name
}
}
}
`);
export default function Home() {
const { data } = useQuery(allPostsQueryDocument);
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<div className="App">
{data && (
<ul>
{data.posts.map((post: Post, i: number) => (
<li key={`${i}`}>
<div>{post.name}</div>
{post.comments?.map((comment: Comment, j: number) => (
<div key={`${j}`}>{comment.name}</div>
))}
<div></div>
</li>
))}
</ul>
)}
</div>
</main>
</>
);
}
codegen時に、名前が変わったクエリのメソッド名やパラメタ名があるときには生成時に失敗する
型が変わるとエラーになる build時にエラーになる
> next build
info - Linting and checking validity of types ...Failed to compile.
./src/pages/index.tsx:42:42
Type error: Argument of type 'number' is not assignable to parameter of type 'string'.
40 | {data.posts.map((post: Post, i: number) => (
41 | <li key={`${i}`}>
> 42 | <div>{"Title: ".concat(post.name)}</div>
| ^
43 | {post.comments?.map((comment: Comment, j: number) => (
44 | <div key={`${j}`}>{comment.name}</div>
45 | ))}
テスティングフレームワーク jestの導入
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom
RailsからNext.jsのリポジトリのテストを実行してその結果を取得する
Tokenを取得する
https://rcmdnk.com/blog/2023/01/24/computer-github/
というものから選べて、Tokenを適用するレポジトリの範囲を決める事ができます。 Only Selectでは1つのTokenで最大50個のレポジトリを選ぶ事が出来ます。
単に公開されている情報をAPI経由で取得したくてrate limitを上げたいだけの場合などは Public Repositories (read-only)にしてpermissionを何も付けずに Tokenを取得すればOKです。
ここでOnly select repositoriesを選んで今回workflowを他からAPIで呼び出したいレポジトリを選んでおきます。
その下でPermissionsの項目で必要なものをNo access
、Read-only
、Read and write
の中から選べるようになっているので 一番上のActionsをRead and write
にします。
他のトリガーを読んで、結果を待ち、成功失敗の結果を取得するもの
Railsの方の設定に追加する
Next.jsで強制的にテストを失敗させた場合
実験・調査・比較結果
内容によって、実験や、調査、比較するものがあれば観点をまとめて比較を書く
箇条書きでダラダラ書くのではなく、表やグラフを用いるべし
考察・提案
実験した結果や調査した内容から得られた知見をまとめる
例: こういうパターンにはAが適しており、次のパターンではBが適している
Githubに検証した際に作ったURLがあればここに貼る
まとめ
行なった内容のまとめ目的から本文の内容を要約する。概要とほぼ同じで良いが、もう少しフランクに感想なども付け加えて良い
参考リンク
- https://zenn.dev/slowhand/articles/4fe99377185100
- https://qiita.com/takano-h/items/cf17ec515b9d850b4923
- https://qiita.com/kyntk/items/7ff8d312480ec913f049
- https://zenn.dev/mybest_dev/articles/a8f3096821851c#rails側のスキーマ定義からフロントエンド用定義の自動生成
- ドキュメントのURL
PR
XではUZUMAKIの新しい働き方や日常の様子を紹介!ぜひフォローをお願いします!
noteではUZUMAKIのメンバー・クライアントインタビュー、福利厚生を紹介!
UZUMAKIではRailsエンジニアを絶賛募集中です。
↓の記事を読んでご興味を持っていただいた方は、ぜひ応募よろしくお願いします!
是非応募宜しくおねがいします!