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.

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

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ề
- 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ểmCanSet, 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 "-". - Ép kiểu là phần khó nhất: đo thật, JSON number là
float64nên phải ép sangint(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ó. - 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ỹ).
Bài tập làm thử
Bài 1 (đọc hiểu). Cho struct và input map:
type Config struct {
Host string `cfg:"host"`
Port int `cfg:"port"`
Timeout string `cfg:"timeout,default=30s"`
Bo string `cfg:"-"`
}
m := map[string]any{
"host": "coffeecode.vn",
"port": float64(8080),
"bo": "xxx",
}
Sau khi decode bằng cơ chế trong bài, giá trị của Timeout và Bo sẽ là gì? Giải thích vì sao.
Đáp án
Timeout = "30s" vì key "timeout" không có trong map, nên decoder dùng giá trị default=30s đọc từ option trong tag. Bo giữ nguyên giá trị rỗng (chuỗi ""), vì tag của nó là "-", khiến decoder continue (bỏ qua field này hoàn toàn) ngay từ bước đọc tag — dù map m có key "bo", decoder không bao giờ đọc tới nó vì field đã bị loại khỏi vòng lặp gán.
Bài 2 (sửa lỗi). Hàm setValue sau thiếu xử lý một trường hợp quan trọng được nêu trong bài — ép kiểu JSON number. Bổ sung đoạn còn thiếu.
func setValue(fv reflect.Value, val any) {
vv := reflect.ValueOf(val)
if vv.Type().AssignableTo(fv.Type()) {
fv.Set(vv)
return
}
switch fv.Kind() {
case reflect.String:
fv.SetString(fmt.Sprintf("%v", val))
case reflect.Bool:
if b, ok := val.(bool); ok {
fv.SetBool(b)
}
}
}
Đáp án
Thiếu case reflect.Int/reflect.Int64 để ép float64 (JSON number) sang int:
switch fv.Kind() {
case reflect.Int, reflect.Int64:
if vv.Kind() == reflect.Float64 {
fv.SetInt(int64(vv.Float()))
}
case reflect.String:
fv.SetString(fmt.Sprintf("%v", val))
case reflect.Bool:
if b, ok := val.(bool); ok {
fv.SetBool(b)
}
}
Thiếu case này là nguồn bug kinh điển: một field int trong struct nhận giá trị số từ JSON (luôn được decode thành float64 trong Go) sẽ không được gán vì kiểu không khớp trực tiếp (AssignableTo trả false) và không có nhánh nào xử lý ép kiểu.
Bài 3 (vận dụng thực tế). Bạn cần viết một decoder biến map[string]string (đọc từ file .env) thành một struct config, dùng tag riêng env:"...". Dựa theo bốn bước cốt lõi trong bài, hãy liệt kê các bước cần làm.
Đáp án
Bốn bước theo đúng khung của bài: (1) duyệt field của struct qua reflect (rt.NumField()/rt.Field(i)); (2) đọc và phân tích tag env của từng field (f.Tag.Get("env")), tách key và các option như default= nếu hỗ trợ, bỏ qua field không có tag hoặc tag là "-"; (3) khớp key đã đọc với giá trị tương ứng trong map nguồn, đồng thời kiểm fv.CanSet() để loại field không export; (4) gán giá trị vào field, kèm ép kiểu nếu cần (ví dụ chuỗi từ .env sang int, bool, hay time.Duration — đây là phần khó nhất, tương tự việc ép float64 sang int trong bài).
Bài 4 (đọc hiểu / bẫy). Một lập trình viên gõ nhầm tag: Host string \cfg:"hsot"`thay vì"host". Chương trình biên dịch và chạy bình thường nhưng field Host` luôn rỗng. Giải thích tại sao lỗi này không bị bắt ở biên dịch, và nêu một cách để phát hiện sớm hơn.
Đáp án
Struct tag là một chuỗi thô (string literal), compiler Go không kiểm tra nội dung của nó có khớp với key thực tế trong dữ liệu nguồn hay không — đây là điểm yếu chung của mọi cách tiếp cận dựa trên tag. Gõ sai chỉ khiến decoder không tìm thấy key "hsot" trong map nguồn, coi như thiếu key, và field giữ giá trị zero-value hoặc dùng default nếu có, không có bất kỳ lỗi hay panic nào. Cách phát hiện sớm hơn: viết test kỹ cho việc decode, hoặc thêm bước validate tag lúc khởi động ứng dụng (fail-fast) để phát hiện tag không khớp với danh sách key mong đợi.
Bài 5 (bẫy/đánh đổi). Đội của bạn định thay thế hoàn toàn thư viện mapstructure bằng decoder tự viết ~30 dòng như trong bài, áp dụng cho toàn bộ hệ thống có nhiều struct config với slice, map lồng nhau, và time.Duration. Đây có phải quyết định hợp lý không? Vì sao?
Đáp án
Không hợp lý cho trường hợp này. Bài học cảnh báo rõ: "ép kiểu là hố sâu — thư viện có sẵn xử lý nhiều ca hơn." Decoder tự viết ~30 dòng chỉ xử lý vài kiểu cơ bản (string, int, bool), trong khi mapstructure xử lý map lồng nhau, slice, con trỏ, time.Duration, decode hook, và weakly-typed input — những thứ hệ thống của đội cần. Tự viết cho đầy đủ mọi trường hợp là công việc lớn và dễ sai; theo bài, chỉ nên tự viết decoder khi nhu cầu đơn giản và cố định (ví dụ một định dạng riêng như CSV/form không có sẵn thư viện), còn khi cần đầy đủ tính năng nên dùng thư viện đã có.
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.