🐁

upstash for Redis を Go で触る ~IaCを添えて~

2023/04/30に公開

はじめに

貧乏エンジニアリング大好き♡たこくんです。

みなさん、upstashというサービスはご存知でしょうか?

https://upstash.com/

Upstashなのかupstashなのかどちらが正しいのか判断できませんでしたが、Upstashは会社名っぽいので本記事ではupstashを使います。間違っていたらすみません。

こちらの方の記事でご紹介されているのでご存知の方も多いかと思います。

https://zenn.dev/tkithrta/articles/a56603a37b08f0

そう、貧乏エンジニアリングにとって重要なクレカ登録なしで利用ができるサービスです。
最高ですね。イケてるサービスですね。
今回は最近個人開発でUpstash for RedisGoを使って触るようになったので実装等をご紹介しようと思います。
upstashには他にもfor Kafkafor QStashがありますが、今回の記事では触れません。
(というより、私もまだ触ったことがありません。非同期処理を実装するためにメッセージシステム探していたので今度触ってみようと思います。)

upstashとは

添付しました、記事の方が丁寧に解説されていたので軽めの説明にします。
サーバーレスなデータベースとしてRedisおよびKafka, QStashを提供しているサービスです。

アカウントを準備する

まずはアカウントを作成します。
トップページからコンソールボタンを押すと以下の画面(執筆時点)に遷移します。

ここでSSOによるログインもしくはメアドとパスワードを用いてサインアップします。
ちなみに、私はこういう系のサービスはGitHubをなるべく使うようにしています。

IaCで管理する

コンソール画面をぽちぽちしていれば簡単にRedisを使えるようになりますが、Terraformを使ったIaCで管理する方法をご紹介します。
公式サイトにもリンクが記載されていますが、以下のリポジトリにてTerraformのプロバイダーが公開されています。

https://github.com/upstash/terraform-provider-upstash

最低でも必要な実装およびディレクトリ構成は以下になります。

├── backend.tf # tfstateを保存するためのバックエンド設定(省略)
├── main.tf    # providerの設定
└── upstash.tf # upstashのリソース設定

main.tf

terraform {
  required_providers {
    upstash = {
      source  = "upstash/upstash"
      version = "1.3.0"
    }
  }
}

upstash.tf

variable "upstash_email" {
  type      = string
  nullable  = false
  sensitive = true
}

variable "upstash_api_key" {
  type      = string
  nullable  = false
  sensitive = true
}

variable "name" {
  type     = string
  nullable = false
  default  = "platform"
}

variable "cloud_provider_region" {
  type     = string
  nullable = false
  default = "us-central1"
}

provider "upstash" {
  email   = var.upstash_email
  api_key = var.upstash_api_key
}

resource "upstash_redis_database" "redis" {
  database_name = var.name
  region        = var.cloud_provider_region
  tls           = "true"
}

data "upstash_redis_database_data" "redis_data" {
  database_id = resource.upstash_redis_database.redis.database_id
}

emailはアカウント登録で設定したメアドとなります。
api_keyはコンソール画面にて Account > Management API > Create API Key にて作成します。
api_keyuuid形式です。このキーは慎重に取り扱ってください。

※ バックエンド設定はみなさんのお好きなもの(AWS, GCP etc...)をご利用ください。私はTerraform Cloudを利用しています。
※ regionにus-central1を設定しているのは、他の無料サービスを使っていると東京リージョンがなかったりすることがあるためです。us-central1は無料サービスでも用意されることがあるので使っています。
※ コンソールで作成するときにも分かることだと思いますが、upstashAWSまたはGCP環境に構築されているみたいです。無料サービスあるあるです。
※ 私はemailapi_keyなどのシークレット値は最近sopsを用いて管理しています。機会があればこの実装についても記事を書こうかなと思います。

Goで触る

ありがたいことにupstashRedisをデプロイすると下記のような画面でGoのコードが提示されます。

ライブラリはgo-redisを使用します。

https://github.com/redis/go-redis

ちょっと触るだけならこれでよいですが、実装を工夫しクリーンアーキテクチャっぽくかつジェネリクスを使って実装してみます。

https://blog.tai2.net/the_clean_architecture.html

├── domain
│   ├── cache
│   │   └── cache.go    # クリーンアーキテクチャでいうところのRepository層
│   └── model
│       └── model.go    # ドメインモデル(cacheに保存する対象)
├── adapter
│   └── kvs
│       └── kvs.go      # クリーンアーキテクチャでいうところのGateway層
├── driver
│   └── redis
│       └── redis.go    # redisとの接続を設定
└── main.go             # ここで実装を呼び出す(usecase層的立ち位置)

domain/model/model.go

package model

type Model struct {
	ID   string
	Name string
}

domain/cache/cache.go

package cache

import (
	"context"
	"time"
)

