📑

難しいことはよくわからないけど、dataloader/v7を使いたい

2024/03/11に公開

はじめに

はじめまして、バックエンドエンジニアのninomaeです。

graph-gophers/dataloader/v7がgenericsに対応したけど、ぱっと見ただけでは使い方がよくわからなかったので、導入までの流れを具体の実装と一緒にまとめておきたいと思います。

そもそもデータローダが何をするか、どんなシーンで使うとよいかについては、今回は扱いません。

https://zenn.dev/ispec_inc/articles/ispec-dataloader

GraphQLの導入とDataLoader導入について紹介されています。導入の背景やメリットについて気になる方は、合わせてご覧ください。

コードについて

今回の記事で使っているサンプルコードは以下のリポジトリに格納してあります。
記事内では説明に必要な部分のコードのみ掲載しているので、それ以外の部分で参照したいものがあれば以下のリポジトリを御覧ください。
https://github.com/ninomae42/dataloader-v7-sample

導入の流れ

導入の流れは以下の通りです。

  1. DataLoaderの集合を管理する構造体を定義する
  2. DataLoaderに呼び出された際に実際に実行される(データをフェッチする)関数を定義する
  3. 1で作成した構造体のコンストラクタ内で、2で作成した関数を使ってLoaderインスタンスを初期化する
  4. context.Contextに対するDataLoaderインスタンスのsetter/getterを定義する
  5. middlewareの定義をしてcontextにDataLoaderインスタンスをセットする
  6. resolver内でDataLoaderを呼び出してデータをフェッチする

1. DataLoaderの集合を管理する構造体を定義する

loader/loader.go
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の内容
domain/id.go
package domain

type ID string

func NewID() ID {
	// NOTE: mock value
	return ID("uuid")
}

func (id ID) String() string {
	return string(id)
}
domain/string.go
package domain

type String string

func (s String) String() string {
	return string(s)
}
domain/user/user.go
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に呼び出された際に実際に実行される関数を定義する

loader/user.go
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
}

DataErrorを持つ構造体になっており、Dataの部分に取得したデータを、Errorの部分にデータフェッチの際に発生したエラーを格納します。

3. 1で作成した構造体のコンストラクタ内で、2で作成した関数を使ってLoaderインスタンスを初期化する

loader/loader.go
// 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を定義する

loader/context.go
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インスタンスをセットする

middleware/middleware.go
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を呼び出してデータをフェッチする

resolver/todo.go
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関数の返り値は、それぞれThunkManyThunkという関数になっています。

この関数を呼び出すと、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サーバーサイド入門

ispec

Discussion