Slide 1

Slide 1 text

xoのコード生成でgqlgenのDataLoader実装を楽にした 話 syumai Asakusa.go #1 (2023/10/13)

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

本日話すこと GraphQLとDataLoaderについて xoについて xoで生成すべきロジック 最終形

Slide 6

Slide 6 text

GraphQLとDataLoaderについて

Slide 7

Slide 7 text

GraphQLとは GraphQLとは API のために作られたクエリ言語であり、既存のデータに対するクエリ を実行するランタイムです。理解できる完全な形で API 内のデータについて記述しま す。 GraphQLとは?メリットや概要を入門ガイドで学ぶ | CircleCI https://circleci.com/ja/blog/introduction-to-graphql/

Slide 8

Slide 8 text

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/

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

GraphQLと N+1 問題 GraphQLでは、クライアント側が選択的にObjectのどのフィールドを読みに行くか決 定できる -> リクエストを受け取るまで、サーバー側はどの種類のリソースがレスポンスに 含まれるか判断できない 一般的なWeb APIの多くは、リクエストに対するレスポンスに含まれるリソースの種類 が事前に決まっている

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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 }

Slide 14

Slide 14 text

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クエリは存在しない!

Slide 15

Slide 15 text

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 }

Slide 16

Slide 16 text

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 }

Slide 17

Slide 17 text

ここまでのおさらい GraphQLサーバーの実装を素朴に行うと、簡単にN+1クエリ問題が発生する

Slide 18

Slide 18 text

DataLoader データフェッチ処理を遅延し、バッチ処理 & キャッシュする機構 GraphQLに限らず使えるメンタルモデル https://github.com/graphql/dataloader

Slide 19

Slide 19 text

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 }

Slide 20

Slide 20 text

(参考) メルカリ Shops での例 https://engineering.mercari.com/blog/entry/20210818-mercari-shops-nestjs-graphql-server/

Slide 21

Slide 21 text

(参考) メルカリ Shops での例 https://engineering.mercari.com/blog/entry/20210818-mercari-shops-nestjs-graphql-server/

Slide 22

Slide 22 text

graph-gophers/dataloader GoによるDataLoaderの(わりとよく使われる)実装 遅延ロードからのバッチ処理 リクエストスコープでのインメモリキャッシュ https://github.com/graph-gophers/dataloader

Slide 23

Slide 23 text

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)

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

graph-gophers/dataloader の使い方イメージ

Slide 27

Slide 27 text

graph-gophers/dataloader の使い方イメージ やることは以下の通り i. 事前処理 a. バッチ関数の登録 ii. リゾルバの処理 a. バッチ関数の取得 b. バッチ関数の呼び出し

Slide 28

Slide 28 text

さっきの例を当てはめると… /*** * 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 { ... }

Slide 29

Slide 29 text

実装例 ispecさんの記事ベースで社内で行った実装を紹介 https://zenn.dev/ispec_inc/articles/ispec-dataloader

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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 }

Slide 32

Slide 32 text

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 }

Slide 33

Slide 33 text

実装の課題

Slide 34

Slide 34 text

とにかく手で書きたくない DataLoaderの実装はかなり辛いことがわかった バッチ関数の実装を手で書きたくない 全テーブルのレコードに対して、一個ずつ実装していく必要がある 何のためにDBスキーマからコード生成しているんだ?と言う気持ちに バッチ関数を登録する処理を手で書きたくない 同上 バッチ関数を取得する処理も手で書きたくない 同上

Slide 35

Slide 35 text

そこで登場するのが… xo DBスキーマからコード生成できる ここまでのサンプルコードで示してきた model.GetUser などはxoで生成した関 数 https://github.com/xo/xo

Slide 36

Slide 36 text

xoの特徴 DBスキーマから生成するコードの、テンプレートをカスタマイズできる 手書きのテンプレートをゴリゴリ書くことで無限の可能性を得られる

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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 }

Slide 39

Slide 39 text

やったこと バッチ関数の実装の自動生成 バッチ関数の登録処理の自動生成 バッチ関数の呼び出し処理の自動生成

Slide 40

Slide 40 text

実装方針 graph-gopher/dataloaderからは以下を行う loader.Load() 1IDからの1レコード取得 loader.LoadMany() 複数IDからの複数レコード取得

Slide 41

Slide 41 text

最終形 model.GetUser() に対して loader.GetUser() を生成できるようになった! GraphQLリゾルバ内でのdataloaderの利用が簡単になりました

Slide 42

Slide 42 text

詳細については ... 別資料で準備中!

Slide 43

Slide 43 text

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