Upgrade to Pro — share decks privately, control downloads, hide ads and more …

xoのコード生成でgqlgenのDataLoader実装を楽にした話

syumai
October 13, 2023

 xoのコード生成でgqlgenのDataLoader実装を楽にした話

2024/6/7の発表の前編です。

syumai

October 13, 2023
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スキーマの例 type Todo { id: ID! text: String! done: Boolean!

    user: User! # Objectが別のObjectを持てる } type User { id: ID! name: String! } type Query { todos: [Todo!]! } # https://gqlgen.com/getting-started/
  3. GraphQLクエリの例 query ListTodos { todos { text done } }

    todos query -> Todo と辿り、一回で全ての必要なTodoのデータを取得する
  4. GraphQLクエリの例 query ListTodos { todos { text done user {

    id name } } } todos query -> Todo -> User と辿り、一回で全ての必要なTodoとUserのデータを取得 する
  5. Protobuf (gRPC) の例 message Todo { string id = 1;

    string text = 2; bool done = 3; User user = 4; // 必ずUserも取得されることを期待する } message User { string id = 1; string name = 2; } message ListTodosRequest {} // 本当だったら `limit` など入れますが省略 message ListTodosResponse { repeated Todo todos = 1; } service TodoService { rpc ListTodos(ListTodosRequest) returns (ListTodosReponse); }
  6. Protobuf (gRPC / connect-go) の実装例: 1 func (s *TodoServiceServer) ListTodos(context.Context,

    *connect.Request[v1.ListTodosRequest]) (*connect.Response[v1.ListTodosResponse], error) { // 全てのTodoの取得 todos, err := model.ListAllTodos(ctx, s.db) if err != nil { return nil, err } userIDs := lo.Map(todos, func (todo *model.Todo, _ int) string { return todo.UserID }) // 全てのTodoに紐付くUserの取得 users, err := model.ListUsersByMultiUserIDs(ctx, s.db, userIDs) if err != nil { return nil, err } userMap := // (略) userID => *v1.User のmap return connect.NewResponse(&v1.ListTodosResponse{ Todos: lo.Map(todos, func (todo *model.Todo, _ int) *v1.Todo { return &v1.Todo{ID: todo.ID, Text: todo.Text, Done: todo.Done, User: userMap[todo.UserID]} }), }), nil }
  7. Protobuf (gRPC / connect-go) の実装例: 2 初めから返す結果が決まっているなら、DBに投げるクエリを変更して、Joinしておく ような実装も可能 func (s

    *TodoServiceServer) ListTodos(context.Context, *connect.Request[v1.ListTodosRequest]) (*connect.Response[v1.ListTodosResponse], error) { // todos table と users table を Join したクエリを投げる関数なら一発 todoAndUsers, err := model.ListAllJoinedTodoAndUsers(ctx, s.db) if err != nil { return nil, err } return connect.NewResponse(&v1.ListTodosResponse{ Todos: lo.Map(todoAndUsers, func (todoAndUser *model.TodoAndUser, _ int) *v1.Todo { ... }), }), nil } どちらの例にも、N+1クエリは存在しない!
  8. GraphQL (gqlgen) の実装例 func (r *queryResolver) Todos(ctx context.Context) ([]*gql.Todo, error)

    { // 全てのTodoの取得 todos, err := model.ListAllTodos(ctx, s.db) if err != nil { return nil, err } return lo.Map(todos, func (todo *model.Todo, _ int) *gql.Todo { return &gql.Todo{ ID: todo.ID, Text: todo.Text, Done: todo.Done, // IDだけをTodo構造体に設定する UserID: todo.UserID, } }), nil } func (r *todoResolver) User(ctx context.Context, obj *gql.Todo) (*gql.User, error) { // Todoから受け取ったUserIDを使って取得 user, err := model.GetUser(ctx, s.db, obj.UserID) // <- ここが問題! if err != nil { return nil, err } return &gql.User{ ID: user.ID, Name: user.Name, }, nil }
  9. GraphQL (gqlgen) の実装例 ここでの model.GetUser 関数は、単にIDを使ってDBにUserを取得しに行くだけの関 数 GraphQLのField Resolverは、取得された親Objectを起点に並列に処理される TodoがN件存在したら、GetUserがN回呼ばれる

    -> 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) // <- ここが問題! if err != nil { return nil, err } return &gql.User{ ID: user.ID, Name: user.Name, }, nil }
  10. DataLoader さっきの例で言うと、 todoResolver の  User メソッド内の model.GetUser を遅 延してバッチ処理にしたい func

    (r *todoResolver) User(ctx context.Context, obj *gql.Todo) (*gql.User, error) { user, err := model.GetUser(ctx, s.db, obj.UserID) // <- ここを遅延処理したい! if err != nil { return nil, err } return &gql.User{ ID: user.ID, Name: user.Name, }, nil }
  11. graph-gophers/dataloader の使い方 // バッチ関数 batchFn := func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result

    { var results []*dataloader.Result // データを取ってresultsに詰める return results } loader := dataloader.NewBatchedLoader(batchFn) // 遅延ロードする関数 thunk := loader.Load(context.TODO(), dataloader.StringKey("key1")) result, err := thunk() if err != nil { ... } log.Printf("value: %#v", result)
  12. grpah-gophers/dataloader の遅延処理 16ミリ秒の遅延処理を行う 16ミリ秒の範囲内で発生した読み込み処理は、バッチ関数によって一つにまとめ られる // NewBatchedLoader constructs a new

    Loader with given options. func NewBatchedLoader[K comparable, V any](batchFn BatchFunc[K, V], opts ...Option[K, V]) *Loader[K, V] { loader := &Loader[K, V]{ batchFn: batchFn, inputCap: 1000, wait: 16 * time.Millisecond, } https://github.com/graph- gophers/dataloader/blob/ab736ad423a90b8560fb932a3c523693b52c8f35/dataloader.go#L180
  13. SQLの実行イメージ (一休Developers Blogより引用) -- 親の accommodation と rating を取得 select

    name from accommodation where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; -- 近隣施設を取得 select accommodation_id, name from neighborhood_accommodation where accommodation_id = ?; -- 近隣施設の数だけ rating を取得するクエリが発行される。 。 。 select rating from review_summary where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; -- ↑ではなく、↓のように一括で取ってほしい select rating, accommodation_id from review_summary where accommodation_id in (?, ?, ?, ?, ?); https://user-first.ikyu.co.jp/entry/go-graphql-dataloader
  14. さっきの例を当てはめると… /*** * 1. 事前処理 */ // 1-1. バッチ関数の登録 batchFn

    := func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { var results []*dataloader.Result ... return results } loader := dataloader.NewBatchedLoader(batchFn) /*** * 2. リゾルバの処理 */ // 2-1. バッチ関数の取得 thunk := loader.Load(context.TODO(), dataloader.StringKey("key1")) // 2-2. バッチ関数の呼び出し result, err := thunk() if err != nil { ... }
  15. 1.事前処理の実装例 const ( // バッチ関数のキーを定義 UserKey = "user" ) type

    Loader struct { db *sql.DB } // バッチ関数を実装 func (l *Loader) LoadUsers(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { users, err := model.ListUsersByMultiID(ctx, l.db, keys.Keys()) return lo.Map(users, func (user *model.User, _ int) *dataloader.Result { return &dataloader.Result{Data: user, Error: err} }) } // バッチ関数のマップを作る func newBatchFuncMap(db *sql.DB) map[key]dataloader.BatchFunc { l := &Loader{db: db} return map[key]dataloader.BatchFunc{ UserKey: l.LoadUsers, } }
  16. 1.事前処理の実装例 // 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() batchFuncs := newBatchFuncs(db) for k, f := range batchFuncs { // バッチ関数を一つずつキーで取得可能な形にする ctx = context.WithValue(ctx, k, dataloader.NewBatchedLoader(f)) } next.ServeHTTP(w, r.WithContext(ctx)) }) } } // Contextからバッチ関数を取得する func DataloaderFromContext(ctx context.Context, k key) *dataloader.Loader { l, ok := ctx.Value(k).(*dataloader.Loader) if !ok { panic("Can't find dataloader with key: " + k) } return l }
  17. 2.リゾルバの処理 func (r *todoResolver) User(ctx context.Context, obj *gql.Todo) (*gql.User, error)

    { // 2-1. バッチ関数の取得 loader := DataloaderFromContext(ctx, UserLoaderKey) key := dataloader.StringKey(obj.UserID) thunk := loader.Load(ctx, key) // 2-2. バッチ関数の呼び出し result, err := thunk() if err != nil { return nil, err } // 型アサーション user := result.(*model.User) // 結果の返却 return &gql.User{ ID: user.ID, Name: user.Name, }, nil }
  18. xoのテンプレートの例 // {{ $typeName }} represents a row from '{{

    $table }}'. type {{ $typeName }} struct { {{- range $typeFields }} {{ .Name }} {{ .Type }} `db:"{{ .Col.ColumnName }}"` // {{ .Col.ColumnName }} {{- end }} }
  19. xoのテンプレートの例 // Get{{ $typeName }} gets a {{ $typeName }}

    by primary key(s) func Get{{ $typeName }}(ctx context.Context, db QueryRowerContext{{ goparamlist .PrimaryKeyFields true true }}, opts ...GetOption) (*{{ $typeName }}, error) { // sql query const sqlstr = `SELECT ` + `{{ colnames $typeFields }} ` + `FROM {{ $table }} ` + `WHERE {{ colnamesquery .PrimaryKeyFields " AND " }}` var {{ $short }} {{ $typeName }} err := db.QueryRowContext(ctx, sqlstr{{ goparamlist .PrimaryKeyFields true false }}).Scan({{ fieldnames $typeFields (print "&" $short) }}) ... return &{{ $short }}, nil }