Command Box bài học 8: Nguyên lý thiết kế Single Responsibility cho từng lệnh con (Sub-command)
Khi mới xây dựng được bộ khung Command Box chạy mượt mà, hầu hết các kỹ sư đều rơi vào một cơn "say đòn" tự động hóa: cố gắng nhồi nhét mọi bước xử lý vào trong một câu lệnh siêu cấp duy nhất.
Bạn bắt đầu với lệnh cbox order check chỉ để kiểm tra trạng thái đơn hàng. Vài hôm sau, bạn thêm cờ --fix để tự động sửa dữ liệu lệch. Tuần tiếp theo, bạn gắn thêm cờ --reindex-es, --push-kafka và --notify-telegram vào chính câu lệnh đó. Kết quả là bạn tạo ra một "God Sub-command" (Lệnh con toàn năng) với hơn 15 chiếc cờ (flags) chồng chéo nhau, không ai dám chạy vì sợ gõ nhầm một cờ là kích hoạt hiệu ứng dây chuyền làm sập hệ thống.
Bài học thứ 8 này sẽ đưa nguyên lý Single Responsibility Principle (SRP - Nguyên lý đơn trách nhiệm) từ lập trình hướng đối tượng kết hợp với Triết lý Unix (Unix Philosophy) vào việc thiết kế cấu trúc Sub-command — giúp mỗi lệnh con trong Command Box luôn sắc bén, an toàn và có khả năng kết hợp (composable) vô hạn.
1. Nhận diện "Quái vật Franken-Command" trong dự án nội bộ
Làm sao để biết một lệnh con trong Command Box của bạn đang vi phạm nghiêm trọng nguyên lý Đơn trách nhiệm? Hãy nhìn vào một câu lệnh thực tế thường thấy khi thiếu quy hoạch kiến trúc:
Bash
cbox db manage --dump --restore-to=staging --anonymize-users --restart-redis --send-slack
Câu lệnh trên mang trong mình 4 căn bệnh thiết kế trầm trọng:
-
Cờ điều khiển thay thế cho Hành động (Boolean Flags as Verbs): Khi bạn dùng các cờ như
--dump,--restore,--deletebên trong cùng một lệnhmanage, bạn đang bắt một hàmRunEduy nhất phải gánh 3-4 nhánhif/elsehoàn toàn khác biệt nhau về bản chất nghiệp vụ. -
Sự phụ thuộc ngầm giữa các Flags (Flag Coupling Hell): Cờ
--anonymize-userschỉ có tác dụng nếu có mặt cờ--restore-to, còn nếu chỉ chạy--dumpthì cờ đó lại vô nghĩa. Việc kiểm tra tính hợp lệ (Validation) cho các tổ hợp cờ này sẽ biến code CLI của bạn thành một ma trận rối rắm. -
Vi phạm ranh giới Đọc và Ghi (Read/Write Mixing): Một lệnh mang tên
checkhoặcinspect(mang tính chất chỉ đọc - Read-only) nhưng lại lén lút chứa cờ--auto-repairlàm thay đổi dữ liệu trong Database. Điều này phá vỡ hoàn toàn sự tin tưởng của người vận hành khi muốn gõ lệnh kiểm tra trên môi trường Production. -
Không thể tái sử dụng từng phần (Zero Composability): Khi đồng nghiệp chỉ muốn chạy riêng bước "ẩn danh dữ liệu user (
anonymize)" trên một file SQL có sẵn mà không muốn dump lại từ đầu, họ hoàn toàn bó tay vì logic đó đã bị khóa chặt bên trong luồng chạy chung.
2. 3 Định luật Thiết kế Đơn trách nhiệm cho Sub-command
Thay vì tạo ra những con dao Thụy Sĩ cồng kềnh, hãy áp dụng triết lý của Doug McIlroy (cha đẻ của Unix Pipe): "Mỗi chương trình chỉ làm một việc thật tốt. Để làm một công việc mới, hãy xây dựng phần mới thay vì làm phức tạp hóa phần cũ bằng những tính năng phụ".
① Quy tắc "Một Động từ - Một Kết quả" (One Verb, One Outcome)
Mỗi lệnh con ở tầng lá (Leaf Sub-command - lệnh trực tiếp thực thi) chỉ được đại diện cho duy nhất một hành động nguyên tử (Atomic Action).
-
Sai:
cbox service control --start --stop --restart -
Đúng: Tách thành 3 lệnh con độc lập:
cbox service start,cbox service stop, vàcbox service restart. Mỗi lệnh có tập Flags riêng biệt, mô tả--helpriêng và logic kiểm tra đầu vào hoàn toàn độc lập.
② Tách bạch tuyệt đối giữa Lệnh Truy vấn và Lệnh Tác động (CQRS cho CLI)
Áp dụng tư tưởng CQRS (Command Query Responsibility Segregation) ngay trên giao diện dòng lệnh của bạn:
-
Nhóm lệnh Query (Read-only): Các lệnh có động từ như
get,list,inspect,diff,status,lag. Nhóm này cam kết 100% không làm thay đổi trạng thái hệ thống. Kỹ sư có thể gõ thoải mái trên Production lúc nửa đêm mà không cần lo lắng. -
Nhóm lệnh Command (Mutating): Các lệnh có động từ như
sync,migrate,purge,reset,deploy. Nhóm này làm thay đổi dữ liệu, bắt buộc phải đi kèm cơ chế xác nhận an toàn (--dry-runhoặc Confirm Prompt).
| Phản mẫu (Vi phạm SRP) | Thiết kế chuẩn SRP & CQRS | Lợi ích vận hành |
|---|---|---|
cbox order check -i 100 --fix |
cbox order diff -i 100 (Chỉ kiểm tra độ lệch)cbox order sync -i 100 (Thực thi đồng bộ) |
Tách biệt bước chẩn đoán lỗi và bước can thiệp dữ liệu. |
cbox kafka lag --group=g1 --reset |
cbox kafka lag --group=g1 (Xem độ trễ)cbox kafka reset-offset --group=g1 (Đặt lại offset) |
Không bao giờ xảy ra tai nạn gõ nhầm cờ làm mất message khi đang xem lag. |
cbox docker clean --logs --volumes |
cbox log cleancbox docker prune-volumes |
Đúng đối tượng (Noun), đúng trách nhiệm của từng tài nguyên. |
③ Liên kết các lệnh nhỏ qua Đường ống (Unix Pipe |) VÀ Lệnh Điều phối (Workflow Command)
Khi tách nhỏ các lệnh theo chuẩn SRP, một câu hỏi thực tế sẽ nảy sinh: "Vậy nếu tôi muốn chạy liên hoàn 3 bước (Lấy danh sách đơn lỗi Đồng bộ lại Bắn thông báo) thì phải gõ 3 lần à?"
Bạn có 2 cách chuẩn kiến trúc để giải quyết mà không làm bẩn từng lệnh đơn lẻ:
-
Cách 1: Thiết kế đầu ra chuẩn để nối ống (
Pipe-friendly)Cho phép lệnh truy vấn chỉ in ra danh sách ID thô (qua cờ
-q/--quiet), và lệnh xử lý nhận danh sách ID từ luồngstdin:Bash
# Lệnh 'list' chỉ tìm đơn lỗi, lệnh 'sync' chỉ làm nhiệm vụ đồng bộ cbox order list --status=stuck --quiet | cbox order sync --stdin -
Cách 2: Tạo một Lệnh Điều phối cấp cao (Orchestrator / Macro Command)
Ở tầng
internal/core/, vì các hàm nghiệp vụ đã được tách rời sạch sẽ (như Bài học 4), bạn có thể tạo riêng một lệnh workflow gọi tuần tự các service đó mà không cần nhồi nhét cờ vào lệnh cơ bản.
3. Thực hành viết code: Tái cấu trúc từ "God Command" sang SRP + Pipe trong Golang
Hãy xem cách chúng ta đập bỏ một lệnh order manage ôm đồm để tách thành 2 lệnh con chuyên biệt (find-stuck và requeue) có khả năng truyền dữ liệu cho nhau qua dấu gạch đứng | (Unix Pipe) cực kỳ mượt mà:
Bước 1: Lệnh Truy vấn (cbox order find-stuck) – Chỉ đọc và xuất dữ liệu sạch
Go
package order
import (
"fmt"
"github.com/spf13/cobra"
"github.com/your-org/cbox/internal/core/order"
)
func NewFindStuckCmd(svc *order.Service) *cobra.Command {
var minutes int
var quiet bool
cmd := &cobra.Command{
Use: "find-stuck",
Short: "Tìm các đơn hàng bị kẹt trạng thái quá thời gian quy định (Read-only)",
Example: ` # Xem bảng danh sách đơn bị kẹt trên 30 phút
cbox order find-stuck --minutes=30
# Chỉ xuất danh sách ID để truyền sang lệnh khác qua Pipe
cbox order find-stuck -m 30 -q | cbox order requeue --stdin`,
RunE: func(cmd *cobra.Command, args []string) error {
orders, err := svc.FindStuckOrders(cmd.Context(), minutes)
if err != nil {
return err
}
// Nếu bật cờ -q (--quiet), chỉ in đúng ID trên từng dòng ra stdout để phục vụ Pipe
if quiet {
for _, o := range orders {
fmt.Println(o.ID)
}
return nil
}
// Nếu chạy bình thường, gọi tầng UI vẽ bảng đẹp mắt cho người đọc
printOrderTable(orders)
return nil
},
}
cmd.Flags().IntVarP(&minutes, "minutes", "m", 15, "Số phút bị kẹt tối thiểu")
cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Chỉ in ra ID đơn hàng (dùng để kết hợp với Pipe)")
return cmd
}
Bước 2: Lệnh Tác động (cbox order requeue) – Nhận dữ liệu linh hoạt từ Flag hoặc stdin
Go
package order
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/your-org/cbox/internal/core/order"
)
func NewRequeueCmd(svc *order.Service) *cobra.Command {
var ids []string
var fromStdin bool
cmd := &cobra.Command{
Use: "requeue",
Short: "Đẩy lại danh sách đơn hàng vào hàng đợi Kafka để xử lý lại",
PreRunE: func(cmd *cobra.Command, args []string) error {
// Nếu người dùng truyền cờ --stdin, tự động đọc danh sách ID từ luồng ống dẫn (Pipe)
if fromStdin {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
ids = append(ids, line)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("lỗi khi đọc dữ liệu từ stdin: %w", err)
}
}
if len(ids) == 0 {
return fmt.Errorf("không có ID đơn hàng nào được cung cấp (dùng --id hoặc --stdin)")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "🔄 Đang đẩy lại %d đơn hàng vào Kafka...\n", len(ids))
if err := svc.RequeueOrders(cmd.Context(), ids); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "✔ Hoàn tất đẩy lại %d đơn hàng thành công!\n", len(ids))
return nil
},
}
cmd.Flags().StringSliceVarP(&ids, "id", "i", nil, "Danh sách mã đơn hàng (ví dụ: -i ORD1,ORD2)")
cmd.Flags().BoolVar(&fromStdin, "stdin", false, "Đọc danh sách mã đơn hàng từ luồng đầu vào chuẩn (Pipe)")
return cmd
}
Sức mạnh đạt được từ thiết kế này:
-
Khi cần kiểm tra nhanh:
cbox order find-stuck -m 60(An toàn tuyệt đối). -
Khi cần đẩy lại 1 đơn lẻ:
cbox order requeue -i ORD999(Rõ ràng, tường minh). -
Khi cần xử lý hàng loạt từ file có sẵn của bộ phận CSKH gửi:
cat cskh_orders.txt | cbox order requeue --stdin. -
Khi cần tự động hóa liên hoàn:
cbox order find-stuck -m 60 -q | cbox order requeue --stdin.
4. Bài kiểm tra "Chữ VÀ" (The "AND" Test) khi thiết kế Sub-command
Mỗi khi bạn định viết thêm một lệnh mới hoặc thêm một flag mới vào Command Box, hãy tự đọc to câu mô tả ngắn (Short description) của lệnh đó lên:
-
Nếu câu mô tả của bạn chứa chữ "VÀ" hoặc "HOẶC" (Ví dụ: "Lệnh này dùng để kiểm tra dung lượng ổ cứng VÀ xóa các file log cũ của Nginx"), đó là dấu hiệu 100% bạn đang vi phạm Single Responsibility!
-
Quy chuẩn: Một lệnh con không bao giờ nên có quá 5-7 Flags cục bộ. Nếu số lượng cờ vượt quá con số này, hãy dừng lại và tách nhánh nó thành các sub-command con sâu hơn một cấp.
💡 Bài tập thực hành cuối bài (Dành cho độc giả):
Hãy rà soát lại danh sách các lệnh hoặc script bạn đang có. Tìm xem có lệnh nào đang ôm cả việc Kiểm tra (Read) lẫn việc Sửa đổi (Write) qua một chiếc cờ kiểu
--fixhay--cleankhông? Hãy thử tách nó ra thành 2 lệnh riêng biệt và trang bị thêm cờ-q(--quiet) cùng khả năng đọc từos.Stdinđể nối chúng lại với nhau bằng dấu|trên terminal nhé!
All Rights Reserved