2-3. encoding/json - 構造体タグ、Marshaler、ストリーミング

所要時間: 40-60分(2セッション分) コミット内容: ~/learn/go/level2/day03/ に Marshal/Unmarshal の各パターンと、独自 Marshaler の実装


このレッスンのゴール

  • omitempty の罠(bool / int のゼロ値)を回避できる
  • PATCH 用に *bool *string のポインタフィールドを設計できる
  • DisallowUnknownFields で typo フィールドを弾ける
  • Encoder/Decoder のストリーミングで巨大JSON を省メモリ処理
  • カスタム MarshalJSON/UnmarshalJSON で独自フォーマットに対応

なぜ学ぶか - API の通貨を真剣に扱う

API の通貨は JSON」。これがガタつくと API 全体が信用を失う。Go の encoding/json は強力だが、5つの罠(ゼロ値と未設定の区別不能、null 無視、巨大JSON で OOM、未知フィールド黙殺、リフレクションで遅い)がある。これを知らずに API を書くと、本番でデータ消失・OOM・互換性事故 が起きる。本章は単なる「動かす」を超えた「設計する」レベルに到達する。

前章とのつながり

1-6_構造体とメソッドjson:"name" json:"-" のタグを学んだ。本章では omitempty の罠ポインタフィールドによる nil/値の区別ストリーミング処理 など実務レベルに踏み込む。2-2_ルーティングPOST /todos ハンドラ本体がここで埋まる。

これができると何が嬉しいか

  • PATCH リクエストで「未指定フィールドはそのまま、指定フィールドは更新」 が書ける
  • 巨大ログ JSON(100MB+)を 1MB 以下のメモリで処理 できる
  • イベントバスやWebhook の遅延パース(json.RawMessage)が書ける
  • omitempty 罠で本番事故を起こさない

ストーリー導入: omitempty は「黙って消える」呪い

Done bool \json:“done,omitempty”`と書いて満足。デプロイ。1週間後、ユーザーから「通知 OFF にしたのに通知が来る」のクレーム。原因:Done = false(ユーザーが OFF を選んだ状態)が **omitempty で JSON 出力から消える** → クライアント側は「未指定 = デフォルト ON」と解釈。**この罠を知らないと必ず一度は踏む**。ポインタ化(*bool`)で nil/true/false の3状態を区別するのが本章の核心。


大前提: なぜ JSON 処理を真剣にやるか

JSON は API の 通貨。これがガタつくと API 全体が信用を失う。Go の encoding/json は標準で十分強力だが、5つのハマりどころがある:

  1. ゼロ値と「未設定」の区別ができない(Done bool だと false が「未設定」か「完了済でない」か不明)
  2. JSON の null を構造体で受けると無視される
  3. 巨大 JSON を json.Unmarshal で読むとメモリ爆発
  4. 未知フィールドを 黙って無視する(API 仕様変更を見逃す)
  5. ベンチマークすると 意外と遅い(リフレクションを使うため)

これらを知らずに JSON API を書くと、本番でデータ消失・OOM・互換性事故が起きる。


セッション①: Marshal / Unmarshal と struct tag(30分)

0. 録画スタート&作業ディレクトリ

mkdir -p ~/log ~/learn/go/level2/day03
cd ~/learn/go/level2/day03
script ~/log/go_level2_day03.log
 
# Go モジュール初期化
go mod init example.com/level2/day03

1. 基本: Marshal / Unmarshal

package main
 
import (
	"encoding/json"
	"fmt"
)
 
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}
 
func main() {
	// Marshal: Go の値 → JSON バイト列
	u := User{ID: 1, Name: "Taro", Email: "taro@example.com"}
	b, _ := json.Marshal(u)
	fmt.Println(string(b))  // {"id":1,"name":"Taro","email":"taro@example.com"}
 
	// Unmarshal: JSON バイト列 → Go の値
	raw := []byte(`{"id":2,"name":"Hanako","email":"hanako@example.com"}`)
	var u2 User
	json.Unmarshal(raw, &u2)
	fmt.Printf("%+v\n", u2)
}

encoding/json の本質

  • Marshal: Go の値を リフレクション で覗いて、json: タグを見ながら JSON テキストに変換
  • Unmarshal: JSON をパース → リフレクションで構造体のフィールドに代入

リフレクション = 実行時に型情報を取り出す機能。便利だが 遅い。後で代替ライブラリ(jsoniter, sonic, go-json)の話をする。

