Help us understand the problem. What is going on with this article?

go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)

お題

表題の通り。GraphQL関連では前々回、frontend に「nuxtjs/apollo」、backend に「go+gqlgen」の組み合わせでGraphQLサービスを作るというのをやった。
その時は backend は単に固定のJSONを返すだけでお茶を濁したのだけど、もちろんサービスとして成り立たせる上で固定のJSON返すだけなんてことはない。
ので、今回は、永続化実装。

関連記事索引

開発環境

# 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://gorm.io/ja_JP/

実践

今回の全ソースは下記。
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。

[docker-compose.yml]
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も用意。

[persistence/init/1_create.sql]
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)

テーブル作成もできているか確認。
Screenshot from 2019-12-28 02-05-48.png
うん、OK。

GraphQLスキーマ

最初に「TODO情報」と「ユーザ情報」のモデルを定義。
「TODO情報」モデルの中には「ユーザ情報」モデルを含んでおり、「ユーザ情報」モデルの中にも”複数の”「TODO情報」モデルを含んでいる。
このように定義しておくと、クライアントからこのモデルを取得するクエリを発行する際に階層構造ごと1クエリで取得することができる。

次に以下4つのクエリを定義。
・全TODO情報取得
・1TODO情報取得
・全ユーザ情報取得
・1ユーザ情報取得

次は新規作成用のパラメータを定義。
・「NewTodo」という名でTODO登録時のパラメータを定義
・「NewUser」という名でユーザ登録時のパラメータを定義

最後に以下2つのミューテーションを定義。
・TODO情報登録
・ユーザ情報登録

[schema/schema.graphql]
# 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コネクション初期化処理を追加

[server/server.go]
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実装。

[models/models.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 を使った各種ソース自動生成をするとわかる。

[gqlgen.yml]
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の中身は。

[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のモデルにはない」が関係してくるのが以下。
todoResolverUserメソッド
userResolverTodosメソッド

どう関係してくるかについては、実装して動作確認するのが一番わかりやすいので、まずは実装。

ユーザ登録(TODO登録は省略)

クライアントから以下の要求があると、

[schema/schema.graphql]
type Mutation {
  createUser(input: NewUser!): ID!
}

input NewUser {
  name: String!
}

リゾルバーの以下が発火し、

[resolver.go]
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
}
[models_gen.go]
type NewUser struct {
    Name string `json:"name"`
}

user テーブルに対象ユーザ情報を登録する。

[database/user.go]
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"
}

実践してみると、以下の通り。
Screenshot from 2019-12-28 03-18-24.png

データベース上も、反映されている。
Screenshot from 2019-12-28 03-19-32.png

1ユーザ取得(TODO取得は省略)

クライアントから以下の要求があると、

[schema/schema.graphql]
type Query {
  user(id: ID!): User!
}

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

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

リゾルバーの以下が発火し、

[resolver.go]
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のモデルには無い

[models/models.go]
type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

user テーブルから対象ユーザ情報を取得する。

[database/user.go]
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
}

実践してみると、以下の通り。
Screenshot from 2019-12-28 03-47-30.png

ん?
Goのモデル上は、Userの中にTodo配列は持っていないのに、レスポンスに "todos" が含まれている・・・。

これ、実は、リゾルバーのqueryResolver.Userが発火して、その処理が終わった後、続いて以下も発火している。
以下のTodosの引数にあるobj *models.Userに、上記でDBから取得したユーザ情報が入っているので、そのobjからIDを取得してユーザに紐づくTODO群を取得する処理が流せるというしかけ。

[resolver.go]
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

sky0621
Go使い。最近はRustラブ。Webアプリケーション作ることが多い。フロントエンドもクラウド(GCP好き)もそれなりに触る。2019/10からGraphQLも嗜む。
https://github.com/sky0621/Curriculum-Vitae/blob/master/README.md
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away