GraphQLとDataloader

gqlgenのサンプルコードがスッと入ってこなかったので整理

参考URL

Optimizing N+1 database queries using Dataloaders — gqlgen

GitHub - graph-gophers/dataloader: Implementation of Facebook's DataLoader in Golang

【GraphQL × Go】 N+1問題を解決するgqlgen + dataloaderの実装方法とCacheの実装オプション - LayerX エンジニアブログ

gqlgenのGetting Startedで使っているスキーマを使用します。

Building GraphQL servers in golang — gqlgen

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

type User {
  id: ID!
  name: String!
}

type Query {
  todos: [Todo!]!
}

Dataloaderって何

データ取得をまとめ(Batch)たりキャッシュしたりする仕組み。
GraphQLではlazy loadingによるN+1問題の回避に使われる。

resolverの分割とN+1問題

todo resolver内でuserを取得するのではなく、userを取得するresolverを分けることができる。
こうすることでuserフィールドが必要な時だけuser resolverが呼ばれるため、userフィールドが不要なクエリに対してuserの取得処理をスキップすることができる。

一方で取得したN件のtodosに対して、それぞれuser resolverが実行されるためN+1問題が発生する。

lazy loading

短い時間で必要なidを貯めて、時間経過後にデータをまとめて取得する方法。
GraphQLではそれぞれ実行されたuser resolverでidを貯めていき、時間経過後にidを全て受け取った上で取得処理(SQLなど)を実行する。
N件発行されていたSELECT FROM users WHERE id=?SELECT FROM users WHERE id IN (?, ?, ?, ?)の1クエリにまとめるイメージ。

その他の対処法

GraphQLでは有効ではないが一般的なN+1問題回避法について

いずれもresolverの分割と相性が悪く、userフィールドが必要な場合のみuserを読み込むという方法が難しいためGraphQLでは微妙になる

eager loading

Todoを取得するSELECT文と、Userを取得するSELECT文を実行して両方の結果をコード(ORM)側で繋ぎ合わせる方法

JOIN

TodoでのSelect時にUserをJOINで結合してしまう方法
柔軟にフィールドを取捨選択するようなクエリではなかったのと、resolverの仕組みを理解していなかったため私は実務でこれをやっていました。

Go(gqlgenとgraph-gophers/dataloader)での例

loaderの生成

lazy loadingの仕組みから、user resolverで実行するidを貯めていく処理貯めたidからuserをまとめて取得する処理の実装が必要になりそうである。
その前にdataloaderからloaderを作成する。

//dataloaderドキュメントから
// create Loader with an in-memory cache
loader := dataloader.NewBatchedLoader(batchFn)

batchFn := func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
  var results []*dataloader.Result
  // do some async work to get data for specified keys
  // append to this list resolved values
  return results
}

貯めたidからuserをまとめて取得する処理

上記のbatchFnに相当し、gqlgenドキュメントにおけるgetUsersはこれを実装したもの。

SELECT * FROM users WHERE id IN()を実行し、型を合わせていく

//gqlgenドキュメントから
func (u *userReader) getUsers(ctx context.Context, userIds []string) []*dataloader.Result[*model.User] {
//貯めたidからIN句を作る
//SELECT id, name FROM users WHERE id IN (?, ?, ?, ?, ?)
    stmt, err := u.db.PrepareContext(ctx, `SELECT id, name FROM users WHERE id IN (?`+strings.Repeat(",?", len(userIds)-1)+`)`)
    (略)
//result配列を作り
    result := make([]*dataloader.Result[*model.User], 0, len(userIds))
//SQLの結果をresultに詰めていく
    for rows.Next() {
                 (略)
        result = append(result, &dataloader.Result[*model.User]{Data: &user})
    }
    return result
}

idを貯めていく処理

dataloader導入前はuser resolver内でSELECT文を実行していたが、その代わりにloader.Load()を叩く
Loadはidを渡した後に処理がブロックされ、batchFnが実行されたタイミングで返り値としてuserを受け取るイメージ

//gqlgenドキュメントから
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
    return loaders.GetUser(ctx, obj.UserID)
}

// GetUser returns single user by id efficiently
func GetUser(ctx context.Context, userID string) (*model.User, error) {
    loaders := For(ctx)//contextに詰めたloaderを取り出す
    return loaders.UserLoader.Load(ctx, userID)()
}

その他の処理

上記が主な処理であり、gqlgenドキュメントでの残りの処理は

  • 複数のloaderをloadersにまとめる(func NewLoaders)
  • リクエストに対してloadersを生成してcontextに仕込むmiddleware(func Middleware)
  • loadersをcontextから取り出す(func For)

など

実行してみる

userをいくつか生成し、Queryの各所にログを仕込んで処理の流れを確認しました。

// User is the resolver for the user field.
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
    fmt.Printf("user resolver for ID %s\n", obj.ID)
    user, err := loaders.GetUser(ctx, obj.UserID)
    fmt.Printf("user ID %s loaded!!\n", obj.ID)
    return user, err
}

