お題
表題の通り。GraphQL関連では前々回、frontend に「nuxtjs/apollo」、backend に「go+gqlgen」の組み合わせでGraphQLサービスを作るというのをやった。
その時は backend は単に固定のJSONを返すだけでお茶を濁したのだけど、もちろんサービスとして成り立たせる上で固定のJSON返すだけなんてことはない。
ので、今回は、永続化実装。
関連記事索引
- 第12回「GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)」
- 第11回「Dataloadersを使ったN+1問題への対応」
- 第10回「GraphQL(gqlgen)エラーハンドリング」
- 第9回「GraphQLにおける認証認可事例(Auth0 RBAC仕立て)」
- 第8回「GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例」
- 第7回「GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)」
- 第6回「GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)」
- 第5回「DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動」
- 第4回「graphql-codegenでフロントエンドをGraphQLスキーマファースト」
- 第3回「go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)」
- 第2回「NuxtJS(with Apollo)のTypeScript対応」
- 第1回「frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る」
開発環境
# OS - Linux(Ubuntu)
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.2 LTS (Bionic Beaver)"
# バックエンド
言語 - Go
$ go version
go version go1.13.3 linux/amd64
パッケージマネージャ - Go Modules
IDE - Goland
GoLand 2019.2.5
Build #GO-192.7142.48, built on November 8, 2019
# Dockerコンテナ
Docker
$ docker -v
Docker version 18.09.2, build 6247962
docker-compose
$ docker-compose -v
docker-compose version 1.23.1, build b02f1306
# DB接続ツール
DataGrip
DataGrip 2019.3.1
Build #DB-193.5662.58, built on December 18, 2019
参考
GraphQL
https://graphql.org/
https://gqlgen.com/
O-Rマッパー
実践
今回の全ソースは下記。
https://github.com/sky0621/study-graphql/tree/v0.3.0
設計
TODOアプリの真似事。
以下の機能を持つ。
- ユーザ情報を1件登録
- ユーザ情報を1件取得(★そのユーザと紐づくTODO情報も同時に取得可能)
- 全ユーザ情報を取得(★同上)
- TODO情報を1件登録
- TODO情報を1件取得(★そのTODOを登録したユーザ情報も同時に取得)
- 全TODO情報を取得(★同上)
ローカルにDBサーバ立てる
docker-compose使うのでYamlを書く。
DBは何でもよかったので、とりあえずMySQL。
version: '3'
services:
db:
restart: always
image: mysql:5.7.24
command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: localuser
MYSQL_PASSWORD: localpass
MYSQL_DATABASE: localdb
volumes:
- ./persistence/init:/docker-entrypoint-initdb.d
あと、Docker起動時にコンテナ内のデータベースにテーブル作成したいので以下のDDLも用意。
CREATE TABLE IF NOT EXISTS `todo` (
`id` varchar(64) NOT NULL,
`text` varchar(256) NOT NULL,
`done` bool NOT NULL,
`user_id` varchar(64) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin;
CREATE TABLE IF NOT EXISTS `user` (
`id` varchar(64) NOT NULL,
`name` varchar(256) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin;
準備できたので起動。
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql
$
$ ll docker-compose.yml
-rw-r--r-- 1 sky0621 sky0621 399 Dec 22 20:12 docker-compose.yml
$
$ ls -lR persistence/
persistence/:
total 8
drwxr-xr-x 2 sky0621 sky0621 4096 Dec 22 20:14 init
-rw-r--r-- 1 sky0621 sky0621 99 Dec 22 19:18 README.md
persistence/init:
total 4
-rw-r--r-- 1 sky0621 sky0621 405 Dec 22 20:14 1_create.sql
$
$ sudo docker-compose up
Creating network "study-graphql_default" with the default driver
Creating study-graphql_db_1_3a13f8e517b8 ... done
Attaching to study-graphql_db_1_f411e51b48e6
db_1_f411e51b48e6 | Initializing database
・
・
・
db_1_f411e51b48e6 | 2019-12-27T17:02:30.129056Z 0 [Note] mysqld: ready for connections.
db_1_f411e51b48e6 | Version: '5.7.24' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server (GPL)
GraphQLスキーマ
最初に「TODO情報」と「ユーザ情報」のモデルを定義。
「TODO情報」モデルの中には「ユーザ情報」モデルを含んでおり、「ユーザ情報」モデルの中にも”複数の”「TODO情報」モデルを含んでいる。
このように定義しておくと、クライアントからこのモデルを取得するクエリを発行する際に階層構造ごと1クエリで取得することができる。
次に以下4つのクエリを定義。
・全TODO情報取得
・1TODO情報取得
・全ユーザ情報取得
・1ユーザ情報取得
次は新規作成用のパラメータを定義。
・「NewTodo
」という名でTODO登録時のパラメータを定義
・「NewUser
」という名でユーザ登録時のパラメータを定義
最後に以下2つのミューテーションを定義。
・TODO情報登録
・ユーザ情報登録
# GraphQL schema example
#
# https://gqlgen.com/getting-started/
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
type User {
id: ID!
name: String!
todos: [Todo!]!
}
type Query {
todos: [Todo!]!
todo(id: ID!): Todo!
users: [User!]!
user(id: ID!): User!
}
input NewTodo {
text: String!
userId: String!
}
input NewUser {
name: String!
}
type Mutation {
createTodo(input: NewTodo!): ID!
createUser(input: NewUser!): ID!
}
Go実装
main関数にサーバ起動時のDBコネクション初期化処理を追加
package main
import (
"log"
"net/http"
"os"
"github.com/99designs/gqlgen/handler"
"github.com/jinzhu/gorm"
"github.com/sky0621/study-graphql/backend"
_ "github.com/go-sql-driver/mysql"
)
const dataSource = "localuser:localpass@tcp(127.0.0.1:3306)/localdb?charset=utf8&parseTime=True&loc=Local"
const defaultPort = "5050"
func main() {
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
// 主にここの処理
db, err := gorm.Open("mysql", dataSource)
if err != nil {
panic(err)
}
if db == nil {
panic(err)
}
defer func() {
if db != nil {
if err := db.Close(); err != nil {
panic(err)
}
}
}()
db.LogMode(true)
http.Handle("/", handler.Playground("GraphQL playground", "/query"))
http.Handle("/query", handler.GraphQL(backend.NewExecutableSchema(backend.Config{Resolvers: &backend.Resolver{DB: db}})))
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
モデル定義
続いて、GraphQLスキーマにも定義した「TODO情報」モデルと「ユーザ情報」モデルのGo実装。
package models
type Todo struct {
ID string `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
GraphQLスキーマで定義したモデルと比較するとわかるのだけど、例えば「TODO情報」モデル。
GraphQLスキーマでは、こう。
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
Go側の実装には「user: User!
」に該当する要素がない。
「ユーザ情報」モデルもしかり。
type User {
id: ID!
name: String!
todos: [Todo!]!
}
「TODO情報」モデルの配列がGo側の実装にはない。
これが実はポイント。
gqlgen関連
上記で”ポイント”としたのは、以下のように gqlgen を使った各種ソース自動生成をするとわかる。
schema:
- ../schema/schema.graphql
exec:
filename: generated.go
model:
filename: models_gen.go
resolver:
filename: resolver.go
type: Resolver
autobind:
- github.com/sky0621/study-graphql/backend/models
models:
User:
model: models.User
Todo:
model: models.Todo
ソース自動生成
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql/backend
$
$ ll gqlgen.yml
-rw-r--r-- 1 sky0621 sky0621 388 Dec 22 23:32 gqlgen.yml
$
$ go run github.com/99designs/gqlgen init
・
・
・
$
$ ll resolver.go
-rw-rw-r-- 1 sky0621 sky0621 3.7K Dec 26 01:07 resolver.go
gqlgenコマンドの使用については以下参照。
https://qiita.com/sky0621/items/8abd445edba347e8f6f1#gqlgenコマンドによるスケルトン生成
自動生成されたresolver.go
の中身は。
package backend
import (
"context"
"github.com/sky0621/study-graphql/backend/models"
)
// THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
type Resolver struct{}
func (r *Resolver) Mutation() MutationResolver {
return &mutationResolver{r}
}
func (r *Resolver) Query() QueryResolver {
return &queryResolver{r}
}
func (r *Resolver) Todo() TodoResolver {
return &todoResolver{r}
}
func (r *Resolver) User() UserResolver {
return &userResolver{r}
}
type mutationResolver struct{ *Resolver }
func (r *mutationResolver) CreateTodo(ctx context.Context, input NewTodo) (string, error) {
panic("not implemented")
}
func (r *mutationResolver) CreateUser(ctx context.Context, input NewUser) (string, error) {
panic("not implemented")
}
type queryResolver struct{ *Resolver }
func (r *queryResolver) Todos(ctx context.Context) ([]*models.Todo, error) {
panic("not implemented")
}
func (r *queryResolver) Todo(ctx context.Context, id string) (*models.Todo, error) {
panic("not implemented")
}
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
panic("not implemented")
}
func (r *queryResolver) User(ctx context.Context, id string) (*models.User, error) {
panic("not implemented")
}
type todoResolver struct{ *Resolver }
func (r *todoResolver) User(ctx context.Context, obj *models.Todo) (*models.User, error) {
panic("not implemented")
}
type userResolver struct{ *Resolver }
func (r *userResolver) Todos(ctx context.Context, obj *models.User) ([]*models.Todo, error) {
panic("not implemented")
}
まあ、いろいろ書いてあるのだけど、これらは全てGraphQLスキーマに定義したクエリとミューテーションを実行する上で必要なロジック群。
この中でも、先ほど言った「GraphQLスキーマにあった『user: User!
』がGoのモデルにはない」が関係してくるのが以下。
・todoResolver
のUser
メソッド
・userResolver
のTodos
メソッド
どう関係してくるかについては、実装して動作確認するのが一番わかりやすいので、まずは実装。
ユーザ登録(TODO登録は省略)
クライアントから以下の要求があると、
type Mutation {
createUser(input: NewUser!): ID!
}
input NewUser {
name: String!
}
リゾルバーの以下が発火し、
func (r *mutationResolver) CreateUser(ctx context.Context, input NewUser) (string, error) {
log.Printf("[mutationResolver.CreateUser] input: %#v", input)
id := util.CreateUniqueID()
err := database.NewUserDao(r.DB).InsertOne(&database.User{
ID: id,
Name: input.Name,
})
if err != nil {
return "", err
}
return id, nil
}
type NewUser struct {
Name string `json:"name"`
}
user テーブルに対象ユーザ情報を登録する。
func (d *userDao) InsertOne(u *User) error {
res := d.db.Create(u)
if err := res.Error; err != nil {
return err
}
return nil
}
type User struct {
ID string `gorm:"column:id;primary_key"`
Name string `gorm:"column:name"`
}
func (u *User) TableName() string {
return "user"
}
1ユーザ取得(TODO取得は省略)
クライアントから以下の要求があると、
type Query {
user(id: ID!): User!
}
type User {
id: ID!
name: String!
todos: [Todo!]!
}
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
リゾルバーの以下が発火し、
func (r *queryResolver) User(ctx context.Context, id string) (*models.User, error) {
log.Printf("[queryResolver.User] id: %s", id)
user, err := database.NewUserDao(r.DB).FindOne(id)
if err != nil {
return nil, err
}
return &models.User{
ID: user.ID,
Name: user.Name,
}, nil
}
↓重ね重ね言うけど、GraphQLスキーマのモデルにはある「複数のTODO情報」に関する要素がGoのモデルには無い
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
user テーブルから対象ユーザ情報を取得する。
func (d *userDao) FindOne(id string) (*User, error) {
var users []*User
res := d.db.Where("id = ?", id).Find(&users)
if err := res.Error; err != nil {
return nil, err
}
if len(users) < 1 {
return nil, nil
}
return users[0], nil
}
ん?
Goのモデル上は、Userの中にTodo配列は持っていないのに、レスポンスに "todos" が含まれている・・・。
これ、実は、リゾルバーのqueryResolver.User
が発火して、その処理が終わった後、続いて以下も発火している。
以下のTodos
の引数にあるobj *models.User
に、上記でDBから取得したユーザ情報が入っているので、そのobj
からID
を取得してユーザに紐づくTODO群を取得する処理が流せるというしかけ。
func (r *userResolver) Todos(ctx context.Context, obj *models.User) ([]*models.Todo, error) {
log.Println("[userResolver.Todos]")
todos, err := database.NewTodoDao(r.DB).FindByUserID(obj.ID)
if err != nil {
return nil, err
}
var results []*models.Todo
for _, todo := range todos {
results = append(results, &models.Todo{
ID: todo.ID,
Text: todo.Text,
Done: todo.Done,
})
}
return results, nil
}
まとめ
今回のように実装すると、TODO情報取得クエリならTODO情報取得のリゾルバーが発火し、ユーザ情報取得クエリならユーザ情報取得のリゾルバーが発火する。
各々の処理を実装しておけば、GraphQLライブラリの中で適宜、必要な処理を呼び、結果をGraphQLスキーマのレスポンス構造に合わせてクライアントに返してくれる。
ここで問題が1つ。
ユーザ情報取得クエリでは、「TODO情報」モデルの中に「ユーザ情報」モデルが含まれていて、TODO情報を取得するたびに紐づくユーザ情報の取得も走る。
つまり、TODO情報がDBに100件あるとしたら、「全TODO情報」取得クエリをクライアントから発行し、レスポンスに含める要素として「ユーザ情報」も要求した場合、以下の通り101回のデータベースIOが発生する。
・全TODO情報を todo テーブルから select するSQL発行
・上記取得結果の1TODO情報ごとに、紐づくユーザ情報を取得するための user テーブルへの select するSQL発行
N+1問題というやつ。
これを解消するのは簡単で、全TODO情報を取得するSQLの中で user テーブルをJOINして持ってくれば101回のSQLなんてならず1SQLで済む。
ただ、そうすると、全TODO情報を取得するクエリをクライアントから実行する時、たとえ「今回はレスポンスにユーザ情報含めなくていい」ケースでも、サーバ側で必ず user テーブルをJOINして select するSQLが発行されることになる。
かといって、それを避けるには、レスポンスに何を求められているかに応じてSQL文を切り替えるなんてロジックを実装しなくちゃいけなくなる。
できるならどちらも避けたい。という問題への対処として、dataloaderというライブラリ(?)があるらしい。
次回以降のどこかで試してみようかな。以下など参考になりそう。
https://qiita.com/yuku_t/items/2c1735cbf45e75c0bfb8