#始めに
GolangでCQRSのQに使える高速なDBは無いもんかと考えてたりしたので、とりあえずRedisで遊んでみた。
クライアントはgithub.com/gomodule/redigoを使います。
※Redisの解説はしません(そもそもSETとGETくらいしか知らない)。
##構成##
Golang: 1.9
Redis(Docker): 4.0
redigo: 2.0
##成果物##
https://github.com/lightstaff/go-redis-example
※実装はexampleディレクトリの中にあります。
#モデル
遊びなのでシンプルに、「下図のような感じのモデルをRedisに保存し、それをビュー(ビューモデル)として求めたい」こととします。
Redisにはjson.Marshal
でバイナリ化したものを保存するつもり(というか他の保存方法が分からないの)ですが、悩みどころなのはキーとバイナリ化前のデータです。
- シンプルにユーザーIDをキーにしてユーザー単位で保存した場合・・・一覧取得が面倒。
- キーを単一にして全ユーザーをsliceにして保存した場合・・・一覧取得は一発、ID検索等はsliceを走査=面倒。
- キーを単一にしてユーザーIDをキーにしたmapで保存した場合・・・一覧取得も、ID検索も楽そう。
というわけで、map化を採用し、定義します。コードでニュアンスが伝わればいいなぁ・・・。
package example
// Radisにはmap[string]*Userのバイナリが保存される
// User is user model
type User struct {
ID string
Name string
Contacts map[string]*Contact // ContactをIDで捕まえるため
}
// Contact is contact model
type Contact struct {
ID string
Email string
UserID string
}
package example
// UserView is user view
type UserView struct {
ID string
Name string
Contacts []*ContactView
}
// ContactView is contact view
type ContactView struct {
ID string
Email string
}
#サービス
一応やり取りをサービスとして定義します。
-
SetUser(u *User) error
・・・ユーザーを保存 -
SetContact(c *Contact) error
・・・連絡先を保存 -
GetUserViews() ([]*UserView, error)
・・・ユーザービューを一覧取得 -
GetUserView(id string) (*UserView, error)
・・・ユーザービューをID指定取得 -
Remove() error
・・・キーごと削除(テスト用)
package example
import (
"encoding/json"
"errors"
"fmt"
"log"
"github.com/gomodule/redigo/redis"
)
// Service is service interface
type Service interface {
SetUser(u *User) error
SetContact(c *Contact) error
GetUserViews() ([]*UserView, error)
GetUserView(id string) (*UserView, error)
Remove() error
}
// ServiceImpl is service implement
type ServiceImpl struct {
Conn redis.Conn
}
// SetUser is set user
func (s *ServiceImpl) SetUser(u *User) error {
um, err := s.get()
if err != nil {
return err
}
um[u.ID] = u
if err := s.set(um); err != nil {
return err
}
log.Printf("[INFO] set user. id: %s\n", u.ID)
return nil
}
// SetContact is set contact
func (s *ServiceImpl) SetContact(c *Contact) error {
um, err := s.get()
if err != nil {
return err
}
if v, ok := um[c.UserID]; ok {
if v.Contacts == nil {
v.Contacts = map[string]*Contact{c.ID: c}
} else {
v.Contacts[c.ID] = c
}
um[c.UserID] = v
} else {
return fmt.Errorf("unknown user. id: %s", c.UserID)
}
if err := s.set(um); err != nil {
return err
}
log.Printf("[INFO] set contact. id: %s\n", c.ID)
return nil
}
// GetUserViews is get user views
func (s *ServiceImpl) GetUserViews() ([]*UserView, error) {
um, err := s.get()
if err != nil {
return nil, err
}
users := make([]*UserView, 0)
for _, u := range um {
contacts := make([]*ContactView, 0)
for _, c := range u.Contacts {
contacts = append(contacts, &ContactView{
ID: c.ID,
Email: c.Email,
})
}
users = append(users, &UserView{
ID: u.ID,
Name: u.Name,
Contacts: contacts,
})
}
log.Printf("[INFO] get user views. length: %d\n", len(users))
return users, nil
}
// GetUserView is get user view by id
func (s *ServiceImpl) GetUserView(id string) (*UserView, error) {
um, err := s.get()
if err != nil {
return nil, err
}
u, ok := um[id]
if !ok {
return nil, fmt.Errorf("not found user. id: %s", id)
}
contacts := make([]*ContactView, 0)
for _, c := range u.Contacts {
contacts = append(contacts, &ContactView{
ID: c.ID,
Email: c.Email,
})
}
log.Printf("[INFO] get user view. id: %s\n", u.ID)
return &UserView{
ID: u.ID,
Name: u.Name,
Contacts: contacts,
}, nil
}
// Remove is remove key
func (s *ServiceImpl) Remove() error {
if s.Conn == nil {
return errors.New("not initialized redis conn")
}
if _, err := s.Conn.Do("DEL", "users"); err != nil {
return err
}
log.Print("[WARN] remove users")
return nil
}
func (s *ServiceImpl) set(um map[string]*User) error {
if s.Conn == nil {
return errors.New("not initialized redis conn")
}
bytes, err := json.Marshal(um)
if err != nil {
return err
}
if _, err := s.Conn.Do("SET", "users", bytes); err != nil {
return err
}
return nil
}
func (s *ServiceImpl) get() (map[string]*User, error) {
if s.Conn == nil {
return nil, errors.New("not initialized redis conn")
}
um := make(map[string]*User)
exists, err := redis.Int(s.Conn.Do("EXISTS", "users"))
if err != nil {
return nil, err
}
if exists == 0 {
return um, nil
}
bytes, err := redis.Bytes(s.Conn.Do("GET", "users"))
if err != nil {
return nil, err
}
if bytes == nil {
return um, nil
}
if err := json.Unmarshal(bytes, &um); err != nil {
return nil, err
}
return um, nil
}
やってることはシンプルなmapの置き換えとmap→sliceの変換なので特に解説することはありません。Redisが死んだときのリカバーとかまでは考えません!。
#テスト
今回は雑にテストしてお終いにします。
package example
import (
"testing"
"github.com/gomodule/redigo/redis"
"github.com/google/uuid"
)
func TestService(t *testing.T) {
conn, err := redis.DialURL("redis://192.168.99.100")
if err != nil {
t.Fatal(err)
}
s := &ServiceImpl{
Conn: conn,
}
// remove
if err := s.Remove(); err != nil {
t.Fatal(err)
}
// set user
uID := uuid.New().String()
cID1 := uuid.New().String()
u := &User{
ID: uID,
Name: "test",
Contacts: map[string]*Contact{cID1: &Contact{
ID: cID1,
Email: "first@test.com",
UserID: uID,
}},
}
if err := s.SetUser(u); err != nil {
t.Fatal(err)
}
// set contact
cID2 := uuid.New().String()
c := &Contact{
ID: cID2,
Email: "second@test.com",
UserID: uID,
}
if err := s.SetContact(c); err != nil {
t.Fatal(err)
}
// get user view
uv, err := s.GetUserView(uID)
if err != nil {
t.Fatal(err)
}
if uv.ID != u.ID {
t.Error("user view id not eqaul input user id")
}
t.Logf("get user view. data: %v", uv)
// get user views
uvs, err := s.GetUserViews()
if err != nil {
t.Fatal(err)
}
if len(uvs) != 1 {
t.Error("user views length is not 1")
}
t.Logf("get user views. data: %v", uvs)
}
#終わりに
mapがどれくらいリソース食うか読めないので、RDBのクエリより早くなるかは不安でしかない・・・。
##参考にさせてもらいました##
- 【Redis】Go言語で高速呼び出しKVS【Redigo】https://qiita.com/chan-p/items/5c5e7cc1e966f8a90422