【Golang×Next.js×GraphQL】で雛形アプリケーションを作ってみた

2022/08/19に公開

こんにちは、あきのです。

この記事では、GolangNext.jsGraphQLを使ったWebアプリ開発を爆速で進める上での、土台作りをご紹介していきます。

実際にGolangとNext.jsのプロジェクトをそれぞれ作成し、使用するライブラリ等の解説を挟みながらGraphQLで通信を行うところまでを見ていきたいと思います。

Github Repo

実装したコードはGithubで公開しています。

フロント(Next.js)

https://github.com/onikan27/graphql-test-webapp

サーバー(Golang)

https://github.com/onikan27/graphql-test-api

環境

サーバー

  • gogo1.19
  • gqlgenv0.17.14

フロント

  • next12.2.5
  • react: 18.2.0
  • graphql16.6.0
  • typescript4.7.4
  • @apollo/client3.6.9

以下、本題です。

環境構築

最初にサクッと環境構築していきます。

サーバー側の環境構築:Golang

最初にGolangからプロジェクトを作成していきます。

今回は公式のGithubのREADME.mdを参考に進めていきます。

最初にGolangのプロジェクトを管理するディレクトリを作成して、go mod initしていきます。

ターミナル
$ mkdir graphql-test-api
$ cd graphql-test-api
$ go mod init github.com/[username]/graphql-test-api

次にプロジェクトのtools.goに対して、gqlgenを追加していきます。

gqlgenとは?

  • GoでGraphQLサーバーを構築するためのライブラリ
  • スキーマファーストに開発を進めていくことが可能
  • Codegenの機能を使うことによって、ユーザー(開発者)はアプリの構築に集中することが可能

では、実際にgqlgenを使ってプロジェクトを作成していきます。

ターミナル
$ printf '// +build tools\npackage tools\nimport _ "github.com/99designs/gqlgen"' | gofmt > tools.go
$ go mod tidy

次に、gqlgenのinitコマンドを使った雛形を作成していきます。

ターミナル
$ go run github.com/99designs/gqlgen init

実行が完了したら、下記のサーバーを立ち上げるコマンドを実行してhttp://localhost:8080/にアクセスしてみてください

ターミナル
$ go run server.go

下記のような画面が表示されていれば成功です🎉

フロント側の環境構築:Next.js

次にNext.jsプロジェクトを作成していきます。

今回はcreate-next-appコマンドを使ってサクッと作成していこうかと思います。

ターミナル
$ npx create-next-app@latest --ts

実行途中で下記のようにプロジェクトの名前が聞かれるので、任意の値を指定してください。

ターミナル
✔ What is your project named? … graphql-test-webapp

実行が完了したら、プロジェクト配下に移動して下記のサーバーを立ち上げるコマンドを実行してhttp://localhost:3000/にアクセスしてみてください

ターミナル
$ cd graphql-test-webapp
$ yarn run dev

下記のような画面が表示されていれば成功です🎉

GraphQLで通信してみる

環境構築が完了したので実際にGraphQLで通信していきたいと思います!

サーバー側の実装:Golang

GraphQLのスキーマ定義

graph/schema.graphqlsに定義した情報をもとに、自動でファイルが生成されます。僕の環境ではデフォルトで下記のような記述がされていました。

# GraphQL schema example
#
# https://gqlgen.com/getting-started/

type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
}

type User {
  id: ID!
  name: String!
}

type Query {
  todos: [Todo!]!
}

input NewTodo {
  text: String!
  userId: String!
}

type Mutation {
  createTodo(input: NewTodo!): Todo!
}
  • type: モデル情報を記述
  • Query: データフェッチのエンドポイントを定義
  • input: Mutationのオブジェクトの定義
  • Mutation: データを修正するエンドポイントを定義

ここに記述して、go run github.com/99designs/gqlgen generateを実行することで graph/generated/generated.gograph/model/models_gen.goが生成されます。

せっかくなので、自動生成されたファイルを確認しておきます。最初はモデル定義から。

graph/model/models_gen.go
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package model

type NewTodo struct {
	Text   string `json:"text"`
	UserID string `json:"userId"`
}

type Todo struct {
	ID   string `json:"id"`
	Text string `json:"text"`
	Done bool   `json:"done"`
	User *User  `json:"user"`
}

