0

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:

  1. 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, --delete bên trong cùng một lệnh manage, bạn đang bắt một hàm RunE duy nhất phải gánh 3-4 nhánh if/else hoàn toàn khác biệt nhau về bản chất nghiệp vụ.

  2. Sự phụ thuộc ngầm giữa các Flags (Flag Coupling Hell): Cờ --anonymize-users chỉ có tác dụng nếu có mặt cờ --restore-to, còn nếu chỉ chạy --dump thì 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.

  3. Vi phạm ranh giới Đọc và Ghi (Read/Write Mixing): Một lệnh mang tên check hoặc inspect (mang tính chất chỉ đọc - Read-only) nhưng lại lén lút chứa cờ --auto-repair là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.

  4. 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ả --help riê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-run hoặ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 clean

cbox 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 →\rightarrow Đồng bộ lại →\rightarrow 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ồng stdin:

    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 --fix hay --clean khô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

Viblo
Let's register a Viblo Account to get more interesting posts.