當 CLI 命令需要處理批次資料匯入、檔案下載或遠端服務部署時,執行時間往往需要數秒到數分鐘。如果程式在執行期間完全沒有印出訊息,使用者就無法判斷它是還在正常處理、仍在等待網路連線,還是已經當機停擺。
遇到這種狀況,使用者如果按下 Ctrl+C,程式若沒有妥善處理取消訊號,就可能留下寫到一半的殘缺資料,或是在背景殘留未關閉的連線與暫存檔。
要設計好長時間運行的 CLI 命令,必須建立一套包含觀察、限制、中止與收尾的完整機制。本篇以資料庫批次匯入工具 dbtool 為例(例如執行 dbtool users import users.csv 寫入上萬筆資料),示範如何在 stderr 回報進度、用 Go 的 Context 疊加逾時與 Ctrl+C 取消訊號、透過 errgroup 控制併行數量,並在命令結束或中止時清理資源與回報已處理的狀態。
範例程式碼位於 cli-sample/dbtool/。
當命令需要執行數秒以上,終端機至少要在開始時說明目前正在進行的操作。如果可以預估總量,則進一步回報處理進度:
$ dbtool users import users.csv
Importing users from users.csv...
Processed 6,400 / 10,000 users
無法預知總筆數時,可以改為顯示目前執行的任務階段:
Validating users.csv...
Uploading batches to remote database...
Updating user indexes...
輸出進度與狀態訊息時,必須寫入 stderr而非 stdout。Standard Error的名稱裡雖然有個Error,但在 UNIX 設計標準中,stderr 的角色是診斷與輔助資訊流(Diagnostic Stream),專門接收非資料類的 Log、進度與提示;stdout 則只留給命令最終要交出的主要資料。
如果將 Importing users... 等進度訊息寫入 stdout,當使用者使用管道或重導向將結果寫入檔案時:
$ dbtool users import users.csv --output json > result.json
產出的 result.json 就會混入進度文字而導致格式破壞(例如變成無效的 JSON)。將進度寫到 stderr 可以在重導向 stdout 時,依然在終端機畫面上看到進度更新。
作業系統與自動化腳本判斷命令是否失敗,看的是 Exit Code(0 代表成功,非 0 代表失敗),並不會因為 stderr 有輸出文字就認定命令出錯。著名的 CLI 工具(如 git clone 或 curl)也都是將進度條與狀態訊息寫在 stderr。
匯入資料時,如果遠端資料庫連線中斷或外部 API 失去回應,沒有設定逾時的 CLI 會無限期停滯在等待狀態,占用連線與記憶體。Timeout 能在指定期限到達後主動中斷等待,並回傳明確的錯誤訊息。
在 Go 中,單次 HTTP 請求或 API 呼叫可以使用 context.WithTimeout 來限制等待時間:
// 建立一個最多等待 5 秒的 Context
requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
// 函數結束時釋放計時器與相關資源
defer cancel()
// 將帶有逾時限制的 Context 傳遞給 HTTP Request
req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, body)
if err != nil {
return err
}
// 發送請求;超過 5 秒未回應時,Do 會自動中斷並回傳 context.DeadlineExceeded
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
當等待時間超過 5 秒,http.DefaultClient.Do 會主動中斷請求,並回傳 context.DeadlineExceeded。defer cancel() 則確保若請求提早完成,能立即釋放計時器資源。
設定了 Context 逾時後,不能只在最外層函式收到錯誤時回傳,否則背景的迴圈或 I/O 操作仍會在背景繼續執行。Context 必須作為第一個參數一路傳入資料處理函式與迴圈中:
func importUsers(ctx context.Context, rows []UserRow) error {
for _, row := range rows {
// 每次處理下一筆前先檢查 Context 是否已逾時或被取消
if err := ctx.Err(); err != nil {
return err
}
if err := writeUser(ctx, row); err != nil {
return err
}
}
return nil
}
在這個迴圈中,writeUser 接收同一個 Context。當 Context 逾時時,writeUser 內部的 DB 或網路操作會立刻中斷;迴圈也會在處理下一筆資料前透過 ctx.Err() 發現取消訊號並提早退出,避免繼續執行剩餘的幾千筆資料。
使用者在終端機按下 Ctrl+C 時,作業系統會向 Process 發送 SIGINT 訊號。理想的 CLI 應該讓按下 Ctrl+C 的效果與 Timeout 逾時一致:停止工作、清理資源並退出。
Go 的 signal.NotifyContext 可以在收到作業系統訊號時取消 Context。將它與 context.WithTimeout 疊加使用,就能用同一個 Context 處理使用者主動取消與時間到期:
func runImport(parent context.Context, source string, timeout time.Duration) error {
// 監聽 Ctrl+C (SIGINT) 與 SIGTERM
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
defer stop()
// 在訊號 Context 之上加上整體逾時限制
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return importUsers(ctx, source)
}
這段程式碼建立了一套雙重保障:無論是使用者按下 Ctrl+C,還是超過了 timeout 限制,派生出的 ctx 都會被取消,並將訊號傳入 importUsers 內部的每一個網路請求與資料庫寫入操作。
當 dbtool users import 需要向外部驗證數千筆使用者的 email 時,單執行緒依序呼叫會讓總等待時間隨著筆數線性拉長。此時可以使用 errgroup 並行執行驗證,並用限制通道(semaphore)控制最大並行數量:
func validateUsers(ctx context.Context, users []User) error {
g, ctx := errgroup.WithContext(ctx)
// 最多同時執行 4 個並行檢查,避免耗盡連線池或觸發 API rate limit
g.SetLimit(4)
for _, u := range users {
u := u
// g.Go 會啟動一個 goroutine 來執行傳入的函式,並由 errgroup 追蹤其執行狀態與 error
g.Go(func() error {
return validateEmail(ctx, u.Email)
})
}
// 等待所有 worker 完成,若有任一 worker 失敗,ctx 也會被自動取消
return g.Wait()
}
errgroup.WithContext 的好處在於:只要其中一個 worker 回傳錯誤(例如 API 429 或連線失敗),傳給其他 worker 的 Context 就會自動被取消,防止其他 worker 繼續做無用功。
需要特別注意:不能將背景工作直接以 go func() 丟出後就讓 main 函式結束。當 main 退出時,Process 會立刻被終止,尚未完成的 Goroutine 會被強制關閉,導致資料寫入到一半斷裂。所有 Goroutine 都必須透過 errgroup 或 sync.WaitGroup 確保 g.Wait() 完成後才能退出程式。
當 importUsers 因為 Timeout 或 Ctrl+C 中止時,單純回傳 context deadline exceeded 傳給使用者是不夠的。使用者需要知道:剛才處理到第幾筆?已經寫入的資料會怎樣?
收到取消訊號後,所有已開啟的暫存檔、資料庫連線與 HTTP response body 都必須被關閉與刪除。在 worker 中應搭配 select 監聽 ctx.Done(),並利用 defer 確保資源釋放:
g.Go(func() error {
tempFile, err := os.CreateTemp("", "import-*.tmp")
if err != nil {
return err
}
// goroutine 退出時必然執行清理,移除暫存檔
defer func() {
tempFile.Close()
os.Remove(tempFile.Name())
}()
for {
select {
case <-ctx.Done():
// 收到取消訊號,回傳 Context 錯誤並引發 defer 清理
return ctx.Err()
case row, ok := <-dataCh:
if !ok {
return nil
}
if err := writeRow(ctx, tempFile, row); err != nil {
return err
}
}
}
})
select 機制確保 worker 在等待新資料時也能隨時回應 Context 取消事件,defer 則確保暫存檔不會殘留在使用者的硬碟中。
當匯入作業在第 6,400 筆時被 Ctrl+C 中斷,CLI 必須根據資料庫架構與業務語意,明確交代結果:
canceled:{
"status": "canceled",
"processed": 6400,
"created": 6387,
"failed": 13
}
明確的終端機回報能讓使用者知道哪些資料已經入庫、哪些需要重新執行,避免重複寫入或遺漏。
如果在閱讀前面程式碼時對某些 Go 語法與併行機制感到陌生,以下為本篇出現的核心機制說明:
defer 延遲呼叫與資源清理機制Go 語言沒有傳統物件導向語言的 try-finally 區塊,而是透過 defer 關鍵字來處理資源清理。
defer 會將其後方的函式呼叫暫存至堆疊中,延後到「包含該 defer 的外層函式 return」的前一刻執行。無論函式是正常執行完成,還是因錯誤提前返回,defer 註冊的清理動作都保證會被執行:
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
// 確保當前函式結束前,HTTP 回應體一定會被關閉
defer resp.Body.Close()
當同一個函式內有多個 defer 時,Go 會以「後進先出(LIFO)」的堆疊順序倒序執行。
context.Context 跨層級傳遞與樹狀取消在 Go 語言中,context.Context 是標準庫用來跨 API 邊界與 Goroutine 傳遞請求範疇、逾時時間與取消訊號的核心機制。
Context 具有不可變性(Immutability)與樹狀繼承特性。當使用 context.WithTimeout 或 signal.NotifyContext 衍生子 Context 時,會以傳入的 Context 為父節點建立節點樹:
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
當父 Context 因收到 Ctrl+C(SIGINT)而被取消,或逾時時間到達時,取消訊號會單向自動向下廣播至所有子 Context。標準庫與第三方套件(如 net/http 或 database/sql)皆透過監聽 ctx.Done() Channel 來感知取消並中斷 I/O 操作。
select 語法與 Channel 多重監聽select 是 Go 語言專門用來處理 Channel 多工(Multiplexing)的控制結構。它的語法類似 switch,但每個 case 必須是一個 Channel 的接收或傳送操作:
for {
select {
case <-ctx.Done():
return ctx.Err()
case row, ok := <-dataCh:
if !ok {
return nil
}
if err := writeRow(ctx, tempFile, row); err != nil {
return err
}
}
}
當有多個 Channel 同時處於等待狀態時,select 會阻塞 Goroutine,直到其中任何一個 case 準備就緒:
case <-ctx.Done()::當 Context 被取消或逾時時,該 Channel 會釋出訊號,讓 worker 立刻回傳 ctx.Err() 退出。case row, ok := <-dataCh::從資料 Channel 讀取項目。當 Channel 關閉且無剩餘資料時,第二個布林回傳值 ok 會變為 false,代表生產者已完成發送,Worker 可以正常結束。