當 CLI 從單一指令擴充成多個 subcommands,各個指令會開始共用相同的資料與業務規則。
這篇我們以一個待辦清單工具為例,它提供四個 subcommands,資料保存在本機 JSON 檔案:
mycli task list # 列出所有 task
mycli task create my task # 建立內容為 "my task" 的 task
mycli task detail 1 # 查看 ID 為 1 的 task
mycli task delete 1 # 刪除 ID 為 1 的 task
最直覺的寫法,是把所有邏輯都寫進 Cobra 的 RunE。但以 task create 為例,一個函式就得包辦整套流程:
RunE: func(command *cobra.Command, args []string) error {
// 步驟 1. 將 args 組合成 task 內容。
// 步驟 2. 移除前後空白並驗證內容長度。
// 步驟 3. 找出 tasks.json 的路徑。
// 步驟 4. 讀取並解析 JSON。
// 步驟 5. 分配下一個 task ID。
// 步驟 6. 將 task 寫回 JSON 檔案。
// 步驟 7. 把建立結果印到 stdout。
// 步驟 8. 包裝並回傳錯誤。
return nil
}
當 list、detail 與 delete 也各自讀寫同一個 JSON 檔案時,相同的驗證與讀寫邏輯會散落各處。只要調整資料結構就必須同步修改多個 commands,而且測試 command 時還會直接存取使用者的實體檔案。因此架構需要明確劃分責任邊界並規範依賴方向,把命令列協調、業務規則、檔案讀寫與畫面輸出拆開,讓各層的變更與測試都能限縮在獨立範圍。
範例程式位於 cli-sample/cobra-architecture/。
現在要替這個 task CLI 規劃專案結構:分成以下四個 packages:
cmd 定義 Command、接收命令列輸入,並安排其他部分的呼叫順序。app 執行內容驗證、ID 驗證與排序等 task 規則。store 實作 app 需要的資料存取介面;這個範例使用本機 JSON 檔案。ui 將結果轉成表格、JSON 或操作確認訊息。.
├── main.go 將錯誤轉成訊息與 exit code
├── cmd/
│ ├── factory.go 定義 commands 需要的介面並保存依賴
│ ├── root.go 建立共用物件與 command tree
│ ├── task_list.go 協調 task list
│ ├── task_create.go 協調 task create
│ ├── task_detail.go 協調 task detail
│ └── task_delete.go 協調 task delete
└── internal/
├── app/
│ └── task.go 定義 Task、use cases 與資料規則
├── store/
│ └── json.go 讀寫本機 JSON 檔案
└── ui/
└── renderer.go 輸出 table、JSON 與操作結果
功能程式碼放在 internal/,Go 編譯器會禁止模組外的程式 import 這些 package。對不打算被當作 library 使用的 CLI,這是合適的預設值。若某些 package 確實要開放外部引用,才需要改放 pkg/。
專案目錄確定後,實作從 Command 的入口開始。在分層架構中,cmd 是系統的最外層,負責處理與終端機介面的互動。它的責任非常單純:接收 Cobra 解析後的輸入、轉交給 app 執行業務邏輯,最後把結果交給 ui 輸出。它自己不處理 task 規則,也不直接碰觸實體檔案。
實作 cmd package 時,先把使用者可以輸入的命令整理成 Command tree,確認哪些節點只負責分組,哪些子 commands 會真正執行操作。這個 task CLI 以 mycli 作為 root Command,task 作為父 Command,底下有 list、create、detail 與 delete 四個子 commands:
mycli
└── task
├── list
├── create <content...>
├── detail <id>
└── delete <id>
確定 Command tree 後,每個子 command 的 RunE 只需要扮演協調者,負責兩項工作:輸入轉換與流程協調。
cmd 只處理與命令列介面有關的輸入。它讀取 Cobra 解析後的 args 與 flags,再將這些原始字串組合或轉換成 app 方法需要的參數。task create 的 Command 定義在 newTaskCreateCmd:
func newTaskCreateCmd(factory *Factory) *cobra.Command {
return &cobra.Command{
Use: "create <content...>",
Args: cobra.MinimumNArgs(1),
RunE: func(command *cobra.Command, args []string) error {
content := strings.Join(args, " ")
task, err := factory.Tasks.Create(
command.Context(),
content,
)
if err != nil {
return fmt.Errorf("create task: %w", err)
}
return factory.Renderer.RenderTaskCreated(
command.ErrOrStderr(),
task,
)
},
}
}
newTaskCreateCmd 透過 factory 取得 app 與 renderer。factory.Tasks 的型別由 TaskService 介面定義:
type TaskService interface {
Create(ctx context.Context, content string) (app.Task, error)
}
這段程式碼體現了兩個關鍵的架構邊界:
RunE 把命令列字串組裝成 content 後傳給 Tasks.Create。TaskService 介面只接收標準型別,不包含 args、flags 或 *cobra.Command,確保 Cobra 的框架細節不會滲透進核心業務 app。TaskService 與 TaskRenderer 介面,而不是具體的檔案儲存或終端輸出。測試 Command 時,能直接傳入 fake service 與 bytes.Buffer,驗證參數解析與呼叫流程,不需存取使用者的實體檔案。在分層架構中,app 是核心業務層。它代表整個工具最純粹的商業規則,不依賴任何外部框架或儲存媒介(不 import Cobra,也不 import 檔案讀寫 package)。
當 cmd 將字串轉換為乾淨的參數後,就交由 app 執行具體操作。這一層負責定義 Task 資料結構、驗證業務規則,並透過抽象介面取得或保存資料:
type Task struct {
ID int `json:"id"`
Content string `json:"content"`
}
App 需要存取資料,但為了不被特定的儲存機制綁死,app 定義 TaskStore 介面,規定底層必須提供的方法:
type TaskStore interface {
List(ctx context.Context) ([]Task, error)
Create(ctx context.Context, content string) (Task, error)
Get(ctx context.Context, id int) (Task, error)
Delete(ctx context.Context, id int) error
}
type TaskService struct {
store TaskStore
}
func NewTaskService(store TaskStore) *TaskService {
return &TaskService{store: store}
}
這裡體現了架構上的依賴反轉(Dependency Inversion):
TaskService 依賴的是自己定義的 TaskStore 介面,而不是具體的 JSONTaskStore。所有業務規則與驗證都在 app 內集中處理,不論未來是哪個 Command 或新介面呼叫,都會套用相同的驗證規範。例如建立 task 時,先去除空白並驗證長度,通過後才呼叫 store:
func (s *TaskService) Create(
ctx context.Context,
content string,
) (Task, error) {
content = strings.TrimSpace(content)
if content == "" {
return Task{}, errors.New("task content is required")
}
if utf8.RuneCountInString(content) > MaxTaskContentLength {
return Task{}, fmt.Errorf(
"task content must not exceed %d characters",
MaxTaskContentLength,
)
}
task, err := s.store.Create(ctx, content)
if err != nil {
return Task{}, fmt.Errorf("save task: %w", err)
}
return task, nil
}
Detail 與 Delete 共同需要的 ID 驗證也集中處理,ID 不合法時直接阻擋,不會繼續存取儲存層;若 store 找不到資料回傳 app.ErrTaskNotFound,app 會保留此錯誤並補上 ID 上下文,讓最外層能夠辨識:
func validateTaskID(id int) error {
if id <= 0 {
return errors.New("task id must be greater than zero")
}
return nil
}
像清單排序這類會影響操作結果的規則,也由 app 處理。List 從 store 取得 tasks 後,會依 ID 排序再回傳:
func (s *TaskService) List(ctx context.Context) ([]Task, error) {
tasks, err := s.store.List(ctx)
if err != nil {
return nil, fmt.Errorf("load tasks: %w", err)
}
slices.SortFunc(tasks, func(a, b Task) int {
return a.ID - b.ID
})
return tasks, nil
}
在分層架構中,store 屬於底層的基礎設施層。它的唯一職責是落實 app.TaskStore 介面定義的方法,把資料持久化的技術細節(檔案路徑、JSON 序列化、寫入安全性)徹底封裝在 package 內部。
App 只向介面要資料或存資料,不需要知道檔案怎麼讀寫。以範例中的 JSONTaskStore 為例,Create 負責讀取 tasks.json、分配 ID 並安全寫回:
func (s *JSONTaskStore) Create(
ctx context.Context,
content string,
) (app.Task, error) {
// 讀取檔案前,先確認 command 尚未取消。
if err := ctx.Err(); err != nil {
return app.Task{}, err
}
// 讀取現有 tasks 與下一個可用的 ID。
data, err := s.read()
if err != nil {
return app.Task{}, err
}
// ID 由 store 分配,app 只傳入驗證完成的內容。
task := app.Task{
ID: data.NextID,
Content: content,
}
data.NextID++
data.Tasks = append(data.Tasks, task)
// s.write 先寫入暫存檔,再取代原檔。
if err := s.write(data); err != nil {
return app.Task{}, err
}
return task, nil
}
這樣的設計讓檔案操作的細節全部留在 store:不管是分配新 ID,還是先寫入暫存檔再覆寫原檔(避免中途斷電損毀 JSON),app 都不需要關心。
更重要的是,因為 app 只依賴 TaskStore 介面,未來若想把 JSON 換成 SQLite,只要另外寫一個 store 實作即可,app 與 cmd 完全不必修改。
App 回傳 task 結果後,接下來由 ui 將資料轉成使用者在終端看到的內容。ui 負責產生 table、JSON 或操作確認訊息;cmd 則選擇 stdout 或 stderr,再將對應的 io.Writer 傳給 ui。
ui.CLIUI 定義 RenderTasks,接收輸出目的地、tasks 與輸出格式,再將完整結果寫入 out:
func (CLIUI) RenderTasks(
out io.Writer,
tasks []app.Task,
format string,
) error {
// cmd 決定 out 指向 stdout 或其他 writer,ui 只寫入 out。
switch format {
case "table":
// tabwriter 會緩衝欄位,完成所有列後必須 Flush。
table := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
if _, err := fmt.Fprintln(
table,
"ID\tCONTENT",
); err != nil {
return fmt.Errorf("write table header: %w", err)
}
for _, task := range tasks {
if _, err := fmt.Fprintf(
table,
"%d\t%s\n",
task.ID,
task.Content,
); err != nil {
return fmt.Errorf("write task: %w", err)
}
}
return table.Flush()
case "json":
// JSON 與 table 使用同一個 out,不直接存取 os.Stdout。
return writeJSON(out, tasks)
default:
return fmt.Errorf(
"unsupported output format %q",
format,
)
}
}
task list 的 RunE 先呼叫 Tasks.List 取得資料,app 成功回傳後再呼叫 RenderTasks:
RunE: func(command *cobra.Command, args []string) error {
// 呼叫 app 取得排序完成的 tasks。
tasks, err := factory.Tasks.List(command.Context())
if err != nil {
return fmt.Errorf("list tasks: %w", err)
}
// 將 tasks、輸出格式與 stdout 交給 renderer。
return factory.Renderer.RenderTasks(
command.OutOrStdout(),
tasks,
outputFormat,
)
}
RenderTasks 接收 io.Writer、tasks 與輸出格式,這裡劃分了 ui 與 cmd 的責任。ui 決定 task 要轉成 table 或 JSON,再寫入傳進來的 writer;cmd 則決定 writer 指向 stdout 或 stderr。
list 與 detail 回傳的是 task 資料,因此 cmd 傳入 command.OutOrStdout()。資料留在 stdout,Shell 管道才能將 JSON 交給 jq 繼續處理:
mycli task list --output json | jq '.[].content'
最後在CLI 啟動時,cmd/root.go 會建立 JSONTaskStore、TaskService 與 CLIUI,再將它們交給 Cobra Command tree 使用:
func Execute() error {
dataFile, err := taskDataFile()
if err != nil {
return err
}
// 建立 store,再注入 app。
taskStore := store.NewJSONTaskStore(dataFile)
taskService := app.NewTaskService(taskStore)
// 將 app 與 ui 交給所有 commands。
factory := &Factory{
Tasks: taskService,
Renderer: ui.CLIUI{},
}
return NewRootCmd(factory).Execute()
}
四個 task commands 共用同一個 task service 與 renderer。程式在 cmd/factory.go 定義 Factory,將這兩個依賴收在一起:
type Factory struct {
Tasks TaskService
Renderer TaskRenderer
}
NewRootCmd(factory) 將同一個 Factory 傳給四個 task commands。main.go 只呼叫 cmd.Execute(),並處理最外層的錯誤與 Exit Code。
這個 task CLI 到這裡完成架構拆分:
cmd 轉換命令列輸入,並協調 app 與 renderer 的呼叫順序。app 執行 task 的驗證、排序與錯誤處理規則。store 讀取與保存 task 資料。ui 將 task 結果轉成 table、JSON 或操作訊息。root.go 建立各個物件,並接上它們的依賴。調整 task 規則時修改 app,更換儲存方式時修改 store 與組裝程式,新增輸出格式時修改 ui,調整命令列介面時則修改 cmd。只要 package 之間的介面不變,其他部分就不需要同步修改。
依賴透過介面與 io.Writer 傳入,測試時也能改用 fake store、fake service 或 bytes.Buffer,不會碰到使用者的 tasks.json 與全域輸出。
CLI 繼續增加 commands 時,RunE 仍只負責輸入轉換與呼叫順序;task 規則、資料存取與輸出格式則繼續留在 app、store 與 ui。