なぜ struct タグが必要か

Go のフィールド名は 公開フィールドが大文字始まりName)。一方 JSON は キャメルケース / スネークケース が一般的(name, user_name)。

type User struct {
    Name string `json:"name"`        // JSON では "name"
    UserName string `json:"user_name"` // JSON では "user_name"
}

タグがなければフィールド名がそのまま使われる("Name" のようにパスカルケースになる)。API は大概キャメルかスネークなので、必ずタグを付ける。

2. struct tag のオプション

type User struct {
	ID       int    `json:"id"`
	Name     string `json:"name"`
	Email    string `json:"email,omitempty"`   // 空文字なら出力しない
	Password string `json:"-"`                  // JSON から完全除外
	Age      int    `json:"age,string"`         // 数値を文字列として扱う(JS の BigInt 問題対策)
	internal string                             // 小文字なのでそもそも出力されない
}

よく使うオプション

オプション意味使い所
json:"name"キー名指定必須
json:"name,omitempty"ゼロ値なら省略Optional 表現
json:"-"完全除外パスワード、内部フィールド
json:",omitempty"キー名はそのまま、省略のみ改名したくない時
json:",string"数値を文字列化JS の Number 53bit 制限対策

omitempty の最大の落とし穴

omitempty「ゼロ値なら省略」。これが地雷:

type Task struct {
    Done bool `json:"done,omitempty"`
}

done: true」「done: false」「未指定」の3状態を表現したいが、omitempty を付けると false は JSON に出力されないfalse が bool のゼロ値だから)。

修正パターン:

// ポインタにすれば nil / true / false の3状態を表現できる
Done *bool `json:"done,omitempty"`

nil なら省略、*bool = false なら "done": false*bool = true なら "done": true

3. ポインタ vs 値 - nil と zero value の戦い

type Patch struct {
	Name  *string `json:"name,omitempty"`
	Email *string `json:"email,omitempty"`
	Age   *int    `json:"age,omitempty"`
}
 
// PUT /users/42 のリクエスト Body が {"name": "新しい名前"} だった時
// → Name は非 nil、Email と Age は nil
// → 「Name だけ更新する」という意図を表現できる

*string / *int を使う場面

Go の悩み: 「設定されていない」と「ゼロ値が設定された」を区別できない

パターン用途
string値が必須、空文字も意味のある値ではない場合
*stringnull / 未指定 / 空文字 を区別したい場合
sql.NullStringDB 連携で NULL を表現したい場合
json.RawMessageJSON 構造を保持したまま遅延パース

ポインタは PATCH リクエスト(部分更新)で特に重要。{"name": "Taro"} を受けて「Name だけ更新、他はいじらない」を表現するには、フィールドがポインタじゃないと無理。

アンチパターン: bool に omitempty を素で付ける

type Settings struct {
    Notifications bool `json:"notifications,omitempty"`
}
 
s := Settings{Notifications: false}
json.Marshal(s)  // → {}  ← Notifications が消える!

なぜNG: ユーザーが「通知 OFF」を選んだのに JSON では「設定されていない」と区別できない状態に。クライアント側の UI が「デフォルト ON」のままになり、ON 表示にもかかわらず通知が来ない、という事故。

修正: *bool にする、または omitempty を外す。

4. 未知フィールドと DisallowUnknownFields

// クライアントが {"id":1, "name":"Taro", "secret_field":"???"} を送ってきた場合
 
type User struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}
 
var u User
json.Unmarshal([]byte(`{"id":1,"name":"Taro","secret_field":"???"}`), &u)
// secret_field は黙って捨てられる!警告なし!

デフォルト挙動の罠: 未知フィールドは無視

encoding/json未知フィールドを黙って無視する。これが原因の事故:

  • API 仕様変更でクライアントが古いフィールド名を送り続ける → 黙ってデータが消える
  • typo した時に気づけない: {"emial":"x@y.com"}Email フィールドに何も入らない、エラーもなし

対策: DisallowUnknownFields

dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var u User
if err := dec.Decode(&u); err != nil {
    http.Error(w, "unknown field: "+err.Error(), http.StatusBadRequest)
    return
}

これで未知フィールドが来たら エラー にできる。書き込み API(POST/PUT/PATCH)には常に付けるべき。データ整合性とセキュリティ(過剰なフィールド注入の防止)の両方で効く。

5. Encoder / Decoder(ストリーミング)

