RailsとNext.jsを使ったサービス環境構築編

概要

サーバサイドをRails、フロントエンドをNex.jsとしGraphQLを使ったシンプルサービスの構築手順を説明します。 この記事で構築したシステムを元に、次回のスキーマの変更に強いCIを構築する方法を説明します。

目次

目的

何のためにこれをするのか、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
image

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
image

同様にコメント追加用の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

動作確認

image

ここまでできたら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 という名前のクエリを定義し、その結果として idname のフィールドを返すようになります。

ミューテーションを作成する場合は、以下のような形式でミューテーションを定義します。

const ADD_DATA = gql`
  mutation AddData($name: String!) {
    addData(name: $name) {
      id
      name
    }
  }
`;

このように定義することで、addData という名前のミューテーションを定義し、その結果として idname のフィールドを返すようになります。また、$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の定義ファイルを自動生成してコミットする

ここで、自動生成することでコミットミスを防ぐ

image
image

.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
image

いくつか細かい問題が発生した修正も入っているので詳しくはこちらの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時にエラーになる

image
> 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 accessRead-onlyRead and write の中から選べるようになっているので 一番上のActionsRead and writeにします。

image

他のトリガーを読んで、結果を待ち、成功失敗の結果を取得するもの

Railsの方の設定に追加する

image

Next.jsで強制的にテストを失敗させた場合

image

実験・調査・比較結果

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

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

考察・提案

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

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

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

まとめ

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

参考リンク

PR

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

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

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

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

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