0

Command Box bài học 10: Quản lý phiên bản (Versioning) và lập tài liệu tự động cho CLI tool

Bạn vừa sửa xong một bug nghiêm trọng trong lệnh cbox db migrate, build lại file binary rồi gửi vào nhóm chat cho cả team tải về. Hai ngày sau, một đồng nghiệp nhắn tin báo lỗi y hệt cái bug bạn vừa sửa hôm trước. Bạn hỏi: "Anh đã tải bản mới nhất em gửi hôm thứ Ba chưa?". Đồng nghiệp gãi đầu: "Ủa anh cũng không nhớ file cbox trong /usr/local/bin của anh là bản từ tuần trước hay bản mới nữa, làm sao để kiểm tra?".

Đó là lúc bạn nhận ra: Một công cụ không có định danh phiên bản (Versioning) và tài liệu đi kèm (Documentation) thì không thể vận hành ở quy mô đội nhóm. Nhưng nếu mỗi lần sửa code bạn lại phải nhớ vào sửa tay chuỗi const Version = "1.0.2" và mở file README.md ra cập nhật lại danh sách flags, chắc chắn bạn sẽ quên chỉ sau 3 lần release.

Bài học số 10 — bài học khép lại Giai đoạn 1 (Tư duy kiến trúc & Nền tảng) — sẽ hướng dẫn bạn tự động hóa 100% hai công việc nhàm chán này: nhúng thông tin bản build động từ Git thông qua trình biên dịch Go (ldflags) và biến chính mã nguồn Cobra thành cỗ máy tự động xuất bản tài liệu Markdown / Man Pages.

image.png

(Hình minh họa: Khi các thành phần Subcommand, Option và Argument của CLI thay đổi theo thời gian, hệ thống Versioning và Tài liệu tự động sẽ giữ cho người dùng luôn đồng bộ với cấu trúc mới nhất).

1. Tại sao const Version = "1.0.0" là một cái bẫy?

Rất nhiều lập trình viên quản lý phiên bản CLI bằng cách khai báo một hằng số cứng trong code:

Go

const Version = "1.2.0" // Cách làm thủ công đầy rủi ro

Cách làm này sớm muộn cũng dẫn đến 3 vấn đề thực tế:

  1. Quên cập nhật khi hotfix: Khi đang vội sửa lỗi lúc nửa đêm, bạn sửa logic, chạy go build rồi gửi file đi ngay mà quên tăng số 1.2.0 lên 1.2.1. Kết quả là có 2 file binary khác nhau về ruột nhưng cùng in ra v1.2.0.

  2. Thiếu thông tin truy vết (Zero Traceability): Chỉ một con số 1.2.0 không cho bạn biết file binary đó được build từ mã Git Commit Hash nào, vào ngày giờ nào, và được biên dịch bằng phiên bản Go bao nhiêu.

  3. Lệch pha với Git Tag: Trên GitLab/GitHub bạn đánh tag v1.3.0, nhưng trong code vẫn nằm ở 1.2.0.

2. Chuẩn Semantic Versioning (SemVer) dành riêng cho Command Box

Với thư viện hoặc API, SemVer (MAJOR.MINOR.PATCH) đã quá quen thuộc. Nhưng đối với một CLI Tool, đâu là ranh giới để tăng từng con số? Hãy áp dụng quy ước chuẩn sau để không bao giờ làm gãy các script CI/CD đang gọi Command Box của bạn:

Thành phần Khi nào phải tăng số? Ví dụ thực tế trên Command Box
MAJOR (X.0.0)
(Thay đổi gây gãy - Breaking Changes)
Khi bạn xóa/đổi tên một sub-command, đổi tên một flag, thay đổi cấu trúc JSON xuất ra ở stdout, hoặc đổi ý nghĩa của Exit Code. Đổi cờ --id thành --order-id, hoặc xóa lệnh cbox db sync.
MINOR (1.X.0)
(Thêm tính năng tương thích ngược)
Khi thêm một sub-command mới, hoặc thêm một flag tùy chọn (optional flag) có giá trị mặc định mà không ảnh hưởng lệnh cũ. Thêm lệnh mới cbox kafka inspect, hoặc thêm cờ --timeout vào lệnh cũ.
PATCH (1.2.X)
(Sửa lỗi & Tối ưu nội bộ)
Khi sửa bug bên trong tầng internal/core, tối ưu tốc độ query, hoặc sửa lỗi chính tả trên giao diện mà không đổi cách gõ lệnh. Sửa lỗi rò rỉ kết nối MySQL trong lệnh cbox db dump.

