Hình dung việc đọc JSON vào struct như chép một tờ khai tự do sang một tờ khai có ô định sẵn. JSON đến dưới dạng tự do; encoding/json là người chép nó vào các ô có kiểu cố định của struct. Vấn đề là người chép này lịch sự tới mức nguy hiểm — không bao giờ kêu lên khi có gì lệch: thứ không khớp ô nào thì lặng lẽ vứt, số quá to cho ô thì cắt bớt, ô để trống thì không phân biệt "không khai" với "khai số 0", và ô bạn quên để công khai thì người chép không nhìn thấy. Cả bốn cái bẫy của bài hôm nay đều là cùng một tính nết đó. encoding/json nằm trong thư viện chuẩn và bạn sẽ dùng hằng ngày; đây là bốn chỗ nó im lặng làm điều bạn không mong đợi.

Tag và quy tắc cơ bản

type Nguoi struct {
	Ten   string    `json:"ten"`
	Tuoi  int       `json:"tuoi,omitempty"`
	Bimat string    `json:"-"`
	rieng string
	Luc   time.Time `json:"luc"`
}
  {"ten":"Minh","luc":"2026-08-13T10:00:00Z"}

Ba trường biến mất, mỗi cái vì một lý do khác nhau:

Tuoi vì omitempty và giá trị là zero. Bimat vì tag json:"-". rieng vì viết thường — bài 19 đã nói: phản chiếu không thấy trường không xuất khẩu.

Cái thứ ba là bẫy số một với người mới: struct trông đúng, JSON ra thiếu trường, không có lỗi nào.

time.Time được mã hoá thành RFC 3339 tự động, và đọc lại được — đây là một trong số ít kiểu thư viện chuẩn có xử lý JSON sẵn.

Bẫy một: trường lạ bị bỏ im lặng

  {"ten":"Lan","khong_co":123}  ->  err=<nil>

Go bỏ qua trường không có trong struct, không báo gì. Với API bên ngoài thì đó là hành vi đúng — bên kia thêm trường mới không nên làm hỏng bạn.

Nhưng với cấu hình nội bộ thì nó nguy hiểm: gõ nhầm timeout thành timout và bạn im lặng dùng giá trị mặc định.

dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
  json: unknown field "khong_co"

Bật nó cho mọi tệp cấu hình. Đây là cùng bài học với FAIL_ON_UNKNOWN_PROPERTIES của Jackson ở bài 60 sê-ri Java.

Bẫy hai: số thành float64

  m["n"] kiểu float64 giá trị 42

Unmarshal vào map[string]any hoặc any thì mọi số thành float64 — kể cả số nguyên. Ép về int phải qua int(m["n"].(float64)).

Và với số lớn:

  {"id": 12345678901234567890}  ->  1.2345678901234567e+19

Mất độ chính xác. float64 chỉ giữ chính xác được 53 bit, nên ID kiểu snowflake hay số tiền tính bằng đơn vị nhỏ nhất sẽ sai.

Hai cách chữa:

dec.UseNumber()          // số thành json.Number, giữ nguyên chuỗi

hoặc — cách tôi khuyên — định nghĩa struct thay vì dùng any. Với Id int64 trong struct, Go đọc đúng.

Đây là lý do nên tránh map[string]any cho dữ liệu bạn biết cấu trúc.

Bẫy ba: không phân biệt "thiếu" với "bằng 0"

  {}       ->  N=<nil>
  {"n":0}  ->  N=0

Với trường int thường, cả hai trường hợp đều cho 0 và bạn không biết người gửi có ý gì. Với API PATCH, đây là khác biệt giữa "đừng đổi" và "đặt về 0".

Dùng con trỏ:

type Cau struct{ N *int `json:"n"` }

nil nghĩa là không có trong JSON; trỏ tới 0 nghĩa là có và bằng 0.

Cái giá là mã dùng phải kiểm nil khắp nơi, nên chỉ dùng ở những trường thật sự cần phân biệt.

Bẫy bốn: omitempty không làm điều bạn nghĩ

omitempty bỏ trường khi giá trị là zero value: 0, "", false, nil, slice/map rỗng.

Nghĩa là Tuoi: 0 biến mất — dù đó có thể là tuổi thật (trẻ sơ sinh) hoặc số dư thật.

Và nó không bỏ struct rỗng: Diem{} vẫn được ghi ra thành {"x":0,"y":0}. Muốn bỏ thì phải dùng con trỏ.

