Closed12

GoでGraphQL: gqlgen + gorm

maruwaremaruware

DBにつなぐ。とりあえず試すためにSQLiteで動かす。

external/database.go
package external

import (
	"github.com/maruware/gqlgen-todos/entity"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

func ConnectDatabase() (*gorm.DB, error) {
	db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
	if err != nil {
		return nil, err
	}

	err = db.AutoMigrate(&entity.User{}, &entity.Todo{})
	if err != nil {
		return nil, err
	}
	return db, nil
}

entity/user.go
package entity

type User struct {
	ID   uint
	Name string
}
entity/todo.go
package entity

type Todo struct {
	ID     uint
	Text   string
	Done   bool
	UserID uint
	User   User
}

Resolverで

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

としていたのをDB接続に差し替える。

graph/resolver.go
type Resolver struct {
	DB *gorm.DB
}

server.go

server.go
db, err := external.ConnectDatabase()
if err != nil {
	panic(err)
}

srv := handler.NewDefaultServer(
	generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{DB: db}}))

と初期化する。

maruwaremaruware

SDLを編集。ユーザー登録するようにする。
Queryはuserをid指定で取得するようにする。

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

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

type Query {
  user(id: String!): User!
}

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

input NewUser {
  Name: String!
}

type Mutation {
  createUser(input: NewUser!): User!
  createTodo(input: NewTodo!): Todo!
}

modelは以下のように定義する。

model/user.go
package model

import (
	"fmt"
	"github.com/maruware/gqlgen-todos/entity"
)

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

func NewUserFromEntity(e *entity.User) *User {
	return &User{
		ID:   fmt.Sprintf("%d", e.ID),
		Name: e.Name,
	}
}
model/todo.go
package model

import (
	"fmt"
	"github.com/maruware/gqlgen-todos/entity"
)

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

func NewTodoFromEntity(e *entity.Todo) *Todo {
	return &Todo{
		ID:     fmt.Sprintf("%d", e.ID),
		Text:   e.Text,
		Done:   e.Done,
		UserID: fmt.Sprintf("%d", e.UserID),
	}
}

go run github.com/99designs/gqlgen generate すると
models_gen.go は以下のようになる。

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 NewUser struct {
	Name string `json:"Name"`
}
maruwaremaruware

resolver実装の空きを埋める。
いったんresolverの中でgorm.DBインスタンス直叩きの形で実装する。

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"
	"strconv"

	"github.com/maruware/gqlgen-todos/entity"
	"github.com/maruware/gqlgen-todos/graph/generated"
	"github.com/maruware/gqlgen-todos/graph/model"
)

func (r *mutationResolver) CreateUser(ctx context.Context, input model.NewUser) (*model.User, error) {
	record := entity.User{
		Name: input.Name,
	}
	if err := r.DB.Create(&record).Error; err != nil {
		return nil, err
	}

	res := model.NewUserFromEntity(&record)

	return res, nil
}

func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
	userID, err := strconv.Atoi(input.UserID)
	if err != nil {
		return nil, err
	}
	record := entity.Todo{
		Text:   input.Text,
		UserID: uint(userID),
	}
	if err := r.DB.Create(&record).Error; err != nil {
		return nil, err
	}

	res := model.NewTodoFromEntity(&record)
	return res, nil
}

func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
	idn, err := strconv.Atoi(id)
	if err != nil {
		return nil, err
	}
	var u entity.User
	if err := r.DB.Find(&u, idn).Error; err != nil {
		return nil, err
	}
	return &model.User{
		ID:   fmt.Sprintf("%d", u.ID),
		Name: u.Name,
	}, nil
}

func (r *userResolver) Todos(ctx context.Context, obj *model.User) ([]*model.Todo, error) {
	userID, err := strconv.Atoi(obj.ID)
	if err != nil {
		return nil, err
	}
	var records []entity.Todo
	if err := r.DB.Where("user_id", userID).Find(&records).Error; err != nil {
		return nil, err
	}

	todos := []*model.Todo{}
	for _, record := range records {
		todos = append(todos, model.NewTodoFromEntity(&record))
	}

	return todos, nil
}

// 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} }

// User returns generated.UserResolver implementation.
func (r *Resolver) User() generated.UserResolver { return &userResolver{r} }

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

go run ./server.go して http://localhost:8080 にアクセス。

mutation {
  createUser(input: {Name:"Taro"}) {
    id
    name
  }
}
mutation {
  createTodo(input: {userId:"1", text:"buy coffee"}) {
    id
    name
  }
}
query {
  user(id:"1")
}

とかする。

maruwaremaruware

go run github.com/99designs/gqlgen generate がめんどくさいのでMakefileを作っておく

generate:
	go run github.com/99designs/gqlgen generate
run:
	go run ./server.go
maruwaremaruware

N+1問題を作るためにtodosを取得してtodo.userも取得できるようにしておく。

schema.graphqls
type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
}
type Query {
  user(id: String!): User!
  todos: [Todo!]!
}

