Slide 1

Slide 1 text

コード生成を活用したgqlgen + dataloaderの実装パタ ーン解説 syumai (Unofficial) Go Conference 2024 Pre Party (2024/6/7)

Slide 2

Slide 2 text

自己紹介 syumai Go Documentation 輪読会 / ECMAScript 仕様輪読会 主催 株式会社ベースマキナで管理画面のSaaSを開発中 GoでGraphQLサーバー (gqlgen) や TypeScriptでフロントエン ドを書いています Twitter: @__syumai Website: https://syum.ai

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

ベースマキナとは? DBやAPIの接続設定 & 呼び出し設定をするだけで、簡単にUI生成が行える管理画面 SaaS API呼び出しへの権限設定や、レビュー依頼 / 承認機能も簡単に使えます https://about.basemachina.com

Slide 5

Slide 5 text

本日話すこと DBスキーマをベースとした、DataLoader実装の自動生成について

Slide 6

Slide 6 text

本日話さないこと GraphQLやDataLoaderについての解説 xoについての解説

Slide 7

Slide 7 text

前提 gqlgenとxoを使っています DataLoaderにはgraph-gophers/dataloaderを使います

Slide 8

Slide 8 text

話す順 DataLoaderなしの実装、ありの実装、最終形の実装の紹介 最終形の実装に必要な要素 実装方針 実際に行った変更、実装例

Slide 9

Slide 9 text

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!]! }

Slide 10

Slide 10 text

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 }

Slide 11

Slide 11 text

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 }

Slide 12

Slide 12 text

最終形 実装がシンプル、かつ 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 }

Slide 13

Slide 13 text

ここからの説明は、次のようなDBスキーマを前提とします tenants { // テナント id string } users { // ユーザー(テナントに所属) tenant_id string id string } projects { // プロジェクト(テナントに所属) tenant_id string id string } project_users { // プロジェクトとユーザーの中間テーブル(ユーザーは任意の数のプロジェクトに所属) project_id string user_id string }

Slide 14

Slide 14 text

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)

Slide 15

Slide 15 text

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)

Slide 16

Slide 16 text

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)

Slide 17

Slide 17 text

最終形の実装に必要な要素 Loader経由で、以下のデータを取得できる 主キーによる単体データ セカンダリインデックスによる複数データ 主キーによる単体データ取得は、複合主キーにも対応する

Slide 18

Slide 18 text

実装方針

Slide 19

Slide 19 text

主キーによる単体データの取得 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) で呼 び出せる(主キーの構造体を内部で組み立てる)ようにする

Slide 20

Slide 20 text

セカンダリインデックスによる複数データの取得 複合主キーの例と同様、キーを1つの値にまとめる対応が必要。 (今回は対応し切れなかったので、インデックスの最初のキーのみ対応) また、データ取得をバッチ処理するため、インデックスのキーを複数(Userに紐付く Tenant AのID, Tenant BのID ... etc)受け付ける関数が必要。

Slide 21

Slide 21 text

実際に行った変更、実装例 ここで示した内容の、テーブルスキーマに依存する部分をxoのテンプレートで生成します (xoテンプレの解説は省略)

Slide 22

Slide 22 text

model package側の変更 元々は、アプリケーション内で直接呼ばれるユースケースのある関数のみ生成していた DataLoader用に必要になった関数を追加で生成するようにした 元々生成していた関数 * GetUser(id) * ListUsersByTenantID(tenantID) * ListUsersByMultiIDs(ids) 追加で生成する関数 // 複合主キー対応用の関数 (複合主キーでなくても自動生成する) * GetUserByPrimaryKey(userPrimaryKey) * ListUsersByMultiPrimaryKeys(userPrimaryKeys) // インデックスキーは最初のキーのみバッチ取得に対応する * ListUsersByMultiTenantIDs(tenantIDs)

Slide 23

Slide 23 text

loader package側で生成する関数と、model packageとの関係 loader packageの関数は、それぞれmodel package側の以下の関数を呼ぶ形で実装す る * GetUser(id) - ListUsersByMultiPrimaryKeys(userPrimaryKeys) * ListUsersByTenantID(tenantID) - ListUsersByMultiTenantIDs(tenantIDs) // GetUserと共通のloaderが使える * ListUsersByMultiIDs(ids) - ListUsersByMultiPrimaryKeys(userPrimaryKeys)

Slide 24

Slide 24 text

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インタフェースを実装している ) }

Slide 25

Slide 25 text

loader packageの自動生成関数の実装 load関数は、Contextからloaderを取得して、取得結果を型アサーションして返す 型アサーションで ok を見ておらず、安全なコードとは言えないが、自動生成コ ードなので許容 (正直ここではあまりジェネリクスのメリットは無い) func load[T any](ctx context.Context, k key, loaderKey dataloader.Key) (T, error) { var zero T loader := DataloaderFromContext(ctx, k) thunk := loader.Load(ctx, loaderKey) v, err := thunk() if err != nil { return zero, err } return v.(T), nil }

Slide 26

Slide 26 text

バッチ関数の自動生成 生成する必要があるのは、 主キーによる単体データ セカンダリインデックスによる複数データ のバッチ取得を実現するための関数。 例として、usersに対しては、 バッチ呼び出しされたUserIDの一覧のそれぞれに対応するUserを返す関数 結果はUserのスライスになる バッチ呼び出しされたTenantIDの一覧のそれぞれに対応するUserの一覧を返す関数 結果はUserのスライスのスライスになる の2つを自動生成する。 セカンダリインデックスに依存するバッチ関数は、インデックスの数分生成する。

Slide 27

Slide 27 text

loaderの自動登録 この箇所の実装方法は様々な方針が考えられるので、あくまでただの一例

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

まとめ DataLoaderの実装を毎回手で行うのは大変 DataLoaderの実装を自動生成するなら、以下をバッチで取得する関数を事前準備とし て生成するようにする 主キーによる単体データ セカンダリインデックスによる複数データ 複合キーは、構造体にまとめて単体の値として扱えるようにして、DataLoaderのキー に使う 下準備が十分に出来たら、あとは気合で実装可能

Slide 31

Slide 31 text

ご清聴ありがとうございました!