第 5 章:股票報價(記憶體假資料)
這一章在做什麼? 我們要提供股票報價查詢:列出所有報價、查單一代號。報價不接真實券商,用記憶體裡的假資料模擬,還會讓價格每隔幾秒小幅隨機波動,做出「即時」的感覺。
為什麼挑這章深入 interface? 因為報價是「今天用假資料、明天可能換成真 API」的最佳範例。我們用 Go 的 interface 定義「報價服務該有哪些能力」,handler 只依賴這個介面——將來把實作換成真資料源,handler 一行都不用改。這章也會碰到 Go 特有的 goroutine 並發 與 map 並發安全 問題。
步驟 1:先想清楚設計目標(為什麼要 interface)
如果我們直接寫一個「從 map 拿價格」的具體型別,handler 就跟「資料存在 map 裡」這件事綁死了。哪天要改成打真實報價 API,handler 也得跟著改。
更好的做法:先定義一個 interface(合約)——「任何報價服務都要能『查單一報價』和『列出所有報價』」。handler 只認這個合約,不管背後是 map 還是真 API。
[handler] QuoteHandler ── 只依賴 ──▶ interface: QuoteService
▲
┌───────────────┴───────────────┐
MemoryQuoteService (未來) RealQuoteService
(記憶體假資料,本章做) (打真實 API,不用動 handler)
🔁 JS 對照 這就是「對介面寫程式,不對實作寫程式」。在 TypeScript 你會定義
interface QuoteService { ... },handler 接收QuoteService型別,實作可以隨意抽換。Go 一樣,只是它的 interface 是隱式實作(第 0 章步驟 8)——實作型別不用宣告implements,方法對得上就算數。
步驟 2:定義 Quote 與 QuoteService interface
報價不存進資料庫(純記憶體),所以 Quote 這個型別我們直接定義在 service 層。建立 internal/service/quote_service.go,先寫型別與介面:
// internal/service/quote_service.go
package service
import (
"errors"
"math/rand"
"sort"
"strings"
"sync"
"time"
)
var ErrQuoteNotFound = errors.New("查無此股票代號")
// Quote 一檔股票的報價(價格為整數分,沿用第 4 章的金額策略)
type Quote struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Price int64 `json:"price_cents"`
}
// QuoteService 是「報價服務」的合約:任何報價來源都要能做這兩件事
type QuoteService interface {
GetQuote(symbol string) (Quote, error)
ListQuotes() []Quote
}
Quote.Price一樣是int64、單位「分」(沿用第 4 章)。QuoteService只列「能力」,不含任何實作細節。下一步的記憶體版本會去實現它。
步驟 3:記憶體實作 + map 並發安全
接在同一個檔案後面,寫出記憶體版實作:
// MemoryQuoteService 用記憶體 map 當作報價來源
type MemoryQuoteService struct {
mu sync.RWMutex // 保護底下的 map
quotes map[string]Quote // key 是股票代號
}
// NewMemoryQuoteService 建立並塞入一批假資料
func NewMemoryQuoteService() *MemoryQuoteService {
return &MemoryQuoteService{
quotes: map[string]Quote{
"AAPL": {Symbol: "AAPL", Name: "Apple Inc.", Price: 19550},
"TSLA": {Symbol: "TSLA", Name: "Tesla, Inc.", Price: 25000},
"GOOG": {Symbol: "GOOG", Name: "Alphabet Inc.", Price: 17800},
"MSFT": {Symbol: "MSFT", Name: "Microsoft Corp.", Price: 42000},
"AMZN": {Symbol: "AMZN", Name: "Amazon.com, Inc.", Price: 18500},
},
}
}
// GetQuote 查單一報價(找不到回 ErrQuoteNotFound)
func (s *MemoryQuoteService) GetQuote(symbol string) (Quote, error) {
s.mu.RLock() // 上「讀鎖」:可多人同時讀
defer s.mu.RUnlock()
q, ok := s.quotes[strings.ToUpper(symbol)]
if !ok {
return Quote{}, ErrQuoteNotFound
}
return q, nil
}
// ListQuotes 列出所有報價(依代號排序,讓輸出穩定)
func (s *MemoryQuoteService) ListQuotes() []Quote {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]Quote, 0, len(s.quotes))
for _, q := range s.quotes {
list = append(list, q)
}
sort.Slice(list, func(i, j int) bool {
return list[i].Symbol < list[j].Symbol
})
return list
}
幾個重點:
map[string]Quote的 key 用大寫代號;GetQuote先strings.ToUpper(symbol),這樣查aapl或AAPL都找得到。q, ok := s.quotes[key]:這是 Go 從 map 取值的標準寫法——第二個回傳值ok是 bool,告訴你 key 存不存在。(直接s.quotes[key]在查無時會回零值,分不出「值是零」還是「沒這個 key」。)sort.Slice:Go map 的迭代順序是故意隨機的(語言設計如此,避免你依賴順序)。所以列清單時我們排序一下,輸出才穩定。- 為什麼需要
sync.RWMutex? 見下方。
map 並發安全(Go 特有、JS 工程師很容易忽略)
Gin 會用多個 goroutine 同時處理進來的請求;下一步我們還會加一個背景 goroutine 不斷改價格。問題是:Go 的 map 不是並發安全的——只要有一個 goroutine 在寫、同時另一個在讀,程式會直接崩潰:
fatal error: concurrent map read and map write
解法是用鎖。我們用 sync.RWMutex(讀寫鎖):
- 讀取時用
RLock()/RUnlock():允許多個 goroutine 同時讀(讀不會互相干擾)。 - 寫入時用
Lock()/Unlock():獨佔(寫的時候誰都不能讀或寫)。
🔁 JS 對照 Node.js 是單執行緒事件迴圈,同一時間只有一段 JS 在跑,所以你幾乎不用煩惱「同時讀寫同一個物件」。 Go 不一樣:它的 goroutine 是真正並行、可能跑在不同 CPU 核心上。所以共用的可變資料(這裡的 map)一定要自己加鎖保護。這是從 JS 跳到 Go 最需要建立的新意識之一。 (
sync.RWMutex沒有 JS 對應物,因為 JS 根本沒有這個問題。)
步驟 4:背景 goroutine 模擬股價波動
讓價格活起來:開一個背景 goroutine,每隔幾秒把每檔股票的價格隨機微調 ±1%。接在同一個檔案後面:
// applyRandomTick 把每檔價格隨機微調 ±1%(寫入,需要寫鎖)
func (s *MemoryQuoteService) applyRandomTick() {
s.mu.Lock() // 上「寫鎖」:獨佔
defer s.mu.Unlock()
for symbol, q := range s.quotes {
// rand.Float64() 回傳 [0,1),這裡換算成 -0.01 ~ +0.01
deltaPct := (rand.Float64()*2 - 1) * 0.01
newPrice := int64(float64(q.Price) * (1 + deltaPct))
if newPrice < 1 {
newPrice = 1 // 價格至少 1 分
}
// 注意:map 的值不可直接改欄位,要「取出複本→改→塞回去」
q.Price = newPrice
s.quotes[symbol] = q
}
}
// StartPriceSimulation 啟動背景 goroutine,每隔 interval 微調一次股價
func (s *MemoryQuoteService) StartPriceSimulation(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C { // 每次 ticker 觸發就跑一輪
s.applyRandomTick()
}
}()
}
逐段解釋:
go func() { ... }():go關鍵字啟動一個 goroutine——一個極輕量的並行執行單位。這個匿名函式會在背景獨立跑,不擋住主程式。time.NewTicker(interval):建立一個定時器,每隔interval就往它的 channelticker.C送一個訊號。for range ticker.C:每收到一次訊號就跑一輪applyRandomTick。這個迴圈會一直跑(直到程式結束)。- 寫入用
Lock()(不是RLock()),因為我們在改 map。 - map 值不可直接改欄位:
s.quotes[symbol].Price = x在 Go 是編譯錯誤(map 的值不可定址)。要「取出複本q、改q、再s.quotes[symbol] = q塞回去」。這是 Go 的小陷阱,記住即可。 rand.Float64():Go 1.20 起,全域亂數產生器會自動以隨機種子初始化,所以不用再呼叫rand.Seed,每次跑都不一樣。
🔁 JS 對照
StartPriceSimulation的概念像:setInterval(() => applyRandomTick(), interval);
setInterval把 callback 排進事件迴圈;Go 的go func(){ for range ticker.C {...} }()則是真的開一條背景執行緒在跑。差別在 Go 是真並行,所以裡頭改共用 map 一定要加鎖(setInterval的 callback 因單執行緒不用)。
步驟 5:handler(List / Get,含 path 參數與 404)
建立 internal/handler/quote_handler.go:
// internal/handler/quote_handler.go
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"no-logic-trade/internal/service"
)
type QuoteHandler struct {
quotes service.QuoteService // ★ 依賴的是「介面」,不是具體型別
}
func NewQuoteHandler(quotes service.QuoteService) *QuoteHandler {
return &QuoteHandler{quotes: quotes}
}
// quoteResponse 統一報價的回應格式(同時給整數分與好讀字串)
func quoteResponse(q service.Quote) gin.H {
return gin.H{
"symbol": q.Symbol,
"name": q.Name,
"price_cents": q.Price,
"price": formatCents(q.Price),
}
}
// GET /api/quotes
func (h *QuoteHandler) List(c *gin.Context) {
quotes := h.quotes.ListQuotes()
resp := make([]gin.H, 0, len(quotes))
for _, q := range quotes {
resp = append(resp, quoteResponse(q))
}
c.JSON(http.StatusOK, gin.H{"quotes": resp})
}
// GET /api/quotes/:symbol
func (h *QuoteHandler) Get(c *gin.Context) {
symbol := c.Param("symbol") // 取出路徑參數 :symbol
q, err := h.quotes.GetQuote(symbol)
if err != nil {
if errors.Is(err, service.ErrQuoteNotFound) {
respondError(c, http.StatusNotFound, "查無此股票代號")
return
}
respondError(c, http.StatusInternalServerError, "查詢報價失敗")
return
}
c.JSON(http.StatusOK, quoteResponse(q))
}
重點:
quotes service.QuoteService:handler 持有的是介面型別,不是*MemoryQuoteService。這就是步驟 1 的設計目標落地——handler 完全不知道、也不在乎報價是怎麼來的。c.Param("symbol"):取出 URL 路徑裡的:symbol參數(例如/api/quotes/AAPL取到"AAPL")。- 404 處理:查無代號時用
errors.Is比對ErrQuoteNotFound,回404 Not Found。
🔁 JS 對照
// Express app.get("/api/quotes/:symbol", (req, res) => { const symbol = req.params.symbol; // ≈ c.Param("symbol") ... });
c.Param≈req.params。路由裡的:symbol寫法兩邊一樣。
步驟 6:組裝路由(報價是公開的,不需登入)
更新 setupRouter:報價放在公開區(任何人都能看股價,不必登入)。
// cmd/api/main.go (只列 setupRouter 的變動處)
func setupRouter(db *sqlx.DB, cfg *config.Config) *gin.Engine {
// --- repository 層 ---
userRepo := repository.NewUserRepository()
accountRepo := repository.NewAccountRepository()
transactionRepo := repository.NewTransactionRepository()
// --- service 層 ---
authService := service.NewAuthService(db, userRepo, accountRepo, cfg.JWTSecret)
accountService := service.NewAccountService(db, accountRepo, transactionRepo)
quoteService := service.NewMemoryQuoteService() // ★新增(具體型別)
quoteService.StartPriceSimulation(5 * time.Second) // ★啟動背景股價模擬
// --- handler 層 ---
authHandler := handler.NewAuthHandler(authService)
accountHandler := handler.NewAccountHandler(accountService)
quoteHandler := handler.NewQuoteHandler(quoteService) // ★新增(傳具體型別,自動符合介面)
// --- 路由 ---
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api := r.Group("/api")
{
// 公開
api.POST("/auth/register", authHandler.Register)
api.POST("/auth/login", authHandler.Login)
api.GET("/quotes", quoteHandler.List) // ★新增
api.GET("/quotes/:symbol", quoteHandler.Get) // ★新增
// 需登入
authed := api.Group("")
authed.Use(middleware.AuthRequired(authService))
{
authed.GET("/me", authHandler.Me)
authed.POST("/account/deposit", accountHandler.Deposit)
authed.GET("/account/balance", accountHandler.Balance)
}
}
return r
}
注意這裡有一個漂亮的細節:
- 我們建立的是具體型別
quoteService := service.NewMemoryQuoteService()(因為要呼叫只有它才有的StartPriceSimulation)。 - 但傳進
NewQuoteHandler(quoteService)時,它自動被當成QuoteService介面(隱式實作)。 - 這就是「建立時用具體型別、使用時依賴介面」的典型 Go 寫法。
別忘了在
import區確認有"time"(5 * time.Second會用到)。
✅ 如何執行 / 驗證這一章成功
go run ./cmd/api
報價是公開的,不需要 token。
驗證 1:列出所有報價
curl http://localhost:8080/api/quotes
預期(依代號排序,價格為當下值):
{"quotes":[
{"name":"Apple Inc.","price":"195.50","price_cents":19550,"symbol":"AAPL"},
{"name":"Amazon.com, Inc.","price":"185.00","price_cents":18500,"symbol":"AMZN"},
{"name":"Alphabet Inc.","price":"178.00","price_cents":17800,"symbol":"GOOG"},
{"name":"Microsoft Corp.","price":"420.00","price_cents":42000,"symbol":"MSFT"},
{"name":"Tesla, Inc.","price":"250.00","price_cents":25000,"symbol":"TSLA"}
]}
驗證 2:查單一報價(大小寫都可)
curl http://localhost:8080/api/quotes/aapl
預期:
{"name":"Apple Inc.","price":"195.50","price_cents":19550,"symbol":"AAPL"}
驗證 3:查不存在的代號 → 404
curl -i http://localhost:8080/api/quotes/NOPE
預期:
HTTP/1.1 404 Not Found
...
{"error":"查無此股票代號"}
驗證 4:價格會隨時間變動(背景 goroutine 生效)
連續查兩次 AAPL,中間等個 6 秒(模擬器每 5 秒跳一次):
curl -s http://localhost:8080/api/quotes/AAPL | grep -o '"price_cents":[0-9]*'
sleep 6
curl -s http://localhost:8080/api/quotes/AAPL | grep -o '"price_cents":[0-9]*'
預期兩次 price_cents 略有不同(在 ±1% 內跳動),例如:
"price_cents":19550
"price_cents":19612
四項都通過,代表 interface 設計、記憶體報價、map 並發保護、背景股價模擬都正常。🎉
這一章你完成了什麼
- 用 interface(
QuoteService) 把「報價能力」與「報價來源」解耦,handler 只依賴介面,未來可無痛抽換成真 API。 - 體會「建立時用具體型別、使用時依賴介面」的 Go 慣用法。
- 認識 Go 特有的並發課題:map 不是並發安全的,用
sync.RWMutex(讀鎖可共享、寫鎖獨佔)保護共用資料。 - 第一次用 goroutine(
go func(){})+time.Ticker做背景工作,並學到 map 值不可定址的小陷阱。 - 學到從 map 取值的
value, ok := m[key]寫法、以及 map 迭代順序隨機需自行sort。
下一步 → 第 6 章:下單(市價買 / 賣)⭐ 本專案核心 我們會把報價、餘額、持股全部串起來:市價單即時以報價成交,買單檢查餘額、賣單檢查持股,並用一筆資料庫交易確保「扣款 / 加股 / 寫單 / 記帳」要嘛全部成功、要嘛全部不留——這是整個專案最關鍵、也最能展現後端嚴謹度的一章。