Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

コード生成を活用したgqlgen+dataloaderの実装パターン解説

syumai
June 07, 2024

 コード生成を活用したgqlgen+dataloaderの実装パターン解説

コード生成を活用したgqlgen + dataloaderの実装パターンについて解説します。
かなり実装寄りの話で、あまり一般化できていないのですが、事例の一つとして参考になれば幸いです。

事前知識については、下記の発表資料を参考にしていただければと思います!
https://speakerdeck.com/syumai/xonokotosheng-cheng-tegqlgennodataloadershi-zhuang-wole-nisitahua

syumai

June 07, 2024
Tweet

More Decks by syumai

Other Decks in Programming

Transcript

  1. 自己紹介 syumai Go Documentation 輪読会 / ECMAScript 仕様輪読会 主催 株式会社ベースマキナで管理画面のSaaSを開発中

    GoでGraphQLサーバー (gqlgen) や TypeScriptでフロントエン ドを書いています Twitter: @__syumai Website: https://syum.ai
  2. GraphQLスキーマの例 todos queryを呼んで Todo -> user と辿ると、 TodoResolver.User() メソッドで N+1

    クエリが発生する Todoの件数が増えるほど問題になる type Todo { id: ID! text: String! done: Boolean! user: User! } type User { id: ID! name: String! } type Query { todos: [Todo!]! }
  3. DataLoaderなしの実装 実装はシンプルだが、N+1 問題がある func (r *todoResolver) User(ctx context.Context, obj *gql.Todo)

    (*gql.User, error) { // Todoから受け取ったUserIDを使って取得 user, err := model.GetUser(ctx, s.db, obj.UserID) // <- ここでN+1クエリが発生! if err != nil { return nil, err } return &gql.User{ ID: user.ID, Name: user.Name, }, nil }
  4. DataLoaderありの実装 結構冗長な処理がある loader は、自動生成されておらず、手動で実装している func (r *todoResolver) User(ctx context.Context, obj

    *gql.Todo) (*gql.User, error) { // バッチ関数の取得 (ContextにHTTP Middlewareで仕込んでいる) loader := DataloaderFromContext(ctx, UserLoaderKey) key := dataloader.StringKey(obj.UserID) thunk := loader.Load(ctx, key) // バッチ関数の呼び出し result, err := thunk() // ここで遅延が入って、バッチ処理でデータ取得される if err != nil { return nil, err } // 型アサーション user := result.(*model.User) // 結果の返却 return &gql.User{ ID: user.ID, Name: user.Name, }, nil }
  5. 最終形 実装がシンプル、かつ N+1 問題にも対応できている 型アサーションも無い 移行も簡単 model を loader に置き換えて、

    db を渡すのをやめるだけで、DataLoaderを 使った実装に差し替えられるようなインタフェース設計としました func (r *todoResolver) User(ctx context.Context, obj *gql.Todo) (*gql.User, error) { user, err := loader.GetUser(ctx, obj.UserID) if err != nil { return nil, err } return &gql.User{ ID: user.ID, Name: user.Name, }, nil }
  6. ここからの説明は、次のようなDBスキーマを前提とします tenants { // テナント id string } users {

    // ユーザー(テナントに所属) tenant_id string id string } projects { // プロジェクト(テナントに所属) tenant_id string id string } project_users { // プロジェクトとユーザーの中間テーブル(ユーザーは任意の数のプロジェクトに所属) project_id string user_id string }
  7. xoで生成するloader packageの関数のシグニチャ データの単体取得 (単一主キー) package model func GetUser( ctx context.Context,

    db QueryRowerContext, id string, ) (*User, error) package loader func GetUser( ctx context.Context, id string, ) (*model.User, error)
  8. xoで生成するloader packageの関数のシグニチャ データの単体取得 (複合主キー) package model func GetProjectUser( ctx context.Context,

    db QueryRowerContext, projectID string, userID string, ) (*ProjectUser, error) package loader func GetProjectUser( ctx context.Context, projectID string, userID string, ) (*model.ProjectUser, error)
  9. xoで生成するloader packageの関数のシグニチャ データの複数取得 (セカンダリインデックス) package model func ListUsersByTenantID( ctx context.Context,

    db QueryRowerContext, tenantID string, ) ([]*User, error) package loader func ListUsersByTenantID( ctx context.Context, tenantID string, ) ([]*model.User, error)
  10. 主キーによる単体データの取得 graph-gophers/dataloader では、Load()やLoadMany()がキーとして単一の値を受け 付けることを想定している func (l *Loader[K, V]) Load(originalContext context.Context,

    key K) Thunk[V] func (l *Loader[K, V]) LoadMany(originalContext context.Context, keys []K) ThunkMany[V] 複合主キーも一つの値にまとめないといけない -> テーブルに対応する主キーの構造体型を自動生成する type UserPrimaryKey struct { ID string } type ProjectUserPrimaryKey struct { ProjectID string UserID string } 実際に loader.GetProjectUser で呼び出す時は、 (ctx, projectID, userID) で呼 び出せる(主キーの構造体を内部で組み立てる)ようにする
  11. model package側の変更 元々は、アプリケーション内で直接呼ばれるユースケースのある関数のみ生成していた DataLoader用に必要になった関数を追加で生成するようにした 元々生成していた関数 * GetUser(id) * ListUsersByTenantID(tenantID) *

    ListUsersByMultiIDs(ids) 追加で生成する関数 // 複合主キー対応用の関数 (複合主キーでなくても自動生成する) * GetUserByPrimaryKey(userPrimaryKey) * ListUsersByMultiPrimaryKeys(userPrimaryKeys) // インデックスキーは最初のキーのみバッチ取得に対応する * ListUsersByMultiTenantIDs(tenantIDs)
  12. loader package側で生成する関数と、model packageとの関係 loader packageの関数は、それぞれmodel package側の以下の関数を呼ぶ形で実装す る * GetUser(id) -

    ListUsersByMultiPrimaryKeys(userPrimaryKeys) * ListUsersByTenantID(tenantID) - ListUsersByMultiTenantIDs(tenantIDs) // GetUserと共通のloaderが使える * ListUsersByMultiIDs(ids) - ListUsersByMultiPrimaryKeys(userPrimaryKeys)
  13. loader packageの自動生成関数の実装 loader package内に実装された共通の load 関数と loadMany 関数を呼び分けて各関 数は実装される load

    や loadMany を切り出すことで、各 GetXXX 関数間でのコード重複はほぼ 無くなる const UserLoaderKey key = "User" func GetUser(ctx context.Context, id string) (*model.User, error) { return load[*model.User](ctx, UserLoaderKey, UserKey{id}, // UserKeyはdataloader.Keyインタフェースを実装している ) }
  14. loaderの自動登録 自動生成するバッチ関数に対応するキー(下記、UserLoaderKey)を生成する。 context.WithValue でバッチ関数を登録し、後から取得するために使う。 init関数を活用して、loader packageの初期化時に自動的にpackageグローバルで保持 するmapへバッチ関数の初期化用関数を登録する。 後からDBのinjectionが必要なので、引数に受け付けられるようにする package loader

    type key string var batchFuncInitializerMap = map[key]func(l *Loader) dataloader.BatchFunc{} const UserLoaderKey key = "User" // バッチ関数の数分init関数を生成する(initはいくらあってもOK) func init() { batchFuncInitializerMap[UserLoaderKey] = func(l *Loader) dataloader.BatchFunc { return l.loadUsersByMultiPrimaryKeys } }
  15. loaderの自動登録 HTTP MiddlewareでDataLoaderのバッチ関数をContextに登録する時に、事前に作っ ておいたmapを使う package http func WithDataloader(db *sqlx.DB) func(http.Handler)

    http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // ここで事前に作ったmapに登録したバッチ関数の初期化関数にDBをinjectする batchFuncs := newBatchFuncs(db) for k, f := range batchFuncs { // Contextに、DBをinjectしたバッチ関数を登録する ctx = context.WithValue(ctx, k, dataloader.NewBatchedLoader(f)) } next.ServeHTTP(w, r.WithContext(ctx)) }) } }