3. Kỹ thuật ldflags: Bơm thông tin Git vào Binary ngay lúc Build

Trình biên dịch của Golang sở hữu một vũ khí cực kỳ lợi hại: cờ -ldflags "-X ...". Nó cho phép bạn ghi đè giá trị của bất kỳ biến kiểu string nào bên trong code ngay tại thời điểm gõ lệnh go build mà không cần chạm vào file mã nguồn!

Bước 1: Tạo package internal/version/version.go

Thay vì dùng const, hãy khai báo các biến var với giá trị mặc định dành cho lúc chạy thử trên máy dev:

Go

package version

import (
	"fmt"
	"runtime"
)

// Các biến này sẽ được trình biên dịch Go tự động ghi đè lúc chạy lệnh build
var (
	Version   = "dev"             // Sẽ nhận Git Tag (ví dụ: v1.4.2)
	Commit    = "none"            // Sẽ nhận mã Git Commit SHA ngắn (ví dụ: a8f9c21)
	BuildDate = "unknown"         // Sẽ nhận thời gian build chuẩn ISO-8601
)

// FullInfo trả về thông tin chi tiết để phục vụ debug và đối chiếu
func FullInfo() string {
	return fmt.Sprintf(
		"cbox version %s\n• Git Commit: %s\n• Built At:   %s\n• Go Version: %s\n• OS/Arch:    %s/%s",
		Version, Commit, BuildDate, runtime.Version(), runtime.GOOS, runtime.GOARCH,
	)
}

Bước 2: Tự động hóa hoàn toàn trong Makefile

Trong file Makefile ở thư mục gốc, bạn dùng các lệnh của git và date để lấy thông số thực tế ngay tại thời điểm build và truyền thẳng vào go build:

Makefile

MODULE_NAME := github.com/your-org/cbox
VERSION     := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
COMMIT      := $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
BUILD_DATE  := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")

LDFLAGS := -s -w \
	-X '$(MODULE_NAME)/internal/version.Version=$(VERSION)' \
	-X '$(MODULE_NAME)/internal/version.Commit=$(COMMIT)' \
	-X '$(MODULE_NAME)/internal/version.BuildDate=$(BUILD_DATE)'

.PHONY: build
build:
	@echo "🔨 Đang đóng gói Command Box phiên bản $(VERSION)..."
	go build -ldflags="$(LDFLAGS)" -o bin/cbox ./cmd/cbox

Lưu ý điểm đắt giá: Cờ --dirty trong lệnh git describe --tags --always --dirty sẽ tự động gắn thêm đuôi -dirty (ví dụ: v1.2.0-dirty) nếu bạn sửa code trên máy mà chưa commit đã vội build gửi cho người khác. Nhìn vào đuôi -dirty khi đồng nghiệp gõ cbox version, bạn biết ngay họ đang chạy một bản build nháp không chính thống!

4. Lập tài liệu tự động (Docs-as-Code): Viết code xong là có sẵn Wiki & Man Pages

Nỗi ám ảnh lớn nhất của người làm công cụ là: Thêm flag mới vào code nhưng quên sửa file README.md. Người dùng đọc Wiki thấy ghi cờ --user, gõ vào thì báo lỗi vì trong code đã đổi thành --username từ tháng trước.

Với nguyên lý Single Source of Truth (Một nguồn sự thật duy nhất), bạn tuyệt đối không viết tài liệu hướng dẫn sử dụng bằng tay. Vì bạn đã khai báo đầy đủ các trường Use, Short, Long, Example và danh sách Flags trong Cobra, hãy dùng chính gói [github.com/spf13/cobra/doc](https://github.com/spf13/cobra/doc) để tự động xuất toàn bộ cây lệnh của Command Box ra bộ tài liệu Markdown hoặc Linux Man Pages chỉ trong 0.1 giây!

Thực hành viết lệnh ẩn cbox docs generate:

Go

package cli

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
	"github.com/spf13/cobra/doc"
)