batchFnであるgetUsersでは引数のuserIdをもとにUserを作って返します。

func (u *userReader) getUsers(ctx context.Context, userIds []string) []*dataloader.Result[*model.User] {
    fmt.Println("batchFn !!")
    fmt.Println("userIds: ", userIds)
    result := make([]*dataloader.Result[*model.User], 0, len(userIds))
    for i := 0; i < len(userIds); i++ {
        user := model.User{
            ID:   userIds[i],
            Name: "aaa",
        }
        result = append(result, &dataloader.Result[*model.User]{Data: &user})
    }
    return result
}

user resolverのLoadで処理が止まり、batchFnが1度実行されてからresolverに値が返されていることがわかります。

todos Query //func (r *queryResolver) Todos
user resolver for ID T21 //func (r *todoResolver) User(Load前)
user resolver for ID T5
user resolver for ID T61
user resolver for ID T2
user resolver for ID T85
user resolver for ID T19
user resolver for ID T7
user resolver for ID T90
batchFn !! //func (u *userReader) getUsers
user ID T61 loaded!! //func (r *todoResolver) User(Load後)
user ID T90 loaded!!
user ID T85 loaded!!
user ID T2 loaded!!
user ID T21 loaded!!
user ID T7 loaded!!
user ID T19 loaded!!
user ID T5 loaded!!

配列の場合はどうなる??

サンプルではtodoとuserが1対1でしたが、1対多の場合も気になったのでTaskに紐づくUserを単数のleaderと複数のmembersに分けました。

type Todo {
  id: ID!
  text: String!
  done: Boolean!
  leader: User!
  members: [User!]!
}

同じbatchFnにleaderのidとmembersのidをごっちゃに入れてloadした時、どうなるでしょうか。
Loadの引数と返り値のIDが変化していないか見てみます。

todo作成時にleaderIdを引数で固定(1)、memberIdをランダムに2つ入れます。

 todo := &model.Todo{
        Text:      input.Text,
        ID:        fmt.Sprintf("T%d", randNumber),
        LeaderID:  input.LeaderID,
        MemberIDs: []string{fmt.Sprintf("%d", memberRand1), fmt.Sprintf("%d", memberRand2)},
    }

gqlgen.ymlにfieldを追加してgenerateすると空のresolver(leader, members)が生えます。

  Todo:
    fields:
      leader:
        resolver: true
      members:
        resolver: true

生成されたresolverを実装します。渡したidと帰ってきたidをprintでチェックします。

// Leader is the resolver for the leader field.
func (r *todoResolver) Leader(ctx context.Context, obj *model.Todo) (*model.User, error) {
    user, err := loaders.GetUser(ctx, obj.LeaderID)
    fmt.Printf("leader resolver loaded. before_leaderId = %s, after_leaderId= %s\n", obj.LeaderID, user.ID)
    return user, err
}

// Members is the resolver for the members field.
func (r *todoResolver) Members(ctx context.Context, obj *model.Todo) ([]*model.User, error) {
    users, _ := loaders.GetUsers(ctx, obj.MemberIDs)

    var userIDs []string
    for _, v := range users {
        userIDs = append(userIDs, v.ID)
    }

    fmt.Printf("member resolver loaded. before_MemberIDs = %v, after_MemberIDs= %v\n", obj.MemberIDs, userIDs)

    return users, nil
}

loaders.GetUsersの内部ではLoadの複数版であるLoadManyを叩いています。

// GetUsers returns many users by ids efficiently
func GetUsers(ctx context.Context, userIDs []string) ([]*model.User, []error) {
    fmt.Println("Load memberIds: ", userIDs)
    loaders := For(ctx)
    return loaders.UserLoader.LoadMany(ctx, userIDs)()
}

3件入れた後、queryを実行しました。

入力([leaderId, memberID1, memberId2])
userIds:  [1 58 34]
userIds:  [1 84 23]
userIds:  [1 3 46]
todos Query
Load leaderId:  1
Load leaderId:  1
Load memberIds:  [58 34]
Load leaderId:  1
Load memberIds:  [84 23]
Load memberIds:  [3 46]
batchFn !!
userIds:  [1 58 34 84 23 3 46] // batchFnに渡されたids
leader resolver loaded. before_leaderId = 1, after_leaderId= 1
leader resolver loaded. before_leaderId = 1, after_leaderId= 1
member resolver loaded. before_MemberIDs = [3 46], after_MemberIDs= [3 46]
member resolver loaded. before_MemberIDs = [58 34], after_MemberIDs= [58 34]
member resolver loaded. before_MemberIDs = [84 23], after_MemberIDs= [84 23]
leader resolver loaded. before_leaderId = 1, after_leaderId= 1

というわけでbatchFn内でまとめてデータをfetchしても、内部でうまいこと対応するデータを返してくれているようです。