Bạn đã dùng mapstructure (biến map[string]any từ config/JSON thành struct), viper, hay các thư viện binding form/env. Tất cả dùng chung một cơ chế: đọc struct tag qua reflect, khớp key nguồn với field, ép kiểu, và gán. Nghe phức tạp, nhưng lõi chỉ ~30 dòng. Bài này ta tự viết một decoder như vậy — chạy được thật — để hiểu các thư viện đó từ bên trong, và để tự viết binding cho định dạng riêng khi cần.

Struct với tag tùy biến

Định nghĩa struct với tag cfg của riêng ta, hỗ trợ cả option (default=) và bỏ qua (-):

type Config struct {
	Host    string `cfg:"host"`
	Port    int    `cfg:"port"`
	Debug   bool   `cfg:"debug"`
	Timeout string `cfg:"timeout,default=30s"`  // có default khi thiếu key
	Bo      string `cfg:"-"`                    // bỏ qua field này
}

Cú pháp tag này (key + option phân tách bằng dấu phẩy) giống hệt json:"ten,omitempty" — đó là quy ước chung mà mọi thư viện tag-based theo.

Ảnh chụp đoạn mã Go nền tối minh hoạ tự viết decoder map sang struct bằng struct tag cộng reflect, hiểu mapstructure config loader từ bên trong đọc tag khớp key ép kiểu xử lý default 30 dòng reflect, một struct với tag tùy biến type Config struct Host string cfg host Port int cfg port Debug bool cfg debug Timeout string cfg timeout default 30s có default Bo string cfg gạch bỏ qua, hai duyệt field cộng phân tích tag for i bằng 0 i nhỏ hơn rt.NumField i f bằng rt.Field i tag bằng f.Tag.Get cfg if tag rỗng hoặc tag gạch continue bỏ field không tag hoặc gạch parts bằng strings.Split tag phẩy timeout default 30s key bằng parts 0 đọc option default từ parts 1, ba khớp key ép kiểu xử lý default fv bằng rv.Field i if không fv.CanSet continue field không export bỏ val ok bằng m key if không ok thiếu key dùng default if def khác rỗng setFromString fv def continue có key gán cộng ép kiểu setValue fv val setValue xử lý gán trực tiếp nếu cùng kiểu hoặc ép json number là float64 sang int string bool đây chính là logic của mapstructure, bốn vì sao đáng học hiểu mapstructure viper config loader từ trong ra tự viết binding cho định dạng riêng CSV form env tùy biến hành vi validate cộng default cộng rename trong một lượt duyệt

Hình 1: Decoder tự viết. Struct với tag cfg, duyệt field + phân tích tag, khớp key + ép kiểu + xử lý default.

Bốn bước của mọi decoder tag-based

for i := 0; i < rt.NumField(); i++ {
	f := rt.Field(i)
	tag := f.Tag.Get("cfg")
	if tag == "" || tag == "-" { continue }   // bỏ field không tag / "-"
	parts := strings.Split(tag, ",")           // "timeout,default=30s"
	key := parts[0]
	// đọc option default= từ parts[1:]
	fv := rv.Field(i)
	if !fv.CanSet() { continue }               // field không export → bỏ
	val, ok := m[key]
	if !ok {                                    // THIẾU key → dùng default
		if def != "" { setFromString(fv, def) }
		continue
	}
	setValue(fv, val)                           // CÓ key → gán + ép kiểu
}

Bốn bước cốt lõi: (1) duyệt field qua reflect, (2) đọc + phân tích tag (key + option), (3) khớp key nguồn với field và kiểm CanSet, (4) gán + ép kiểu — đây là phần khó nhất.

Ép kiểu: phần khó nhất

setValue xử lý việc gán, gồm ép kiểu khi kiểu nguồn khác kiểu field:

func setValue(fv reflect.Value, val any) {
	vv := reflect.ValueOf(val)
	if vv.Type().AssignableTo(fv.Type()) { fv.Set(vv); return }  // cùng kiểu → gán
	switch fv.Kind() {
	case reflect.Int, reflect.Int64:
		if vv.Kind() == reflect.Float64 { fv.SetInt(int64(vv.Float())) }  // json number!
	case reflect.String: fv.SetString(fmt.Sprintf("%v", val))
	case reflect.Bool: if b, ok := val.(bool); ok { fv.SetBool(b) }
	}
}

Ca kinh điển: JSON number là float64, nhưng field struct là int — phải ép. Đây là nguồn bug phổ biến khi decode JSON vào struct, và là lý do các thư viện binding phức tạp: chúng phải xử lý mọi cặp kiểu (float→int, string→time.Duration, string→enum...).

Đo thật: decoder chạy đúng