make generate して足りない実装を追加する。

schema.resolvers.go
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
	var records []entity.Todo
	if err := r.DB.Find(&records).Error; err != nil {
		return nil, err
	}

	todos := []*model.Todo{}
	for _, record := range records {
		todos = append(todos, model.NewTodoFromEntity(&record))
	}
	return todos, nil
}

func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
	userID, err := strconv.Atoi(obj.UserID)
	if err != nil {
		return nil, err
	}
	var record entity.User
	if err := r.DB.Find(&record, userID).Error; err != nil {
		return nil, err
	}
	return model.NewUserFromEntity(&record), nil
}
maruwaremaruware

N+1問題が起きていることを確認するためにログにクエリ出力する。

schema.resolvers.go
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
	userID, err := strconv.Atoi(obj.UserID)
	if err != nil {
		return nil, err
	}
	var record entity.User
        // Debug()を挿入してログにクエリを出力
	if err := r.DB.Debug().Find(&record, userID).Error; err != nil {
		return nil, err
	}
	return model.NewUserFromEntity(&record), nil
}

playground で以下を実行

query {
  todos {
    id
    text
    user {
      name
    }
  }
}
[0.091ms] [rows:1] SELECT * FROM `users` WHERE `users`.`id` = 1
[0.743ms] [rows:1] SELECT * FROM `users` WHERE `users`.`id` = 1
[0.844ms] [rows:1] SELECT * FROM `users` WHERE `users`.`id` = 1

とSELECTがTodoの数分走る。

maruwaremaruware

GQLGenのドキュメントに記載のあるvektah/dataloadenを使う。
https://gqlgen.com/reference/dataloaders/

ドキュメントに記載のあるコマンドだとエラーで動かない。Issueも立っている。
https://github.com/vektah/dataloaden/issues/35

空のディレクトリだとpackage定義が見つからないというのが原因のようなので

go get github.com/vektah/dataloaden
mkdir dataloader
cd dataloader
touch tmp.go
echo 'package dataloader' >> tmp.go

としてから

go run github.com/vektah/dataloaden UserLoader string *github.com/[username]/gqlgen-todos/graph/model.User

とすると userloader_gen.go が生成される。

maruwaremaruware

ドキュメントだとUserLoaderのインスタンスをstructから作っているが、
userloader_gen.go を見るとNewUserLoaderという関数があるのでこちらを使う形にする。

また、gormを使う形にしつつ少しリファクタして以下

dataloader/middleware.go
package dataloader

import (
	"context"
	"net/http"
	"strconv"
	"time"

	"github.com/maruware/gqlgen-todos/entity"
	"github.com/maruware/gqlgen-todos/graph/model"
	"gorm.io/gorm"
)

type loadersKeyType string

const loadersKey loadersKeyType = "dataloaders"

type Loaders struct {
	UserByID *UserLoader
}

func DataLoaderMiddleware(db *gorm.DB, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), loadersKey, &Loaders{
			UserByID: NewUserLoader(UserLoaderConfig{
				MaxBatch: 100,
				Wait:     1 * time.Millisecond,
				Fetch: func(keys []string) ([]*model.User, []error) {
					ids := make([]int, len(keys))
					for i, key := range keys {
						id, err := strconv.Atoi(key)
						if err != nil {
							return nil, []error{err}
						}
						ids[i] = id
					}

					var records []entity.User
					if err := db.Debug().Find(&records, ids).Error; err != nil {
						return nil, []error{err}
					}

					userByID := map[string]*model.User{}
					for _, record := range records {
						user := model.NewUserFromEntity(&record)
						userByID[user.ID] = user
					}

					users := make([]*model.User, len(ids))
					for i, key := range keys {
						users[i] = userByID[key]
					}

					return users, nil
				},
			}),
		})
		r = r.WithContext(ctx)
		next.ServeHTTP(w, r)
	})
}

func For(ctx context.Context) *Loaders {
	return ctx.Value(loadersKey).(*Loaders)
}

server に middleware を組み込む。

server.go
http.Handle("/query", dataloader.DataLoaderMiddleware(db, srv))

todoResolver#User を以下のように書き換える

schema.resolvers.go
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
	return dataloader.For(ctx).UserByID.Load(obj.UserID)
}

UserやTodoを適当に追加で登録して、

query {
  todos {
    id
    text
    user {
      id
      name
    }
  }
}

と実行するとログに

[0.134ms] [rows:2] SELECT * FROM `users` WHERE `users`.`id` IN (2,1)

と出る。

maruwaremaruware

server で chi を使うようにしつつ、
middleware を chi用に修正しつつ、
リファクタ

server.go
package main

import (
	"log"
	"net/http"
	"os"

	"github.com/go-chi/chi"
	"github.com/go-chi/chi/middleware"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/maruware/gqlgen-todos/dataloader"
	"github.com/maruware/gqlgen-todos/external"
	"github.com/maruware/gqlgen-todos/graph"
	"github.com/maruware/gqlgen-todos/graph/generated"
)

