第 5 章:股票報價(記憶體假資料)

第 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:定義 QuoteQuoteService 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 用大寫代號GetQuotestrings.ToUpper(symbol),這樣查 aaplAAPL 都找得到。
  • 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 就往它的 channel ticker.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.Paramreq.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(讀鎖可共享、寫鎖獨佔)保護共用資料。
  • 第一次用 goroutinego func(){})+ time.Ticker 做背景工作,並學到 map 值不可定址的小陷阱。
  • 學到從 map 取值的 value, ok := m[key] 寫法、以及 map 迭代順序隨機需自行 sort

下一步 → 第 6 章:下單(市價買 / 賣)⭐ 本專案核心 我們會把報價、餘額、持股全部串起來:市價單即時以報價成交,買單檢查餘額、賣單檢查持股,並用一筆資料庫交易確保「扣款 / 加股 / 寫單 / 記帳」要嘛全部成功、要嘛全部不留——這是整個專案最關鍵、也最能展現後端嚴謹度的一章。