Command Box bài học 4: Giải phẫu cấu trúc thư mục chuẩn cho một dự án Command Box
Khi mới bắt đầu viết CLI bằng Golang, hầu hết mọi người đều xuất phát từ một file main.go duy nhất. Thêm lệnh thứ hai, thứ ba, bạn vẫn nhét chung vào đó hoặc tách tạm ra vài file db.go, kafka.go nằm ngang hàng. Nhưng chỉ sau 2 tháng, khi Command Box phình lên 30 lệnh con (sub-commands), tích hợp cả MySQL, Elasticsearch, Docker API lẫn hệ thống cấu hình đa môi trường, thư mục dự án sẽ biến thành một "nồi lẩu thập cẩm" không thể bảo trì.
Bài học thứ 4 này sẽ mổ xẻ cách tổ chức kiến trúc thư mục (Project Layout) chuẩn mực cho một Command Box thực chiến — giúp code luôn sạch, dễ viết Unit Test và dễ dàng mở rộng từ 5 lệnh lên 500 lệnh mà không bị rối.
1. 3 "Căn bệnh" kinh điển khi tổ chức code CLI tự phát
Trước khi đi vào cấu trúc chuẩn, hãy điểm mặt 3 sai lầm khiến các dự án công cụ nội bộ thường chết yểu vì không ai dám sửa code:
-
Căn bệnh 1: Nhét toàn bộ Business Logic vào hàm
Runcủa CLI (Fat Command): Bạn viết trực tiếp code khởi tạo kết nối Database, câu lệnh SQL, vòng lặp xử lý dữ liệu và cả lệnhfmt.Printlnngay bên trong thân hàmRunEcủa thư viện Cobra. Hậu quả là bạn không thể viết Unit Test cho logic đó nếu không chạy giả lập cả dòng lệnh, và cũng không thể tái sử dụng hàm đó cho một sub-command khác. -
Căn bệnh 2: Lạm dụng Biến toàn cục (
Global State Pollution): Khai báo các biếnvar dbConn *sql.DBhayvar cfg Configở cấp độ package để chỗ nào cũng gọi được. Khi một lệnh cần kết nối cùng lúc tới cả DB Staging và DB Production để so sánh dữ liệu, toàn bộ kiến trúc biến toàn cục này lập tức sập nguồn. -
Căn bệnh 3: Phụ thuộc vòng tròn (
Circular Dependency): Khi các nhóm lệnh bắt đầu lớn lên (ví dụ: nhóm lệnhordercần gọi một hàm kiểm tra của nhóm lệnhkafka, và ngược lại), việc không phân tách rõ tầng giao diện (CLI Layer) và tầng dịch vụ (Service Layer) sẽ dẫn đến lỗiimport cycle not allowedkinh điển trong Go.
2. Sơ đồ giải phẫu cấu trúc thư mục chuẩn (Standard CLI Layout)
Kết hợp chuẩn golang-standards/project-layout với kiến trúc của các dự án CLI lớn như GitHub CLI (gh) và Kubernetes (kubectl), đây là cấu trúc thư mục tối ưu nhất cho một Command Box:
Plaintext
cbox/
├── cmd/
│ └── cbox/
│ └── main.go # Điểm khởi đầu duy nhất (Siêu mỏng, chỉ gọi thực thi)
├── internal/
│ ├── cli/ # TẦNG GIAO DIỆN DÒNG LỆNH (Presentation Layer)
│ │ ├── root.go # Khởi tạo Root Command, Global Flags (--config, --verbose)
│ │ ├── db/ # Nhóm lệnh: cbox db ...
│ │ │ ├── db.go # Lệnh cha 'db'
│ │ │ ├── dump.go # Lệnh con 'cbox db dump'
│ │ │ └── migrate.go # Lệnh con 'cbox db migrate'
│ │ ├── kafka/ # Nhóm lệnh: cbox kafka ...
│ │ │ ├── kafka.go # Lệnh cha 'kafka'
│ │ │ └── lag.go # Lệnh con 'cbox kafka lag'
│ │ └── order/ # Nhóm lệnh: cbox order ...
│ │ ├── order.go
│ │ └── sync.go # Lệnh con 'cbox order sync'
│ ├── core/ # TẦNG NGHIỆP VỤ LÕI (Business / Service Layer)
│ │ ├── database/ # Logic xử lý backup, restore, query DB
│ │ ├── messaging/ # Logic kết nối Kafka, kiểm tra offset, bắn bản tin
│ │ └── order/ # Logic đối soát và đồng bộ đơn hàng
│ ├── config/ # Quản lý đọc/ghi file cấu hình (~/.cbox.yaml) và ENV
│ │ └── config.go
│ └── ui/ # TẦNG HIỂN THỊ (Terminal UI Helpers)
│ ├── printer.go # In màu sắc, format thông báo chuẩn (Success, Warn, Error)
│ ├── table.go # Vẽ bảng biểu trên terminal
│ └── spinner.go # Thanh tiến trình, hiệu ứng chờ
├── pkg/ # Thư viện dùng chung (nếu muốn share cho project khác import)
├── scripts/ # Script hỗ trợ build, cài đặt (install.sh, completions)
├── Makefile # Tự động hóa build, test, cross-compile
├── .goreleaser.yaml # Cấu hình đóng gói phát hành tự động
├── go.mod
└── go.sum
3. Nguyên tắc vàng: Phân tách 3 tầng (3-Tier CLI Architecture)
Nhìn vào cấu trúc trên, toàn bộ trái tim của Command Box nằm trong thư mục internal/ (đảm bảo code nội bộ được bảo vệ, không bị bên ngoài import bừa bãi) và được chia làm 3 tầng tách biệt:
| Tầng kiến trúc | Thư mục đại diện | Trách nhiệm duy nhất | Những thứ CẤM làm ở tầng này |
|---|---|---|---|
| 1. Tầng Giao diện (CLI Layer) | internal/cli/ |
Định nghĩa tên lệnh, nhận Flags/Args, kiểm tra đầu vào cơ bản (Validation), gọi xuống tầng core, và dùng ui để in kết quả. |
CẤM viết câu lệnh SQL, cấm gọi HTTP request trực tiếp, cấm xử lý vòng lặp nghiệp vụ phức tạp. |
| 2. Tầng Nghiệp vụ (Core/Service Layer) | internal/core/ |
Chứa toàn bộ logic thực thi: kết nối DB, gọi Docker Socket, xử lý thuật toán, chạy đa luồng (Goroutines). Trả về dữ liệu thô (Struct) hoặc error. |
CẤM import thư viện cobra, cấm đọc trực tiếp cờ dòng lệnh, hạn chế tối đa lệnh fmt.Println ra màn hình. |
| 3. Tầng Hiển thị (UI Layer) | internal/ui/ |
Chịu trách nhiệm trang điểm cho dữ liệu: biến mảng Struct thành bảng (Table), tô màu xanh/đỏ, hiển thị Spinner quay vòng. | CẤM chứa logic nghiệp vụ hay thay đổi dữ liệu đầu vào. |
Lợi ích thực tế: Giả sử ngày mai sếp bảo: "Lệnh
cbox order synccủa em chạy ngon quá, giờ anh muốn biến nó thành một API hoặc một con Cronjob chạy định kỳ trên server". Nhờ tách riêng tầnginternal/core/order, bạn chỉ cần bê nguyên package đó gắn vào HTTP Controller là xong trong 5 phút, vì nó hoàn toàn không bị dính chặt vào thư viện dòng lệnh!
4. Giải phẫu luồng chạy thực tế qua Code (Dependency Injection trong CLI)
Để tránh dùng biến toàn cục (Global Variables) và giúp code dễ test, chúng ta hãy xem cách một lệnh đi từ main.go xuống tới tận lõi nghiệp vụ thông qua kỹ thuật truyền phụ thuộc (Factory Pattern cho Command):
Bước 1: cmd/cbox/main.go – Giữ cho điểm khởi đầu siêu mỏng
File main.go không nên chứa bất kỳ logic nào ngoài việc khởi tạo Context (hỗ trợ hủy lệnh khi bấm Ctrl + C) và xử lý mã thoát (Exit Code):
Go
package main
import (
"context"
"os"
"os/signal"
"syscall"
"github.com/your-org/cbox/internal/cli"
)
func main() {
// Lắng nghe tín hiệu Ctrl+C (SIGINT) để dừng lệnh an toàn
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := cli.Execute(ctx); err != nil {
os.Exit(1)
}
}
Bước 2: internal/cli/order/sync.go – Đóng gói Command bằng Hàm khởi tạo
Thay vì khai báo biến toàn cục var syncCmd = &cobra.Command{...}, hãy viết một hàm NewSyncCmd() trả về *cobra.Command. Mọi biến nhận giá trị từ Flags đều được gói gọn trong một struct syncOptions cục bộ:
Go
package order
import (
"fmt"
"github.com/spf13/cobra"
"github.com/your-org/cbox/internal/core/order"
"github.com/your-org/cbox/internal/ui"
)
type syncOptions struct {
orderID int
force bool
}
func NewSyncCmd(svc *order.Service, printer *ui.Printer) *cobra.Command {
opts := &syncOptions{} // Không dùng biến Global!
cmd := &cobra.Command{
Use: "sync",
Short: "Đồng bộ dữ liệu đơn hàng giữa DB và Elasticsearch",
RunE: func(cmd *cobra.Command, args []string) error {
printer.Info(fmt.Sprintf("Đang bắt đầu đồng bộ đơn hàng #%d...", opts.orderID))
// Gọi xuống tầng Core, truyền Context từ tầng trên xuống
result, err := svc.SyncOrder(cmd.Context(), opts.orderID, opts.force)
if err != nil {
printer.Error("Đồng bộ thất bại: " + err.Error())
return err
}
printer.Success(fmt.Sprintf("Hoàn tất! Đã cập nhật %d bản ghi trong %v", result.UpdatedCount, result.Duration))
return nil
},
}
cmd.Flags().IntVarP(&opts.orderID, "id", "i", 0, "ID đơn hàng")
cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Bắt buộc ghi đè")
_ = cmd.MarkFlagRequired("id")
return cmd
}
Bước 3: internal/cli/order/order.go – Lắp ráp các mảnh ghép (Tree Wiring)
Lệnh cha order chỉ làm nhiệm vụ gom các lệnh con lại thành một nhánh cây:
Go
package order
import (
"github.com/spf13/cobra"
"github.com/your-org/cbox/internal/core/order"
"github.com/your-org/cbox/internal/ui"
)
func NewOrderCmd(svc *order.Service, printer *ui.Printer) *cobra.Command {
cmd := &cobra.Command{
Use: "order",
Short: "Các công cụ kiểm tra và xử lý dữ liệu đơn hàng",
}
// Gắn các lệnh con vào lệnh cha
cmd.AddCommand(NewSyncCmd(svc, printer))
// cmd.AddCommand(NewTraceCmd(svc, printer))
// cmd.AddCommand(NewCancelCmd(svc, printer))
return cmd
}
5. 3 Quy tắc "Sống còn" khi mở rộng thư mục Command Box
-
Mỗi nhóm lệnh lớn là một sub-package riêng trong
internal/cli/: Đừng để tất cả các lệnh nằm phẳng lì trong một thư mục. Khi gõcbox db ..., toàn bộ các lệnh liên quan đến database phải nằm gọn tronginternal/cli/db/. -
Truyền
context.Contextxuyên suốt từ CLI xuống Core: Trong mọi hàm ở tầnginternal/core/, tham số đầu tiên luôn phải làctx context.Context. Điều này giúp khi người dùng lỡ gõ lệnh nặng và bấmCtrl + C, câu lệnh query DB hoặc HTTP request bên dưới sẽ tự động bị hủy ngay lập tức thay vì chạy ngầm tiếp. -
Không bao giờ gọi
os.Exit()hoặclog.Fatal()sâu bên trong code: Việc ngắt chương trình đột ngột ở tầng dưới sẽ làm các lệnhdefer(như đóng kết nối DB, xóa file tạm, giải phóng Docker container) không kịp chạy. Hãy luôn trả vềerrorngược lên trên và chỉos.Exit(1)tại điểm cuối cùng làmain.go.
💡 Bài tập thực hành cuối bài (Dành cho độc giả):
Hãy khởi tạo một thư mục rỗng và dựng ngay bộ khung chuẩn bằng các lệnh sau trên terminal của bạn:
Bash
mkdir -p cbox/{cmd/cbox,internal/{cli,core,config,ui},scripts} cd cbox && go mod init github.com/yourname/cboxThử áp dụng mô hình
New<Name>Cmd()không dùng biến toàn cục cho 1 lệnh đầu tiên của bạn để cảm nhận sự gọn gàng của kiến trúc phân tầng!
All rights reserved