Với Go 1.24 có thêm omitzero phân biệt rõ hơn, nhưng trên 1.23 thì omitempty là thứ bạn có.

Streaming: Decoder và Encoder

// đọc từ HTTP request
var p Nguoi
if err := json.NewDecoder(r.Body).Decode(&p); err != nil { ... }

// ghi ra HTTP response
json.NewEncoder(w).Encode(p)

Dùng dạng này thay vì ReadAll rồi Unmarshal — nó không nạp cả thân request vào bộ nhớ.

Và luôn giới hạn kích thước với dữ liệu từ ngoài:

r.Body = http.MaxBytesReader(w, r.Body, 1<<20)   // 1MB

Không có nó, một request 10GB làm hết bộ nhớ máy chủ.

Tuỳ biến bằng MarshalJSON

type Tien int64      // lưu bằng xu

func (t Tien) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf(`"%d.%02d"`, t/100, t%100)), nil
}

Cài json.Marshaler và json.Unmarshaler để kiểm soát hoàn toàn. Hữu ích cho tiền, ngày tháng định dạng riêng, enum thành chuỗi.

Cẩn thận đệ quy vô hạn: gọi json.Marshal(t) bên trong MarshalJSON của chính t. Cách chuẩn là định nghĩa kiểu phụ:

type alias Tien
json.Marshal(alias(t))     // alias KHÔNG có method MarshalJSON

Hiệu năng

encoding/json dùng phản chiếu và không phải nhanh nhất. Với dịch vụ throughput cao, các thư viện như json-iterator hay sonic nhanh hơn vài lần.

Nhưng đừng đổi trước khi đo — với đa số dịch vụ, JSON không phải chỗ nghẽn, và thư viện chuẩn ổn định hơn.

Nếu chỉ nhớ một thứ, nhớ rằng người chép không bao giờ kêu — thử đúng ba mươi giây:

type T struct {
	Ten string
	tuoi int
}
j, _ := json.Marshal(T{Ten: "a", tuoi: 30})
fmt.Println(string(j))

In ra {"Ten":"a"}. Trường tuoi biến mất và không có lỗi — đúng cái ô bạn quên để công khai mà người chép không nhìn thấy. Ba mươi giây đó là lý do quy tắc chữ hoa ở bài 19 đáng nhớ.

Mẫu số chung

JSON là một định dạng không lược đồ, kiểu lỏng, còn ngôn ngữ của bạn có kiểu — nên cái ranh giới giữa hai bên vốn dĩ mất mát, và mọi thư viện JSON đều im lặng về sự mất mát đó theo mặc định. Ba chỗ lệch lặp lại ở mọi ngôn ngữ:

  • JSON chỉ có một kiểu số (một số thực IEEE), nên một ID 64-bit hay giá trị độ-chính-xác-cao bị mất chính xác khi đi qua nó. Cách chữa của cả ngành giống hệt nhau: gửi ID lớn dưới dạng chuỗi — Twitter và Discord làm đúng vậy — và chính JavaScript phải thêm BigInt cũng vì lý do này.
  • Trường lạ bị bỏ qua trừ khi bạn chủ động bật nghiêm ngặt: DisallowUnknownFields của Go, FAIL_ON_UNKNOWN_PROPERTIES của Jackson, #[serde(deny_unknown_fields)] của Rust. Dễ dãi thì tốt cho API bên ngoài, nguy hiểm cho cấu hình của chính mình.
  • "Vắng mặt / null / giá trị" là ba trạng thái JSON phân biệt được mà một kiểu hai-giá-trị thì không, nên bạn phải với tới con trỏ / Optional / nullable — đúng cái thang ba bậc của bài về NULL trong SQL.

Sợi chỉ chung đáng mang theo: một bộ chuyển đổi không bao giờ kêu ca không có nghĩa là nó đã chép dữ liệu của bạn trung thực. Sự im lặng của nó là mặc định, không phải bảo đảm. Nên ở mọi ngôn ngữ, ba thói quen giống nhau: định nghĩa một lược đồ có kiểu thay vì một túi any; bật chế độ nghiêm ngặt ở nơi bạn làm chủ cả hai đầu; và quyết định có ý thức việc "vắng mặt" ánh xạ thành gì. Người chép lịch sự sẽ không bao giờ nhắc bạn những điều đó.

