Đọc và ghi JSON trong Go với encoding/json
1. Tổng quan
JSON là định dạng trao đổi dữ liệu phổ biến nhất giữa các service. Go hỗ trợ JSON sẵn trong thư viện chuẩn qua package encoding/json, bạn không cần cài thêm gì.
Bài này giúp bạn chuyển struct sang JSON và ngược lại, điều khiển tên trường bằng struct tag, xử lý JSON động và stream dữ liệu lớn. Bạn cũng sẽ biết cách viết kiểu tuỳ biến và test chúng. Cuối bài là các lỗi hay gặp để bạn tránh khi đưa code lên production.
Các ví dụ dùng Go 1.24 trở lên.
2. Ghi JSON với json.Marshal
Ta bắt đầu với một đơn hàng gồm thông tin khách hàng:
package main
import (
"encoding/json"
"fmt"
"log"
)
type Customer struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
}
type Order struct {
ID int `json:"id"`
Customer Customer `json:"customer"`
Items []string `json:"items"`
Total float64 `json:"total"`
Note string `json:"-"`
createdBy string
}
func main() {
order := Order{
ID: 1001,
Customer: Customer{Name: "Lan"},
Items: []string{"Cà phê", "Bánh mì"},
Total: 45000,
Note: "giao trước 9h",
createdBy: "admin",
}
data, err := json.Marshal(order)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}
Kết quả:
{"id":1001,"customer":{"name":"Lan"},"items":["Cà phê","Bánh mì"],"total":45000}
Có vài điểm cần chú ý trong đoạn code trên:
json.Marshaltrả về[]bytevàerror. Bạn luôn phải kiểm traerror.- Tag
json:"id"đổi tên trường thànhidtrong JSON. Nếu không có tag, Go dùng nguyên tên trường làID. Emailrỗng nên bị bỏ đi nhờomitempty.Notecó tagjson:"-"nên luôn bị bỏ qua.createdBybắt đầu bằng chữ thường, tức là unexported.encoding/jsonkhông nhìn thấy trường này.- Ký tự tiếng Việt được giữ nguyên dạng UTF-8, không bị escape.
Nếu cần JSON dễ đọc, ví dụ để ghi log hoặc ghi file cấu hình, bạn dùng json.MarshalIndent:
data, _ := json.MarshalIndent(order.Customer, "", " ")
fmt.Println(string(data))
{
"name": "Lan"
}
3. Struct tag: điều khiển chi tiết đầu ra
Tag json có dạng json:"tên,tuỳ_chọn,...". Các tuỳ chọn hay dùng:
| Tag | Ý nghĩa |
|---|---|
json:"name" |
Đổi tên trường |
json:",omitempty" |
Bỏ trường nếu là zero value: "", 0, false, nil, slice hoặc map rỗng |
json:",omitzero" |
Bỏ trường nếu là zero value, kể cả struct như time.Time (Go 1.24+) |
json:",string" |
Ghi số hoặc bool dưới dạng chuỗi |
json:"-" |
Luôn bỏ qua |
omitempty không có tác dụng với struct. Ví dụ dưới đây cho thấy sự khác biệt:
type Shipment struct {
OrderID int64 `json:"order_id,string"`
ShippedAt time.Time `json:"shipped_at,omitempty"`
PaidAt time.Time `json:"paid_at,omitzero"`
}
data, _ := json.Marshal(Shipment{OrderID: 1001})
fmt.Println(string(data))
{"order_id":"1001","shipped_at":"0001-01-01T00:00:00Z"}
ShippedAt vẫn xuất hiện vì omitempty không coi struct time.Time rỗng là “empty”. PaidAt dùng omitzero nên bị bỏ đúng như mong đợi. OrderID được ghi thành chuỗi "1001" nhờ tuỳ chọn string. Cách này hữu ích khi client là JavaScript và không đọc được số nguyên 64-bit chính xác.
4. Đọc JSON với json.Unmarshal
Chiều ngược lại, bạn truyền vào con trỏ tới biến đích:
input := []byte(`{
"id": 1002,
"Customer": {"name": "Minh", "email": "minh@example.com"},
"items": ["Trà đào"],
"total": 20000,
"coupon": "SALE10"
}`)
var order Order
if err := json.Unmarshal(input, &order); err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", order)
{ID:1002 Customer:{Name:Minh Email:minh@example.com} Items:[Trà đào] Total:20000 Note: createdBy:}
Bạn để ý hai hành vi mặc định:
- Key được so khớp không phân biệt hoa thường.
"Customer"vẫn khớp với tagcustomer. - Key lạ như
"coupon"bị bỏ qua im lặng. Trường không có trong JSON giữ nguyên giá trị hiện tại.
Lưu ý: Nếu bạn truyền giá trị thay vì con trỏ, ví dụ
json.Unmarshal(input, order), bạn sẽ nhận lỗijson: Unmarshal(non-pointer main.Order). Dữ liệu không được ghi vào đâu cả.
4.1. Từ chối trường lạ
Với API nhận request từ client, bỏ qua key lạ thường che giấu lỗi gõ sai tên trường. Bạn có thể bật chế độ nghiêm ngặt qua json.Decoder:
dec := json.NewDecoder(bytes.NewReader(input))
dec.DisallowUnknownFields()
var order Order
err := dec.Decode(&order)
fmt.Println(err)
json: unknown field "coupon"
DisallowUnknownFields chỉ có trên Decoder, không có tuỳ chọn tương đương cho json.Unmarshal.
5. JSON động với map[string]any
Khi không biết trước cấu trúc, ví dụ webhook từ bên thứ ba, bạn có thể decode vào map[string]any:
var payload map[string]any
_ = json.Unmarshal([]byte(`{"event":"order.paid","order_id":9007199254740993}`), &payload)
fmt.Printf("%T %v\n", payload["event"], payload["event"])
fmt.Printf("%T %.0f\n", payload["order_id"], payload["order_id"])
string order.paid
float64 9007199254740992
Mọi số được decode thành float64. Vì vậy order_id bị mất chính xác: giá trị gốc tận cùng bằng 3 nhưng kết quả tận cùng bằng 2. Đây là lỗi rất khó phát hiện.
Cách khắc phục là gọi UseNumber, để số được giữ nguyên dạng chuỗi json.Number:
dec := json.NewDecoder(strings.NewReader(`{"order_id":9007199254740993}`))
dec.UseNumber()
var payload map[string]any
_ = dec.Decode(&payload)
id, _ := payload["order_id"].(json.Number).Int64()
fmt.Println(id)
9007199254740993
Nếu chỉ một phần payload là động, bạn có thể dùng json.RawMessage để hoãn việc decode phần đó:
type Event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
Sau khi đọc Type, bạn gọi json.Unmarshal(e.Data, &order) với struct phù hợp. Cách này an toàn kiểu hơn nhiều so với dò từng key trong map.
6. Stream dữ liệu với Encoder và Decoder
Marshal và Unmarshal làm việc trên []byte, nên toàn bộ dữ liệu phải nằm trong bộ nhớ. json.Encoder và json.Decoder làm việc trực tiếp với io.Writer và io.Reader. Chúng phù hợp với HTTP body, file và dữ liệu NDJSON (mỗi dòng là một JSON).
func main() {
input := strings.NewReader(`{"id":1,"total":10000}
{"id":2,"total":25000}
{"id":3,"total":5000}`)
dec := json.NewDecoder(input)
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
sum := 0.0
for {
var o Order
err := dec.Decode(&o)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
log.Fatal(err)
}
sum += o.Total
}
_ = enc.Encode(map[string]float64{"sum": sum})
}
{"sum":40000}
Vòng lặp gọi Decode cho tới khi gặp io.EOF, mỗi lần đọc đúng một object. Encode tự thêm ký tự xuống dòng sau mỗi giá trị. SetEscapeHTML(false) ngăn Go đổi <, >, & thành \u003c, \u003e, \u0026. Mặc định Go escape các ký tự này để an toàn khi nhúng JSON vào HTML.
Trong HTTP handler, bạn dùng đúng mẫu này:
func createOrder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // tối đa 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var o Order
if err := dec.Decode(&o); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(o)
}
http.MaxBytesReader giới hạn kích thước body. Nhờ vậy client không thể gửi payload khổng lồ làm cạn bộ nhớ của server.
7. Kiểu tuỳ biến
Giả sử trạng thái đơn hàng lưu bằng int trong code nhưng phải hiển thị dạng chuỗi trong JSON. Bạn implement encoding.TextMarshaler và encoding.TextUnmarshaler:
type Status int
const (
StatusPending Status = iota
StatusPaid
StatusShipped
)
var statusNames = map[Status]string{
StatusPending: "pending",
StatusPaid: "paid",
StatusShipped: "shipped",
}
func (s Status) MarshalText() ([]byte, error) {
name, ok := statusNames[s]
if !ok {
return nil, fmt.Errorf("status không hợp lệ: %d", s)
}
return []byte(name), nil
}
func (s *Status) UnmarshalText(b []byte) error {
for k, v := range statusNames {
if v == string(b) {
*s = k
return nil
}
}
return fmt.Errorf("status không hợp lệ: %q", b)
}
Khi dùng trong struct:
type OrderStatus struct {
ID int `json:"id"`
Status Status `json:"status"`
}
data, _ := json.Marshal(OrderStatus{ID: 1001, Status: StatusPaid})
fmt.Println(string(data))
{"id":1001,"status":"paid"}
MarshalText dùng value receiver nên hoạt động với cả Status và *Status. UnmarshalText bắt buộc dùng pointer receiver vì nó phải sửa giá trị. Dùng TextMarshaler thay vì json.Marshaler có thêm lợi ích: kiểu Status dùng được làm key của map, ví dụ map[Status]int.
Khi cần kiểm soát toàn bộ object, bạn viết MarshalJSON. Cẩn thận lỗi đệ quy vô hạn: nếu bên trong gọi json.Marshal(o) với chính kiểu đó, hàm sẽ tự gọi lại chính nó. Cách xử lý là dùng alias type:
func (o Order) MarshalJSON() ([]byte, error) {
type alias Order // alias không mang method MarshalJSON
return json.Marshal(struct {
alias
ItemCount int `json:"item_count"`
}{alias(o), len(o.Items)})
}
alias có cùng các trường như Order nhưng không có method, nên json.Marshal dùng hành vi mặc định. Trường embedded được “trải phẳng” ra object ngoài, kèm thêm item_count.
8. Viết test
Test round-trip, tức là ghi rồi đọc lại, giúp bạn bắt lỗi khi ai đó đổi tag hoặc logic tuỳ biến. File status_test.go:
package main
import (
"bytes"
"encoding/json"
"testing"
)
func TestStatusRoundTrip(t *testing.T) {
tests := []struct {
status Status
want string
}{
{StatusPending, `{"id":1,"status":"pending"}`},
{StatusPaid, `{"id":1,"status":"paid"}`},
{StatusShipped, `{"id":1,"status":"shipped"}`},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
data, err := json.Marshal(OrderStatus{ID: 1, Status: tt.status})
if err != nil {
t.Fatalf("marshal lỗi: %v", err)
}
if string(data) != tt.want {
t.Errorf("got %s, want %s", data, tt.want)
}
var back OrderStatus
if err := json.Unmarshal(data, &back); err != nil {
t.Fatalf("unmarshal lỗi: %v", err)
}
if back.Status != tt.status {
t.Errorf("got %v, want %v", back.Status, tt.status)
}
})
}
}
func TestRejectUnknownField(t *testing.T) {
dec := json.NewDecoder(bytes.NewBufferString(`{"id":1,"coupon":"X"}`))
dec.DisallowUnknownFields()
var o OrderStatus
if err := dec.Decode(&o); err == nil {
t.Fatal("mong đợi lỗi unknown field, nhận nil")
}
}
go test ./...
ok example.com/shop 0.003s
Test dạng bảng (table-driven) là cách viết quen thuộc trong Go. Mỗi case chạy trong t.Run riêng, nên khi một case lỗi, bạn thấy ngay case nào sai.
9. Lỗi hay gặp
- Trường không xuất hiện trong JSON. Gần như luôn do tên trường viết thường (unexported). Hãy đổi thành chữ hoa và dùng tag để đặt tên JSON.
nullthay vì[]. Slicenilđược ghi thànhnull. Nếu client cần mảng rỗng, hãy khởi tạoItems: []string{}.- Mất chính xác số lớn. Xảy ra khi decode vào
any. Hãy dùng struct có kiểu cụ thể (int64), hoặcUseNumber, hoặc tag,string. - Lỗi kiểu dữ liệu bị nuốt. Khi
Unmarshalgặp*json.UnmarshalTypeError, nó vẫn điền các trường khác. Đừng dùng struct đó nếuerr != nil. []bytethành chuỗi base64. Đây là hành vi có chủ ý. Nếu bạn muốn văn bản thường, hãy dùngstring.time.Timetheo RFC 3339. Muốn định dạng khác nhưdd/MM/yyyy, bạn phải viết kiểu tuỳ biến.- Gọi
json.Marshalbên trongMarshalJSONvới chính kiểu đó. Kết quả là stack overflow. Hãy dùng alias type như ở mục 7.
Lưu ý:
encoding/jsondùng reflection nên không phải lựa chọn nhanh nhất. Với đa số API, tốc độ của nó hoàn toàn đủ. Chỉ khi profiling cho thấy JSON là nút thắt, bạn mới nên cân nhắc thư viện sinh code. Go 1.25 cũng có bản thử nghiệmencoding/json/v2, bật quaGOEXPERIMENT=jsonv2. Bản này chưa ổn định nên chưa phù hợp cho production.
10. Kết luận
Bạn đã đi qua các thao tác chính với encoding/json:
- Dùng
MarshalvàUnmarshalcho dữ liệu nhỏ. - Dùng struct tag (
omitempty,omitzero,string,-) để điều khiển đầu ra. - Dùng
DecodervớiDisallowUnknownFieldsvàUseNumberđể đọc dữ liệu an toàn hơn. - Dùng
EncodervàDecoderđể stream dữ liệu. - Dùng
TextMarshalerhoặcMarshalJSONcho kiểu tuỳ biến.
Nguyên tắc chung là ưu tiên struct có kiểu rõ ràng thay vì map[string]any, và luôn kiểm tra error. Bạn cũng nên viết test round-trip cho các kiểu tuỳ biến. Ba thói quen này giúp bạn tránh phần lớn lỗi JSON trong các service viết bằng Go.