GoでGraphQL: gqlgen + gorm
GQLGenのGetting Startedから始める。
一通り進めるとメモリ上にTodoを保存する形で動作する。
DBにつなぐ。とりあえず試すためにSQLiteで動かす。
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
}
package entity
type User struct {
ID uint
Name string
}
package entity
type Todo struct {
ID uint
Text string
Done bool
UserID uint
User User
}
Resolverで
type Resolver struct{
todos []*model.Todo
}
としていたのをDB接続に差し替える。
type Resolver struct {
DB *gorm.DB
}
server.go
で
db, err := external.ConnectDatabase()
if err != nil {
panic(err)
}
srv := handler.NewDefaultServer(
generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{DB: db}}))
と初期化する。
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は以下のように定義する。
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,
}
}
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
は以下のようになる。
// 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"`
}
resolver実装の空きを埋める。
いったんresolverの中でgorm.DBインスタンス直叩きの形で実装する。
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 }
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")
}
とかする。
go run github.com/99designs/gqlgen generate
がめんどくさいのでMakefileを作っておく
generate:
go run github.com/99designs/gqlgen generate
run:
go run ./server.go
N+1問題を作るためにtodosを取得してtodo.userも取得できるようにしておく。
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
type Query {
user(id: String!): User!
todos: [Todo!]!
}
make generate
して足りない実装を追加する。
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
}
N+1問題が起きていることを確認するためにログにクエリ出力する。
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の数分走る。
GQLGenのドキュメントに記載のあるvektah/dataloadenを使う。
ドキュメントに記載のあるコマンドだとエラーで動かない。Issueも立っている。
空のディレクトリだと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
が生成される。
ドキュメントだとUserLoaderのインスタンスをstructから作っているが、
userloader_gen.go
を見るとNewUserLoader
という関数があるのでこちらを使う形にする。
また、gormを使う形にしつつ少しリファクタして以下
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 を組み込む。
http.Handle("/query", dataloader.DataLoaderMiddleware(db, srv))
todoResolver#User
を以下のように書き換える
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)
と出る。
server で chi を使うようにしつつ、
middleware を chi用に修正しつつ、
リファクタ
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))
}
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)
}
dataloaderにgraph-gophers/dataloader
を使う形にしてみる。
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)
})
}
}
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みたいな型からデータを取り出す
という感じ