Hình dung việc đọc cấu hình như làm thủ tục ở quầy lễ tân. Có hai cách. Cách tệ: mỗi phòng trong toà nhà lại bắt bạn moi giấy tờ ra trình lại — và đến phòng thứ một nghìn mới phát hiện bạn thiếu mất một tờ. Cách đúng: quầy lễ tân đọc hết giấy tờ một lần lúc bạn vào, đóng dấu kiểm tra ngay tại chỗ, rồi phát cho bạn một tấm thẻ gọn để mang theo khắp nơi. Đọc cấu hình trong Go nên là cái quầy lễ tân đó: gom mọi cờ, biến môi trường và tệp vào một struct lúc khởi động, kiểm một lần, rồi truyền tấm thẻ ấy xuống. Go không có framework cấu hình trong thư viện chuẩn, nên bài này về cách tự dựng cái quầy đó.

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 — chính là cái quầy lễ tân:

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. Đây đúng là cái "mỗi phòng bắt trình giấy lại".

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. Đây là tấm thẻ bạn phát ra từ quầy.

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 — đây là con dấu kiểm tra ở quầy. 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.

Muốn biết dịch vụ của mình đã có quầy lễ tân chưa, đếm một con số:

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, nghĩa là biến môi trường đang được moi ra rải rác khắp mã. 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.

Mẫu số chung

Việc đọc cấu hình có hai trục mà mọi ngôn ngữ đều phải chọn, và Go đứng ở một chỗ rất đặc trưng trên cả hai.

Trục thứ nhất: tự viết hay dùng framework. Go nghiêng hẳn về tự viết — "năm mươi dòng thắng một phụ thuộc" là đúng tinh thần của nó. Các ngôn ngữ khác rải đều trên phổ: Rust có envconfig/figment gọn nhẹ; Node có dotenv cộng envalid hay zod để kiểm; còn Spring (bài trước) nằm hẳn ở đầu nặng-đô với cả một cơ chế @ConfigurationProperties. Không có điểm nào "đúng" tuyệt đối — nhưng bài học chung là chọn thứ nhẹ nhất mà vẫn kiểm được, vì mỗi phụ thuộc cấu hình cũng là một thứ phải hiểu và bảo trì.

Trục thứ hai, sâu hơn và đáng mang theo hơn: phân tích ở biên, đừng tra cứu rải rác. Đây chính là nguyên tắc nổi tiếng "parse, don't validate" — biến những chuỗi môi trường không kiểu thành một giá trị có kiểu và đã kiểm ngay tại cửa vào, một lần, để toàn bộ mã còn lại tin vào kiểu chứ không tin vào os.Getenv trả về gì. Cái quầy lễ tân của Go, @ConfigurationProperties của Spring, zod của Node, figment của Rust — tất cả đang làm đúng một việc: dựng một ranh giới nơi dữ liệu hỗn độn bên ngoài trở thành một struct sạch bên trong. Một khi main đã cầm tấm thẻ đã đóng dấu ấy, không hàm nào phía sau còn phải hỏi "biến này có không, có đúng định dạng không" nữa — câu hỏi đó đã được trả lời dứt điểm ở cửa. Và cái bẫy bí mật-trong-biến-môi-trường (/proc/environ, docker inspect) thì phổ quát ở mọi stack; đọc từ tệp hoặc kho bí mật là câu trả lời chung.

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

Bài tập làm thử

Bài 1 (đọc hiểu). Với thứ tự ưu tiên "cờ > biến môi trường > tệp > mặc định" mà bài viết nêu, nếu một ứng dụng có --cong=9000 trên dòng lệnh, biến môi trường CONG=8000, và tệp cấu hình ghi cong: 7000, giá trị cổng cuối cùng được dùng là bao nhiêu?

Đáp án

9000 — cờ dòng lệnh có độ ưu tiên cao nhất trong thứ tự cờ > biến môi trường > tệp > mặc định, nên nó ghi đè lên cả biến môi trường lẫn giá trị trong tệp.

Bài 2 (sửa lỗi). Hàm đọc cấu hình sau có một lỗ hổng bảo mật theo đúng nguyên tắc "bí mật thì không có mặc định" trong bài. Tìm và sửa.

func Doc() CauHinh {
	c := CauHinh{
		Cong: 8080,
		DSN:  "postgres://user:matkhaumacdinh@localhost/db",
	}
	if v := os.Getenv("DSN"); v != "" {
		c.DSN = v
	}
	return c
}
Đáp án

Lỗi: DSN (chuỗi kết nối CSDL, chứa thông tin nhạy cảm) có giá trị mặc định hard-code — nếu quên đặt biến môi trường DSN, ứng dụng vẫn "chạy được" nhưng dùng thông tin đăng nhập mẫu, dễ bị lộ hoặc bị dùng nhầm trong sản xuất. Theo bài viết, "mặc định phải hợp lý" chỉ áp dụng cho thứ vô hại; bí mật thì phải chết ngay nếu thiếu:

func Doc() (CauHinh, error) {
	c := CauHinh{Cong: 8080}
	c.DSN = os.Getenv("DSN")
	if c.DSN == "" {
		return c, errors.New("thiếu DSN")
	}
	return c, nil
}

Bài 3 (đọc hiểu). Vì sao bật dec.DisallowUnknownFields() khi đọc tệp cấu hình JSON lại quan trọng? Điều gì xảy ra nếu người vận hành gõ nhầm timout thay vì timeout mà không bật cờ này?

Đáp án

Không bật DisallowUnknownFields, một trường gõ sai tên như timout sẽ bị bộ giải mã JSON âm thầm bỏ qua (vì nó không khớp trường nào trong struct đích), và ứng dụng cứ thế dùng giá trị mặc định của timeout mà không có bất kỳ cảnh báo nào — người vận hành tưởng đã cấu hình đúng nhưng thực ra không có tác dụng gì. Bật cờ này khiến trường hợp đó trở thành lỗi được báo ngay lúc đọc tệp, thay vì một hành vi sai lặng lẽ.

Bài 4 (vận dụng thực tế). Viết một hàm KiemTra() cho struct CauHinh có hai trường Cong int và HanCho time.Duration, theo đúng mẫu "chết ngay nếu thiếu/sai" mà bài viết khuyến nghị, kiểm tra cổng hợp lệ (1-65535) và hạn chờ phải dương.

Đáp án
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 hàm này ngay sau khi đọc cấu hình lúc khởi động (main gọi log.Fatal nếu lỗi) để 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.

Bài 5 (bẫy/đánh đổi). Bài viết khuyên "đọc bí mật từ tệp thay vì biến môi trường khi có thể". Nêu lý do kỹ thuật cụ thể được đưa ra, và mô tả một cách kẻ tấn công hoặc công cụ vận hành có thể vô tình làm lộ biến môi trường chứa bí mật.

Đáp án

Lý do: biến môi trường hiện ra ở nhiều nơi hơn tệp được bảo vệ quyền truy cập — cụ thể là trong /proc/<pid>/environ (đọc được nếu có quyền truy cập tiến trình), trong kết quả docker inspect (bất kỳ ai có quyền Docker daemon đều xem được), và trong log lỗi của một số thư viện (nhiều thư viện debug in ra toàn bộ biến môi trường khi crash để hỗ trợ chẩn đoán, vô tình làm lộ bí mật). Đọc bí mật từ một tệp có quyền hạn chế (hoặc từ kho bí mật chuyên dụng) tránh được cả ba đường rò rỉ này.