feat(lyra)!: 接入 Redis 并添加缓存连通性实验
- 封装缓存读写、JSON 编解码、过期时间与操作超时 - 集中组装 Redis 依赖,在测试接口验证分类缓存读写 - 更新依赖并移除原 handler 测试 BREAKING CHANGE: 配置节由 redis 改为 cache,新增 db 和 timeout 配置项
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Entry[T any] struct {
|
||||
Key string
|
||||
Value T
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
client *redis.Client
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
func NewCache(client *redis.Client, timeout time.Duration) *Cache {
|
||||
return &Cache{
|
||||
client: client,
|
||||
operationTimeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) Get[T any](ctx context.Context, key string) (T, bool, error) {
|
||||
var zero T
|
||||
operationCtx, cancel := context.WithTimeout(ctx, c.operationTimeout)
|
||||
defer cancel()
|
||||
|
||||
payload, err := c.client.Get(operationCtx, key).Result()
|
||||
cancel()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return zero, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return zero, false, fmt.Errorf("读取缓存失败:%w", err)
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal([]byte(payload), &value); err != nil {
|
||||
return zero, false, fmt.Errorf("缓存解码失败:%w", err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func (c *Cache) Set[T any](ctx context.Context, entry Entry[T]) error {
|
||||
if entry.TTL < 0 {
|
||||
return fmt.Errorf("缓存过期时间必须大于等于零: %v", entry.TTL)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("写入缓存失败:%w", err)
|
||||
}
|
||||
payload, err := json.Marshal(entry.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("缓存编码失败:%w", err)
|
||||
}
|
||||
operationCtx, cancel := context.WithTimeout(ctx, c.operationTimeout)
|
||||
defer cancel()
|
||||
if err := c.client.Set(operationCtx, entry.Key, payload, entry.TTL).Err(); err != nil {
|
||||
return fmt.Errorf("写入缓存失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -10,7 +10,7 @@ type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
HTTP HTTPConfig `mapstructure:"http"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
@@ -32,9 +32,11 @@ type DatabaseConfig struct {
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
type CacheConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
DB int `mapstructure:"db"`
|
||||
Timeout int `mapstructure:"timeout"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
|
||||
@@ -1,30 +1,56 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"lyra/internal/api"
|
||||
"lyra/internal/cache"
|
||||
"lyra/internal/httpx"
|
||||
"lyra/internal/model"
|
||||
"net/http"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TestHandler struct {
|
||||
categoryModel model.CategoryModel
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
func NewTestHandler(categoryModel model.CategoryModel) *TestHandler {
|
||||
return &TestHandler{categoryModel: categoryModel}
|
||||
func NewTestHandler(categoryModel model.CategoryModel, cache *cache.Cache) *TestHandler {
|
||||
return &TestHandler{categoryModel: categoryModel, cache: cache}
|
||||
}
|
||||
|
||||
func (h *TestHandler) Hello() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
categories, err := h.categoryModel.FindAll(c.Request.Context())
|
||||
return func(ctx *gin.Context) {
|
||||
categories, err := h.categoryModel.FindAll(ctx.Request.Context())
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败")
|
||||
httpx.Fail(ctx, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, toCategoryResponses(categories))
|
||||
if err := h.cache.Set(ctx.Request.Context(), cache.Entry[[]*model.Category]{
|
||||
Key: "test:categories",
|
||||
Value: categories,
|
||||
TTL: 5 * time.Minute,
|
||||
}); err != nil {
|
||||
slog.ErrorContext(ctx.Request.Context(), "缓存分类列表失败")
|
||||
return
|
||||
}
|
||||
testResult, _, err := h.cache.Get[[]*model.Category](ctx.Request.Context(), "test:categories")
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx.Request.Context(), "获取缓存分类列表失败")
|
||||
}
|
||||
if len(testResult) == 0 {
|
||||
slog.ErrorContext(ctx.Request.Context(), "缓存分类列表为空")
|
||||
} else {
|
||||
for _, v := range testResult {
|
||||
fmt.Printf("%+v\n", v)
|
||||
}
|
||||
}
|
||||
|
||||
httpx.OK(ctx, toCategoryResponses(categories))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lyra/internal/handler"
|
||||
"lyra/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type fakeCategoryModel struct {
|
||||
categories []*model.Category
|
||||
err error
|
||||
calls int
|
||||
gotCtx context.Context
|
||||
}
|
||||
|
||||
func (f *fakeCategoryModel) FindAll(ctx context.Context) ([]*model.Category, error) {
|
||||
f.calls++
|
||||
f.gotCtx = ctx
|
||||
return f.categories, f.err
|
||||
}
|
||||
|
||||
func serveHello(t *testing.T, fake *fakeCategoryModel, req *http.Request) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/test", handler.NewTestHandler(fake).Hello())
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func newHelloRequest() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
}
|
||||
|
||||
func TestHelloMapsCategoriesToResponse(t *testing.T) {
|
||||
fake := &fakeCategoryModel{categories: []*model.Category{
|
||||
{ID: 1, Slug: "go-basics", Title: "Go 基础", Sort: 1},
|
||||
{ID: 2, Slug: "sql-practice", Title: "SQL 练习", Sort: 2},
|
||||
}}
|
||||
|
||||
recorder := serveHello(t, fake, newHelloRequest())
|
||||
|
||||
if fake.calls != 1 {
|
||||
t.Fatalf("模型调用次数 = %d, want 1", fake.calls)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
want := `{"success":true,"data":[{"id":1,"slug":"go-basics","title":"Go 基础","sort":1},{"id":2,"slug":"sql-practice","title":"SQL 练习","sort":2}]}`
|
||||
if got := recorder.Body.String(); got != want {
|
||||
t.Fatalf("body = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelloReturnsEmptyArrayForNoCategories(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
categories []*model.Category
|
||||
}{
|
||||
{name: "空切片", categories: []*model.Category{}},
|
||||
{name: "nil结果", categories: nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fake := &fakeCategoryModel{categories: tt.categories}
|
||||
|
||||
recorder := serveHello(t, fake, newHelloRequest())
|
||||
|
||||
if fake.calls != 1 {
|
||||
t.Fatalf("模型调用次数 = %d, want 1", fake.calls)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
want := `{"success":true,"data":[]}`
|
||||
if got := recorder.Body.String(); got != want {
|
||||
t.Fatalf("body = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelloReturnsInternalErrorOnModelFailure(t *testing.T) {
|
||||
modelErr := errors.New("数据库连接被拒绝: 10.0.0.8:5432")
|
||||
fake := &fakeCategoryModel{err: modelErr}
|
||||
|
||||
recorder := serveHello(t, fake, newHelloRequest())
|
||||
|
||||
if fake.calls != 1 {
|
||||
t.Fatalf("模型调用次数 = %d, want 1", fake.calls)
|
||||
}
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
|
||||
}
|
||||
want := `{"success":false,"error":{"code":10002,"message":"获取分类列表失败"}}`
|
||||
if got := recorder.Body.String(); got != want {
|
||||
t.Fatalf("body = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), modelErr.Error()) {
|
||||
t.Fatalf("响应泄漏了内部错误信息: %q", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
func TestHelloPassesRequestContextToModel(t *testing.T) {
|
||||
fake := &fakeCategoryModel{categories: []*model.Category{}}
|
||||
reqCtx := context.WithValue(context.Background(), contextKey{}, "req-42")
|
||||
req := newHelloRequest().WithContext(reqCtx)
|
||||
|
||||
serveHello(t, fake, req)
|
||||
|
||||
if fake.gotCtx != reqCtx {
|
||||
t.Fatalf("模型收到非请求 context: %v", fake.gotCtx)
|
||||
}
|
||||
if got, _ := fake.gotCtx.Value(contextKey{}).(string); got != "req-42" {
|
||||
t.Fatalf("context 值 = %q, want %q", got, "req-42")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user