難しいことはよくわからないけど、dataloader/v7を使いたい
はじめに
はじめまして、バックエンドエンジニアのninomaeです。
graph-gophers/dataloader/v7がgenericsに対応したけど、ぱっと見ただけでは使い方がよくわからなかったので、導入までの流れを具体の実装と一緒にまとめておきたいと思います。
そもそもデータローダが何をするか、どんなシーンで使うとよいかについては、今回は扱いません。
GraphQLの導入とDataLoader導入について紹介されています。導入の背景やメリットについて気になる方は、合わせてご覧ください。
コードについて
今回の記事で使っているサンプルコードは以下のリポジトリに格納してあります。
記事内では説明に必要な部分のコードのみ掲載しているので、それ以外の部分で参照したいものがあれば以下のリポジトリを御覧ください。
導入の流れ
導入の流れは以下の通りです。
- DataLoaderの集合を管理する構造体を定義する
- DataLoaderに呼び出された際に実際に実行される(データをフェッチする)関数を定義する
- 1で作成した構造体のコンストラクタ内で、2で作成した関数を使ってLoaderインスタンスを初期化する
- context.Contextに対するDataLoaderインスタンスのsetter/getterを定義する
- middlewareの定義をしてcontextにDataLoaderインスタンスをセットする
- resolver内でDataLoaderを呼び出してデータをフェッチする
1. DataLoaderの集合を管理する構造体を定義する
package loader
import (
"github.com/graph-gophers/dataloader/v7"
"github.com/ninomae42/dataloader-v7/domain"
"github.com/ninomae42/dataloader-v7/domain/user"
)
// Loaders DataLoaderの集合
type Loader struct {
UserLoader *dataloader.Loader[domain.ID, user.User]
}
domain, domain.Userの内容
package domain
type ID string
func NewID() ID {
// NOTE: mock value
return ID("uuid")
}
func (id ID) String() string {
return string(id)
}
package domain
type String string
func (s String) String() string {
return string(s)
}
package user
import (
"errors"
"github.com/ninomae42/dataloader-v7/domain"
)
var ErrNotFound = errors.New("user: user not found")
type User struct {
ID domain.ID
Name domain.String
}
dataloader.Loader
の型定義は以下になります
type Loader[K comparable, V any] struct {
// contains filtered or unexported fields
}
genericsが使われてて一見するとわかりにくいけど、検索のベースになるデータの型をKの部分に、と検索結果のデータの型をVの部分の型引数にそれぞれいれるだけです。
今回の例では
- 検索のベースになるデータの型: domain.ID (uuid, intなど)
- 検索結果のデータの型: user.User (なんでもいい。データローダから取得したいデータの型をいれる)
2. DataLoaderに呼び出された際に実際に実行される関数を定義する
package loader
import (
"context"
"github.com/graph-gophers/dataloader/v7"
"github.com/ninomae42/dataloader-v7/domain"
"github.com/ninomae42/dataloader-v7/domain/user"
"github.com/ninomae42/dataloader-v7/registry"
"gorm.io/gorm"
)
type userLoader struct {
db *gorm.DB
repo user.Repository
}
// NewUserLoader ユーザーのローダーのコンストラクタ
func NewUserLoader(r registry.Registry) userLoader {
return userLoader{
db: r.Repository.DB(),
repo: r.Repository.NewUserRepostiory(),
}
}
// BatchedLoader 実際にデータの取得を行う関数
func (u userLoader) BatchedLoader(ctx context.Context, ids []domain.ID) []*dataloader.Result[user.User] {
result := make([]*dataloader.Result[user.User], len(ids))
users, err := u.repo.GetByIds(ctx, u.db, ids)
if err != nil {
for i := range ids {
result[i] = &dataloader.Result[user.User]{Error: err}
}
return result
}
userByID := make(map[domain.ID]user.User, len(users))
for _, user := range users {
userByID[user.ID] = user
}
for i, id := range ids {
if u, ok := userByID[id]; ok {
result[i] = &dataloader.Result[user.User]{Data: u}
} else {
result[i] = &dataloader.Result[user.User]{Error: user.ErrNotFound}
}
}
return result
}
重要な部分はBatchedLoader関数です。この関数が実際にDataLoaderでデータをフェッチしてくるための関数になります。
type BatchFunc[K comparable, V any] func(context.Context, []K) []*Result[V]
データをフェッチしてくる関数のインターフェースはこの様に定義されています。この部分もgenericsになっていますが、
- 入力
- context.Context
- 検索のベースになるデータのスライス
- 出力
-
Result
のポインタのスライス
-
このように、DataLoaderとして使える関数のインターフェースは固定されています。
データベースのインスタンスやレポジトリの実装など、データフェッチの際に必要な依存関係を構造体に持たせることで、使えるようになります。
BatchFunc
関数の返り値として指定されているResult
型の定義は以下のように定義されています。
type Result[V any] struct {
Data V
Error error
}
Data
とError
を持つ構造体になっており、Dataの部分に取得したデータを、Errorの部分にデータフェッチの際に発生したエラーを格納します。
3. 1で作成した構造体のコンストラクタ内で、2で作成した関数を使ってLoaderインスタンスを初期化する
// Loader DataLoaderの集合
type Loader struct {
UserLoader *dataloader.Loader[domain.ID, user.User]
}
// New Loaderのコンストラクタ
func New(r registry.Registry) Loader {
userLoader := NewUserLoader(r) // 依存関係の注入
return Loader{
UserLoader: dataloader.NewBatchedLoader(userLoader.BatchedLoader),
}
}
registryの実装
package registry
import (
"context"
"github.com/ninomae42/dataloader-v7/domain/user"
"github.com/ninomae42/dataloader-v7/repository"
"gorm.io/gorm"
)
type Registry struct {
Repository Repository
}
func New(ctx context.Context) (Registry, error) {
return Registry{}, nil
}
type Repository struct {
db *gorm.DB
}
func (r Repository) DB() *gorm.DB {
return r.db
}
func (r Repository) NewUserRepostiory() user.Repository {
return repository.User{}
}
- registryの中には
*gorm.DB
を格納するフィールドがあり、メソッドとして各Repositoryのインターフェースを返すメソッドを定義しています。 - 他にもサービスなどを構造体メンバーに追加することで依存関係の注入が簡単になります。
このコンストラクタの中で、データベース接続やレポジトリの実装などの依存関係の注入を行う。
他にもDataLoaderに取ってきてほしいデータがある場合は、Loader構造体にメンバーを追加して、コンストラクタの中で初期化してあげればよいです。
4. context.Contextに対するDataLoaderインスタンスのsetter/getterを定義する
package loader
import (
"context"
"errors"
)
type loaderKey struct {}
var loaderContextkey = loaderKey{}
// ErrLoaderNotFound DataLoaderが見つからない
var ErrLoaderNotFound = errors.New("loader: loader not found in context")
// WithContext DataLoaderインスタンスをContextにセットする
func WithContext(ctx context.Context, loader Loader) context.Context {
return context.WithValue(ctx, loaderContextkey, loader)
}
// FromContext DataLoaderインスタンスをContextから取得する
func WithContext(ctx context.Context, loader Loader) context.Context {
loader, ok := ctx.Value(loaderContextkey).(Loaders)
if !ok {
return Loader{}, ErrLoaderNotFound
}
return loader, nil
}
リクエストごとにDataLoaderのインスタンスを作成して、context
に対して付与する実装にすることが一般的なため、セッターとゲッターを定義します。
5. middlewareの定義をしてcontextにDataLoaderインスタンスをセットする
package middleware
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/ninomae42/dataloader-v7/loader"
"github.com/ninomae42/dataloader-v7/registry"
)
type middleware struct {
registry registry.Registry
}
func newMiddleware(r registry.Registry) *middleware {
return &middleware{
registry: r,
}
}
// withDataLoader DataLoaderをセットするミドルウェアの実装
func (m middleware) withDataLoader(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := loader.New(m.registry)
ctx = loader.WithContext(ctx, l)
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
}
// Mount middlewareを適用したrouterを返す
func Mount(ctx context.Context, r *chi.Mux) (*chi.Mux, error) {
registry, err := registry.New(ctx)
if err != nil {
return nil, err
}
m := newMiddleware(registry)
r.Usr(m.authenticationMiddleware) // 必要に応じて実装する
r.Use(m.withDataLoader)
return r, nil
}
後はこのMountに対してrouterを渡せばmiddlewareが使えます。
6. resolver内でDataLoaderを呼び出してデータをフェッチする
package resolver
import (
"context"
"github.com/ninomae42/dataloader-v7/domain/todo"
"github.com/ninomae42/dataloader-v7/loader"
)
type Todo struct {
model todo.Todo
}
func NewTodo(model todo.Todo) Todo {
return Todo{model: model}
}
func (t Todo) ID() string {
return t.model.ID.String()
}
func (t Todo) Name() string {
return t.model.Name.String()
}
func (t Todo) Assignees(ctx context.Context) ([]User, error) {
loader, err := loader.FromContext(ctx)
if err != nil {
return []User{}, err
}
thunkMany := loader.UserLoader.LoadMany(ctx, t.model.Assignees)
results, errs := thunkMany()
for _, err := range errs {
if err != nil {
return []User{}, err
}
}
users := make([]User, len(results))
for i, u := range results {
users[i] = NewUser(u)
}
return users, nil
}
Assignees
メソッド内で実際にDataLoaderを呼び出しています。
contextからDataLoaderのインスタンスを取得し、今回は複数のリソースを取得したいのでLoadMany
メソッドを呼び出しています。
LoadMany関数、Load
関数の返り値は、それぞれThunkMany
、Thunk
という関数になっています。
この関数を呼び出すと、Resultの値が取得されるまでブロッキングするようになっており、値の取得が完了するとResultを返します。
type Thunk[V any] func() (V, error) // Loadの返り値
type ThunkMany[V any] func() ([]V, []error) // LoadManyの返り値
thunkManyの場合は、[]error
が入ってくるので、for文でイテレートしてerrorが入っていないか確認します。
おわりに
genericsは便利なのですが、初見でみたときにわかりにくいなと思ったので、自分用メモとして今回の記事を作成しました。
loader以外のパッケージについてはご自分のプロジェクトに合わせてカスタマイズしてみてください。
もし、この記事がどなた様かの参考になれば幸いです。
参考
dataloader package - github.com/graph-gophers/dataloader/v7 - Go Packages
N+1問題の回避 - dataloaderの導入|Goで学ぶGraphQLサーバーサイド入門
Discussion