Ngày mai: net/http — dựng máy chủ trong mười dòng.

Bài tập làm thử

Bài 1 (đọc hiểu). Struct sau được marshal ra JSON. Kết quả in ra là gì, và giải thích lý do mỗi trường xuất hiện hoặc biến mất?

type Nguoi struct {
	Ten   string    `json:"ten"`
	Tuoi  int       `json:"tuoi,omitempty"`
	Bimat string    `json:"-"`
	rieng string
}
j, _ := json.Marshal(Nguoi{Ten: "Minh", Bimat: "x", rieng: "y"})
fmt.Println(string(j))
Đáp án

In ra {"ten":"Minh"}. Tuoi biến mất vì có omitempty và giá trị đang là zero (0). Bimat biến mất vì tag json:"-". rieng biến mất vì viết thường — không xuất khẩu (unexported), phản chiếu của encoding/json không thấy được trường này, và không có lỗi nào báo cả.

Bài 2 (sửa lỗi). Đây là tệp cấu hình nội bộ đọc bằng JSON. Người viết gõ nhầm timeout thành timout, nhưng chương trình chạy "bình thường" với giá trị mặc định sai. Sửa đoạn mã đọc để bắt được lỗi gõ nhầm này.

var cfg Config
json.NewDecoder(r).Decode(&cfg)
Đáp án
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil {
	return err
}

Mặc định Go bỏ qua trường lạ trong JSON một cách im lặng — đúng cho API bên ngoài nhưng nguy hiểm cho cấu hình nội bộ. DisallowUnknownFields() làm Decode trả lỗi json: unknown field "timout", bắt được lỗi gõ nhầm ngay lập tức.

Bài 3 (vận dụng thực tế). Một API PATCH nhận struct Cau struct{ N int json:"n" }. Body {} (không có trường n) và body {"n":0} khi decode vào struct này cho ra cùng giá trị N == 0, nên tầng nghiệp vụ không phân biệt được "đừng đổi" với "đặt về 0". Sửa struct để phân biệt được hai trường hợp.

Đáp án
type Cau struct {
	N *int `json:"n"`
}

Dùng con trỏ: nếu JSON không có trường n, N là nil (nghĩa là "không có trong JSON, đừng đổi"); nếu JSON có "n":0, N trỏ tới giá trị 0 (nghĩa là "có, và bằng 0"). Đổi lại là mã dùng phải kiểm nil ở mọi nơi đọc N, nên chỉ nên dùng cho trường thật sự cần phân biệt ba trạng thái này.

Bài 4 (bẫy/đánh đổi). Đoạn mã dưới đây unmarshal một số nguyên rất lớn vào any. Giải thích tại sao giá trị in ra bị sai, và nêu hai cách khắc phục.

var v any
json.Unmarshal([]byte(`{"id": 12345678901234567890}`), &v)
m := v.(map[string]any)
fmt.Println(m["id"])
Đáp án

In ra một số dạng khoa học kiểu 1.2345678901234567e+19, mất độ chính xác so với số gốc. Nguyên nhân: unmarshal vào map[string]any (hay any) khiến mọi số JSON đều thành float64, mà float64 chỉ giữ chính xác được 53 bit nên ID lớn kiểu snowflake bị sai. Hai cách khắc phục: (1) gọi dec.UseNumber() trên json.Decoder để số thành json.Number giữ nguyên dạng chuỗi; (2) tốt hơn — định nghĩa struct cụ thể với trường Id int64 thay vì dùng any, Go sẽ đọc đúng giá trị.

Bài 5 (đọc hiểu — MarshalJSON đệ quy). Đoạn mã sau bị lỗi gì khi chạy, và cách sửa đúng là gì?

type Tien int64

func (t Tien) MarshalJSON() ([]byte, error) {
	return json.Marshal(t)   // gọi lại chính t
}
Đáp án

Đệ quy vô hạn dẫn tới tràn ngăn xếp: json.Marshal(t) bên trong MarshalJSON của chính kiểu Tien lại gọi tới MarshalJSON của t một lần nữa, cứ thế lặp lại mãi. Cách sửa chuẩn là định nghĩa một kiểu phụ có cùng kiểu nền nhưng không có method MarshalJSON:

type alias Tien
return json.Marshal(alias(t))

alias(t) không có method MarshalJSON nên json.Marshal dùng cách mã hoá mặc định, tránh được vòng lặp.