// HTTP ハンドラでよく見るパターン
func handler(w http.ResponseWriter, r *http.Request) {
	var in User
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "bad json", 400)
		return
	}
 
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"received": in})
}

Marshal / Unmarshal vs Encoder / Decoder

関数入出力メモリ
json.Marshalバイト列を返す全体を一度メモリに載せる
json.Unmarshalバイト列を受ける全体を一度メモリに載せる
json.NewEncoder(w).Encode(v)io.Writer に書くストリーミング(少しずつ書ける)
json.NewDecoder(r).Decode(&v)io.Reader から読むストリーミング

HTTP の r.Bodyio.Readerwio.Writer。だから Encoder / Decoder が自然

ただし Encode は末尾に 改行 \n を追加することに注意(ndjson の名残)。気になる場合は bytes.Buffer + Marshal の方が制御しやすい。

6. ストリーミング処理(大JSON を省メモリで処理)

// 100MB の JSON 配列 [{...}, {...}, ...] を1件ずつ処理する
 
func processStream(r io.Reader) error {
	dec := json.NewDecoder(r)
 
	// 開きカッコ [ を読み飛ばす
	if _, err := dec.Token(); err != nil {
		return err
	}
 
	// 配列の中身を1つずつ Decode
	for dec.More() {
		var item User
		if err := dec.Decode(&item); err != nil {
			return err
		}
		// item をDB保存、ログ出力など
		fmt.Println(item.Name)
	}
 
	// 閉じカッコ ] を読む
	if _, err := dec.Token(); err != nil {
		return err
	}
	return nil
}

このコードの意義

普通に json.Unmarshal(allBytes, &slice) すると 100MB 全部をメモリに乗せて、さらにスライスのコピーで合計 200MB+ 消費する。

Decoder + Token() + More() + Decode の組み合わせなら、1件分のメモリしか使わない。バッチ ETL、ログ取り込み、データ移行の場面で必須テクニック。


セッション②: カスタム Marshaler、json.RawMessage、性能(30分)

7. json.Marshaler / json.Unmarshaler を実装する

type DateOnly time.Time
 
// json.Marshaler を実装
func (d DateOnly) MarshalJSON() ([]byte, error) {
	t := time.Time(d)
	return []byte(`"` + t.Format("2006-01-02") + `"`), nil
}
 
// json.Unmarshaler を実装
func (d *DateOnly) UnmarshalJSON(data []byte) error {
	s := strings.Trim(string(data), `"`)
	t, err := time.Parse("2006-01-02", s)
	if err != nil {
		return err
	}
	*d = DateOnly(t)
	return nil
}

カスタム Marshaler の出番

標準のシリアライズでは表現できないフォーマットを使いたい時:

  • 日付フォーマット: time.Time はデフォルト RFC3339 だが、API 仕様で 2024-01-15 だけ欲しい
  • enum を文字列で表現: Go 側は type Status int だが JSON では "active" / "pending" で出したい
  • 金額をセント単位で扱うが JSON では円表記: 内部 int(12345) → JSON "12345.67"
  • 空配列と null の使い分け: デフォルトでは nil スライスは null になる。[] にしたいなら自前で MarshalJSON
// よくある「nil スライスを [] にしたい」パターン
func (u *User) MarshalJSON() ([]byte, error) {
    if u.Friends == nil {
        u.Friends = []string{}
    }
    type Alias User  // 無限再帰回避
    return json.Marshal((*Alias)(u))
}

8. json.RawMessage - 遅延パース

type Event struct {
	Type    string          `json:"type"`
	Payload json.RawMessage `json:"payload"`  // ← 一旦バイト列のままキープ
}
 
func handle(raw []byte) {
	var e Event
	json.Unmarshal(raw, &e)
 
	switch e.Type {
	case "user.created":
		var p UserCreated
		json.Unmarshal(e.Payload, &p)
		// ...
	case "order.placed":
		var p OrderPlaced
		json.Unmarshal(e.Payload, &p)
		// ...
	}
}

json.RawMessage は「JSON のまま持っておく」型

一度に全構造を解析せず、後で必要に応じてパースしたい時に使う。

ユースケース:

  • イベントバス: 共通の「型 + ペイロード」構造で、ペイロードはイベント種別ごとに違う
  • Webhook 受信: 送信元によってボディ構造が違うが、共通フィールド(event_type 等)だけ先に読みたい
  • ログ転送: ログ本体は触らず転送先に流すだけ

