Goのキャッシュ実装をジェネリクスで共通化する

はじめに

GoでRedisを使ったキャッシュを実装する際、同じような処理を何度も書くことになりそうだったので共通化しました。

実装

Redisへリクエストする処理をinterfaceで定義しておきます。

Go
type Client interface {
	Get(context.Context, string) ([]byte, bool, error)
	Set(context.Context, string, []byte, time.Duration) error
}

このinterfaceを満たすclientを実装します。Redisへの接続には github.com/go-redis/redis を使いました。

Go
type client struct {
	c *redis.Client
}

func NewClient(addr, pass string) cache.Client {
	c := redis.NewClient(
		&redis.Options{
			Addr:         addr,
			Password:     pass,
			PoolSize:     20,
			MinIdleConns: 10,
			MaxConnAge:   2 * time.Minute,
		},
	)
	return &client{
		c: c,
	}
}

func (c *client) Get(
	ctx context.Context,
	key string,
) ([]byte, bool, error) {
	bytes, err := c.c.Get(ctx, key).Bytes()
	// キャッシュが存在しない場合
	if err == redis.Nil {
		return nil, false, nil
	}
	if err != nil {
		return nil, false, fmt.Errorf("failed to get from redis: %w", err)
	}
	return bytes, true, nil
}

func (c *client) Set(
	ctx context.Context,
	key string,
	bytes []byte,
	expiration time.Duration,
) error {
	err := c.c.Set(ctx, key, bytes, expiration).Err()
	if err != nil {
		return fmt.Errorf("failed to set to redis: %w", err)
	}
	return nil
}

このinterfaceを使って「キャッシュがあればそれを返し、なければDBから取得し、キャッシュに保存しつつ返す」という処理を実装します。

Go
type Cache[T any] struct {
	client     Client
	expiration time.Duration
	sfg        *singleflight.Group
}

func NewCache[T any](client Client, expiration time.Duration) *Cache[T] {
	return &Cache[T]{
		client:     client,
		expiration: expiration,
		sfg:        &singleflight.Group{},
	}
}

func (c *Cache[T]) GetOrSet(
	ctx context.Context,
	key string,
	callback func(context.Context) (T, error),
) (T, error) {
	// singleflightでリクエストをまとめる
	a, err, _ := c.sfg.Do(key, func() (any, error) {
		// キャッシュから取得
		bytes, exist, err := c.client.Get(ctx, key)
		if err != nil {
			log.Println(err)
		}
		if exist {
			return bytes, nil
		}
		// キャッシュがなければcallbackを実行
		t, err := callback(ctx)
		if err != nil {
			return nil, err
		}
		bytes, err = json.Marshal(t)
		if err != nil {
			return nil, err
		}
		// キャッシュに保存
		err = c.client.Set(ctx, key, bytes, c.expiration)
		if err != nil {
			log.Println(err)
		}
		return bytes, nil
	})
	var t T
	if err != nil {
		return t, err
	}
	bytes, ok := a.([]byte)
	if !ok {
		// 実装上、起きることはないはず
		return t, fmt.Errorf("failed to get from cache: invalid type %T", a)
	}
	err = json.Unmarshal(bytes, &t)
	if err != nil {
		return t, err
	}
	return t, nil
}

キャッシュ対象の型をジェネリクスで置いています。callbackにはDBから取得する処理を渡す想定です。こちらの記事を参考に、キャッシュが切れたタイミングでDBに大量のリクエストが飛ばないようにsingleflightを使っています。

例として、UserオブジェクトをID指定で取得するような処理にキャッシュを適用してみます。

Go
type userRepository struct {
	db    *sql.DB
	cache *cache.Cache[*user.User]
}

func NewUserRepository(
	db *sql.DB,
	cacheClient cache.Client,
) user.Repository {
	return &userRepository{
		db: db,
		cache: cache.NewCache[*user.User](
			cacheClient,
			time.Minute,
		),
	}
}

func (r *userRepository) GetByID(
	ctx context.Context,
	id int,
) (*user.User, error) {
	return r.cache.GetOrSet(ctx, makeUserKey(id),
		func(ctx context.Context) (*user.User, error) {
			var u *user.User
			// r.dbを使ってDBから取得する
			return u, nil
		},
	)
}

func makeUserKey(id int) string {
	return fmt.Sprintf("user:id:%d", id)
}

Cache[T any] は基本的にどんな型に対しても使えるので、他にキャッシュしたいデータが増えても同じように使い回すことができます。

終わりに

既存の実装を大きく変更することなくキャッシュ実装を共通化できました。今回、キャッシュの削除や複数取得などは実装していませんが、同じような方法で実装できると思うので、そちらも追加するつもりです。