ハンズオン形式で、GoでGraphQLサーバを構築するための基礎知識を紹介します。
完成版は、GitHubに上げてあります。
GraphQLの概要
GraphQLとは
GraphQLは、Facebookが作ったAPI形式です。公式ページから引用します。
GraphQLは、APIのためのクエリ言語であり、データに対して定義した型システムを使ってクエリを実行するためのサーバーサイドランタイムです。
GraphQLの使い方
以下のGraphQLのスキーマがあるとします。
type Query {
me: User
}
type User {
id: ID
name: String
profileUrl: String
}
次のようなGraphQLクエリを実行することができます。クエリは、HTTPのPOSTかGETリクエストです。生のHTTPリクエストではなく、AplloやRelayといったGraphQLクライアントライブラリを使ってリクエストすることが多いです。
クエリ
{
me {
id
name
profileUrl
}
}
レスポンス
{
"me": {
"id": "user:123456"
"name": "田中太郎"
"profileUrl": "https://xxx.yyy.zzz/..."
}
}
GraphQLの特徴として、レスポンスのフィールドを絞ることもできます。
レスポンスのフィールドを絞ったクエリ
{
me {
name
}
}
GraphQLの良いところ
- レスポンスのフィールドが指定できるので、オーバフェッチ/アンダーフェッチを回避できます
- 型があります
- ApolloなどのGraphQLクライアントは、クエリの結果をいい感じにローカルキャッシュしてくれます
- 1リクエストに複数のクエリや更新操作を含めることができます
個人的には、レスポンスフィールドを指定できる点が良いです。モバイルアプリとWeb Frontがある状況で、それぞれにAPIを用意したり、独自のフィールド指定の仕組みを作ったりしなくても、両方のユースケースで過不足ないレスポンスを返すことができます。
GoでGraphQLサーバを作る
ここからは、GoのGraphQLサーバを作っていきます。
使うライブラリ:gqlgen
gqlgenというライブラリを使います。
GraphQLサーバを実現するライブラリは、コードファーストとスキーマファーストに大別されます。gqlgenはスキーマファーストに分類されます。gqlgenを使う理由は、GoのスキーマファーストのGraphQLライブラリの中で人気だからです。後は、仕様から考えるのが自分の性にあっているからです。
gqlgenを使ったGraphQLの開発の流れは以下のようになります。
- GraphQLのスキーマを定義
- スキーマからGoとしての型定義やリゾルバの雛形を生成する
- リゾルバはスキーマとデータソースを結びつける役割を持ちます。雛形は
- 生成したリゾルバの雛形を実装する
GraphQLサーバを動かす
まず、gqlgenのGetting Startedの手順を実施してください。以降の説明は、Getting Startedを実施した前提です。
N+1問題とその対処
gqlgenのGetting Startedを完了すると、Todoの一覧を取得する以下のクエリが実行できるはずです。
query findTodos {
todos {
text
done
user {
name
}
}
}
現時点だと、このクエリは、Todoの一覧を取得した後に、各TodoごとにTodoの登録者の情報を取得します。Todoの一覧の数が5個なら、Todo一覧処理で1回、User取得処理を5回行うことになります。User取得処理で、RDBのSELECTをしたり他のサービスのGet APIを呼ぶと、俗に言うN+1問題が起きます。
dataloader
GraphQLのN+1問題を回避するために、dataloaderを使います。dataloaderは、複数のデータ取得リクエストを1つにまとめてくれるライブラリです。
Go製の有名なdataloaderは2つあります。
-
graph-gophers/dataloader
- コード生成をしない方式です
- データを取得するときに対象のデータを一意に識別するkeyを指定し、結果はResult[V any]に格納して返します。
- keyには、intやstringなどのcomparableな型が使えます
-
vektah/dataloaden
- gqlgenと同じ作者です
- Modelごとにdataloaderのコードを生成する方式です
- データを取得するときに対象のデータを一意に識別するkeyを指定し、取得結果は取得対象のデータの型の変数に格納して返します
- keyには、intやstringなどのcomparableな型が使えます
私は前者の方が使いやすいと思います。この記事でも前者を使います。
dataloaderを使ったデータ取得処理の実装
graph/dataloaders.go
graph/dataloaders.goというファイルを作り、以下のコードを書いてください。
package graph
import (
"context"
"fmt"
"github.com/graph-gophers/dataloader/v7"
"github.com/kamikazezirou/gql-example/graph/model"
"net/http"
)
type Loaders struct {
UserById *dataloader.Loader[string, *model.User]
}
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), loadersKey, &Loaders{
UserById: dataloader.NewBatchedLoader(func(ctx context.Context, userIds []string) []*dataloader.Result[*model.User] {
fmt.Println("batch get users:", userIds)
// ユーザIDのリストからユーザ情報を取得する
// サンプル実装なので適当な値を返していますが、プロダクト実装では以下のようにしてください。
// - "SELECT * FROM users WHERE id IN (id1, id2, id3, ...)"のようなSQLでDBからユーザ情報を一括取得する
// - 他のサービスのBatch Read APIを呼ぶ
// それでN+1問題を回避することができます。
results := make([]*dataloader.Result[*model.User], len(userIds))
for i, id := range userIds {
results[i] = &dataloader.Result[*model.User]{
Data: &model.User{ID: id, Name: "user " + id},
Error: nil,
}
}
return results
}),
})
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
type contextKey int
var loadersKey contextKey
func ctxLoaders(ctx context.Context) *Loaders {
return ctx.Value(loadersKey).(*Loaders)
}
実装のポイントは以下です。
- dataloaderを保持するためのstruct Loadersを定義します
- サンプルでは1つしかdataloaderがありませんが、プロダクトでは複数のdataloaderを持つことになるので、集約するstructを作っています
- net/httpのhttp.Handlerのinterceptorを作り、この中でLoadersを作成してhttp.RequestのContextに埋め込みます
- GraphQLは通信プロトコルは普通のhttpなので、サーバもnet/httpで実現されています。リゾルバの処理の前にdataloaderを作成したいので、interceptorとして実装します。
- Loadersを作る際、NewBatchedLoaderでユーザ情報のdataloader Loaderを作ります
- NewBatchedLoaderの引数は関数になっていますが、その関数の中でDBからのReadなり、他のサービスのBach Read APIを実行するなりします。
- contextからLoadersを取り出せるようにしておきます
最後に、コードを書いたら、以下のコマンドを実行しておいてください。
go mod tidy
server.go
このファイルのhttp.Handlerを設定している箇所を以下のように変更してください。
// http.Handle("/query", srv)
http.Handle("/query", graph.Middleware(srv))
先程作ったinterceptorを、GraphQLの処理本体の前段に設定しています。
graph/schema.resolvers.go
todoResolver#User()を、以下のように実装してください。
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
thunk := ctxLoaders(ctx).UserById.Load(ctx, obj.UserID)
item, err := thunk()
if err != nil {
return nil, err
} else {
return item, nil
}
}
Middlewareがcontextに埋め込んだDataloaderを取得して、それ経由でユーザ情報を取得します。Dataloaderの呼び出し引数には、データを一意に識別できる値を渡します。
dataloaderの動作を見る
まず、server.goを実行して、Webブラウザでhttp://localhost:8080/を開き、Playgroundを表示してください。
次に、以下のGraphQLクエリを実行して、Todoデータを登録してください。最初に述べたように、GraphQLはRESTと異なり、1つのリクエストに複数の操作を含められます。
mutation createTodo {
first: createTodo(input: { text: "todo1", userId: "1" }) {
user {
id
}
text
done
}
second: createTodo(input: { text: "todo2", userId: "2" }) {
user {
id
}
text
done
}
third: createTodo(input: { text: "todo3", userId: "1" }) {
user {
id
}
text
done
}
}
最後に、以下のGraphQLクエリを実行して、Todoデータの一覧を取得してみてください。
query findTodos {
todos {
text
done
user {
name
}
}
}
dataloader導入前であればユーザ情報の取得を3回行いますが、dataloader導入後は複数のユーザ情報の取得が1回にまとめられます。
dataloaderについての補足
1. 重複したリクエストのKeyをまとめてくれる
GraphQLサーバの標準出力を見てください。以下のように出力されているはずです。
batch get users: [1 2]
"batch get users: [1 2 1]"とはなっていません。dataloaderはリクエストのKeyが重複しているとまとめてくれるので、こうなります。それによって無駄なデータ取得を回避できます。
2. レスポンスをキャッシュしてくれる
以下の流れで処理が実行されるとします。
- リクエストを受ける
- datalaoderでIDが1のユーザを取得する
- 時間のかかる処理
- datalaoderでIDが1のユーザを取得する
- レスポンスを返す
このとき2回目のユーザ取得の際、メモリキャッシュから値が返されます。なお、キャッシュの動作はオプションで切り替えることができます。
3. 複数リクエストをまとめる挙動について
dataloaderは一定時間内のデータ取得リクエストを1つにまとめてくれます。デフォルトだと、16ms間隔でデータ取得リクエストをまとめてくれます。もちろん、この間隔の変更は可能です。また、まとめるリクエストの最大数も指定することが可能で、デフォルトは1000個です。
スキーマ設計パターン
ここからは、GraphQLのスキーマ設計のパターンを3つ紹介します。
- viewerクエリ:ログインユーザの取得クエリ
- nodeクエリ:nodeを実装した型をIDから取得するクエリ
- Connectionsパターン:Paginationのための設計パターン
ここに紹介するパターンはGitHubのGraphQL APIでも使われています。
viewer(ログインユーザの取得)
ログインユーザの情報を取得するクエリ名は、viewerとすることが慣例です。試しに実装してみましょう。
まず、スキーマを以下のように変更してください。
type Query {
todos: [Todo!]!
viewer: User! # ここを追加
}
次に、コードを生成しなおしてください。
go run github.com/99designs/gqlgen
graph/schema.resolvers.goが更新され、リゾルバのメソッドにViewerが追加されているはずです。以下のように実装してください。
func (r *queryResolver) Viewer(ctx context.Context) (*model.User, error) {
// プロダクトコードでは、ユーザを認証して、そのユーザの情報を返すようにしてください。
return &model.User{
ID: "user:1",
Name: "user1",
}, nil
}
これで実装は終わりです。動作を見てみましょう。
server.goを実行して、Webブラウザでhttp://localhost:8080/を開き、Playgroundを表示してください。
実際にviewerを実行してみてください。具体的なクエリを示します。
{
viewer {
id
name
}
}
正しく実装できれば、レスポンスは次のようになります。
{
"data": {
"viewer": {
"id": "1",
"name": "user1"
}
}
}
node:IDからNodeを実装した型を取得する
IDからNodeを実装した型を取得するクエリnodeを用意します。IDからデータを取得するクエリを型ごとに定義する必要がなくり、クエリの見通しが良くなります。
スキーマ定義とコード生成
スキーマを変更し、Todo型にNodeインターフェースを実装させ、さらにnodeクエリを追加します。
interface Node {
id: ID!
}
type Todo implements Node {
id: ID!
text: String!
done: Boolean!
user: User!
}
type Query {
# 他のクエリは省略
node(id: ID!): Node
}
次に、Goのmodelファイルに以下のメソッドを追加します。
func (t *Todo) IsNode() {}
func (t *Todo) GetID() string {
return t.ID
}
最後に、コードを生成しなおしてください。
go run github.com/99designs/gqlgen
TodoのIDをGlobal Unique かつ 型が分かるようにする
Node interfaceを実装した型のIDは、Global Unique かつ その値から型を判定できるようにする必要があります。nodeクエリは、IDによって返すべき型が変わるからです。今回はUserにNodeをimplementsさせませんでしたが、それをimplementesさせたとします。その場合、node(id: "TodoのID")ではTodoを返し、node(id:"UserのID")だとUserを返す必要があります。IDに型情報が含まれていなければ、GraphQLサーバはTodoとUserのどちらを返したら良いか判断できません。
IDの具体例として、GitHubのGraphQL API上でのGitHubリポジトリのIDをbase64でデコードしたものを示します。値に型情報が入っていることが分かります。
010:Repository12345678
ここまでの実装だと、TodoのIDは、"T123456"というような形式になっているはずです。一応、IDの先頭の"T"から、IDからTodo型であることが判別できますが、分かりづらいので"T"ではなく"todo:"に変更してみましょう。
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
todo := &model.Todo{
Text: input.Text,
ID: fmt.Sprintf("todo:%d", rand.Int()), // ここを変更
UserID: input.UserID,
}
r.todos = append(r.todos, todo)
return todo, nil
}
nodeクエリのリゾルバの実装
次に、nodeクエリのリゾルバを実装します。
func (r *queryResolver) Node(ctx context.Context, id string) (model.Node, error) {
s := strings.Split(id, ":")
t := s[0]
switch t {
case "todo":
for _, todo := range r.todos {
if todo.ID == id {
return todo, nil
}
}
return nil, errors.New("not found")
default:
return nil, fmt.Errorf("unknwon type:%s", t)
}
}
nodeクエリを実行してみる
サーバを再起動した後、GraphQL Playgroundから、以下のクエリを実行してTodoを登録してください。結果のidはメモしておいてください。
mutation createTodo {
first: createTodo(input: { text: "todo1", userId: "1" }) {
id
user {
id
}
text
done
}
}
次にnodeクエリを実行してください。Todoの情報が取得できるはずです。
{
node(id: "<先程登録したTodoのID>") {
id
...on Todo {
text
user {
id
name
}
}
}
}
...on Todo { } という記法ですが、nodeの戻り値はinterface Nodeなので、それをTodoに変換するためのものです。
IDについて補足
API ClientからnodeのIDの形式を意識させないようにしましょう。ここで実装した"todo:1"のように可読性のある形でAPI Clientに返すと、nodeのIDの形式を意識した実装をしてしまうかもしれません。なので、nodeのIDをBase64でエンコードしておくのが良いようです(機密情報ではないので完全に隠す必要はないです)。
ただ、可読性のあるIDには、GraphQL PlaygroundでAPIを試しやすいという利点もあります。不特定多数のユーザが使うAPIでない、APIユーザの教育ができる、IDの値にバイナリを含める必要がない、という状況なら、Base64でエンコードもしないのも有りだと思います。
Connectionsパターン(Pagination)
ConnectionsパターンはPaginationのためのスキーマの設計パターンです。手を動かして理解するのが早いので手を動かしてみましょう。
このパターンの詳細を知りたい方は、Facebook製のGraphQLクライアントjsライブラリRelayのドキュメントを参照願います。
一覧取得クエリの定義
まず、全てのTodoの一覧を取得するfindTodosの定義をConnectionsパターンに合わせた形に、書き換えます。
type Query {
todos(
after: String
before: String
first: Int
last: Int
) : TodoConnection!
}
引数の意味は以下のとおりです。
- after:データ取得開始位置(cursor)を指定します
- このcursorで指し示される項目より後の項目が取得対象になります
- before:データ取得終了位置(cursor)を指定します
- このcursorで指し示される項目より前の項目が取得対象になります
- first:先頭からの何件取得するか
- last:末尾から何件取得するか
補足1
firstとlastの同時指定は、このクエリの使い方としては推奨されていません。両方同時指定されたらエラーにすると良いと思います。また、first/lastのどちらかの指定も必須にすると良いと思います。GitHubのGraphQL APIは、そういう動作になっています。
補足2
クエリの結果の項目の順序は、first/afterを使用する場合も、last/beforeを使用する場合も、同じにすべきです。たとえば、IDの昇順で、item1, item2, item3があるとします。このとき、一覧取得クエリの引数でfirst=3を指定した場合も、last=3を指定した場合も、結果の並びは[item1, item2, item3]とします。
Connection/PageInfo/Edgeの定義
続けて、スキーマに3つのtype定義を追加します。
type TodoEdge {
cursor: String!
node: Todo!
}
type PageInfo {
endCursor: String
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
}
type TodoConnection {
edges: [TodoEdge!]!
pageInfo: PageInfo!
}
- Connection
- EdgeのスライスとPageInfoを持ちます
- 一覧取得クエリのレスポンスになります
- Edge/PageInfo以外にtotalCountなどの付属情報を持たせるのも良いです
- PageInfo
- Edge
- nodeと一覧の中での位置を示すcursorを持ちます
- 一覧がIDの昇順 or 降順だとしたら、cursorにはIDを含めておくイメージです
- 参考:GitHubのGraphQL APIでリポジトリ一覧を取得することができますが、各Edgeのcursorは、バイナリデータをbase64でエンコードしたもので、バイナリの下4バイトはリポジトリのIDになっています
- nodeは項目のデータ本体で、その型にはNode interfaceを実装させます
- nodeと一覧の中での位置を示すcursorを持ちます
実装
実装の紹介は省略します。
実装方法は想像しやすいですが、サンプル実装はかなり面倒ですので。
Connectionsパターンの良いところ
- cursorベースのページングなので、offsetベースのページングよりパフォーマンスが良いし、途中でデータを追加されたりしても影響を受けづらい
- このパターンに従っていると、Relay/ApolloなどのGraphQLクライアントライブラリが気を利かせてくれる
- mutation(データ更新)のときに、クライアント側のキャッシュを更新してくれる
- クライアント側のページングの実装が楽になる
- 一覧取得クエリの仕様がある程度統一される
参考資料
-
GraphQLの公式ドキュメント
- 文法だけでなくベストプラクティスもあります
-
ShopifyのGraphQL設計チュートリアル
- 設計ガイドラインとしてどうぞ
- 日本語翻訳もあります
-
GitHubのGraphQL Exploer
- GraphQLを試すのに良いです
- スキーマをダウンロードできるので、設計の参考にできます
-
gqlgenの公式ドキュメント
- この記事で触れていない認証やエラーハンドリングのやり方が書かれています
-
Production Ready GraphQL
- GraphQLスキーマの設計、複雑すぎるクエリへの対処といったプラクティスがまとまっています。GraphQLスキーマの設計の考え方はとても参考になりました
- 手前味噌ですが、本書のスキーマ設計の主張の概要を別記事で紹介しています
最後に
以上で、GoでGraphQLサーバを構築する最低限の知識を共有しました。誰かの役に立てば幸いです。