Ảnh chụp bảng kết quả đo thật nền tối decoder tự viết gán đúng 4 field cộng default cộng bỏ qua go run reflect cộng struct tag Go 1.23 arm64 map string any sang Config, input map và output struct input map bằng host coffeecode.vn port float64 8080 debug true timeout thiếu không có key bo output bằng Host coffeecode.vn Port 8080 Debug true Timeout 30s Bo rỗng, từng field xử lý đúng Field Host Tag cfg host Kết quả coffeecode.vn Cơ chế gán string trực tiếp Field Port Tag cfg port Kết quả 8080 Cơ chế ép float64 json sang int Field Debug Tag cfg debug Kết quả true Cơ chế gán bool Field Timeout Tag cfg default 30s Kết quả 30s Cơ chế thiếu key dùng default Field Bo Tag cfg gạch Kết quả rỗng Cơ chế bỏ qua tag gạch decoder 30 dòng xử lý gán trực tiếp ép kiểu json number float64 sang int là ca kinh điển option default khi thiếu key và bỏ qua field tag gạch đây chính xác là cách mapstructure viper hoạt động, bốn bước cốt lõi của mọi decoder tag-based 1 duyệt field struct qua reflect NumField Field 2 đọc cộng phân tích tag key cộng option default omitempty 3 khớp key nguồn sang field kiểm CanSet 4 gán cộng ép kiểu đây là phần khó float64 sang int string sang time, cốt lõi decoder duyệt field cộng đọc tag cộng khớp key cộng ép kiểu reflect đo thật 4 field gán đúng default khi thiếu gạch bỏ qua ép kiểu json number là float64 phải ép sang int ca hay gặp default đọc option default X từ tag khi key thiếu hiểu mapstructure viper config loader chính là mẫu này

Hình 2: Input map → output struct. Mỗi field xử lý đúng: gán string, ép float64→int (Port=8080), gán bool, dùng default (Timeout=30s), bỏ qua tag "-" (Bo rỗng).

Chạy thật với input {host:"coffeecode.vn", port:float64(8080), debug:true} (thiếu timeout):

output = {Host:coffeecode.vn Port:8080 Debug:true Timeout:30s Bo:}

Mỗi field xử lý đúng: Host gán string trực tiếp; Port ép float64 (JSON number) sang int = 8080; Debug gán bool; Timeout thiếu key nên dùng default "30s" từ tag; Bo bị bỏ qua vì tag "-". Đây chính xác là cách mapstructure/viper hoạt động — chỉ trong ~30 dòng reflect.

Ứng dụng thực tế

Viết binding cho định dạng riêng. Khi cần biến CSV row, form values, biến môi trường, hay một định dạng tùy biến thành struct theo tag, mẫu này là khung. Bạn có tag riêng (csv:"...", form:"...") và logic ép kiểu phù hợp — không phụ thuộc thư viện ngoài.

Hiểu và gỡ lỗi các thư viện tag-based. Khi mapstructure không decode đúng (field bỏ trống, ép kiểu sai), hiểu cơ chế bên trong giúp bạn biết ngay lý do: field không export (CanSet=false), tag sai, hay kiểu nguồn không ép được. Bạn đọc lỗi của chúng chính xác hơn.

Gộp nhiều hành vi trong một lượt duyệt. Vì bạn kiểm soát vòng lặp reflect, có thể gộp validate + default + rename + ép kiểu trong một lượt duyệt field — hiệu quả hơn gọi nhiều thư viện riêng cho từng việc.

Đánh đổi cần cân nhắc

Ép kiểu là hố sâu — thư viện có sẵn xử lý nhiều ca hơn. Decoder ~30 dòng của ta chỉ xử lý vài kiểu cơ bản. mapstructure xử lý map lồng nhau, slice, con trỏ, time.Duration, custom decode hook, weakly-typed input... Tự viết cho mọi trường hợp là công việc lớn và dễ sai. Chỉ tự viết khi nhu cầu đơn giản và cố định; dùng thư viện khi cần đầy đủ.

Reflection ở decoder có chi phí (bài trước). Duyệt field + ép kiểu qua reflect chậm hơn code tĩnh. Với config load (một lần lúc khởi động) không đáng lo. Nhưng nếu decode mỗi request (parse body vào struct hàng nghìn lần/giây), cân nhắc cache "kế hoạch" decode theo kiểu, hoặc codegen. Đo trước.

Struct tag không được compiler kiểm. Tag là chuỗi thô — gõ sai (cfg:"hsot" thay "host") không có lỗi biên dịch, chỉ field không được gán lúc chạy. Đây là điểm yếu chung của mọi cách tiếp cận tag-based. Test kỹ, và cân nhắc validate tag lúc khởi động (fail-fast nếu tag sai).

Ba ý mang về

  1. Mọi decoder tag-based có bốn bước qua reflect: duyệt field (NumField/Field), đọc + phân tích tag (key + option như default=), khớp key nguồn với field và kiểm CanSet, rồi gán + ép kiểu — đo thật một decoder ~30 dòng gán đúng 4 field, dùng default khi thiếu key, và bỏ qua field tag "-".
  2. Ép kiểu là phần khó nhất: đo thật, JSON number là float64 nên phải ép sang int (ca kinh điển gây bug) — thư viện đầy đủ (mapstructure) xử lý nhiều cặp kiểu, map lồng nhau, time.Duration, decode hook mà decoder tự viết đơn giản không có.
  3. Tự viết decoder để hiểu và cho nhu cầu đơn giản, dùng thư viện cho đầy đủ: mẫu này là khung cho binding định dạng riêng (CSV/form/env) và giúp gỡ lỗi mapstructure/viper — nhưng ép kiểu đầy đủ là hố sâu, reflect có chi phí (cache/codegen nếu decode nóng), và tag không được compiler kiểm (test kỹ).

Phần sau ta chuyển sang tính năng thay thế reflect ở nhiều chỗ, an toàn kiểu hơn: Phần sau mổ xẻ generics nâng cao — type set và constraint, cách định nghĩa ràng buộc kiểu phức tạp, và khi nào generics thay được reflect.