Đọ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.Marshal trả về []byte và error. Bạn luôn phải kiểm tra error.
  • Tag json:"id" đổi tên trường thành id trong JSON. Nếu không có tag, Go dùng nguyên tên trường là ID.
  • Email rỗng nên bị bỏ đi nhờ omitempty.
  • Note có tag json:"-" nên luôn bị bỏ qua.
  • createdBy bắt đầu bằng chữ thường, tức là unexported. encoding/json khô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:

  1. Key được so khớp không phân biệt hoa thường. "Customer" vẫn khớp với tag customer.
  2. 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ỗi json: 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.
  • null thay vì []. Slice nil được ghi thành null. Nếu client cần mảng rỗng, hãy khởi tạo Items: []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ặc UseNumber, hoặc tag ,string.
  • Lỗi kiểu dữ liệu bị nuốt. Khi Unmarshal gặp *json.UnmarshalTypeError, nó vẫn điền các trường khác. Đừng dùng struct đó nếu err != nil.
  • []byte thà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ùng string.
  • time.Time theo 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.Marshal bên trong MarshalJSON với chính kiểu đó. Kết quả là stack overflow. Hãy dùng alias type như ở mục 7.

Lưu ý: encoding/json dù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ệm encoding/json/v2, bật qua GOEXPERIMENT=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 Marshal và Unmarshal cho dữ liệu nhỏ.
  • Dùng struct tag (omitempty, omitzero, string, -) để điều khiển đầu ra.
  • Dùng Decoder với DisallowUnknownFields và UseNumber để đọc dữ liệu an toàn hơn.
  • Dùng Encoder và Decoder để stream dữ liệu.
  • Dùng TextMarshaler hoặc MarshalJSON cho 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.

golangjsonencoding/jsonbackend