// ジェネリクスを使って保存する型の自由度を確保する
type Cache[T any] interface {
	Get(context.Context, string) (T, error) // 取得メソッド
	Set(context.Context, string, T, time.Duration) error // 保存メソッド
	Del(context.Context, string) error // 削除メソッド
}

adapter/kvs/kvs.go

package kvs

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/takokun778/fs-go-redis/domain/cache"
)

const prefix = "%s:%s" // prefixのフォーマット

type Factory[T any] interface {
	KVS(string, *redis.Client) (*KVS[T], error)
}

var _ cache.Cache[any] = (*KVS[any])(nil)

type KVS[T any] struct {
	Prefix string // 基本的にキーが被ることは想定していないが、わかりやすくするためにprefixを設定できるようにしている
	Client *redis.Client
}

// Getを実装
func (kvs *KVS[T]) Get(ctx context.Context, key string) (T, error) {
	var value T

	key = fmt.Sprintf(prefix, kvs.Prefix, key)

	str, err := kvs.Client.Get(ctx, key).Result()
	if err != nil {
		return value, fmt.Errorf("failed to get cache: %w", err)
	}

	if err := json.Unmarshal([]byte(str), &value); err != nil { // json形式で保存しているためUnmarshalする
		return value, fmt.Errorf("failed to unmarshal json: %w", err)
	}

	return value, nil
}

// Setを実装
func (kvs *KVS[T]) Set(ctx context.Context, key string, value T, ttl time.Duration) error {
	val, err := json.Marshal(value) // json形式で保存するためにMarshalする
	if err != nil {
		return fmt.Errorf("failed to marshal json: %w", err)
	}

	key = fmt.Sprintf(prefix, kvs.Prefix, key)

	if err := kvs.Client.Set(ctx, key, val, ttl).Err(); err != nil {
		return fmt.Errorf("failed to set cache: %w", err)
	}

	return nil
}

// Delを実装
func (kvs *KVS[T]) Del(ctx context.Context, key string) error {
	key = fmt.Sprintf(prefix, kvs.Prefix, key)

	if _, err := kvs.Client.Del(ctx, key).Result(); err != nil {
		return fmt.Errorf("failed to del cache: %w", err)
	}

	return nil
}

driver/redis/redis.go

package redis

import (
	"context"
	"fmt"

	"github.com/redis/go-redis/v9"
	"github.com/takokun778/fs-go-redis/adapter/kvs"
)

var (
	_ kvs.Factory[any] = (*Redis[any])(nil)
)

type Redis[T any] struct{}

func New[T any]() *Redis[T] {
	return &Redis[T]{}
}

func (rds *Redis[T]) KVS(
	prefix string,
	client *redis.Client,
) (*kvs.KVS[T], error) {
	return &kvs.KVS[T]{
		Prefix: prefix,
		Client: client,
	}, nil
}

func NewRedis(env string, url string) (*redis.Client, error) {
	var opt *redis.Options

	if env == "upstash" { // 環境によって接続情報の設定を切り替える
		var err error

		opt, err = redis.ParseURL(url)

		if err != nil {
			return nil, fmt.Errorf("failed to parse redis url: %w", err)
		}
	} else { // ローカル環境でdockerなどを利用して接続する場合
		opt = &redis.Options{
			Addr:     url,
			Password: "",
			DB:       0,
		}
	}

	client := redis.NewClient(opt)

	if err := client.Ping(context.Background()).Err(); err != nil { // 疎通確認を実施
		return nil, err
	}

	return client, nil
}

main.go(usecaseの代わり)

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/google/uuid"
	"github.com/takokun778/fs-go-redis/domain/model"
	"github.com/takokun778/fs-go-redis/driver/redis"
)

const ttl = 60 * time.Second

func main() {
	// rds, err := redis.NewRedis("docker", "localhost:6379") // ローカル環境でdockerなどを利用して接続する場合はこちらを利用
	rds, err := redis.NewRedis("upstash", os.Getenv("UPSTASH_URL"))
	if err != nil {
		panic(err)
	}

	ctx := context.Background()

	fmt.Println("---------- kvs ----------")

	mdl := model.Model{
		ID:   uuid.New().String(),
		Name: "model",
	}

	kvs, err := redis.New[model.Model]().KVS("kvs", rds)
	if err != nil {
		panic(err)
	}

	if err := kvs.Set(ctx, mdl.ID, mdl, ttl); err != nil {
		panic(err)
	}

	got, err := kvs.Get(ctx, mdl.ID)
	if err != nil {
		panic(err)
	}

	log.Printf("got: %+v", got)
}

実行すると以下のような出力となります。

---------- kvs ----------
xxxx/xx/xx xx:xx:x got: {ID:0e47a5de-7bdc-4b72-b6fd-3aeda68ca54b Name:model}

また、upstashはWebコンソール画面でもデータを確認することができます。

