0

Command Box bài học 12: Xử lý Flags, Options và Arguments sao cho tường minh, ít lỗi vặt

Khi thiết kế một câu lệnh trong Command Box, ranh giới giữa một công cụ "dễ gõ, dễ nhớ" và một công cụ "dễ gây tai nạn" nằm ở cách bạn định nghĩa đầu vào. Rất nhiều lập trình viên thiết kế CLI theo cảm tính: lúc thì bắt truyền tham số qua vị trí (cbox deploy staging order-srv), lúc lại bắt truyền qua cờ (cbox deploy --env=staging --service=order-srv), hoặc đặt tên cờ mỗi nơi một kiểu (--id, --order_id, --orderId).

Sự thiếu nhất quán này khiến người dùng liên tục phải gõ --help để tra cứu, hoặc nguy hiểm hơn là truyền nhầm tham số dẫn đến chạy sai logic trên môi trường thực tế. Bài học số 12 sẽ giúp bạn thiết lập bộ quy tắc chuẩn POSIX/GNU để phân định rạch ròi giữa Arguments, Options và Flags, kết hợp cùng các kỹ thuật kiểm soát ràng buộc nâng cao trong Golang (Cobra/pflag).

image.png

(Hình minh họa: Một câu lệnh CLI chuẩn mực luôn tách biệt rõ ràng giữa lệnh thực thi, các tùy chọn mở rộng và đối tượng đích).

1. Phân biệt đúng bản chất: Arguments vs. Options vs. Flags

Trong giao tiếp hàng ngày, anh em kỹ sư thường gọi chung mọi tham số truyền vào CLI là "flag" hoặc "arg". Nhưng khi bắt tay vào thiết kế kiến trúc parser cho Command Box, bạn phải tách bạch 3 khái niệm này:

Thành phần Cú pháp nhận diện Bản chất & Vai trò Ví dụ thực tế
Arguments (Positional Args) Đứng độc lập, không có dấu gạch ngang (-) phía trước. Đối tượng đích (Direct Object) mà hành động trực tiếp nhắm tới. Thứ tự truyền vào quyết định ý nghĩa. cbox logs tail order-service (tên service là Argument)
Options (Key-Value Flags) Có tiền tố --key=value hoặc -k value. Bộ bổ nghĩa (Modifiers) giúp tinh chỉnh cách thức câu lệnh vận hành. Không phụ thuộc thứ tự. cbox db dump --env=staging --limit=500
Flags (Boolean Switches) Có tiền tố --flag hoặc -f, không nhận giá trị đi kèm. Công tắc Bật/Tắt (Switches) làm thay đổi trạng thái hoặc hành vi mặc định (mặc định luôn là false). cbox order sync --force --dry-run -v

2. Quy tắc vàng: Khi nào dùng Positional Argument, khi nào dùng Flag/Option?

Đây là câu hỏi gây tranh cãi nhiều nhất khi thiết kế CLI: "Nên viết cbox order cancel ORD123 hay cbox order cancel --id=ORD123?". Hãy áp dụng 4 nguyên tắc phân xử sau đây:

① Quy tắc "Số lượng tối đa là 1" (The Single-Target Rule)

Chỉ dùng Positional Argument khi đối tượng đó là bắt buộc, hiển nhiên theo ngữ nghĩa của động từ đứng trước, và chỉ có đúng 1 loại đối tượng.

  • Tốt (Tự nhiên, nhanh gọn): cbox service restart payment-api hoặc cbox file upload ./backup.sql.

  • Tệ (Bẫy thứ tự): cbox db copy staging_db prod_db →\rightarrow Cái nào là nguồn (source), cái nào là đích (destination)? Nếu người dùng lỡ tay đảo ngược thứ tự, dữ liệu Staging sẽ ghi đè bay màu dữ liệu Production!

  • Sửa lại bằng Options tường minh: cbox db copy --from=staging_db --to=prod_db.

② Mọi tham số mang tính "Hủy diệt" hoặc "Nhạy cảm" đều phải dùng Option có tên

Với các lệnh có khả năng xóa dữ liệu, đổi trạng thái đơn hàng hoặc tác động vào môi trường thực tế, hãy ép người dùng phải gõ tên cờ tường minh (--env=prod, --target-id=100) thay vì dùng vị trí. Việc bắt ngón tay phải gõ chữ --env= giúp bộ não có thêm 1 giây phản xạ kiểm tra an toàn.

③ Không bao giờ tạo Boolean Flag mang giá trị mặc định là true

Khi người dùng nhìn thấy một cái cờ kiểu --validate, họ mặc định hiểu rằng nếu không truyền cờ thì sẽ không validate. Nếu bạn đặt mặc định là true, để tắt nó đi họ phải gõ --validate=false (rất ngược tay trên terminal).

  • Giải pháp: Nếu hành vi mặc định đã là true, hãy đặt tên cờ theo chiều phủ định: --no-validate hoặc --skip-verify (mặc định là false, khi truyền vào sẽ bật cờ bỏ qua bước kiểm tra).

