Go không có framework cấu hình trong thư viện chuẩn. Bài này về cách tự dựng phần bạn cần.

Ba nguồn, ba chỗ hợp lý

Cờ dòng lệnh — cho công cụ CLI và cho thứ đổi giữa các lần chạy.

cong := flag.Int("cong", 8080, "cổng lắng nghe")
flag.Parse()

Biến môi trường — cho dịch vụ trong container. Đây là chuẩn thực tế, và là thứ Kubernetes cùng Docker Compose đưa vào tự nhiên.

Tệp — cho cấu hình dài, phân cấp, hoặc cần commit vào kho mã.

Thứ tự ưu tiên thường dùng: cờ > biến môi trường > tệp > mặc định.

Gom thành một struct

Đây là phần quan trọng nhất của bài:

type CauHinh struct {
	Cong       int
	DSN        string
	MucLog     string
	HanCho     time.Duration
}

func Doc() (CauHinh, error) {
	c := CauHinh{
		Cong:   8080,
		MucLog: "info",
		HanCho: 10 * time.Second,
	}
	if v := os.Getenv("CONG"); v != "" {
		n, err := strconv.Atoi(v)
		if err != nil { return c, fmt.Errorf("CONG không phải số: %q", v) }
		c.Cong = n
	}
	c.DSN = os.Getenv("DSN")
	if c.DSN == "" { return c, errors.New("thiếu DSN") }
	return c, nil
}

Ba tính chất của mẫu này:

Đọc một lần, lúc khởi động. Không os.Getenv rải rác trong mã nghiệp vụ — đó là trạng thái toàn cục ẩn, không test được, và bạn không biết cấu hình nào thực sự đang được dùng.

Chết ngay nếu thiếu. main gọi log.Fatal khi Doc() trả lỗi. Sai cấu hình lúc khởi động rõ ràng hơn nhiều so với lỗi lúc phục vụ request thứ một nghìn.

Truyền xuống bằng tham số. New(cfg.DSN) chứ không phải hàm nào cũng tự đọc biến môi trường.

Mặc định phải hợp lý

Đúng nguyên tắc zero value ở bài 18: cấu hình rỗng nên chạy được ở môi trường phát triển.

Nhưng bí mật thì không có mặc định. DSN, khoá API, mật khẩu — thiếu là phải hỏng, không được dùng giá trị mẫu. Đây là chỗ khác biệt: mặc định tiện lợi cho thứ vô hại, bắt buộc cho thứ nhạy cảm.

Tệp cấu hình

Với JSON, thư viện chuẩn là đủ — và nhớ bật DisallowUnknownFields như bài 45:

f, err := os.Open(ten)
if err != nil { return err }
defer f.Close()
dec := json.NewDecoder(f)
dec.DisallowUnknownFields()          // gõ nhầm tên trường -> BÁO LỖI
return dec.Decode(&c)

Không có nó, timout thay vì timeout sẽ im lặng dùng mặc định.

YAML cần gopkg.in/yaml.v3. viper gói sẵn mọi thứ (tệp, env, cờ, theo dõi thay đổi) nhưng kéo theo nhiều phụ thuộc — với dịch vụ vừa, tôi thấy năm mươi dòng tự viết đủ và rõ hơn.

Với tệp mặc định nhúng luôn vào binary, dùng embed ở bài 44 — vẫn giữ được "một tệp là chạy".

Bí mật

Đừng commit bí mật vào kho mã, kể cả trong tệp cấu hình mẫu.

Đừng log cấu hình nếu nó chứa bí mật. Nếu muốn log để chẩn đoán, viết String() che đi:

func (c CauHinh) String() string {
	return fmt.Sprintf("CauHinh{Cong:%d, DSN:%s, MucLog:%s}", c.Cong, che(c.DSN), c.MucLog)
}

Bài 98 sê-ri Java đã nói: log container thường được gom về nơi khác và giữ rất lâu.

Đọc bí mật từ tệp thay vì biến môi trường khi có thể. Biến môi trường hiện trong /proc/<pid>/environ, trong docker inspect, và trong log lỗi của một số thư viện.

Kiểm tra cấu hình

func (c CauHinh) KiemTra() error {
	if c.Cong < 1 || c.Cong > 65535 { return fmt.Errorf("cổng không hợp lệ: %d", c.Cong) }
	if c.HanCho <= 0 { return errors.New("hạn chờ phải dương") }
	return nil
}

Gọi ngay sau khi đọc. Cấu hình sai bị bắt trong một mili giây thay vì gây lỗi khó hiểu sau ba giờ chạy.

Thử ba mươi giây

grep -rn 'os.Getenv' --include='*.go' . | grep -v '_test.go' | wc -l

Nếu con số lớn hơn số dòng trong hàm đọc cấu hình của bạn, nghĩa là biến môi trường đang được đọc rải rác. Gom chúng vào một chỗ là việc một buổi, và sau đó bạn nhìn một struct là biết dịch vụ cần gì để chạy.

Ngày mai: slog — gói log có cấu trúc của Go 1.21.