こんな感じでキャッシュ(Redis)を用いた実装においてもクリーンアーキテクチャを考えてみました。

ただ、ジェネリクスを使ったが故なのかgomockを使ってcache.goのモックを作成しようとすると以下のようなコンパイルエラーが発生し上手く作成できませんでした。。。

おまけ:Webコンソール画面でnullが表示される...

個人開発でupstashに公開鍵を保存していたのですが、Webコンソール画面でデータを確認したところnullが表示されていました。
私はこれをバグだと思い込みとりあえずbase64でエンコードして保存することで解決を試みました。
とりあえず正常に保存されたし、動作も問題ないと満足していました。
ところが、今回本記事を執筆するにあたり再び動作検証をしていると、表示がnullなだけでデータ自体は正常に保存されることがわかりました。

redis-cliを用いて取得した結果は以下となります。

json形式で保存しても問題ないことが確認できました。ただ、せっかく実装したのでbase64でエンコードして保存する形式も残しておきます。
また、興味本位でgob形式で実装もしてみたのでよかったらご参考ください。

adapter/kvs/base64.go

package kvs

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/takokun778/fs-go-redis/domain/cache"
)

type Base64Factory[T any] interface {
	Base64(string, *redis.Client) (*Base64[T], error)
}

var _ cache.Cache[any] = (*Base64[any])(nil)

type Base64[T any] struct {
	Prefix string
	Client *redis.Client
}

func (bs *Base64[T]) Get(ctx context.Context, key string) (T, error) {
	var value T

	key = fmt.Sprintf(prefix, bs.Prefix, key)

	str, err := bs.Client.Get(ctx, key).Result()
	if err != nil {
		return value, fmt.Errorf("failed to get cache: %w", err)
	}

	dec, err := base64.StdEncoding.DecodeString(str)
	if err != nil {
		return value, fmt.Errorf("failed to decode base64: %w", err)
	}

	if err := json.Unmarshal(dec, &value); err != nil {
		return value, fmt.Errorf("failed to unmarshal json: %w", err)
	}

	return value, nil
}

func (bs *Base64[T]) Set(ctx context.Context, key string, value T, ttl time.Duration) error {
	val, err := json.Marshal(value)
	if err != nil {
		return fmt.Errorf("failed to marshal json: %w", err)
	}

	key = fmt.Sprintf(prefix, bs.Prefix, key)

	enc := base64.StdEncoding.EncodeToString(val)

	if err := bs.Client.Set(ctx, key, enc, ttl).Err(); err != nil {
		return fmt.Errorf("failed to set cache: %w", err)
	}

	return nil
}

func (bs *Base64[T]) Del(ctx context.Context, key string) error {
	key = fmt.Sprintf(prefix, bs.Prefix, key)

	if _, err := bs.Client.Del(ctx, key).Result(); err != nil {
		return fmt.Errorf("failed to del cache: %w", err)
	}

	return nil
}

adapter/kvs/gob.go

package kvs

import (
	"bytes"
	"context"
	"encoding/gob"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/takokun778/fs-go-redis/domain/cache"
)

type GobFactory[T any] interface {
	Gob(string, *redis.Client) (*Gob[T], error)
}

var _ cache.Cache[any] = (*Gob[any])(nil)

type Gob[T any] struct {
	Prefix string
	Client *redis.Client
}

func (gb *Gob[T]) Get(ctx context.Context, key string) (T, error) {
	var value T

	key = fmt.Sprintf(prefix, gb.Prefix, key)

	str, err := gb.Client.Get(ctx, key).Result()
	if err != nil {
		return value, fmt.Errorf("failed to get cache: %w", err)
	}

	buf := bytes.NewBuffer([]byte(str))

	_ = gob.NewDecoder(buf).Decode(&value)

	return value, nil
}

func (gb *Gob[T]) Set(ctx context.Context, key string, value T, ttl time.Duration) error {
	buf := bytes.NewBuffer(nil)

	_ = gob.NewEncoder(buf).Encode(&value)

	key = fmt.Sprintf(prefix, gb.Prefix, key)

	if err := gb.Client.Set(ctx, key, buf.Bytes(), ttl).Err(); err != nil {
		return fmt.Errorf("failed to set cache: %w", err)
	}

	return nil
}

func (gb *Gob[T]) Del(ctx context.Context, key string) error {
	key = fmt.Sprintf(prefix, gb.Prefix, key)

	if _, err := gb.Client.Del(ctx, key).Result(); err != nil {
		return fmt.Errorf("failed to del cache: %w", err)
	}

	return nil
}

おわりに

貧乏エンジニアリングの強い味方upstashをGoで使う方法を紹介しました。
自分の勘違いで余計な実装をしてしまったところもありましたが、勉強にはなったので結果オーライだと思います。

本記事で紹介のために作成したリポジトリはこちらです。

https://github.com/takokun778/fs-go-redis

朝夜

Discussion