【Golang×Next.js×GraphQL】で雛形アプリケーションを作ってみた
こんにちは、あきのです。
この記事では、Golang
とNext.js
とGraphQL
を使ったWebアプリ開発を爆速で進める上での、土台作りをご紹介していきます。
実際にGolangとNext.jsのプロジェクトをそれぞれ作成し、使用するライブラリ等の解説を挟みながらGraphQLで通信を行うところまでを見ていきたいと思います。
Github Repo
実装したコードはGithubで公開しています。
フロント(Next.js)
サーバー(Golang)
環境
サーバー
-
go
:go1.19
-
gqlgen
:v0.17.14
フロント
-
next
:12.2.5
-
react
:18.2.0
-
graphql
:16.6.0
-
typescript
:4.7.4
-
@apollo/client
:3.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.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
で定義したQuery
とMutation
以外の情報が記述されています。
次にresolverを確認していきます。
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
にスキーマ定義を書いていく -
Query
とMutation
はgraph/schema.resolvers.go
に自動生成される - それ以外のモデル情報は
graph/model/models_gen.go
に自動生成される
実際のエンドポイントのロジックを実装
本来であればDBにアクセスしてデータを取得して返したりすると思うのですが、今回は簡易的にresolver
にハードコーディングして、データはメモリに乗っけておく方法でいきたいと思います。
最初に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
に実際のロジックを書いていきます。
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の設定をここでしておきます。
// 略
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/client
とgraphql-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
を下記のように記述してください。
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
を下記のように修正してください。
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サーバーに対してクエリを投げていきます。下記のように修正してください。
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
を追加してください。
type User {
id: ID!
name: String!
friends: [User!]!
}
type Query {
todos: [Todo!]!
user: User!
}
次に、graph/model/models_gen.go
のUser
を削除して、graph/model/user.go
を作成して、そこにUserモデル情報を定義します。
$ touch 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
に下記のコードが追加されたかと思います。
// 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
を下記のように実装してください。
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