9. interface{} (any) と map[string]any の罠

アンチパターン: 何でも interface{} で受ける

// NG: 何が来てもとりあえず受ける
var data map[string]any
json.Unmarshal(body, &data)
 
name := data["user"].(map[string]any)["name"].(string)
age := int(data["user"].(map[string]any)["age"].(float64))  // JSON 数値はデフォルト float64

なぜNG:

  1. 型アサーションを毎回書く → 可読性最悪
  2. 数値はデフォルト float64(int で受けると panic)
  3. 構造の変更を IDE / コンパイラが検知できない
  4. panic リスクが高い(キー欠損、型不一致で即クラッシュ)

修正: ちゃんと struct で型を定義する。型推測がつかない箇所だけ json.RawMessage で遅延パース。

10. パフォーマンス: encoding/json は意外と遅い

標準 encoding/json のボトルネック

リフレクションを使っているため、特に小さなオブジェクトを大量に処理する場面で遅い。標準だと N 件の Marshal で N 回リフレクションが走る。

ベンチマークの相対値(参考):

ライブラリ速度(小オブジェクト Marshal)
encoding/json1.0x(基準)
json-iterator/go (jsoniter)2-3x 高速
goccy/go-json3-4x 高速
bytedance/sonic5-10x 高速(amd64 のみ、SIMD 使用)

しかし、実アプリで律速になるのはほとんどの場合 DB か外部 API。「Go の JSON が遅い」は計測してから言うべき。

例外: ストリーミングログ、リアルタイム配信、超高 QPS の API ゲートウェイ → sonic などへの置き換えを検討。

// jsoniter は標準互換 API。インポートを差し替えるだけで使える
import jsoniter "github.com/json-iterator/go"
var json = jsoniter.ConfigCompatibleWithStandardLibrary

11. セキュリティ: JSON 経由の DoS

JSON を使った攻撃3パターン

(1) 巨大 JSON でメモリ枯渇

// NG: ボディ無制限で読み込み
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &v)

攻撃者が 1GB の JSON を送れば、サーバーは 1GB+ のメモリを消費して OOM。

対策: http.MaxBytesReader でボディサイズ制限。

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

(2) 深いネスト構造で CPU 枯渇

{ "a": { "a": { "a": { "a": ... } } } }

数千階層ネストすると、パーサーが再帰呼び出しで CPU 大食い & スタックオーバーフロー寸前まで行く。Go の encoding/json はネスト上限が 10000 にデフォルト設定されている(Go 1.21+ で Decoder.UseNumber 周辺の改善あり)。

対策: MaxBytesReader でそもそも巨大 JSON を弾く。

(3) JSON Hijacking(古いブラウザ) 配列を直接返す API は古いブラウザで JS インジェクションされる可能性があった。今は トップレベルを必ずオブジェクトにする のが鉄則:

// NG: 配列直
[{"id":1}, {"id":2}]
 
// OK: オブジェクトでラップ
{"items": [{"id":1}, {"id":2}]}

12. 実装: 安全な JSON API ハンドラのテンプレ

func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	// 1. Content-Type チェック
	ct := r.Header.Get("Content-Type")
	if !strings.HasPrefix(ct, "application/json") {
		return fmt.Errorf("Content-Type must be application/json, got %q", ct)
	}
 
	// 2. ボディサイズ制限
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	defer r.Body.Close()
 
	// 3. 未知フィールド禁止
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
 
	// 4. デコード
	if err := dec.Decode(dst); err != nil {
		return fmt.Errorf("invalid json: %w", err)
	}
 
	// 5. 追加データがないか確認(不正な複数オブジェクト送信を検出)
	if dec.More() {
		return fmt.Errorf("request body must contain a single JSON value")
	}
	return nil
}
 
// 使い方
func createUser(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Name  string `json:"name"`
		Email string `json:"email"`
	}
	if err := decodeJSON(w, r, &in); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	// ... バリデーション、DB 保存
}

このテンプレで防げる事故

  • 巨大ボディによる OOM
  • typo フィールドや余分なフィールド送信
  • Content-Type 偽装(form-urlencoded を JSON として誤受信)
  • 複数 JSON オブジェクトを連続送信する攻撃

書き込み系 API では全て decodeJSON を通すように設計すると、抜け漏れがなくなる。


