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つのハマりどころがある:
- ゼロ値と「未設定」の区別ができない(
Done boolだとfalseが「未設定」か「完了済でない」か不明) - JSON の
nullを構造体で受けると無視される - 巨大 JSON を
json.Unmarshalで読むとメモリ爆発 - 未知フィールドを 黙って無視する(API 仕様変更を見逃す)
- ベンチマークすると 意外と遅い(リフレクションを使うため)
これらを知らずに 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/day031. 基本: 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"}→
対策: 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.Bodyはio.Reader、wはio.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:
- 型アサーションを毎回書く → 可読性最悪
- 数値はデフォルト float64(int で受けると panic)
- 構造の変更を IDE / コンパイラが検知できない
- panic リスクが高い(キー欠損、型不一致で即クラッシュ)
修正: ちゃんと struct で型を定義する。型推測がつかない箇所だけ
json.RawMessageで遅延パース。
10. パフォーマンス: encoding/json は意外と遅い
標準 encoding/json のボトルネック
リフレクションを使っているため、特に小さなオブジェクトを大量に処理する場面で遅い。標準だと N 件の Marshal で N 回リフレクションが走る。
ベンチマークの相対値(参考):
ライブラリ 速度(小オブジェクト Marshal) encoding/json 1.0x(基準) json-iterator/go (jsoniter) 2-3x 高速 goccy/go-json 3-4x 高速 bytedance/sonic 5-10x 高速(amd64 のみ、SIMD 使用) しかし、実アプリで律速になるのはほとんどの場合 DB か外部 API。「Go の JSON が遅い」は計測してから言うべき。
例外: ストリーミングログ、リアルタイム配信、超高 QPS の API ゲートウェイ → sonic などへの置き換えを検討。
// jsoniter は標準互換 API。インポートを差し替えるだけで使える
import jsoniter "github.com/json-iterator/go"
var json = jsoniter.ConfigCompatibleWithStandardLibrary11. セキュリティ: 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 を通すように設計すると、抜け漏れがなくなる。
練習課題
*boolを使った PATCH 用構造体を定義し、{"done": false}を受けたらDone = falseで更新するハンドラを書く- 独自 Marshaler で
time.Timeを2024-05-14形式(日付のみ)でシリアライズ json.RawMessageを使って「typeフィールド別にペイロード構造を切り替えて Decode」を実装DisallowUnknownFields()を有効にして、typo フィールドが弾かれることを確認dd if=/dev/zero bs=1M count=10 | curl --data-binary @- ...で大きなボディを送り、MaxBytesReaderのエラー挙動を観察- オプション: 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/Unmarshal Encoder/Decoder 入出力 []byteio.Reader/io.Writerメモリ 全体ロード ストリーミング 用途 小〜中サイズ HTTP / 大ファイル 末尾改行 なし \n自動付与
フィールド型の選び分け
表現したい 型 必須・空も意味のある値 stringnull / 未指定 / 空 を区別 *stringDB の NULL sql.NullStringJSON 構造を保持して遅延パース json.RawMessagebool で OFF/ON/未指定 *bool★
omitempty の振る舞い
型 omitempty で省略される値 string""int0boolfalse★罠*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:"-"で除外
詰まった時のチートシート
| やりたいこと | コード |
|---|---|
| Marshal | json.Marshal(v) |
| Unmarshal | json.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 エンドポイントで再活用MaxBytesReaderは 3-7_本番準備 のセキュリティチェックで再登場