GoのencOding/jsonを使ってJSONを読み書きする

1. 概要

JSONはサービス間で最も一般的に使われるデータ交換形式です。Goはstandard libraryのencoding/jsonパッケージでJSON をネイティブにサポートしており、追加インストールは不要です。

この記事では、構造体をJSONに変換する方法とその逆、構造体タグでフィールド名を制御する方法、動的JSONの処理、大規模データのストリーミングについて説明します。カスタム型の書き方とそのテスト方法についても学びます。最後に本番環境に投入する際に避けるべき一般的なエラーを紹介します。

例ではGo 1.24以降を使用しています。

2. json.Marshalを使ったJSONの書き込み

では、顧客情報を含むorder(注文)から始めましょう:

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{"コーヒー", "サンドイッチ"},
		Total:     45000,
		Note:      "午前9時前配達",
		createdBy: "admin",
	}

	data, err := json.Marshal(order)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}

結果:

{"id":1001,"customer":{"name":"Lan"},"items":["コーヒー","サンドイッチ"],"total":45000}

上のコードで注意すべき点がいくつかあります:

  • json.Marshalは[]byteとerrorを返します。常にerrorをチェックする必要があります。
  • タグjson:"id"でJSON内のフィールド名をidに変更します。タグがない場合、Goは元のフィールド名IDを使用します。
  • Emailが空なので、omitemptyにより省略されます。
  • Noteにはタグjson:"-"があるため、常に省略されます。
  • createdByは小文字で始まっており、unexportedです。encoding/jsonはこのフィールドを見ることができません。
  • ベトナム語の文字はUTF-8形式のままで、エスケープされません。

読みやすいJSON(ログやconfig fileに保存する場合など)が必要な場合は、json.MarshalIndentを使用します:

data, _ := json.MarshalIndent(order.Customer, "", "  ")
fmt.Println(string(data))
{
  "name": "Lan"
}

3. 構造体タグ:詳細な出力制御

タグjsonはjson:"name,option,..."の形式です。よく使われるオプション:

タグ 意味
json:"name" フィールド名を変更
json:",omitempty" ゼロ値の場合フィールドを省略:""、0、false、nil、empty slice or map
json:",omitzero" ゼロ値の場合フィールドを省略(time.Timeのようなstructも含む、Go 1.24+)
json:",string" 数値またはboolを文字列として書き込む
json:"-" 常に省略

omitemptyはstructに対しては機能しません。以下の例でその違いを示します:

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が表示されるのは、omitemptyがtime.Timeのようなstructの空の値を「empty」と見なさないためです。PaidAtはomitzeroを使用しているため、期待通り省略されます。OrderIDはstringオプションにより文字列"1001"として書き込まれます。これはクライアントがJavaScriptで64ビット整数を正確に読むことができない場合に役立ちます。

4. json.Unmarshalを使ったJSONの読み込み

逆の方向では、ターゲット変数へのポインタを渡します:

input := []byte(`{
	"id": 1002,
	"Customer": {"name": "Minh", "email": "minh@example.com"},
	"items": ["ピーチティー"],
	"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:[ピーチティー] Total:20000 Note: createdBy:}

デフォルトの動作に注意してください:

  1. キーマッチングは大文字小文字を区別しません。"Customer"はcustomerタグとマッチします。
  2. "coupon"のような未知のキーは黙って無視されます。JSONにないフィールドは現在の値のままです。

注意: ポインタではなく値を渡した場合(例:json.Unmarshal(input, order))、json: Unmarshal(non-pointer main.Order)エラーが発生します。データはどこにも書き込まれません。

4.1 未知フィールドの拒否

クライアントからのrequestを受け取るAPIでは、未知のキーを無視するとフィールド名の入力ミスが隠れやすくなります。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はDecoderにのみあり、json.Unmarshalに相当するオプションはありません。

5. 動的JSON

事前に構造がわからない場合(例えばthird partyのwebhook)、map[string]anyにdecodeできます:

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

すべての数値はfloat64にdecodeされます。そのためorder_idは精度が失われます:元の値の最後の桁は3ですが、結果の最後の桁は2になります。これは見つけるのが非常に難しいバグです。

修正方法はUseNumberを呼び出すことで、数値を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

payloadの一部のみが動的な場合、json.RawMessageを使ってその部分のdecodeを遅延させることができます:

type Event struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`
}

Typeを読んだ後、適切なstructでjson.Unmarshal(e.Data, &order)を呼び出します。このアプローチはmapの各キーを検索するより型安全です。

6. EncoderとDecoderでのデータストリーミング

MarshalとUnmarshalは[]byteで動作するため、全データがメモリに収まる必要があります。json.Encoderとjson.Decoderはio.Writerとio.Readerで直接動作します。HTTPボディ、ファイル、NDJSON形式のデータ(1行が1つの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}

