0

Command Box bài học 13: Kiểm tra ràng buộc đầu vào (Validation) ngay từ tầng CLI trước khi thực thi

Ở Bài học 12, chúng ta đã biết cách dùng pflag và Cobra để bắt các lỗi cú pháp cơ bản như truyền thiếu cờ, sai kiểu số nguyên (int) hay xung đột giữa hai cờ (MarkFlagsMutuallyExclusive). Nhưng trong thực tế vận hành Backend, "đúng kiểu dữ liệu" chưa bao giờ đồng nghĩa với "hợp lệ để chạy".

Hãy tưởng tượng bạn chạy lệnh cbox db restore --file=./backup_2026.sql --workers=500 --env=prod. Về mặt cú pháp, mọi thứ đều đúng: ./backup_2026.sql là một chuỗi (string), 500 là một số nguyên (int). Nhưng khi tiến trình chạy được 3 bước: đã kết nối vào Database Production, đã xóa bảng cũ (DROP TABLE), đến bước đọc file dump thì phát hiện ra đường dẫn ./backup_2026.sql bị gõ sai chính tả (file không tồn tại) hoặc số lượng --workers=500 vượt quá giới hạn max_connections của MySQL làm sập luôn cả cụm DB!

Bài học số 13 sẽ hướng dẫn bạn xây dựng Hệ thống phòng thủ Validation đa tầng ngay tại cửa ngõ CLI (Fail-Fast Gatekeeper) — đảm bảo mọi tham số đầu vào phải vượt qua bài kiểm tra ngữ nghĩa và môi trường thực tế trước khi bất kỳ dòng code nghiệp vụ (internal/core) nào được phép kích hoạt.

1. Triết lý "Fail-Fast": Chặn rác trước khi mở kết nối mạng

Trong thiết kế hệ thống phân tán và CLI, Fail-Fast (Thất bại ngay lập tức) là nguyên tắc tiết kiệm tài nguyên và bảo vệ an toàn dữ liệu hàng đầu.

Nếu một câu lệnh chắc chắn sẽ thất bại vì đầu vào vô lý, nó phải báo lỗi trong vài mili-giây đầu tiên tại tầng CLI (PreRunE), chứ không phải sau khi đã tốn 5 giây khởi tạo kết nối SSH, mở Transaction Database hay đẩy một nửa lô bản tin vào Kafka.

Hãy phân tách rõ 3 lớp phễu lọc Validation trong một Command Box chuẩn mực:

Lớp kiểm tra (Validation Layer) Thời điểm kích hoạt Nội dung kiểm tra cụ thể Ví dụ lỗi bị chặn lại
Lớp 1: Cú pháp & Kiểu (Syntactic) Lúc Cobra parse dòng lệnh (Flags.Parse) Kiểu dữ liệu (int, bool, duration), cờ bắt buộc, số lượng args. Truyền --workers=abc hoặc thiếu cờ --id.
Lớp 2: Ngữ nghĩa & Miền giá trị (Semantic) Trong hook PreRunE (Chạy trên RAM, 0ms I/O) Định dạng Regex (Mã đơn hàng, IP, Email), giới hạn Min/Max, logic chéo giữa các biến. Truyền --workers=-5 hoặc --batch-size=999999 (vượt ngưỡng an toàn).
Lớp 3: Môi trường cục bộ (Pre-flight / OS Check) Cuối hook PreRunE (Trước khi gọi RunE) Sự tồn tại của file/thư mục, quyền đọc/ghi (chmod), định dạng cổng mạng, dung lượng ổ cứng trống. Truyền --file=./dump.sql nhưng file không tồn tại hoặc có dung lượng 0 byte.

2. 4 Kịch bản "Validation sống còn" mà mọi Command Box phải có

Đừng đợi xuống tới tầng Service hay Database mới bắt đầu kiểm tra. Ngay tại tầng internal/cli/, bạn cần chặn đứng 4 nhóm đầu vào nguy hiểm sau:

① Kiểm tra ranh giới số học (Boundary & Range Validation)