type User struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

ここでは、graph/schema.graphqlsで定義したQueryMutation以外の情報が記述されています。

次にresolverを確認していきます。

graph/schema.resolvers.go
package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.

import (
	"context"
	"fmt"

	"github.com/[user-name]/graphql-test-api/graph/generated"
	"github.com/[user-name]/graphql-test-api/graph/model"
)

// CreateTodo is the resolver for the createTodo field.
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
	panic(fmt.Errorf("not implemented: CreateTodo - createTodo"))
}

// Todos is the resolver for the todos field.
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
	panic(fmt.Errorf("not implemented: Todos - todos"))
}

// Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

注目すべき点は下記の部分。

// CreateTodo is the resolver for the createTodo field.
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
	panic(fmt.Errorf("not implemented: CreateTodo - createTodo"))
}

// Todos is the resolver for the todos field.
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
	panic(fmt.Errorf("not implemented: Todos - todos"))
}

デフォルトではpanic(fmt.Errorf("not implemented:...のように書かれていますが、ここに処理を書いていきます。

まとめるとこんな感じです。

  • graph/schema.graphqlsにスキーマ定義を書いていく
  • QueryMutationgraph/schema.resolvers.goに自動生成される
  • それ以外のモデル情報はgraph/model/models_gen.goに自動生成される

実際のエンドポイントのロジックを実装

本来であればDBにアクセスしてデータを取得して返したりすると思うのですが、今回は簡易的にresolverにハードコーディングして、データはメモリに乗っけておく方法でいきたいと思います。

最初にgraph/resolver.goを下記のように修正してください。

graph/resolver.go
package graph

import "github.com/[user-name]/graphql-test-api/graph/model"

type Resolver struct {
	todos []*model.Todo
}

今回はこのtodosにデータを保存していきます。

次にgraph/schema.resolvers.goに実際のロジックを書いていきます。

graph/schema.resolvers.go
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
	todo := &model.Todo{
		Text: input.Text,
		ID:   fmt.Sprintf("T%d", rand.Int()),
		User: &model.User{ID: input.UserID, Name: "user " + input.UserID},
	}
	r.todos = append(r.todos, todo)
	return todo, nil
}

func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
	return r.todos, nil
}
  • CreateTodo: ハードコーディングしたtodoをResolverのtodosに保存
  • Todos: Resolverのtodosを返す

ここまで記述できたら、サーバーを一旦落としてgo run server.goを再度実行してhttp://localhost:8080/にアクセスしてみてください。

ブラウザが開けたら左の入力欄に下記のクエリを書いて実行してみてください。

mutation createTodo {
  createTodo(input: { text: "todo", userId: "1" }) {
    user {
      id
    }
    text
    done
  }
}

正常に実行されていれば、下記のようなレスポンスが返ってくると思います。

次に、query todosを試していきます。下記のクエリを実行してみてください。

query findTodos {
  todos {
    text
    done
    user {
      name
    }
  }
}

下記のようにレスポンスが返って来れば成功です🎉

CORS設定

今回はWebフロントと通信するため、CORSの設定をここでしておきます。

server.go
// 略
srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))
c := cors.New(cors.Options{
	AllowedOrigins:   []string{"http://localhost:3000"},
	AllowCredentials: true,
})

http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", c.Handler(srv))
// 略

ここでgithub.com/rs/corsがimportされると思うので、go mod tidyを実行しておいてください。

フロント:Next.js

フロント側では主に@apollo/clientgraphql-codegenを使ってGraqhQLの設定をしていきます。

Apollo Clientとは

公式ドキュメント

Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. Use it to fetch, cache, and modify application data, all while automatically updating your UI.

簡単にいうと、GraphQLのAPIをクライアント側で良い感じに管理することができるライブラリという感じです。

graphql-codegenとは

公式ドキュメント

GraphQLスキーマからコードを生成するツールです。

GraphQL関連パッケージのインストール

では、実際に各パッケージをインストールして設定を行なっていきます。

$ yarn add graphql @apollo/client
$ yarn add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request 
@graphql-codegen/typescript-operations @graphql-codegen/typescript-resolvers

次にNext.jsプロジェクト配下で、graphql関連のファイルを管理するディレクトリ等を作成していきます。