func NewDocsCmd(rootCmd *cobra.Command) *cobra.Command {
	var outDir string
	var format string

	cmd := &cobra.Command{
		Use:    "gen-docs",
		Short:  "Tự động sinh tài liệu Markdown và Man Pages từ cây lệnh hiện tại",
		Hidden: true, // Ẩn khỏi menu --help của người dùng thông thường, chỉ dùng trong CI/CD
		RunE: func(cmd *cobra.Command, args []string) error {
			if err := os.MkdirAll(outDir, 0755); err != nil {
				return err
			}

			// Tắt dòng chữ mặc định "Auto generated by spf13/cobra on..." để tránh làm bẩn Git diff
			rootCmd.DisableAutoGenTag = true

			switch format {
			case "markdown", "md":
				if err := doc.GenMarkdownTree(rootCmd, outDir); err != nil {
					return err
				}
				fmt.Printf("✔ Đã xuất bản bộ tài liệu Markdown tại: %s\n", outDir)

			case "man":
				header := &doc.GenManHeader{
					Title:   "CBOX",
					Section: "1",
					Source:  "Internal Engineering Tools",
				}
				if err := doc.GenManTree(rootCmd, header, outDir); err != nil {
					return err
				}
				fmt.Printf("✔ Đã xuất bản Linux Man Pages tại: %s (Thử chạy: man %s/cbox.1)\n", outDir, outDir)

			default:
				return fmt.Errorf("định dạng '%s' không hỗ trợ (chọn: md | man)", format)
			}
			return nil
		},
	}

	cmd.Flags().StringVarP(&outDir, "out", "o", "./docs", "Thư mục lưu tài liệu sinh ra")
	cmd.Flags().StringVarP(&format, "format", "f", "md", "Định dạng xuất bản (md|man)")
	return cmd
}

Điều kỳ diệu gì sẽ xảy ra khi bạn chạy cbox gen-docs -o ./docs -f md?

  • Cobra sẽ tự động duyệt qua toàn bộ 50 lệnh con trong Command Box của bạn.

  • Tự động tạo ra các file cbox.md, cbox_order.md, cbox_order_sync.md, cbox_db_dump.md...

  • Trong mỗi file có sẵn cú pháp chuẩn, danh sách đầy đủ các Flags cục bộ, Flags kế thừa từ lệnh cha (PersistentFlags), kiểu dữ liệu, giá trị mặc định, phần Example và cả đường link liên kết chéo (Cross-links) giữa lệnh cha và lệnh con!

  • Nếu xuất ra định dạng man và copy vào /usr/local/share/man/man1/, anh em trong team có thể gõ thẳng man cbox-order-sync trên Linux/macOS để đọc tài liệu chuẩn mực như các lệnh hệ thống man ls hay man git!

5. Mẹo nâng cao: Nhắc cập nhật phiên bản mới không gây trễ (Non-blocking Update Notifier)

Khi bạn phát hành phiên bản v1.5.0 lên kho nội bộ, làm sao để 20 thành viên trong team biết mà cập nhật nếu họ vẫn đang dùng bản v1.2.0 cũ kỹ?

Đừng kiểm tra phiên bản mới đồng bộ mỗi lần chạy lệnh (vì sẽ làm lệnh bị chậm đi vài trăm mili-giây khi mạng lag). Hãy áp dụng cơ chế Kiểm tra định kỳ lưu đệm (Cached State Check) giống như gh (GitHub CLI) hay brew:

  1. Mỗi ngày tối đa 1 lần (kiểm tra mốc thời gian last_checked_at lưu trong ~/.cbox/state.json), Command Box mới gọi API nhẹ kiểm tra bản release mới nhất ở luồng phụ.

  2. Nếu phát hiện bản trên server mới hơn bản hiện tại (so sánh SemVer), ở cuối câu lệnh (trong hàm PersistentPostRun), hãy in một dòng thông báo nhẹ nhàng ra stderr:

    Plaintext

    💡 Đã có phiên bản Command Box mới: v1.2.0 -> v1.5.0
       Gõ 'cbox upgrade' để cập nhật các tính năng mới nhất!
    
    

💡 Bài tập thực hành cuối bài (Khép lại Giai đoạn 1):

Hãy thêm cấu hình LDFLAGS vào Makefile và tích hợp lệnh gen-docs vào dự án Command Box của bạn. Thử chạy make build && ./bin/cbox --version để chiêm ngưỡng mã Git Commit thật sự xuất hiện trong binary, sau đó chạy ./bin/cbox gen-docs và mở thư mục ./docs lên xem toàn bộ tài liệu dự án được viết tự động trong nháy mắt!

Vậy là chúng ta đã hoàn thành trọn vẹn 10 bài học đầu tiên (Giai đoạn 1: Tư duy kiến trúc & Nền tảng) của series Command Box! Từ Bài học 11 trở đi, chúng ta sẽ bước vào Giai đoạn 2: Thiết kế CLI, Parser & Trải nghiệm người dùng chuyên sâu.


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í