iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

一般的 CLI 命令屬於一次性執行:當你在終端機輸入 lsgit status 時,作業系統會建立一個獨立的 Process,程式執行完畢就立刻結束 Process,將控制權交還給 Shell 提示符($)。這代表每次執行命令都是全新的起點,前一次執行的記憶體變數、TCP 連線或認證狀態會在 Process 銷毀時一併釋放。

REPL(Read-Eval-Print Loop,讀取-求值-輸出迴圈)則是將控制權留在同一個長命 Process 中。程式啟動後會維持運行並進入內部迴圈,印出專屬的提示符(例如 mysql>claude>),等待使用者連續輸入命令。因為 Process 從未終止,前一次操作建立的資料庫連線、記憶體變數或對話上下文(Context),都能直接供下一次操作繼續使用。

舉 mysql 或 AI CLI(如 claude code)為例。執行 mysql -u root -p 就進入資料庫 Shell,執行 claude 則進入 AI 對話會話;提示符會一直等你一句接一句輸入,離開時輸入 exit 或按 Ctrl-D。這樣的好處是狀態在多次輸入之間延續:連線或上下文載入只做一次,後面每一句查詢或提問都直接沿用,不必每次重新連線或重新提供背景資訊。

claude 的 AI 對話與上下文會話:

$ claude
Welcome to Claude Code!

> 幫我檢查 main.go
已分析 main.go (45 行),發現第 12 行的 HTTP handler 缺少錯誤處理。

> 幫我修正它
已更新 main.go 的第 12 行。上一句讀取的檔案結構與對話上下文均直接保留,不必重新提供檔案背景。

> exit
Bye
$

REPL 的四個字:Read → Eval → Print → Loop

REPL 的名字本身就是它的執行架構:

  • Read:讀取一行使用者輸入(在終端機停在提示符等待按 Enter)。
  • Eval:求值與執行(解析命令字串、調用業務邏輯或發送 API)。
  • Print:印出執行結果或錯誤訊息。
  • Loop:重新回到第一步,等待下一句輸入。

對應到 claude CLI:Read 是讀取你輸入的對話或命令,Eval 是交給模型求值與執行工具,Print 是展示回答與輸出結果,Loop 則是回到提示符等待下一句。

為了理解 REPL 在程式內部的運作機制,我們用 Go 實作一個最小的 Key-Value Store(鍵值儲存庫)。這個工具只提供兩個命令:set <key> <value> 負責存入變數,get <key> 負責讀取變數;輸入 exit 或按 Ctrl-D 則結束會話。

這個範例的重點在於記憶體狀態的持續性:我們會在迴圈外部宣告一個 map[string]string 變數用來記錄資料。因為無限迴圈持續運行,每一次輸入 set 寫入的鍵值對都會保留在同一個 Map 中,隨後輸入 get 便能立即讀出前一次操作留下的結果。

範例程式碼位於 cli-sample/repl-demo/,執行 go run . mini 即可啟動互動會話。

實際執行的互動過程如下:

$ go run . mini  # 啟動並印出 kv> 提示符等待輸入
kv> set x 10     # 寫入鍵值對 x=10
OK               # 印出執行結果,並回到 kv> 等待下一句
kv> get x        # 讀取 x 的值;上一句 set 的 x 依然存在,因為行程與迴圈未結束
10

程式碼的實作核心是一個無限 for 迴圈搭配 bufio.Scannerbufio.Scanner 負責從 os.Stdin(標準輸入)讀取內容,每呼叫一次 Scan() 便會暫停 Process 直到使用者按下 Enter。「停在那裡等待 Enter、按 Enter 拿回一整行」正好就是 REPL 的 Read 階段。讀取到輸入後,再透過 switch 語句解析命令並操作迴圈外的 Map 狀態:

vars := map[string]string{} // 存 set 進來的變數名和值,宣告在迴圈外,每一輪讀寫的都是同一份
scanner := bufio.NewScanner(os.Stdin)

for { // Loop:一句處理完回到這裡,等下一句
    fmt.Print("kv> ")
    if !scanner.Scan() { // Read:讀一行輸入;Ctrl-D(EOF)會回傳 false,結束迴圈
        return
    }
    fields := strings.Fields(scanner.Text()) // 把 "set x 10" 拆成 ["set", "x", "10"]

    switch { // Eval:判斷命令、操作 vars;Print:把結果或錯誤印出來
    case len(fields) == 0:
        continue
    case fields[0] == "exit":
        return
    case fields[0] == "set" && len(fields) == 3:
        vars[fields[1]] = fields[2]
        fmt.Println("OK")
    case fields[0] == "get" && len(fields) == 2:
        if v, ok := vars[fields[1]]; ok {
            fmt.Println(v)
        } else {
            fmt.Println("(沒有這個變數)")
        }
    default:
        fmt.Println("不懂這個命令:", strings.Join(fields, " "))
    }
}