ターミナル
# graphql関連のコードを格納するディレクトリ
$ mkdir graphql

# フロントから実行するqueryを格納するディレクトリ
$ mkdir graphql/query

# codegenの設定を記述するファイル
$ touch graphql/codegen-server.yaml

コード自動生成の設定

次にスキーマ情報から自動生成する時の設定を記述していきます。graphql/codegen-server.yamlを下記のように記述してください。

graphql/codegen-server.yaml
schema: ./graphql/schema.graphql
documents: ./graphql/query/*.graphql
generates:
  ./graphql/dist/client.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-graphql-request

上記のように設定することで./graphql/schema.graphql./graphql/query/*.graphqlの情報をもとにコードを自動的に生成することができます。

クエリを定義

フロント側からデータを操作するためのクエリを定義していきます。todo情報のクエリを管理するファイルを作成していきます。

ターミナル
$ touch graphql/query/todo.graphql

次にgraphql/query/todo.graphqlに下記のように記述してください。

query getTodo {
  todos {
    id
    text
  }
}

mutation createTodo {
  createTodo(input: { text: "todo", userId: "1" }) {
    user {
      id
    }
    text
    done
  }
}

このクエリは先ほどプレイグラウンドで実行したものと、ほとんど同じです。

バックエンドに記述したスキーマ情報をフロント側に同期してコード実行

コードを生成させるためにフロント側でもスキーマ情報を定義したファイルが必要です。すでにバックエンド側で定義しているため、簡易的に同期させるようにしておきたいと思います。

今回は、GolangとNext.jsが同一のディレクトリに存在することを前提にGolang側のスキーマファイルをNext.js側にcpするコマンドを定義しておきます。

最初にNext.jsプロジェクト配下でMakefileを作成してください。

ターミナル
$ touch Makefile

作成したMakefileに下記のようにコマンドを定義してください

gen:
  cp ../graphql-test-api/graph/schema.graphqls ./graphql/schema.graphql
  yarn run graphql-codegen --config ./graphql/codegen-server.yaml

早速下記のコマンドを実行して、フロント側でも自動的にコードを生成してみてください。

ターミナル
$ make gen

distディレクトリにclient.tsというファイルが生成されていれば成功です🎉

Apollo Clientの設定

次にApolloClientの初期化設定を行なっていきます。_app.tsxを下記のように修正してください。

_app.tsx
import type { AppProps } from "next/app";
import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";
import { ApolloProvider } from "@apollo/client";

function MyApp({ Component, pageProps }: AppProps) {
  const link = createHttpLink({
    uri: "http://localhost:8080/query",
    credentials: "include",
  });
  const client = new ApolloClient({
    cache: new InMemoryCache(),
    link: link,
  });

  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

export default MyApp;

ここでApollo Clientを定義しています。詳しい解説は公式ドキュメントを参照ください。

ちなみに初期化のコードは_app.tsxになくてもいいと思うので、hooksとかに切り出すのが良いのかなと思いました。今回は、お試しなのでこのままでいきたいと思います。

次にsrc/pages/index.tsxで実際にGraphQLサーバーに対してクエリを投げていきます。下記のように修正してください。

src/pages/index.tsx
import type { NextPage } from "next";
import { useQuery } from "@apollo/client";

import { GetTodoDocument } from "../graphql/dist/client";
import { GetTodoQuery } from "../graphql/dist/client";

const Home: NextPage = () => {
  const { data } = useQuery<GetTodoQuery>(GetTodoDocument);
  return (
    <div style={{ margin: "0 auto", width: "1000px" }}>
      {data?.todos?.map((todo) => (
        <div key={todo.id}>
          <h1>{todo.text}</h1>
          <p>id:{todo.id}</p>
          <p>text:{todo.text}</p>
        </div>
      ))}
    </div>
  );
};

export default Home;

@apollo/clientが提供しているuseQueryを使ってデータフェッチを行なっています。
下記のようにTodo情報がリストされていれば成功です🎉

ちなみに、Next.jsからTodoを作成する機能は実装していないので、表示されていない場合はhttp://localhost:8080/のプレイグラウンドからcreateTodoで作成しておいてください。

おまけ

本編は以上です。おまけとしてgqlgenの便利な機能であるオーバーフェッチ対策について触れていこうと思います。

オーバーフェッチ対策

オーバーフェッチとは

クライアント側で必要としていないのにも関わらず、データフェッチしてしまうことです。これは例を見ながらの方がわかりやすいと思うので、公式のGithubをもとに解説していきます。

例えば下記のように、あるオブジェクト(ここではUser)に複数オブジェクト(ここではfriends)が紐付いていたとします。

type User {
  id: ID!
  name: String!
  friends: [User!]!
}

あるリクエストではfriends情報が必要だが、あるリクエストではfriends情報は必要ない場合があると思います。 例えば下記のような場合です。

  • 友達一覧: friends情報が必要
  • プロフィール画面: friends情報は必要ない

このような場合に、無駄なリソースをフェッチしないように設定する方法がgqlgenには用意されています。

最初にgqlgen.ymlの設定を変更します。下記の設定を入れることで、指定したモデル情報を自動生成するのではなく、独自に定義することができます。

autobind:
  # 下記のコメントアウトを解除
  - "github.com/[user-name]/graphql-test-api/graph/model"

次にgraph/schema.graphqlsに定義しているUserにfriendsを追加して、Queryにuserを追加してください。

graph/schema.graphqls
type User {
  id: ID!
  name: String!
  friends: [User!]!
}

type Query {
  todos: [Todo!]!
  user: User!
}

次に、graph/model/models_gen.goUserを削除して、graph/model/user.goを作成して、そこにUserモデル情報を定義します。

ターミナル
$ touch graph/model/user.go
graph/model/user.go
package model

type User struct {
  ID   string `json:"id"`
  Name string `json:"name"`
}

諸々の変更ができたら、下記のコマンドを実行してコードを生成してみてください

ターミナル
$ go run github.com/99designs/gqlgen generate

graph/schema.resolvers.goに下記のコードが追加されたかと思います。

graph/schema.resolvers.go
// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context) (*model.User, error) {
	panic(fmt.Errorf("not implemented: User - user"))
}

// Friends is the resolver for the friends field.
func (r *userResolver) Friends(ctx context.Context, obj *model.User) ([]*model.User, error) {
	panic(fmt.Errorf("not implemented: Friends - friends"))
}

// 略
type userResolver struct{ *Resolver }

実際の実装ではFriends関数の中で、obj.id(user.id)を使って検索して返すイメージになります。今回はハードコーディングして擬似的にデータを返すように実装していきます。graph/schema.resolvers.goを下記のように実装してください。

graph/schema.resolvers.go
func (r *queryResolver) User(ctx context.Context) (*model.User, error) {
	user := &model.User{
		ID:   fmt.Sprintf("T%d", rand.Int()),
		Name: "Akino",
	}
	return user, nil
}

func (r *userResolver) Friends(ctx context.Context, obj *model.User) ([]*model.User, error) {
	var (
		message = "友達検索"
		friends []*model.User
	)
	print(message)
	friend := &model.User{
		ID:   fmt.Sprintf("T%d", rand.Int()),
		Name: "Akinoの友達",
	}
	friends = append(friends, friend)
	return friends, nil
}

User関数ではfriends情報には触れずに、user情報だけを返しています。Friends関数ではユーザー情報をハードコーディングして返しています。また、Friends関数ではprintで「友達検索」と標準出力させることで明示的に関数が実行されたかどうか確かめています。

では、go run server.goを実行してサーバーを立ち上げてhttp://localhost:8080/にアクセスしてみてください。

試しにuserのidだけを取得するクエリを実行してみます。

query getUser {
  user {
    id
  }
}

下記のようにレスポンスが返ってきました。Golangのサーバーでは、Friends関数は実行されていないので「友達検索」という標準出力は出てません。

では、次にfriendsをクエリに追加して、実行してみてください。

query getUser {
  user {
    id
    friends {
      id
    }
  }
}

下記のようにfriends情報を含んだレスポンスが返ってきました。さらに、Golangのサーバーで「友達検索」という標準出力が出ていることも確認できました。

実際に試してみたことでクエリ側で要求していなければ、無駄な関数は実行されずに、無駄なリソースのフェッチを防げることが確認できました。

参考記事

Discussion