④ Chuẩn hóa tên gọi trên toàn bộ Command Box (Naming Consistency)

Không được phép ở lệnh db thì dùng --environment, sang lệnh kafka lại dùng --env, còn sang lệnh order lại viết --stage. Hãy lập một bảng từ điển Flag chuẩn ngay từ đầu:

  • -e, --env: Môi trường (local, staging, prod)

  • -o, --output: Định dạng xuất dữ liệu (table, json, yaml)

  • -f, --file: Đường dẫn file đầu vào/đầu ra

  • -n, --dry-run: Chạy thử không ghi dữ liệu

  • -y, --yes: Tự động đồng ý các câu hỏi xác nhận

  • -t, --timeout: Thời gian chờ tối đa (kiểu time.Duration như 30s, 5m)

3. Vũ khí bí mật của Cobra: Kiểm soát Ràng buộc Chéo (Flag Groups)

Một trong những nguyên nhân gây ra "lỗi vặt" nhiều nhất trong code CLI là lập trình viên phải tự viết hàng chục câu lệnh if/else để kiểm tra sự xung đột giữa các cờ. Từ phiên bản Cobra hiện đại, bạn có sẵn 3 hàm khóa ràng buộc tự động cực kỳ mạnh mẽ:

  1. MarkFlagsMutuallyExclusive("flagA", "flagB") (Loại trừ lẫn nhau): Cấm người dùng truyền cả 2 cờ cùng lúc. Ví dụ: trong lệnh đồng bộ đơn hàng, không thể vừa truyền --all (đồng bộ tất cả) lại vừa truyền --id=123 (đồng bộ 1 đơn).

  2. MarkFlagsRequiredTogether("flagA", "flagB") (Đi kèm bắt buộc): Nếu đã truyền cờ A thì bắt buộc phải có cờ B. Ví dụ: nếu truyền --db-user thì bắt buộc phải truyền kèm --db-pass.

  3. MarkFlagsOneRequired("flagA", "flagB") (Ít nhất một cái): Bắt buộc người dùng phải chọn ít nhất 1 trong các cờ được liệt kê. Ví dụ: phải truyền --id hoặc --from-file thì lệnh mới chạy.

4. Thực hành viết code: Xử lý Flags, Options & Arguments chặt chẽ trong Golang

Hãy xem cách kết hợp Custom Type (pflag.Value) để tự động kiểm tra giá trị hợp lệ của --env, kết hợp với Positional Args Validator và Flag Groups trong một lệnh thực tế cbox kafka replay:

Go

package kafka

import (
	"fmt"
	"strings"
	"time"

	"github.com/spf13/cobra"
)

// 1. Tạo kiểu dữ liệu Enum tự kiểm tra hợp lệ (Custom Flag Type) cho Environment
type EnvType string

const (
	EnvLocal   EnvType = "local"
	EnvStaging EnvType = "staging"
	EnvProd    EnvType = "prod"
)

func (e *EnvType) String() string { return string(*e) }
func (e *EnvType) Type() string   { return "env" }
func (e *EnvType) Set(v string) error {
	switch strings.ToLower(v) {
	case "local", "staging", "prod":
		*e = EnvType(strings.ToLower(v))
		return nil
	default:
		return fmt.Errorf("chỉ chấp nhận một trong các giá trị: [local, staging, prod]")
	}
}

// 2. Gom toàn bộ Options/Flags vào một Struct riêng biệt
type replayOptions struct {
	env       EnvType
	messageID string
	fromFile  string
	limit     int
	timeout   time.Duration
	dryRun    bool
}

