iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0

上一篇介紹了如何處理單次請求與回應的 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 時,核心資料流程主要分為三個階段:

  1. 建立連線:watch 命令啟動 Client,連線至 WebSocket 伺服器完成 handshake。
  2. 接收與解析:Client 接收伺服器推送的 JSON 報價,解析為結構體。
  3. 篩選與輸出:Client 將資料傳回 watch 命令,依據參數篩選後印出。

https://ithelp.ithome.com.tw/upload/images/20261002/20111896jtZXCOROqW.png

先在終端機 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 會持續保持連線與顯示,直到連線中斷或手動結束。

實作 watch 命令與 WebSocket 連線

要完成這個即時報價功能,程式碼主要分為兩個檔案:cmd/watch.go 負責命令介面與輸出,prices/client.go 負責 WebSocket 底層連線與訊息讀取。

我們分三個步驟來建立它:

第一步:定義 watch 命令與輸入參數

在 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 會直接回傳錯誤提示。

第二步:實作 WebSocket 長連線與訊息讀取迴圈

接著在 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):解析成功的報價會交由外部傳入的回調函式處理,將網路連線與資料過濾邏輯解耦。

第三步:在 Command 中組裝過濾邏輯並印出

最後回到 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())

使用 Context 與 Signal 中斷阻塞讀取

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 命令,重點在於將長連線通訊重新設計為符合命令列互動的介面:

  1. 從單次查詢轉為即時串流:相較於 HTTP REST API「發送即結束」的單次查詢,WebSocket CLI 讓使用者能以 watch 等子命令持續訂閱並監聽事件。
  2. 在介面端完成資料裁減:即時推送的資料量龐大,CLI 命令介面應透過必要位置參數(如 watch <symbol>)與選填 Flag 提供精準過濾,只輸出關注的訊息。
  3. 將連線納入命令生命週期:長連線必須能被使用者或系統訊號隨時切斷,保持 CLI 乾淨、可預測的執行行為。

上一篇
將既有 REST API 重新設計為 CLI
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言