upstash for Redis を Go で触る ~IaCを添えて~
はじめに
貧乏エンジニアリング大好き♡たこくんです。
みなさん、upstash
というサービスはご存知でしょうか?
Upstash
なのかupstash
なのかどちらが正しいのか判断できませんでしたが、Upstash
は会社名っぽいので本記事ではupstash
を使います。間違っていたらすみません。
こちらの方の記事でご紹介されているのでご存知の方も多いかと思います。
そう、貧乏エンジニアリングにとって重要なクレカ登録なしで利用ができるサービスです。
最高ですね。イケてるサービスですね。
今回は最近個人開発でUpstash for Redis
をGo
を使って触るようになったので実装等をご紹介しようと思います。
upstash
には他にもfor Kafka
とfor QStash
がありますが、今回の記事では触れません。
(というより、私もまだ触ったことがありません。非同期処理を実装するためにメッセージシステム探していたので今度触ってみようと思います。)
upstashとは
添付しました、記事の方が丁寧に解説されていたので軽めの説明にします。
サーバーレスなデータベースとしてRedis
およびKafka
, QStash
を提供しているサービスです。
アカウントを準備する
まずはアカウントを作成します。
トップページからコンソールボタンを押すと以下の画面(執筆時点)に遷移します。
ここでSSOによるログインもしくはメアドとパスワードを用いてサインアップします。
ちなみに、私はこういう系のサービスはGitHubをなるべく使うようにしています。
IaCで管理する
コンソール画面をぽちぽちしていれば簡単にRedis
を使えるようになりますが、Terraform
を使ったIaC
で管理する方法をご紹介します。
公式サイトにもリンクが記載されていますが、以下のリポジトリにてTerraform
のプロバイダーが公開されています。
最低でも必要な実装およびディレクトリ構成は以下になります。
├── 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_key
はuuid
形式です。このキーは慎重に取り扱ってください。
※ バックエンド設定はみなさんのお好きなもの(AWS
, GCP
etc...)をご利用ください。私はTerraform Cloudを利用しています。
※ regionにus-central1
を設定しているのは、他の無料サービスを使っていると東京リージョンがなかったりすることがあるためです。us-central1
は無料サービスでも用意されることがあるので使っています。
※ コンソールで作成するときにも分かることだと思いますが、upstash
はAWS
またはGCP
環境に構築されているみたいです。無料サービスあるあるです。
※ 私はemail
やapi_key
などのシークレット値は最近sopsを用いて管理しています。機会があればこの実装についても記事を書こうかなと思います。
Goで触る
ありがたいことにupstash
でRedis
をデプロイすると下記のような画面でGo
のコードが提示されます。
ライブラリはgo-redis
を使用します。
ちょっと触るだけならこれでよいですが、実装を工夫しクリーンアーキテクチャっぽくかつジェネリクスを使って実装してみます。
├── 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
のモックを作成しようとすると以下のようなコンパイルエラーが発生し上手く作成できませんでした。。。
null
が表示される...
おまけ:Webコンソール画面で個人開発で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で使う方法を紹介しました。
自分の勘違いで余計な実装をしてしまったところもありましたが、勉強にはなったので結果オーライだと思います。
本記事で紹介のために作成したリポジトリはこちらです。
Discussion