GoのカスタムUnmarshalJSON vs 標準 性能対決

に公開

実際のベンチマークデータで見るJSONアンマーシャリング性能の深掘り

Goで複雑で動的なJSON構造を扱うとき、カスタムJSON Unmarshalロジックを書きたくなることがあります。
なので今回はカスタムUnmarshalを書く価値があるのかベンチマークを用いて性能比較してみました!
普段あんまり気にせずjson.Unmarshal書いてる人は勉強になると思います!

課題

動的にネストされた構造を返すJSON APIを受け取り、レスポンスのデータを処理する必要がありました。

{
  "id": "test-123",
  "objectType": "UNIT",
  "content": {
    "unit": {
      "url": "https://example.com/movie",
      "checksum": "abc123"
    }
  }
}

contentフィールドにはobjectTypeに応じて異なるサブオブジェクト(unitcategoryitem)が含まれます。
上記課題に対して、二つのアプローチが考えました。

  1. カスタムUnmarshalJSON - 動的構造を処理するスマートなロジックを書く(想定外の動的な動きをした場合にコードの変更なしに対応できる)
  2. ユニオン構造体による標準JSON - Goの組み込みJSONパッケージにすべて任せる(想定外の動きには対応できない)

実装方法

アプローチ1:カスタムUnmarshalJSON

type Resource struct {
    ID         string `json:"id"`
    ObjectType string `json:"objectType"`
    Content    Details  // 単一のネストされた構造体
}

type Details struct {
    Url      string `json:"url"`
    Checksum string `json:"checksum"`
}

func (r *Resource) UnmarshalJSON(data []byte) error {
    var raw struct {
        ID         string                     `json:"id"`
        ObjectType string                     `json:"objectType"`
        Content    map[string]json.RawMessage `json:"content"`
    }

    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }

    r.ID = raw.ID
    r.ObjectType = raw.ObjectType

    // objectTypeに基づく動的フィールド選択
    objectTypeKey := strings.ToLower(raw.ObjectType)
    contentData, exists := raw.Content[objectTypeKey]
    if !exists {
        return fmt.Errorf("missing '%s' field in content", objectTypeKey)
    }

    return json.Unmarshal(contentData, &r.Content)
}

アプローチ2:ユニオン構造体による標準JSON

type Item struct {
    ID         string `json:"id"`
    ObjectType string `json:"objectType"`
    Content    ContentUnion `json:"content"`
}

type ContentUnion struct {
    Item     *ContentDetail `json:"item,omitempty"`
    Category *ContentDetail `json:"category,omitempty"`
    Unit     *ContentDetail `json:"unit,omitempty"`
}

type ContentDetail struct {
    Url      string `json:"url"`
    Checksum string `json:"checksum"`
}

// アクティブなcontentを取得するヘルパーメソッド
func (c *ContentUnion) GetActive() *ContentDetail {
    if c.ObjectType == "unit" && c.Unit != nil {
        return c.Unit
    }
    if c.ObjectType == "category" && c.Category != nil {
        return c.Category
    }
    if c.ObjectType == "item" && c.Item != nil {
        return c.Item
    }
    return nil
}

ベンチマーク結果

Goのテストフレームワークを使用して、両方のアプローチを包括的なベンチマークを実行しました。

goos: darwin
goarch: arm64
pkg: zeus/pkg/contents/canalplus
cpu: Apple M3

BenchmarkResource_CustomUnmarshal-8    86,374    14,365 ns/op    1,376 B/op    27 allocs/op
BenchmarkItem_StandardUnmarshal-8     200,640     8,301 ns/op      512 B/op    14 allocs/op

結果

標準JSONアプローチ(Item)がカスタム実装(Resource)を完全に上回りました。
考えればわかることなのですが、単純に処理が増えてるので、それは遅くなるはずです。
ただ倍以上パフォーマンスが違ったのは良い学びになりました。

  • 🚀 1.73倍高速 な実行時間(8,301 ns vs 14,365 ns)
  • 💾 2.69倍少ない メモリ使用量(512 B vs 1,376 B per operation)
  • 🔄 1.93倍少ない アロケーション(14 vs 27 per operation)
  • 📈 2.32倍高い スループット(200,640 vs 86,374 operations)

なぜ標準アプローチが勝つのか

1. GoのJSONパッケージは高度に最適化されている

encoding/jsonパッケージは長年にわたって低レベルの最適化が施されており、カスタムコードでは太刀打ちできない。

2. JSON操作が少ない

  • カスタム:2回のjson.Unmarshal呼び出し + 文字列操作 + mapルックアップ
  • 標準:1回のjson.Unmarshal呼び出しだけ

3. メモリプレッシャーが少ない

標準アプローチは構造体フィールドに直接マッピングしますが、カスタムロジックは中間mapを作成し、追加のアロケーションを実行する必要があります。

4. CPUキャッシュ効率

操作が少ないほど、CPUキャッシュの利用率が向上し、コンテキストスイッチが減少します。

Go哲学の実践

このベンチマークは、Goの哲学を完璧に実証しています:「シンプル is the best」。標準ライブラリには理由があり、ちょっとやそっとでは標準ライブラリに太刀打ちできません。

各アプローチを使用する場合

標準JSONを使用する場合:

  • ✅ パフォーマンスが重要
  • ✅ 構造体タグでデータをモデル化できる
  • ✅ JSON構造がある程度予測可能
  • ✅ 保守性を重視する

カスタムUnmarshalJSONを使用する場合

  • ⚠️ JSON構造が極端に動的
  • ⚠️ 複雑な検証ロジックが必要
  • ⚠️ 標準JSONでは本当に処理できない
  • ⚠️ 柔軟性の方がパフォーマンスより重要

Go開発者への重要なポイント

  1. すべてをベンチマークする - 数字が全て
  2. 標準ライブラリを信頼する - 原則、カスタム実装より高速
  3. シンプルなコードは高速なコード - 複雑さにはパフォーマンスコストがある
  4. ユニオン構造体は強力 - インプットが限られてる場合は、ある程度「動的」JSONシナリオを処理できる

結論

ついつい、いい感じに処理するカスタムJSON Unmarshal ロジックを書きたくなるかもしれませんが、パフォーマンスでは勝てません。ユニオン構造体を使用した標準JSONアプローチは、よりシンプルで保守しやすいだけでなく、大幅に高速です。

結果、基礎に戻るのですが、使えるなら標準ライブラリを使えに尽きます。


GoでのJSONパフォーマンスについて、似たような疑問を感じて検証したことありますか?ベンチマークで似たような経験したことありますか?コメントで教えてください!

Discussion