ループはio.EOFに到達するまでDecodeを呼び出し、毎回1つのオブジェクトを読みます。Encodeは各値の後に自動的に改行を追加します。SetEscapeHTML(false)は<、>、&が\u003c、\u003e、\u0026に変換されるのを防ぎます。デフォルトではGoはこれらの文字をエスケープしてHTMLにJSONを埋め込む場合に安全にします。

HTTPハンドラーでは、このパターンを正確に使用してください:

func createOrder(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 最大 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はbodyサイズを制限します。これにより、クライアントが巨大なpayloadを送信してサーバーのメモリを枯渇させることを防げます。

7. カスタム型

order statusがintで保存されているがJSONでは文字列で表示する必要があると仮定します。encoding.TextMarshalerと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 が無効です: %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 が無効です: %q", b)
}

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はvalue receiverを使うためStatusと*Statusの両方で動作します。UnmarshalTextは値を修正する必要があるため、pointer receiverが必須です。json.MarshalerではなくTextMarshalerを使う利点があります:Status型はmapのキーとして使用できます。例えばmap[Status]intなど。

オブジェクト全体を制御する必要がある場合はMarshalJSONを書きます。無限再帰エラーに注意:同じ型で内部からjson.Marshal(o)を呼ぶと、関数は自分自身を呼び出します。解決策はalias typeを使うことです:

func (o Order) MarshalJSON() ([]byte, error) {
	type alias Order // alias は MarshalJSON メソッドを持たない
	return json.Marshal(struct {
		alias
		ItemCount int `json:"item_count"`
	}{alias(o), len(o.Items)})
}

aliasはOrderと同じフィールドを持ちますが、methodがないためjson.Marshalはデフォルトの動作を使用します。embeddedフィールドは外側のオブジェクトに「フラット化」され、item_countが追加されます。

8. テストの書き方

round-tripテスト(書き込んでから読み込みなおす)は、誰かがタグやカスタムロジックを変更した場合にエラーを検出するのに役立ちます。ファイル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("marshaling エラー: %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("unmarshaling エラー: %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("unknown field エラーを期待しましたが nil を受け取りました")
	}
}
go test ./...
ok  	example.com/shop	0.003s

テーブル駆動型テスト(table-driven tests)はGoで一般的な書き方です。各ケースはt.Runで個別に実行されるため、ケースが失敗したときどのケースが失敗したかすぐにわかります。

9. よくあるエラー

  • JSONでフィールドが表示されない。 ほぼ常に、フィールド名が小文字(unexported)であるためです。大文字に変更してタグでJSON名を設定してください。
  • nullの代わりに[]。 nil sliceはnullとして書き込まれます。クライアントが空配列を必要とする場合は、Items: []string{}として初期化してください。
  • 大きな数値の精度喪失。 anyにdecodeする場合に発生します。具体的な型(int64)を持つstructを使うか、UseNumberを使うか、タグ,stringを使用してください。
  • 型エラーが呑み込まれる。 Unmarshalが*json.UnmarshalTypeErrorに遭遇しても、他のフィールドを埋めます。err != nilの場合、そのstructを使用しないでください。
  • []byteがbase64文字列になる。 これは意図的な動作です。通常のテキストが必要な場合は、stringを使用してください。
  • time.TimeはRFC 3339形式。 dd/MM/yyyyのような別の形式が必要な場合は、カスタム型を書く必要があります。
  • MarshalJSON内で同じ型に対してjson.Marshalを呼び出す。 stack overflowが発生します。セクション7のようにalias typeを使用してください。

注意: encoding/jsonはreflectionを使うため、最も高速な選択肢ではありません。ほとんどのAPIでは十分な速度です。profilingでJSONがボトルネックであることが判明した場合のみ、code生成ライブラリの使用を検討すべきです。Go 1.25には実験的なencoding/json/v2もあり、GOEXPERIMENT=jsonv2で有効にできます。このバージョンはまだ安定していないため、本番環境には適していません。

10. まとめ

encoding/jsonの主な操作を学びました:

  • 小規模なデータにはMarshalとUnmarshalを使用します。
  • 出力を制御するために構造体タグ(omitempty、omitzero、string、-)を使用します。
  • より安全にデータを読むために、DisallowUnknownFieldsとUseNumberを使用したDecoderを使用します。
  • データをストリーミングするにはEncoderとDecoderを使用します。
  • カスタム型にはTextMarshalerまたはMarshalJSONを使用します。

一般的な原則は、map[string]anyではなく、明確に型付けされたstructを優先することと、常にerrorをチェックすることです。カスタム型にはround-tripテストも書くべきです。これら3つの習慣があれば、Goで書かれたサービスのJSONエラーのほとんどを避けられます。

golangjsonencoding/jsonbackend