當你在終端機操作這段程式時,很快會遇到一個真實的使用習慣:打字打到一半想放棄當前輸入,或者習慣性地按下 Ctrl-C。在命令中按下 Ctrl-C 原是為了強制終止 Process;但在 REPL 中,如果按下 Ctrl-C 導致程式直接結束,前面好不容易累積存入 Map 的狀態就會全部遺失。

攔截 SIGINT 避免會話中斷

按下 Ctrl-C 時,終端機會將其轉換為作業系統的 SIGINT(中斷訊號)送給 Process。Go 的預設處理機制是收到 SIGINT 就終止 Process,這會直接摧毀整個 REPL 會話。而良好的 REPL 體驗應該是:僅取消當前正在輸入的這句命令,並讓主迴圈繼續運行等待下一句輸入

要讓 Ctrl-C 只取消當前輸入而不終止 REPL,不需要接管終端機的 Raw Mode,使用標準庫的 signal.Notify 攔截 SIGINT 即可:

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt) // 註冊之後 SIGINT 改送進這個 channel,不再觸發 Go 的預設終止行為
go func() {
    for range sigCh {
        fmt.Print("\n(Ctrl-C:這句不算,繼續等下一句;要離開請用 exit 或 Ctrl-D)\nkv> ")
    }
}()

這裡之所以能正常運作,關鍵在於作業系統與終端機驅動程式(TTY Driver)的分工:

  1. Ctrl-C 不會混入文字緩衝區:在終端機預設的規範模式(Canonical Mode)下,按下 Ctrl-C 時,作業系統驅動程式在最底層就會將其攔下並轉換成 SIGINT 訊號,完全不會把 Ctrl-C 這個字元寫入標準輸入(stdin)緩衝區。因此,主迴圈的 scanner.Scan() 根本讀不到任何殘留字元。
  2. 訊號處理與主迴圈平行運行:負責監聽 SIGINT 的 Goroutine 與停在 scanner.Scan() 等待 Enter 的主迴圈是平行運行的。收到 Ctrl-C 訊號時,Goroutine 只是在終端機畫面上換行印出提示並重新補上 kv> 提示符,而 scanner.Scan() 依然安穩地在背景等待使用者按下 Enter。
kv> set x 10
OK
kv> ^C
(Ctrl-C:這句不算,繼續等下一句;要離開請用 exit 或 Ctrl-D)
kv> get x
10

REPL 的適用與不適用情境

適合 REPL:

  • 探索式、需要看結果再決定下一步的操作(查資料庫)。
  • 操作之間有共享狀態(連線、變數、當前目錄、交易)。
  • 使用者會連續下很多句類似的命令。

不適合 REPL(用一次性命令就好):

  • 一句話講得完、能被 script 呼叫的操作 → 用 Flag/Arg。
  • 需要自動化、寫進 CI 或 cron 的流程——REPL 難自動化。

一句命令壞掉,不該連累整個 REPL

REPL 裡的所有輸入都在同一個 Process 中執行。如果某句命令因為非法輸入或程式 Bug 導致崩潰(panic),如果沒有在 Eval 階段抓住錯誤,整個 REPL 就會立刻退出,先前存在記憶體(Cache / 狀態)裡的資料也會全部消失。

要避免單一命令把整個 REPL 搞壞,必須在 Eval 階段攔截錯誤:即使這句命令執行失敗,也只印出錯誤提示,並繼續留在迴圈中等待下一句輸入。

在 Go 中,做法是在執行命令的邏輯外面包一層 recover(),攔截 panic 並印出錯誤訊息,讓外層的主迴圈能夠安全地進入下一輪:

// 在迴圈內部用匿名函式包住 Eval 階段
func() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("命令執行崩潰,已攔截錯誤:", r)
        }
    }()

    // 實際執行 Eval 邏輯;若發生 panic,只會跳出此匿名函式,不會炸毀外層 for 迴圈
    executeCommand(fields)
}()

Go 知識補充:Goroutine 是什麼?

如果在閱讀前面程式碼時對某些 Go 語法感到陌生,以下為本篇出現的核心機制說明:

Goroutine 是 Go 語言的核心特色之一,屬於由 Go Runtime(執行期環境)管轄的輕量級執行緒(Lightweight Thread)

  • 極低的開銷:傳統作業系統執行緒(OS Thread)需要幾 MB 的記憶體,而 Goroutine 初始只需要大約 2 KB 的 Stack 空間,建立與切換的成本極低。
  • 非同步與非阻塞:只要在函式呼叫前加上 go 關鍵字,Go 就會在背景獨立執行這個任務,完全不會卡住當前的程式主流程。
  • Runtime 負責調度:Go 內部有自己的調度器(Scheduler),會自動將數個 Goroutine 對應到作業系統的執行緒上運行,開發者不必手動管理執行緒的建立與銷毀。

使用語法範例:

// 1. 啟動具名函式作為 Goroutine
go processTask("task-1")

// 2. 使用匿名函式(Anonymous Function)啟動 Goroutine
go func() {
    fmt.Println("在背景獨立執行的 Goroutine")
}()

上一篇
Human-friendly CLI 互動:Shell 自動完成、選單與表單
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言