Cache: Redis
Big queries in MySQL or large aggregations in MongoDB may take seconds or even minutes to finish. You definitely don't want to trigger these operations frequently for each user request.
Caching the results in memory is a great way to alleviate this issue.
If your API server is running on a single machine or node, simply putting these results in in-memory HashMaps
or Dictionaries
should solve the problem.
If you have multiple machines or nodes running the API server and sharing common memory, Redis is your best choice here.
Try Redis
-
Install Redis on your machine and start it.
-
Add redis dependency.
go get -u github.com/go-redis/redis/v8
- Update code.
Add infrastructure/cache/helper.go:
package cache
import "context"
type Helper interface {
Save(ctx context.Context, key, value string) error
Load(ctx context.Context, key string) (string, error)
}
Use redis in infrastructure/cache/redis.go:
/*
Package cache has all cache-related implementations.
*/
package cache
import (
"context"
"time"
"github.com/go-redis/redis/v8"
"literank.com/rest-books/infrastructure/config"
)
const (
defaultTimeout = time.Second * 10
defaultTTL = time.Hour * 1
)
type RedisCache struct {
c redis.UniversalClient
}
func NewRedisCache(c *config.CacheConfig) *RedisCache {
timeout := defaultTimeout
if c.Timeout > 0 {
timeout = time.Second * time.Duration(c.Timeout)
}
r := redis.NewClient(&redis.Options{
Addr: c.Address,
Password: c.Password,
DB: c.DB,
ReadTimeout: timeout,
WriteTimeout: timeout,
})
return &RedisCache{
c: r,
}
}
// Save sets key and value into the cache
func (r *RedisCache) Save(ctx context.Context, key, value string) error {
if _, err := r.c.Set(ctx, key, value, defaultTTL).Result(); err != nil {
return err
}
return nil
}
// Load reads the value by the key
func (r *RedisCache) Load(ctx context.Context, key string) (string, error) {
value, err := r.c.Get(ctx, key).Result()
if err != nil {
if err == redis.Nil {
return "", nil
}
return "", err
}
return value, nil
}
Add related config items in infrastructure/config/config.go:
@@ -8,8 +8,9 @@ import (
)
type Config struct {
- App ApplicationConfig `json:"app" yaml:"app"`
- DB DBConfig `json:"db" yaml:"db"`
+ App ApplicationConfig `json:"app" yaml:"app"`
+ Cache CacheConfig `json:"cache" yaml:"cache"`
+ DB DBConfig `json:"db" yaml:"db"`
}
type DBConfig struct {
@@ -23,6 +24,13 @@ type ApplicationConfig struct {
Port int `json:"port" yaml:"port"`
}
+type CacheConfig struct {
+ Address string `json:"address" yaml:"address"`
+ Password string `json:"password" yaml:"password"`
+ DB int `json:"db" yaml:"db"`
+ Timeout int `json:"timeout" yaml:"timeout"`
+}
+
// Parse parses config file and returns a Config.
func Parse(filename string) (*Config, error) {
buf, err := os.ReadFile(filename)
Put in new values in config.yml:
@@ -5,3 +5,8 @@ db:
dsn: "test_user:test_pass@tcp(127.0.0.1:3306)/lr_book?charset=utf8mb4&parseTime=True&loc=Local"
mongo_uri: "mongodb://localhost:27017"
mongo_db_name: "lr_book"
+cache:
+ address: localhost:6379
+ password: test_pass
+ db: 0
+ timeout: 50
Wire in redis connection in application/wire_helper.go:
@@ -2,6 +2,7 @@ package application
import (
"literank.com/rest-books/domain/gateway"
+ "literank.com/rest-books/infrastructure/cache"
"literank.com/rest-books/infrastructure/config"
"literank.com/rest-books/infrastructure/database"
)
@@ -10,6 +11,7 @@ import (
type WireHelper struct {
sqlPersistence *database.MySQLPersistence
noSQLPersistence *database.MongoPersistence
+ kvStore *cache.RedisCache
}
func NewWireHelper(c *config.Config) (*WireHelper, error) {
@@ -21,7 +23,8 @@ func NewWireHelper(c *config.Config) (*WireHelper, error) {
if err != nil {
return nil, err
}
- return &WireHelper{sqlPersistence: db, noSQLPersistence: mdb}, nil
+ kv := cache.NewRedisCache(&c.Cache)
+ return &WireHelper{sqlPersistence: db, noSQLPersistence: mdb, kvStore: kv}, nil
}
func (w *WireHelper) BookManager() gateway.BookManager {
@@ -31,3 +34,7 @@ func (w *WireHelper) BookManager() gateway.BookManager {
func (w *WireHelper) ReviewManager() gateway.ReviewManager {
return w.noSQLPersistence
}
+
+func (w *WireHelper) CacheHelper() cache.Helper {
+ return w.kvStore
+}
Suppose listing all books is a resource-intensive query in your database. In such cases, you may choose to store its query result in Redis for faster retrieval in the future.
Changes in application/executor/book_operator.go:
@@ -2,17 +2,22 @@ package executor
import (
"context"
+ "encoding/json"
"literank.com/rest-books/domain/gateway"
"literank.com/rest-books/domain/model"
+ "literank.com/rest-books/infrastructure/cache"
)
+const booksKey = "lr-books"
+
type BookOperator struct {
bookManager gateway.BookManager
+ cacheHelper cache.Helper
}
-func NewBookOperator(b gateway.BookManager) *BookOperator {
- return &BookOperator{bookManager: b}
+func NewBookOperator(b gateway.BookManager, c cache.Helper) *BookOperator {
+ return &BookOperator{bookManager: b, cacheHelper: c}
}
func (o *BookOperator) CreateBook(ctx context.Context, b *model.Book) (*model.Book, error) {
@@ -29,7 +34,32 @@ func (o *BookOperator) GetBook(ctx context.Context, id uint) (*model.Book, error
}
func (o *BookOperator) GetBooks(ctx context.Context) ([]*model.Book, error) {
- return o.bookManager.GetBooks(ctx)
+ rawValue, err := o.cacheHelper.Load(ctx, booksKey)
+ if err != nil {
+ return nil, err
+ }
+
+ books := make([]*model.Book, 0)
+ if rawValue != "" {
+ // Cache key exists
+ if err := json.Unmarshal([]byte(rawValue), &books); err != nil {
+ return nil, err
+ }
+ } else {
+ // Cache key does not exist
+ books, err = o.bookManager.GetBooks(ctx)
+ if err != nil {
+ return nil, err
+ }
+ value, err := json.Marshal(books)
+ if err != nil {
+ return nil, err
+ }
+ if err := o.cacheHelper.Save(ctx, booksKey, string(value)); err != nil {
+ return nil, err
+ }
+ }
+ return books, nil
}
func (o *BookOperator) UpdateBook(ctx context.Context, id uint, b *model.Book) (*model.Book, error) {
Tune a little bit in adaptor/router.go:
@@ -22,7 +22,7 @@ type RestHandler struct {
func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
rest := &RestHandler{
- bookOperator: executor.NewBookOperator(wireHelper.BookManager()),
+ bookOperator: executor.NewBookOperator(wireHelper.BookManager(), wireHelper.CacheHelper()),
reviewOperator: executor.NewReviewOperator(wireHelper.ReviewManager()),
}
// Create a new Gin router
Those are all the changes you need to incorporate redis. Let's now try the endpoint powered by the new cache system.
Try with curl
List all books:
curl -X GET http://localhost:8080/books
The result is just as before, but the performance improves a lot here. You can see that from the logs of Gin framework.
[GIN] 2024/02/26 - 15:29:58 | 200 | 3.313312ms | 127.0.0.1 | GET "/books"
[GIN] 2024/02/26 - 15:30:26 | 200 | 749.161µs | 127.0.0.1 | GET "/books"
[GIN] 2024/02/26 - 15:30:31 | 200 | 635.58µs | 127.0.0.1 | GET "/books"
Use redis-cli
to check the values in Redis:
redis-cli
Play with keys in the redis client shell:
127.0.0.1:6379> keys *
1) "lr-books"
127.0.0.1:6379> get lr-books
"[{\"id\":1,\"title\":\"Great Book II\",\"author\":\"Carl Smith\",\"published_at\":\"2022-01-01T08:00:00+08:00\",\"description\":\"Another sample book description\",\"isbn\":\"8334567890\",\"total_pages\":3880,\"created_at\":\"2024-02-25T16:29:31.353+08:00\",\"updated_at\":\"2024-02-25T16:29:31.353+08:00\"}]"
127.0.0.1:6379> del lr-books
(integer) 1
Awesome! Redis is at your service now! 💐