練習課題

  1. *bool を使った PATCH 用構造体を定義し、{"done": false} を受けたら Done = false で更新するハンドラを書く
  2. 独自 Marshaler で time.Time2024-05-14 形式(日付のみ)でシリアライズ
  3. json.RawMessage を使って「type フィールド別にペイロード構造を切り替えて Decode」を実装
  4. DisallowUnknownFields() を有効にして、typo フィールドが弾かれることを確認
  5. dd if=/dev/zero bs=1M count=10 | curl --data-binary @- ... で大きなボディを送り、MaxBytesReader のエラー挙動を観察
  6. オプション: jsoniter に置き換え、go test -bench でベンチマーク比較

締め: git で証跡を残す

cd ~/learn/go/level2/day03
git add .
git commit -m "feat(go-json): omitempty落とし穴・カスタムMarshaler・安全な復号テンプレ"
exit

対比表で違いを明確化

Marshal/Unmarshal vs Encoder/Decoder

観点Marshal/UnmarshalEncoder/Decoder
入出力[]byteio.Reader/io.Writer
メモリ全体ロードストリーミング
用途小〜中サイズHTTP / 大ファイル
末尾改行なし\n 自動付与

フィールド型の選び分け

表現したい
必須・空も意味のある値string
null / 未指定 / 空 を区別*string
DB の NULLsql.NullString
JSON 構造を保持して遅延パースjson.RawMessage
bool で OFF/ON/未指定*bool

omitempty の振る舞い

omitempty で省略される値
string""
int0
boolfalse ★罠
*Tnil のみ(推奨)
スライスnil
マップnil

自己評価チェックリスト

手を動かせた

  • *bool でPATCH 構造体を書いた
  • DisallowUnknownFields で typo を弾いた
  • MaxBytesReader で巨大ボディを弾いた
  • カスタム MarshalJSON で日付フォーマットを変えた
  • json.RawMessage で遅延パースした
  • Encoder/Decoder でストリーミング処理を書いた
  • decodeJSON 安全テンプレを写経 → 自作

説明できる

  • omitempty の罠を1分で説明
  • PATCH に *string を使う設計判断
  • DisallowUnknownFields を本番で必須にする理由
  • interface{} で全部受けるアンチパターン
  • JSON 経由の3種類の DoS と対策

やらかし回避

  • bool に素の omitempty を付けない
  • interface{} での 型アサーション地獄を避ける
  • 巨大 JSON を Unmarshal で読まない(OOM 対策)
  • パスワード等を json:"-" で除外

詰まった時のチートシート

やりたいことコード
Marshaljson.Marshal(v)
Unmarshaljson.Unmarshal(b, &v)
HTTP からデコードjson.NewDecoder(r.Body).Decode(&v)
HTTP へエンコードjson.NewEncoder(w).Encode(v)
キー名指定\json:“key”“
空なら省略\json:“key,omitempty”“
除外\json:”-““
未知フィールド禁止dec.DisallowUnknownFields()
サイズ制限r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
遅延パースjson.RawMessage
カスタム形式MarshalJSON() / UnmarshalJSON()

「実務OK」基準

  • omitempty の罠を踏まずに API スキーマを設計できる
  • PATCH リクエスト用にポインタフィールドを設計できる
  • decodeJSON 相当の安全テンプレを即書ける
  • 巨大 JSON をストリーミング処理できる
  • 「encoding/json を sonic に置き換えるべきか」を計測で判断できる

さらに深掘りするなら

  • 標準ライブラリ ソース: src/encoding/json/decode.go - リフレクションの使い方の教科書
  • Alex Edwards: “How to Parse a JSON Request Body in Go” - decodeJSON テンプレの元ネタ的記事
  • bytedance/sonic README: なぜ標準より速いか(SIMD、JIT)の解説
  • 書籍: 『Goプログラミング実践入門』- リフレクション章
  • RFC 8259: JSON のフォーマット仕様(短いので一度は読む価値あり)

次のレッスン

2-4 ミドルウェア でログ・recover・認証・タイムアウトを「合成可能なパーツ」として組み立てる。HTTP ハンドラを http.Handler インターフェースで包んでいくパターンの本領発揮回。

つながりの予告

  • 本章の decodeJSON ヘルパーは 2-4_ミドルウェア共通バリデーションミドルウェア の原型
  • *bool ポインタ設計は 3-2_CRUDハンドラ の PATCH エンドポイントで再活用
  • MaxBytesReader3-7_本番準備 のセキュリティチェックで再登場