func NewReplayCmd() *cobra.Command {
	opts := &replayOptions{
		env: EnvLocal, // Giá trị mặc định an toàn nhất
	}

	cmd := &cobra.Command{
		Use:   "replay <topic-name>",
		Short: "Phát lại (replay) các bản tin lỗi vào Kafka topic đích",
		Example: `  # Replay 1 bản tin cụ thể vào topic 'orders.created' trên môi trường staging
  cbox kafka replay orders.created --id=MSG_9981 --env=staging

  # Replay hàng loạt từ file danh sách kèm chế độ chạy thử (dry-run)
  cbox kafka replay orders.created --file=./dlq_ids.txt --limit=100 --dry-run`,

		// 3. Kiểm soát chặt chẽ Positional Argument: Bắt buộc truyền đúng 1 tên Topic
		Args: cobra.ExactArgs(1),

		// Hỗ trợ gợi ý tên Topic khi người dùng bấm phím Tab
		ValidArgs: []string{"orders.created", "payments.completed", "inventory.updated"},

		RunE: func(cmd *cobra.Command, args []string) error {
			topic := args[0] // An toàn tuyệt đối vì đã có cobra.ExactArgs(1) bảo vệ

			fmt.Printf("🚀 Đang chuẩn bị replay vào topic [%s] (Môi trường: %s)\n", topic, opts.env)
			if opts.dryRun {
				fmt.Println("💡 [DRY-RUN MODE] Chỉ mô phỏng, không bắn bản tin thật vào Kafka.")
			}

			// Gọi xuống tầng Core xử lý...
			return nil
		},
	}

	// 4. Đăng ký các Flags với kiểu dữ liệu chuẩn xác
	cmd.Flags().VarP(&opts.env, "env", "e", "Môi trường đích (local|staging|prod)")
	cmd.Flags().StringVarP(&opts.messageID, "id", "i", "", "Mã bản tin đơn lẻ cần replay")
	cmd.Flags().StringVarP(&opts.fromFile, "file", "f", "", "Đường dẫn file chứa danh sách mã bản tin")
	cmd.Flags().IntVarP(&opts.limit, "limit", "l", 50, "Số lượng bản tin tối đa xử lý trong 1 lần chạy")
	cmd.Flags().DurationVarP(&opts.timeout, "timeout", "t", 30*time.Second, "Thời gian chờ tối đa (ví dụ: 10s, 2m)")
	cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Chạy mô phỏng kiểm tra trước khi thực thi thật")

	// 5. Áp dụng khóa ràng buộc chéo (Flag Groups) - Không cần viết if/else thủ công!
	// Bắt buộc phải truyền --id HOẶC --file
	cmd.MarkFlagsOneRequired("id", "file")
	// Nhưng cấm truyền cả --id VÀ --file cùng một lúc
	cmd.MarkFlagsMutuallyExclusive("id", "file")
	// Cờ --limit chỉ có ý nghĩa khi đi kèm với --file
	cmd.MarkFlagsMutuallyExclusive("id", "limit")

	return cmd
}

Điều gì xảy ra khi người dùng gõ sai trên Terminal?

Nhờ thiết kế trên, bạn không cần viết một dòng if/else nào trong hàm RunE mà vẫn bắt được mọi lỗi ngớ ngẩn với thông báo chuẩn chỉnh từ Cobra:

  • Gõ thiếu tên topic (cbox kafka replay --id=123):Error: accepts 1 arg(s), received 0

  • Truyền sai tên môi trường (cbox kafka replay orders.created --id=123 --env=dev):Error: invalid argument "dev" for "-e, --env" flag: chỉ chấp nhận một trong các giá trị: [local, staging, prod]

  • Truyền cả --id lẫn --file cùng lúc:Error: if any flags in the group [id file] are set none of the others can be; [file id] were all set

  • Truyền thời gian dễ đọc (--timeout=1m30s): Thư viện pflag tự động parse chuỗi "1m30s" thành đúng 90 * time.Second mà không sợ nhầm lẫn đơn vị giây hay mili-giây như khi dùng kiểu số nguyên int!

5. Mẹo xử lý dấu gạch ngang kép -- (End of Options Delimiter)

Một lỗi kinh điển khác khi viết Command Box đóng vai trò "vỏ bọc" (wrapper) gọi xuống công cụ khác (như docker exec, ssh, hoặc go test) là xung đột cờ. Ví dụ: bạn viết lệnh cbox container exec <container-name> <command>. Khi người dùng gõ:

Bash

cbox container exec my-redis redis-cli -p 6380

Parser của Command Box sẽ nhìn thấy cờ -p 6380 và tưởng đó là cờ của cbox, dẫn đến báo lỗi unknown shorthand flag: 'p'!

Cách giải quyết chuẩn POSIX: Hướng dẫn người dùng (và thiết kế hàm nhận args) sử dụng ký hiệu hai dấu gạch ngang -- để báo cho Cobra biết: "Mọi thứ đứng sau dấu -- đều là Arguments nguyên bản truyền thẳng xuống lệnh con, ngừng parse flag tại đây!":

Bash

cbox container exec my-redis -- redis-cli -p 6380 -a mysecret

Trong Cobra, toàn bộ phần phía sau -- sẽ nằm gọn gàng trong mảng args để bạn truyền thẳng vào exec.CommandContext mà không bao giờ bị lỗi xung đột cờ.

💡 Bài tập thực hành cuối bài (Dành cho độc giả):

Hãy mở code Command Box của bạn và tìm tất cả những chỗ đang khai báo cờ thời gian bằng kiểu IntVar (ví dụ --timeout=30 không rõ là giây hay phút) để đổi ngay sang DurationVar (time.Duration). Đồng thời, hãy áp dụng interface pflag.Value (như ví dụ EnvType ở trên) cho cờ --env hoặc --format để loại bỏ hoàn toàn các đoạn code if env != ... lặp đi lặp lại trong dự án!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí