在 Day 10~Day 14,我們把「主要熱點」搬進 Redis,並用 Lua Script 保證原子性與語義完整。
今天該把這顆引擎裝進一台能跑的車:用 Gin 把「搶票」暴露為一個乾淨、可維護、可觀測的 HTTP API。
這不是單純把 client.DecrBy
包起來丟給網路。API 是系統的「契約 (Contract)」與「承諾 (SLO)」。
在這裡做的每個決定——路徑、輸入、輸出、狀態碼、超時、錯誤映射、日誌、追蹤——都會直接影響可維護性與風險暴露面。
POST /api/v1/purchase
{
"ticket_id": 12345,
"user_id": 67890
}
{
"request_id": "2a3b4c...",
"status": "success",
"remaining": 998
}
{
"request_id": "2a3b4c...",
"status": "error",
"error": {
"code": "SOLD_OUT",
"message": "票券已售罄"
}
}
HTTP 狀態碼策略:
關鍵點:不要把所有錯誤都 200 包起來。HTTP 狀態碼是協議的一部分,它讓前端和觀測系統能快速判斷行為,不需要解析字串。
領域錯誤到 HTTP 的對照表:
Domain Error | 說明 | HTTP | code |
---|---|---|---|
ErrSoldOut | 票券無庫存 | 409 | SOLD_OUT |
ErrAlreadyPurchased | 用戶已購買 | 409 | DUPLICATE_PURCHASE |
ErrInvalidArgument | 參數不合法 | 400 | INVALID_ARGUMENT |
其他 error | 非預期 | 500 | INTERNAL |
堅持「單一映射來源」:在 Use Case 層用 errors.Is
判斷並回傳對應的 ApiError
,Handler 只做翻譯與輸出,避免到處散落 switch。
server
:Gin 啟動、路由、中介層(Correlation ID、Recovery、JSON Content-Type)。handler
:HTTP 參數綁定與回應格式化。usecase
:購買流程協調(呼叫 TicketService
)。service
:TicketService
基於 Redis + Lua 執行原子扣減與去重。infra
:Redis Client 建置與生命周期管理。這樣分層的意義是把「IO、協議、領域規則」分開:可測、可替換、不相互污染。
依賴:
go get github.com/gin-gonic/gin github.com/redis/go-redis/v9 github.com/google/uuid
package main
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
// ===== Domain errors =====
var (
ErrSoldOut = errors.New("sold out")
ErrAlreadyPurchased = errors.New("already purchased")
ErrInvalidArgument = errors.New("invalid argument")
)
// ===== Infra: Redis client =====
func newRedisClient() *redis.Client {
return redis.NewClient(&redis.Options{
Addr: "localhost:6379",
PoolSize: 200,
MinIdleConns: 20,
ReadTimeout: 500 * time.Millisecond,
WriteTimeout: 500 * time.Millisecond,
PoolTimeout: 1 * time.Second,
})
}
// ===== Service: TicketService (Lua 保證原子性) =====
type TicketService struct{ rdb *redis.Client }
func NewTicketService(rdb *redis.Client) *TicketService { return &TicketService{rdb: rdb} }
var purchaseScript = redis.NewScript(`
local ticketKey = KEYS[1]
local userKey = KEYS[2]
local ticketID = ARGV[1]
local quantity = tonumber(redis.call('GET', ticketKey) or '0')
if quantity <= 0 then return 0 end
if redis.call('SISMEMBER', userKey, ticketID) == 1 then return -1 end
redis.call('DECRBY', ticketKey, 1)
redis.call('SADD', userKey, ticketID)
return 1
`)
func (s *TicketService) Purchase(ctx context.Context, ticketID, userID int64) (int64, error) {
ticketKey := fmt.Sprintf("ticket:%d", ticketID)
userKey := fmt.Sprintf("user:%d:purchased", userID)
res, err := purchaseScript.Run(ctx, s.rdb, []string{ticketKey, userKey}, ticketID).Int64()
if err != nil { return 0, err }
switch res {
case 1:
// 回傳剩餘量(再取一次避免業務和腳本糾纏)
remain, err := s.rdb.Get(ctx, ticketKey).Int64()
if err != nil { return 0, err }
return remain, nil
case 0:
return 0, ErrSoldOut
case -1:
return 0, ErrAlreadyPurchased
default:
return 0, errors.New("unknown script result")
}
}
// ===== Usecase =====
type PurchaseUsecase struct{ svc *TicketService }
func NewPurchaseUsecase(svc *TicketService) *PurchaseUsecase { return &PurchaseUsecase{svc: svc} }
func (u *PurchaseUsecase) Execute(ctx context.Context, ticketID, userID int64) (int64, error) {
if ticketID <= 0 || userID <= 0 { return 0, ErrInvalidArgument }
return u.svc.Purchase(ctx, ticketID, userID)
}
// ===== HTTP layer =====
type purchaseReq struct {
TicketID int64 `json:"ticket_id" binding:"required"`
UserID int64 `json:"user_id" binding:"required"`
}
func correlationID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader("X-Request-ID")
if rid == "" { rid = uuid.NewString() }
c.Set("request_id", rid)
c.Writer.Header().Set("X-Request-ID", rid)
c.Next()
}
}
func main() {
rdb := newRedisClient()
defer rdb.Close()
if err := rdb.Ping(context.Background()).Err(); err != nil { panic(err) }
svc := NewTicketService(rdb)
uc := NewPurchaseUsecase(svc)
r := gin.New()
r.Use(gin.Recovery(), correlationID())
r.POST("/api/v1/purchase", func(c *gin.Context) {
var req purchaseReq
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, code("INVALID_ARGUMENT"), err.Error())
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
defer cancel()
remaining, err := uc.Execute(ctx, req.TicketID, req.UserID)
if err != nil {
switch {
case errors.Is(err, ErrInvalidArgument):
respondError(c, http.StatusBadRequest, code("INVALID_ARGUMENT"), "參數不合法")
case errors.Is(err, ErrSoldOut):
respondError(c, http.StatusConflict, code("SOLD_OUT"), "票券已售罄")
case errors.Is(err, ErrAlreadyPurchased):
respondError(c, http.StatusConflict, code("DUPLICATE_PURCHASE"), "用戶已購買此票券")
default:
respondError(c, http.StatusInternalServerError, code("INTERNAL"), "系統錯誤")
}
return
}
c.JSON(http.StatusOK, gin.H{
"request_id": c.GetString("request_id"),
"status": "success",
"remaining": remaining,
})
})
_ = r.Run(":8080")
}
type errPayload struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
Error interface{} `json:"error"`
}
func code(c string) map[string]string { return map[string]string{"code": c} }
func respondError(c *gin.Context, httpStatus int, meta map[string]string, msg string) {
payload := errPayload{
RequestID: c.GetString("request_id"),
Status: "error",
Error: gin.H{
"code": meta["code"],
"message": msg,
},
}
c.JSON(httpStatus, payload)
}
說明:
X-Request-ID
可從前端傳入或後端生成,回寫回應,支撐後續日誌與追蹤。purchaseScript
用 redis.Script
,自動處理 NOSCRIPT
(快取失效)情形。remaining
透過二次 GET
取得,避免把輸出耦合進腳本(可測性更好)。X-Request-ID
,向下游(之後的 MQ)傳遞。Content-Type: application/json
,禁用非 JSON 回應。request_id、route、ticket_id、user_id、latency_ms、status、code
。使用 k6
針對 POST /api/v1/purchase
:
PoolStats
:Misses/Timeouts/TotalConns/IdleConns
必須落在期望範圍。X-Request-ID
→ 事件難以串起。