const defaultPort = "8080"

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = defaultPort
	}

	db, err := external.ConnectDatabase()
	if err != nil {
		panic(err)
	}

	srv := handler.NewDefaultServer(
		generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{DB: db}}))

	r := chi.NewRouter()
	r.Use(middleware.Logger)
	dataloaderMw := dataloader.DataLoaderMiddleware(db)

	r.Get("/", playground.Handler("GraphQL playground", "/query"))
	r.With(dataloaderMw).Post("/query", srv.ServeHTTP)

	log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
	log.Fatal(http.ListenAndServe(":"+port, r))
}
dataloader/middleware.go
package dataloader

import (
	"context"
	"net/http"
	"strconv"
	"time"

	"github.com/maruware/gqlgen-todos/entity"
	"github.com/maruware/gqlgen-todos/graph/model"
	"gorm.io/gorm"
)

type loadersKeyType string

const loadersKey loadersKeyType = "dataloaders"

type Loaders struct {
	UserByID *UserLoader
}

func fetchUsersByID(db *gorm.DB) func(keys []string) ([]*model.User, []error) {
	return func(keys []string) ([]*model.User, []error) {
		ids := make([]int, len(keys))
		for i, key := range keys {
			id, err := strconv.Atoi(key)
			if err != nil {
				return nil, []error{err}
			}
			ids[i] = id
		}

		var records []entity.User
		if err := db.Debug().Find(&records, ids).Error; err != nil {
			return nil, []error{err}
		}

		userByID := map[string]*model.User{}
		for _, record := range records {
			user := model.NewUserFromEntity(&record)
			userByID[user.ID] = user
		}

		users := make([]*model.User, len(ids))
		for i, key := range keys {
			users[i] = userByID[key]
		}

		return users, nil
	}
}

func DataLoaderMiddleware(db *gorm.DB) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := context.WithValue(r.Context(), loadersKey, &Loaders{
				UserByID: NewUserLoader(UserLoaderConfig{
					MaxBatch: 100,
					Wait:     1 * time.Millisecond,
					Fetch:    fetchUsersByID(db),
				}),
			})
			r = r.WithContext(ctx)
			next.ServeHTTP(w, r)
		})
	}
}

func For(ctx context.Context) *Loaders {
	return ctx.Value(loadersKey).(*Loaders)
}

maruwaremaruware

dataloaderにgraph-gophers/dataloader を使う形にしてみる。
https://github.com/graph-gophers/dataloader

loader/loader.go
package loader

import (
	"context"
	"fmt"
	"net/http"
	"strconv"
	"time"

	"github.com/graph-gophers/dataloader"
	"github.com/maruware/gqlgen-todos/entity"
	"github.com/maruware/gqlgen-todos/graph/model"
	"gorm.io/gorm"
)

type loadersKeyType string

const loadersKey loadersKeyType = "dataloaders"

type Loaders struct {
	UserByID *dataloader.Loader
}

func newLoaders(db *gorm.DB) *Loaders {
	return &Loaders{
		UserByID: dataloader.NewBatchedLoader(
			newUserLoaderFunc(db),
			dataloader.WithWait(1*time.Millisecond)),
	}
}

func newUserLoaderFunc(db *gorm.DB) dataloader.BatchFunc {
	return func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
		ids := []int{}
		for _, key := range keys {
			id, err := strconv.Atoi(key.String())
			if err != nil {
				continue
			}
			ids = append(ids, id)
		}

		var records []entity.User
		if err := db.Debug().Find(&records, ids).Error; err != nil {
			return []*dataloader.Result{}
		}

		userByID := map[string]*model.User{}
		for _, record := range records {
			user := model.NewUserFromEntity(&record)
			userByID[user.ID] = user
		}

		results := make([]*dataloader.Result, len(keys))
		for i, key := range keys {
			k := key.String()
			results[i] = &dataloader.Result{Data: nil, Error: nil}
			if user, ok := userByID[k]; ok {
				results[i].Data = user
			} else {
				results[i].Error = fmt.Errorf("user[key=%s] not found", k)
			}
		}

		return results
	}
}

func LoadUser(ctx context.Context, id string) (*model.User, error) {
	loader := ctx.Value(loadersKey).(*Loaders)
	thunk := loader.UserByID.Load(ctx, dataloader.StringKey(id))
	data, err := thunk()
	if err != nil {
		return nil, err
	}
	return data.(*model.User), nil
}

func DataLoaderMiddleware(db *gorm.DB) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := context.WithValue(r.Context(), loadersKey, newLoaders(db))
			r = r.WithContext(ctx)
			next.ServeHTTP(w, r)
		})
	}
}
schema.resolver.go
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
	return loader.LoadUser(ctx, obj.UserID)
}

dataloaden と比較して

  • コードジェネレートはしない
  • その代わりinterface{}を使うので型アサーションをする
  • ResultというrustのResultのような型にして返す
  • ThunkというPromiseみたいな型からデータを取り出す

という感じ

このスクラップは2021/01/21にクローズされました