Khi một lệnh có cờ --concurrency, --workers, --batch-size hoặc --limit, kiểu int của Go cho phép truyền từ số âm hàng tỷ đến dương hàng tỷ.

  • Nếu người dùng truyền --workers=0 hoặc -1: Các vòng lặp Worker Pool dùng make(chan Job, workers) sẽ bị treo cứng (Deadlock) vĩnh viễn!

  • Nếu người dùng truyền --batch-size=100000: Câu lệnh INSERT hoặc SELECT ... WHERE id IN (...) sẽ vượt ngưỡng max_allowed_packet của MySQL hoặc làm tràn bộ nhớ RAM (OOM).

  • Quy chuẩn: Luôn khóa chặt khoảng giá trị hợp lệ (ví dụ: 1 <= workers <= 64, 10 <= batchSize <= 5000).

② Kiểm tra File và Đường dẫn hệ thống (Filesystem Sanity Check)

Khi nhận một đường dẫn qua cờ --file hoặc --output-dir, đừng bao giờ chỉ kiểm tra file != "". Hãy kiểm tra theo đúng mục đích sử dụng:

  • Nếu là File đầu vào (Input File): File có tồn tại trên ổ cứng không (os.Stat)? Nó có phải là một file thật hay người dùng truyền nhầm tên một thư mục (info.IsDir())? File có bị rỗng (info.Size() == 0) không?

  • Nếu là File cấu hình nhạy cảm (SSH Key, .env): Quyền truy cập file có quá lỏng lẻo không? (Ví dụ: file private key bắt buộc phải có quyền 0600).

③ Kiểm tra Định dạng Nghiệp vụ (Format & Regex Validation)

Trong hệ thống nội bộ của công ty bạn, các thực thể luôn có quy tắc định danh riêng:

  • Mã đơn hàng luôn bắt đầu bằng ORD- kèm 8 chữ số (^ORD-[0-9]{8}$).

  • Tên Kafka Topic chỉ gồm chữ thường, dấu chấm và gạch ngang.

  • Địa chỉ kết nối phải đúng chuẩn host:port (kiểm tra bằng net.SplitHostPort). Kiểm tra ngay bằng Regex hoặc hàm chuẩn ở CLI sẽ ngăn chặn việc bắn các câu query vô nghĩa xuống Database.

④ Gom nhóm lỗi (Error Aggregation) thay vì bắt sửa từng lỗi một

Bạn có thấy bực mình khi điền một cái form có 3 chỗ sai, nhưng bấm chạy lần 1 nó chỉ báo lỗi dòng số 1; sửa xong bấm chạy lần 2 nó mới báo lỗi dòng số 2 không? Với Golang (từ Go 1.20+), hãy sử dụng errors.Join(errs...) trong hàm Validate() để kiểm tra toàn bộ các cờ cùng một lúc và in ra một danh sách đầy đủ tất cả các điểm chưa hợp lệ trong đúng 1 lần chạy!

3. Thiết kế Pattern Validator chuẩn mực cho Struct Options

Ở Bài học 4 và 12, chúng ta đã gom toàn bộ Flags của một lệnh vào trong một struct xxxOptions. Để code luôn sạch sẽ, hãy gắn thêm một phương thức Validate() error trực tiếp vào struct đó và gọi nó một cách tự động trong PreRunE.

Plaintext

[Người dùng gõ lệnh] 
       │
       ▼
[Cobra Parse Flags] ──(Sai kiểu int/bool)──► ❌ Báo lỗi cú pháp ngay
       │
       ▼
[Hook: PreRunE] ──────► Gọi opts.Validate() ──(Sai miền giá trị / File không tồn tại)──► ❌ Báo lỗi ngữ nghĩa
       │
       ▼ (Đầu vào sạch 100%)
[Hook: RunE] ─────────► Khởi tạo DB/Kafka & Chạy tầng Core (internal/core)

4. Thực hành viết code: Xây dựng bộ Validate() toàn diện trong Golang

Hãy xem cách triển khai một lệnh cbox db import (nạp dữ liệu từ file SQL vào Database với cơ chế chạy song song) được trang bị bộ kiểm tra đầu vào đa tầng không một kẽ hở:

Go

package db

import (
	"errors"
	"fmt"
	"net"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/spf13/cobra"
)

