前言:從 TypeScript 走向 Go 的那一步
過去五年,我幾乎都待在 TypeScript、React、Node.js 的世界裡。從 Uniswap 查詢、NFT 智能合約,到 DeFi Dashboard,習慣的是 JavaScript 生態帶來的那種「輕快與靈活」。
但每次遇到 高併發、網路 I/O 密集 的場景,心裡總覺得少了點什麼。
直到最近,我決定用 Go 寫一個小工具:
同時從 8 家主流加密貨幣交易所——Binance、OKX、Coinbase、Bybit、Bitget、Hyperliquid、Kraken、MEXC——實時抓取 BTC 價格,計算最佳買入價、最佳賣出價與價差。
這個看起來很「小」的 side project,卻徹底改變了我對「併發工程」的理解,也讓我第一次真正體會到:為什麼這麼多基礎設施會選擇 Go。
為什麼是 Go?我終於看懂 goroutine 的世界觀
長期在前端和 Node.js 生態打滾的我,其實對 Go 一直處在「看很多、沒真寫」的狀態。
我知道 Docker、Kubernetes、Prometheus、很多區塊鏈相關工具都用 Go 寫,但這些專案的規模太大,很難直接從裡面理解語言特性背後的設計哲學。
真正讓我動手的,是一個很單純的問題:
如果我要同時查詢 8 個交易所的 BTC 價格,用 JavaScript 的
async/await+Promise.all,會發生什麼事?
表面上,看起來一切都很合理,Promise.all 會一次併發發出八組 HTTP 請求,等全部完成後再把結果打包回來。但實務上跑久了,你會發現有幾個很實在的痛點:只要有一間交易所特別慢,整體結果就被 最慢的那一家 API 綁住;如果中間發生 timeout 或整家服務掛掉,錯誤處理邏輯會開始變得又長又醜;而當你持續增加交易所數量、提高查詢頻率時,整個事件循環本身也會逐漸變成瓶頸。
Go 的思路完全不同。每一間交易所請求都被丟進一個 輕量級 goroutine 裡處理(不是作業系統 thread,開銷非常小),結果則透過 channel 往回送,由 main goroutine 專心負責收集與聚合。這不只是語法的差別,而是 模型的差異:在 Node.js 裡,你更像是在「管理一堆 promise」;而在 Go 裡,你是在「編排一群彼此獨立工作的 goroutine」。
對這類 「N 個獨立任務併行,最後聚合結果」 的場景來說,Go 幾乎是為它量身打造。
從需求反推架構:我想要的是一個什麼樣的行情聚合器?
在寫任何程式之前,我先把自己真正想要的東西講清楚。我希望這個工具能夠盡可能貼近「實時」,也就是同一瞬間一起拉取八家交易所的 BTC 價格,減少時間上的誤差;我也希望就算某一兩家交易所 API 掛掉,整體查詢還是能繼續完成,不會因為單點失效就全盤崩潰。此外,新增第九家、第十家交易所時,最好只需要多寫一個小模組,而不是動到整個 main 邏輯;最後,我想要一眼就能看懂哪一家最便宜、哪一家最貴、價差有多大。
如果用傳統 for 迴圈或 Promise.all,這些需求其實都很容易踩雷。沒處理好 timeout 時,一家卡死就會拖累全局,變成典型的 串行風險;新增交易所時,錯誤處理與重試邏輯寫在一起,程式碼會越來越難維護;錯誤處理、重試機制、JSON 解析全混在同一層,也讓可讀性變得很差。
所以我選擇用一個 模組化設計 來拆解這個問題:
.
├── exchanges/
│ ├── types.go # 統一的 PriceResult 結構
│ ├── binance.go # FetchBinance()
│ ├── okx.go # FetchOKX()
│ ├── coinbase.go # FetchCoinbase()
│ ├── bybit.go # FetchBybit()
│ ├── bitget.go # FetchBitget()
│ ├── hyperliquid.go # FetchHyperliquid()
│ ├── kraken.go # FetchKraken()
│ └── mexc.go # FetchMEXC()
└── main.go # 協調層核心想法只有一句話:
「每個交易所一個檔案、一個 fetch 函數、職責單一。」
實作細節:用 Go 寫一個乾淨的並發查價器
1. 統一數據結構(types.go)
第一步是定義一個所有交易所都會回傳的結構:
package exchanges
type PriceResult struct {
Exchange string
Price float64
Err error
}之所以這樣設計,是因為 channel 裡傳的類型必須一致,main 才能用一個乾淨的迴圈把所有結果收集起來。另一方面,Err 直接掛在結構上也讓後續的錯誤聚合變得很自然:呼叫端只要判斷這個欄位是不是空的,就能決定要不要納入統計。這整個做法算是我在 Go 裡實踐 介面與資料結構先行 的一個小例子。
2. 單一交易所的 Fetch 函數
以 Binance 為例,我在 binance.go 裡大致會這麼做:
func FetchBinance(ch chan PriceResult) {
// 1. 呼叫 REST API,例如:
// https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT
// 2. 解析 JSON,取得 price 欄位
// 3. 將字串轉成 float64
// 4. 透過 channel 把結果丟回去
ch <- PriceResult{
Exchange: "Binance",
Price: 95311.53,
Err: nil,
}
}實作時你會發現,每一家交易所的 API 格式、路徑與 symbol 命名其實都不一樣:Binance 用的是 BTCUSDT,Coinbase 則是 BTC-USD,OKX 又是 BTC-USDT。我刻意在 各自的 Fetch 函數內部吸收這些差異,讓外部世界永遠只看到統一的 PriceResult。
換句話說,呼叫端只在乎「你給我一個 PriceResult 就好」,至於你精確怎麼打 API、怎麼 parse JSON、怎麼處理錯誤,全都包在各自的模組裡。這其實就是 介面隔離原則(Interface Segregation) 在這個小專案裡的一種具體實踐。
3. 協調層:main 如何用 goroutine + channel 併發收集結果?
main.go 最核心的一段目前長這樣:
func main() {
ch := make(chan exchanges.PriceResult)
go exchanges.FetchBinance(ch)
go exchanges.FetchOKX(ch)
go exchanges.FetchCoinbase(ch)
go exchanges.FetchBybit(ch)
go exchanges.FetchBitget(ch)
go exchanges.FetchHyperliquid(ch)
go exchanges.FetchKraken(ch)
go exchanges.FetchMEXC(ch)
results := make([]exchanges.PriceResult, 0, 8)
for range 8 {
results = append(results, <-ch)
}
fmt.Println("Exchange Price (USD)")
fmt.Println("---------------------------")
bestBid := 1e12
bestAsk := 0.0
var bidEx, askEx string
for _, r := range results {
if r.Err != nil {
fmt.Println(r.Exchange, "Error:", r.Err)
continue
}
fmt.Printf("%-15s %.2f\n", r.Exchange, r.Price)
if r.Price < bestBid {
bestBid = r.Price
bidEx = r.Exchange
}
if r.Price > bestAsk {
bestAsk = r.Price
askEx = r.Exchange
}
}
fmt.Println("\nBest Bid:", bestBid, "(", bidEx, ")")
fmt.Println("Best Ask:", bestAsk, "(", askEx, ")")
fmt.Println("Spread:", bestAsk-bestBid)
}這段程式碼裡有幾個我特別喜歡的設計。每個 go exchanges.FetchX(ch) 都是完全獨立的 goroutine,誰先回來不重要,只要把結果丟進 channel,就會被 main 一個一個接住。results := make(..., 0, 8) 先幫我們把切片容量預留好,避免在收集過程中不斷重新配置記憶體。如果未來要新增第九家交易所,也只是多寫一行 go exchanges.FetchYYY(ch),然後把 for range 8 改成 for range 9,不需要動到其他邏輯。
整體的並發模型幾乎是直接映射業務邏輯:請八個人去問八家交易所現在的 BTC 價格,等八個人都回報,最後算出誰最便宜、誰最貴、價差多少。中間沒有 callback hell,也沒有錯綜複雜的 promise chain,程式碼讀起來就像是在用自然語言描述需求一樣。
實際執行:如何在本機跑 go-btc?
如果你想實際體驗這個小工具,可以照著以下步驟操作。
git clone https://github.com/calpa/go-btc.git
cd go-btc
go build ./...
go run .
# 或者
go run main.go終端機中會看到類似輸出(實際數字依當下行情而定):
Exchange Price (USD)
---------------------------
Bybit 95306.10
Coinbase 95294.12
Binance 95311.53
OKX 95312.30
Hyperliquid 95312.50
Bitget 95312.54
Kraken 95312.60
Best Bid: 95294.115 ( Coinbase )
Best Ask: 95312.60 ( Kraken )
Spread: 18.485因為每一家交易所的請求都是併發進行的,所以每次執行時輸出順序不一定相同,但最終算出來的最佳買價、最佳賣價與價差會根據當下市場即時更新。
專案原始碼可以在這裡找到:https://github.com/calpa/go-btc
往工程靠攏:超時、重試與錯誤聚合
上面的程式在 demo 場景下可以正常運作,但只要稍微往「工程實戰」靠近,就一定會遇到更多現實世界的雜訊:API timeout、短暫的 5xx 或 rate limit、甚至是某一家交易所偶發性的錯誤回應。
超時控制:context.WithTimeout
Go 的 context 非常適合做超時控制,例如:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)這樣一來,每一間交易所的請求都有一個明確的時間上限,單一交換所最多只被允許卡住五秒;只要逾時,就會被 context 取消,不至於因為某一家服務掛著不回,就把整體查詢拖到完全失去意義。
重試邏輯:在 Fetch 內部自行處理
對於短暫錯誤(例如偶發的 network error、暫時性 5xx),我傾向在各自的 Fetch 函數裡面加上一層簡單的重試:最多重試幾次,中間稍微 sleep 一下,如果還是失敗就老實地回傳 Err。這樣一來,每家交易所都能有自己的重試策略,不需要把這些細節硬塞在 main 裡,協調層也能保持乾淨。
錯誤聚合:把 Err 當一等公民
前面我們在 PriceResult 裡已經有 Err 欄位,收集結果時邏輯也很直覺,可以寫成下面這樣:
for i := 0; i < 8; i++ {
result := <-ch
if result.Err != nil {
fmt.Printf("%s failed: %v\n", result.Exchange, result.Err)
continue
}
results = append(results, result)
}這種寫法比在 JavaScript 裡一個一個 try/catch promise 要乾淨很多,也比較貼近 Go 一貫的錯誤風格:錯誤就是資料的一部分,要好好處理。
這個設計為什麼讓我覺得「順手」?
做完第一版 go-btc 之後,我回頭梳理了一下,發現之所以覺得這個架構順手,其實跟 Go 的語言特性很有關係。goroutine 與 channel 這兩個原語本身就很直覺:前者就是「並行做一件事」,後者就是「傳遞結果」;開幾十、幾百個 goroutine 也不是什麼大不了的事,不需要像面對傳統 thread 那樣擔心記憶體與切換成本。加上每個 Fetch 函數都可以透過 fake HTTP client 或 mock data 來做單元測試,channel 端也能獨立驗證聚合邏輯,整體測試體驗相當友善。最後,編譯成一個單一 binary、丟到任何有相同架構的機器就能跑,沒有額外 runtime 依賴,部署心智負擔也小很多。
某種程度上,也回到了那句老話:好的設計,不是堆疊更多功能,而是用最少的複雜度解決最核心的問題。
如果要做成「完全體」,我會怎麼演進?
目前 go-btc 還是一個偏 MVP 的 side project,如果真的要往「生產等級工具」走,我心裡大概有幾個演進方向。
第一個方向是動態註冊交易所,用介面取代現階段一行一行 go exchanges.FetchX(ch) 的寫法。未來 main 可以抽象成一個 Fetcher 介面:
type Fetcher interface {
Name() string
Fetch(ctx context.Context) PriceResult
}在 main 裡就能用一個迴圈去跑所有實作:
for _, fetcher := range fetchers {
go func(f Fetcher) {
ch <- f.Fetch(ctx)
}(fetcher)
}這樣新增交易所時,只要多實作一個 Fetcher,再把實例塞進 fetchers slice,不需要再去動 main 的協調邏輯。
第二個方向是資料持久化與監控。如果想做 價差統計與策略回測,我會考慮定時將查價結果寫進時序資料庫(例如 InfluxDB 或 Prometheus),再透過 Grafana 把不同交易所之間的歷史價差、spread 熱力圖、各家 API latency 可視化出來,順便加上一些告警規則:一旦價差超過某個閾值,就透過 Telegram 或 Slack 推播提醒。
第三個方向是 API 化,讓這個工具不再只是 CLI,而是變成其他服務也能使用的價格來源。現在 go-btc 比較接近一個命令列工具,未來可以加上一層很薄的 HTTP API:
func handlePrices(w http.ResponseWriter, r *http.Request) {
// 觸發一次聚合查詢,回傳 JSON
}
func main() {
http.HandleFunc("/api/prices", handlePrices)
http.ListenAndServe(":8080", nil)
}前端可以用 React 做一個簡單儀表板,其他後端服務也能直接呼叫 /api/prices 取得即時行情,整個聚合器就成為團隊裡共用的基礎服務。
第四個方向是更聰明的採樣策略。不是每一秒都必須查八家交易所,在價差很小、市場波動不大的時候,可以適度降低頻率;當價差拉大、波動放大時,再提升查詢密度,甚至優先監控歷史上常出現套利機會的幾個組合。
最後,若未來整體系統演進成微服務架構,我會考慮引入 gRPC,讓 go-btc 以 gRPC 服務的形式對外提供結構化行情資料。這樣在不同語言與平台之間協作時,溝通成本會更低,型別也更有保障。
回頭看:為什麼這個專案非 Go 不可?
回到一開始的問題:
為什麼這次不是繼續用熟悉的 TypeScript / Node.js,而是選擇 Go?
對我來說,答案反而變得很單純。Node.js 非常擅長處理 I/O 密集與實時互動的場景,用來做 Web 後端、WebSocket 服務或是快速迭代的產品原型都很順手;Go 則更適合大量併發、低延遲的情境,尤其是工具型程式、微服務、實時行情聚合與監控系統這種對穩定性與效能要求都很高的任務。
go-btc 這個問題本身,就非常符合「實時行情聚合 + 低延遲比價 + 高併發 I/O」這個組合,放在 Go 這個座標上可以說是剛剛好。
在這個座標系裡,Go 幾乎是天然解。
如果未來我要做一個「WebSocket 實時推送價格更新」的前端,我大概會維持這樣的分工:聚合層由 Go 負責,把各家交易所的行情接進來並整理好;推送層則交給 Node.js 或 Bun 來做 WebSocket 服務,把已經加工好的資料推送到前端。到這個時候,語言選擇就不再是宗教戰爭,而是一個很單純的 工程工具選擇問題。
學 Go 之後,我感受到的「開發節奏差異」
這次用 Go 寫完一個完整的小工具,我對開發節奏的感受也跟以往很不一樣。Go 帶來的抽象層其實不多,但都相當穩健:http、json、context、sync 這些標準庫加起來,大概就能撐住八成以上的需求,不太需要在各種 Web 框架之間來回切換。型別系統則是認真而且強制的,TypeScript 雖然很強大,但在 JavaScript 生態裡,大家總是有辦法繞過型別檢查;Go 則會在編譯期直接把錯誤攔下來,逼你正面面對。部署方面也變得很單純:編譯出來就是一個 binary,丟上去就能跑,不用再為 Node 版本、npm 套件相依或環境差異煩惱。
這些特性讓我重新思考「好用」這件事。好用不一定是功能最多,而是在對的問題上,用最剛好的複雜度把事情做好。
如果你有 AI 專案、網站開發或技術整合需求,歡迎來信交流: partner@calpa.me
歡迎訂閱 Calpa 的頻道,一同將想像力化為可能:

