iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Software Development

Go Clean Architecture API 開發全攻略系列 第 25

[Day 25] 快取服務(一):使用 Redis

  • 分享至 

  • xImage
  •  

當一個資料很少變動、但讀取頻繁的 API(例如「獲取使用者個人資料」)被大量呼叫時,每一次都去查詢資料庫會造成巨大的浪費和壓力。快取,就是將這些熱點資料暫時存放在一個讀取速度更快的儲存(通常是記憶體)中,以加速回應並降低後端資料庫的負載。

Redis 是一個開源的、高效能的、基於記憶體的 Key-Value 儲存,是實現快取服務的絕佳選擇。

快取策略:Cache-Aside Pattern

我們將採用最常用、最經典的 Cache-Aside(旁路快取) 模式。其工作流程如下:

  1. 讀取時:應用程式先向快取(Redis)查詢資料。
    • 如果命中(Hit),則直接回傳資料。
    • 如果未命中(Miss),則向主資料庫(MySQL)查詢資料,取得資料後,先將其存入快取,然後再回傳給客戶端。
  2. 寫入時:應用程式更新(或刪除)主資料庫中的資料,然後直接刪除快取中對應的資料(Cache Invalidation)。這樣,下一次讀取該資料時,就會發生 Miss,從而從資料庫中讀取最新的資料並重新快取。

第一步:設定 Redis 環境

這我們已經在先前的環境設定中完成了,就不在這邊贅述了。
[Day 17] Dockerize 你的 Go 應用:使用 Docker Compose 打造一致的開發環境

第二步:實作快取邏輯

我們這裡會將架構分成兩層:

  1. 資料存取層(Data Access Layer):負責與 Redis 互動,提供快取的讀寫功能。
  2. 服務層(Service Layer):負責業務邏輯,負責快取名稱的定義和快取失效的處理,以及快取內容的型別轉換等等。

1. 資料存取層(Data Access Layer)

我們先來實作一個簡單的 Redis 客戶端,負責與 Redis 進行互動。
這個客戶端會提供基本的 GetSetDel 以及 SetModelGetModel 方法,分別用於讀取和寫入快取資料。

// internal/database/redis/redis.go
package redis

import (
	"github.com/redis/go-redis/v9"
)

type Client struct {
	client *redis.Client
}


func (c *Client) Set(ctx context.Context, key string, value string, expiration time.Duration) error {
	return c.client.Set(ctx, key, value, expiration).Err()
}

func (c *Client) Get(ctx context.Context, key string) (string, error) {
	return c.client.Get(ctx, key).Result()
}

func (c *Client) SetModel(ctx context.Context, key string, value any, expiration time.Duration) error {
	return c.client.Set(ctx, key, value, expiration).Err()
}

func (c *Client) GetModel(ctx context.Context, key string, value any) error {
	v := c.client.Get(ctx, key)
	if v.Err() != nil {
		return v.Err()
	}

	err := v.Scan(value)
	if err != nil {
		return err
	}

	return nil
}

func (c *Client) Del(ctx context.Context, keys ...string) error {
	return c.client.Del(ctx, keys...).Err()
}

1.1 實作 程式中型別 到 Redis 內容的轉換

在我們使用的 Redis Go 客戶端中,Set 會自動將傳入的資料轉換為位元組陣列([]byte)如果傳入的型別符合 encoding.BinaryMarshaler 介面,
而 Scan 則會將讀取到的位元組陣列轉換回原本的型別,如果傳入的型別符合 encoding.BinaryUnmarshaler 介面。

但是如果每一個型別都要實作這兩個介面,會非常麻煩。
因此,我們可以實作一個型別,讓它內嵌我們的資料型別,並實作這兩個介面,來達到自動轉換的效果。
這邊以 gob 為例,實作一個泛型結構 Container[T],用來包裝任意型別 T 的資料,並實作編碼和解碼功能。
當然也可以使用 JSON 或其他編碼方式。

// pkg/gob/gob.go
package gob

import (
	"bytes"
	"encoding/gob"
)

// Container 是一個泛型結構,用於包裝任意型別的資料,並實作編碼和解碼功能。
type Container[T any] struct {
	RawValue T
}

// 實作 encoding.BinaryMarshaler 介面
func (c Container[T]) MarshalBinary() ([]byte, error) {
	var buf bytes.Buffer
	enc := gob.NewEncoder(&buf)
	if err := enc.Encode(c.RawValue); err != nil {
		return nil, err
	}

	return buf.Bytes(), nil
}

// 實作 encoding.BinaryUnmarshaler 介面
func (c *Container[T]) UnmarshalBinary(data []byte) error {
	buf := bytes.NewReader(data)
	dec := gob.NewDecoder(buf)
	if err := dec.Decode(&c.RawValue); err != nil {
		return err
	}

	return nil
}

2. 服務層(Service Layer)

這裏會實作一些提供外部存取的 function,這些 function 會負責快取名稱的定義和快取失效的處理,以及快取內容的型別轉換等等。

建立一個 Service 結構,內含 Redis 客戶端。

// internal/service/cache/cache.go
package service

type Service struct {
	client *redis.Client
}

func NewService(client *redis.Client) *Service {
	return &Service{client: client}
}

建立一些協助轉換的 function。
這裏因為使用了 Generic,所以會變成把 service 當作參數傳入。

// internal/service/cache/help.go
func setGOBModel[T any](ctx context.Context, key string, model *T, expire time.Duration, s *Service) error {
	value := gob.Container[T]{RawValue: *model}
	return s.client.SetModel(ctx, key, value, expire)
}

func getGOBModel[T any](ctx context.Context, key string, s *Service) (*T, error) {
	var value gob.Container[T]
	err := s.client.GetModel(ctx, key, &value)
	if err != nil {
		return nil, err
	}
	return &value.RawValue, nil
}

建立一些快取相關的 function。

// internal/service/cache/cache.go

const expirationBranchInfosKey = 24 * 60 * 60 // 1 day

func (s *Service) getBranchInfosKey() string {
	return "branch_infos"
}

func (s *Service) GetBranchInfos(ctx context.Context) (*[]domain.BranchInfo, error) {
	key := s.getBranchInfosKey()
	return getGOBModel[[]domain.BranchInfo](ctx, key, s)
}

func (s *Service) SetBranchInfos(ctx context.Context, model *[]domain.BranchInfo) error {
	key := s.getBranchInfosKey()
	return setGOBModel(ctx, key, model, expirationBranchInfosKey, s)
}

func (s *Service) VoidBranchInfos(ctx context.Context) error {
	key := s.getBranchInfosKey()
	return s.client.Del(ctx, key)
}

這時候,我們已經很明確地把快取的邏輯和名稱都封裝在服務層中了,
這樣就能確保其他程式碼不會直接操作 Redis,而是透過這些服務層的 function 來進行快取的讀寫和失效處理。

小結:

到這裡,我們已經完成了快取服務的基本架構和實作。
我們實作了一個簡單的 Redis 客戶端,並且在服務層中封裝了快取的邏輯和名稱。
我們會在下一篇,繼續 Cache-Aside Pattern 的實作,並探討一些快取的最佳實踐和注意事項。


上一篇
[Day 24] 從 MVC 到六角形架構:一個後端工程師的思考轉變
下一篇
[Day 26] 快取服務(二):最佳實踐與注意事項
系列文
Go Clean Architecture API 開發全攻略27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言