// Biểu thức chính quy kiểm tra tên Database hợp lệ (chỉ chữ cái, số và gạch dưới)
var validDBNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,64}$`)

type importOptions struct {
	hostAddr   string
	dbName     string
	sqlFile    string
	workers    int
	chunkSize  int
	dropTarget bool
	env        string
}

// Validate thực hiện kiểm tra toàn diện ngữ nghĩa và môi trường trước khi chạy
// Sử dụng errors.Join để trả về TẤT CẢ các lỗi cùng lúc thay vì dừng ở lỗi đầu tiên
func (o *importOptions) Validate() error {
	var errs []error

	// 1. Kiểm tra định dạng mạng (Host:Port)
	host, port, err := net.SplitHostPort(o.hostAddr)
	if err != nil || host == "" || port == "" {
		errs = append(errs, fmt.Errorf("--host (%q) không hợp lệ, phải có định dạng 'hostname:port' (ví dụ: 127.0.0.1:3306)", o.hostAddr))
	}

	// 2. Kiểm tra quy tắc đặt tên Database bằng Regex
	if !validDBNameRegex.MatchString(o.dbName) {
		errs = append(errs, fmt.Errorf("--db (%q) chứa ký tự không hợp lệ (chỉ cho phép chữ, số, gạch dưới, dài 3-64 ký tự)", o.dbName))
	}

	// 3. Kiểm tra ranh giới số học (Range & Boundary Check)
	if o.workers < 1 || o.workers > 32 {
		errs = append(errs, fmt.Errorf("--workers (%d) nằm ngoài ngưỡng an toàn (chỉ cho phép từ 1 đến 32 luồng)", o.workers))
	}
	if o.chunkSize < 100 || o.chunkSize > 10000 {
		errs = append(errs, fmt.Errorf("--chunk-size (%d) phải nằm trong khoảng 100 đến 10.000 bản ghi", o.chunkSize))
	}

	// 4. Kiểm tra thực tế File trên hệ điều hành (Filesystem Pre-flight Check)
	if err := validateSQLFile(o.sqlFile); err != nil {
		errs = append(errs, err)
	}

	// 5. Kiểm tra Ràng buộc Logic Nghiệp vụ Chéo (Cross-field Business Rule)
	if o.env == "prod" && o.dropTarget {
		errs = append(errs, errors.New("chốt chặn an toàn: CẤM sử dụng cờ '--drop-target' khi đang trỏ vào môi trường 'prod'"))
	}

	// Gộp toàn bộ lỗi lại (nếu mảng errs rỗng, errors.Join sẽ trả về nil)
	return errors.Join(errs...)
}

// Hàm phụ trợ kiểm tra sâu một file đầu vào trên ổ cứng
func validateSQLFile(path string) error {
	cleanPath := filepath.Clean(path)
	info, err := os.Stat(cleanPath)
	if err != nil {
		if os.IsNotExist(err) {
			return fmt.Errorf("--file (%q) không tồn tại trên hệ thống", path)
		}
		return fmt.Errorf("không thể truy cập --file (%q): %w", path, err)
	}

	if info.IsDir() {
		return fmt.Errorf("--file (%q) là một thư mục, vui lòng truyền đường dẫn tới file .sql", path)
	}

	if info.Size() == 0 {
		return fmt.Errorf("--file (%q) bị rỗng (0 byte), không có dữ liệu để import", path)
	}

	ext := strings.ToLower(filepath.Ext(cleanPath))
	if ext != ".sql" && ext != ".gz" {
		return fmt.Errorf("--file (%q) có phần mở rộng '%s' không được hỗ trợ (chỉ nhận .sql hoặc .gz)", path, ext)
	}

	return nil
}

Kết nối Validate() vào vòng đời PreRunE của Cobra:

Go

func NewImportCmd() *cobra.Command {
	opts := &importOptions{}

	cmd := &cobra.Command{
		Use:   "import",
		Short: "Nạp dữ liệu tốc độ cao từ file SQL vào Database",
		
		// CỬA NGÕ CHẶN LỖI: Chạy trước khi đụng vào bất kỳ kết nối mạng hay DB nào
		PreRunE: func(cmd *cobra.Command, args []string) error {
			if err := opts.Validate(); err != nil {
				return fmt.Errorf("dữ liệu đầu vào chưa hợp lệ:\n  • %s",
					strings.ReplaceAll(err.Error(), "\n", "\n  • "))
			}
			return nil
		},

		RunE: func(cmd *cobra.Command, args []string) error {
			// Khi code chạy tới được đây, bạn yên tâm 100% là:
			// - File .sql có thật trên ổ cứng và > 0 byte
			// - Số lượng workers nằm trong ngưỡng 1..32
			// - Không bao giờ có chuyện xóa nhầm bảng trên Production!
			fmt.Printf("✔ Kiểm tra đầu vào hoàn tất! Đang mở %d workers để nạp %s...\n", opts.workers, opts.sqlFile)
			return nil
		},
	}

	cmd.Flags().StringVar(&opts.hostAddr, "host", "127.0.0.1:3306", "Địa chỉ Database (host:port)")
	cmd.Flags().StringVarP(&opts.dbName, "db", "d", "", "Tên Database đích (Bắt buộc)")
	cmd.Flags().StringVarP(&opts.sqlFile, "file", "f", "", "Đường dẫn tới file .sql hoặc .gz (Bắt buộc)")
	cmd.Flags().IntVarP(&opts.workers, "workers", "w", 4, "Số luồng chạy song song (1-32)")
	cmd.Flags().IntVar(&opts.chunkSize, "chunk-size", 1000, "Số dòng mỗi lô (100-10000)")
	cmd.Flags().StringVarP(&opts.env, "env", "e", "local", "Môi trường đích (local|staging|prod)")
	cmd.Flags().BoolVar(&opts.dropTarget, "drop-target", false, "Xóa sạch bảng cũ trước khi nạp mới")

	_ = cmd.MarkFlagRequired("db")
	_ = cmd.MarkFlagRequired("file")

	return cmd
}

Trải nghiệm thực tế khi người dùng truyền sai nhiều tham số cùng lúc:

Khi một đồng nghiệp gõ bừa lệnh sau trên terminal:

Bash

cbox db import --host=localhost --db="order-db!" --file=./missing.txt --workers=100 --env=prod --drop-target

Thay vì chạy vào DB rồi chết bất đắc kỳ tử, hoặc bắt họ sửa lắt nhắt 5 lần, Command Box chặn ngay lập tức trong 0.002 giây và in ra bảng tổng hợp lỗi cực kỳ rõ ràng:

Plaintext

✖ LỖI: dữ liệu đầu vào chưa hợp lệ:
  • --host ("localhost") không hợp lệ, phải có định dạng 'hostname:port' (ví dụ: 127.0.0.1:3306)
  • --db ("order-db!") chứa ký tự không hợp lệ (chỉ cho phép chữ, số, gạch dưới, dài 3-64 ký tự)
  • --workers (100) nằm ngoài ngưỡng an toàn (chỉ cho phép từ 1 đến 32 luồng)
  • --file ("./missing.txt") không tồn tại trên hệ thống
  • chốt chặn an toàn: CẤM sử dụng cờ '--drop-target' khi đang trỏ vào môi trường 'prod'

5. Xây dựng thư viện internal/validator dùng chung cho toàn dự án

Khi dự án Command Box của bạn lên tới 30-50 lệnh con, đừng viết lại hàm validateSQLFile hay Regex kiểm tra mã đơn hàng ở từng lệnh một. Hãy gom các hàm kiểm tra phổ biến nhất vào package internal/validator/validator.go:

  • validator.FileExistsNonEmpty(path string, allowedExts ...string) error

  • validator.DirWritable(path string) error (Thử tạo 1 file nháp .tmp rồi xóa ngay để chắc chắn user có quyền ghi vào thư mục đích trước khi chạy job xuất báo cáo dài 10 phút!)

  • validator.InRange(name string, val, min, max int) error

  • validator.OneOf(name string, val string, allowed ...string) error

  • validator.ValidOrderID(id string) error

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

Hãy mở một lệnh trong Command Box của bạn có thao tác xuất dữ liệu ra file (ví dụ --output=./reports/result.json). Hãy thử viết một hàm kiểm tra trong PreRunE đảm bảo rằng: Thư mục ./reports/ phải tồn tại và user hiện tại có quyền ghi (Write Permission) vào đó trước khi chạy câu query Database nặng. Bạn sẽ không bao giờ còn gặp cảnh ngồi đợi tool chạy query mất 5 phút rồi đến bước cuối cùng mới văng lỗi permission denied không lưu được file nữa!


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í