上一篇介紹了如何處理單次請求與回應的 HTTP REST API。在查詢匯率或列出資源時,CLI 發送一次 HTTP 請求、取得結果後輸出並結束執行。
但是當需求轉為即時股價時,伺服器會在命令執行期間持續產生新資料。如果 CLI 繼續沿用 HTTP 模式每隔幾秒呼叫一次 REST API,更新速度會受輪詢(polling)間隔限制;即使伺服器沒有新資料,CLI 仍會頻繁發出無效請求。
這類即時雙向推送的場景需要改用 WebSocket。CLI 在完成 HTTP handshake 後,便能透過這條長連線持續接收伺服器推送的資料,直到連線中斷或使用者結束命令。
以下用 Go 實作即時股價工具 marketcli。範例會建立一個模擬的即時股價 WebSocket 伺服器,由 marketcli watch 連上並解析報價,最後將結果顯示在終端機。
進入 cli-sample/websocket-client 可以在本機試跑這個範例。
cmd/mock_server.go 提供了 mock-server 子命令,可以在終端機啟動服務,提供 ws://127.0.0.1:8080/prices 端點並持續推送模擬報價:
go run . mock-server
mock server listening at ws://127.0.0.1:8080/prices
伺服器推送的資料定義在 PriceUpdate 結構:
type PriceUpdate struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Time time.Time `json:"time"`
}
傳輸時編碼為 JSON:
{"id":"price-update-000001","symbol":"AAPL","price":248,"time":"2026-07-22T09:18:22Z"}
id 用來識別事件,symbol 與 price 為報價內容,time 則是產生時間。CLI Client 會連上此端點並解析接收到的 PriceUpdate。
在架構設計上,程式拆為 Command 層與 Client 層:
watch 命令(Command 層,cmd/watch.go):負責處理 --symbol 與 --min 等命令列參數,定義過濾條件與輸出格式。prices.Client(Client 層,prices/client.go):負責 WebSocket 底層連線、讀取 frame、JSON 解析與欄位驗證;解析成功後再透過 Handle 回調函式將資料傳回 Command 層。當使用者執行 marketcli watch 時,核心資料流程主要分為三個階段:
watch 命令啟動 Client,連線至 WebSocket 伺服器完成 handshake。watch 命令,依據參數篩選後印出。
先在終端機 1 保持 mock-server 執行,並在終端機 2 執行 watch 命令連上 WebSocket:
go run . watch AAPL
連上 ws://127.0.0.1:8080/prices 後,CLI 會持續印出 AAPL 的即時報價:
[17:18:22] AAPL 248.00
[17:18:23] AAPL 249.50
[17:18:24] AAPL 251.00
[17:18:25] AAPL 252.50
加上 --min 則可過濾最低價格門檻:
go run . watch AAPL --min 250
[17:18:24] AAPL 251.00
[17:18:25] AAPL 252.50
watch 會持續保持連線與顯示,直到連線中斷或手動結束。
要完成這個即時報價功能,程式碼主要分為兩個檔案:cmd/watch.go 負責命令介面與輸出,prices/client.go 負責 WebSocket 底層連線與訊息讀取。
我們分三個步驟來建立它:
在 cmd/watch.go 中,核心操作對象 symbol 是必填的股票代碼,我們設定為第一個位置參數(args[0]),而 --min 則作為選填的價格門檻 Flag:
func newWatchCommand() *cobra.Command {
var feedURL string
var minPrice float64
command := &cobra.Command{
Use: "watch <symbol>",
Short: "持續顯示指定股票的即時報價",
Args: cobra.ExactArgs(1),
RunE: func(command *cobra.Command, args []string) error {
symbol := args[0] // 取得必填的位置參數(如 AAPL)
// ... 後續在此處建立 Client 並啟動
},
}
command.Flags().StringVar(&feedURL, "url", defaultFeedURL, "WebSocket 報價來源")
command.Flags().Float64Var(&minPrice, "min", 0, "只顯示不低於此價格的報價")
return command
}
cobra.ExactArgs(1) 能確保使用者若漏傳股票代碼時,Cobra 會直接回傳錯誤提示。
接著在 prices/client.go 的 readConnection 函式中,處理單次 WebSocket 連線建立與訊息接收:
func (c *Client) readConnection(ctx context.Context) (bool, error) {
dialer := c.Dialer
if dialer == nil {
dialer = websocket.DefaultDialer
}
// 1. 發起 TCP 連線並發送 HTTP Upgrade 標頭完成握手
conn, _, err := dialer.DialContext(ctx, c.URL, nil)
if err != nil {
return false, fmt.Errorf("connect: %w", err)
}
defer conn.Close()
for {
// 2. 阻塞等待下一個 WebSocket frame,等待期間不占用 CPU
_, message, err := conn.ReadMessage()
if err != nil {
return true, fmt.Errorf("read: %w", err)
}
// 3. 將 JSON 轉為 PriceUpdate 結構體,並過濾無效訊息
var update PriceUpdate
if err := json.Unmarshal(message, &update); err != nil {
continue
}
if update.ID == "" || update.Symbol == "" || update.Time.IsZero() {
continue
}
// 4. 將解析成功的報價傳給 Handle 回調函式處理
if err := c.Handle(update); err != nil {
return true, &handleError{err: err}
}
}
}
在這段程式碼中:
DialContext:負責連至 Client.URL 並完成握手。傳入 ctx 能確保在連線中斷或逾時時能立即取消連線。ReadMessage:讓當前 Goroutine 停下來等待伺服器推送下一個 frame,不需要發送 HTTP 請求輪詢。c.Handle(update):解析成功的報價會交由外部傳入的回調函式處理,將網路連線與資料過濾邏輯解耦。最後回到 cmd/watch.go 的 RunE 裡面,建立 prices.Client 並提供 Handle 函式。每當 Client 解析完一筆 PriceUpdate,Handle 就會比較股票代碼與門檻金額,符合條件才印到終端機:
client := prices.Client{
URL: feedURL,
Handle: func(update prices.PriceUpdate) error {
// 比對股票代碼與價格門檻
if update.Symbol != symbol {
return nil
}
if update.Price < minPrice {
return nil
}
// 符合條件,格式化寫入標準輸出 stdout
_, err := fmt.Fprintf(
command.OutOrStdout(),
"[%s] %s %.2f\n",
update.Time.Local().Format("15:04:05"),
update.Symbol,
update.Price,
)
return err
},
}
return client.Run(command.Context())
main.go 使用 signal.NotifyContext,收到 SIGINT 或 SIGTERM 時取消 context:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := cmd.Execute(ctx); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
取消 context 時,ReadMessage 可能仍在等待下一筆資料。連線建立後,context.AfterFunc 會在 context 取消時關閉 WebSocket,讓 ReadMessage 返回:
stopClosing := context.AfterFunc(ctx, func() {
_ = conn.Close()
})
defer stopClosing()
因此使用者按下 Ctrl+C 後,不必等到伺服器推送下一筆資料,命令就能結束。
把 WebSocket 轉化為 CLI 命令,重點在於將長連線通訊重新設計為符合命令列互動的介面:
watch 等子命令持續訂閱並監聽事件。watch <symbol>)與選填 Flag 提供精準過濾,只輸出關注的訊息。