📑

Goの構造体の埋め込みとJSON

に公開

Goの構造体の埋め込みとJSON

TL;DR

  • Goの構造体埋め込みはJSON出力時に基本的にフラット化されます(ネストされない)
  • 例外として、JSONタグを指定するとネストされ、インターフェース型では型名がキーになります。
  • 競合時はタグ付き優先、複数競合なら無視というルールがあります。

はじめに

Goで埋め込みのある構造体をAPIで送受信するコードを書いているときに、個人的な直感と異なる動作に遭遇したので少し調べてみました。
埋め込んだ構造体はネストされたJSONとして出力されると考えていたのですが、実際にはフラットに展開されました。
調査したところ公式にこのような挙動とのことなので軽くまとめてみました。

挙動の確認:コードと結果

基本挙動:埋め込みはフラットに展開される

まず、具体的なコードで確認してみましょう。以下はAddress構造体をPersonVal構造体に埋め込む例です。

package main

import (
    "encoding/json"
    "fmt"
)

type Address struct {
    City string `json:"city"`
    Zip  int    `json:"zip"`
}

type PersonVal struct {
    Name string `json:"name"`
    Address // 値で埋め込み
}

func main() {
    pv := PersonVal{
        Name: "Alice",
        Address: Address{City: "Tokyo", Zip: 100},
    }
    b, _ := json.MarshalIndent(pv, "", "  ")
    fmt.Println(string(b))
}

実行結果は以下のようになります。

{
  "name": "Alice",
  "city": "Tokyo",
  "zip": 100
}

ご覧の通り、Addressがネストされることなく、cityzipがトップレベルに展開されています。これがGoの基本的な挙動です。

例外1:JSONタグを指定するとネストされる

一方、埋め込み時にJSONタグを明示的に指定すると、挙動が異なります。匿名フィールドに名前を付けた扱いとなり、ネストされた構造としてJSONに反映されます。

type InnerTagged struct { A int `json:"a"` }

type OuterWithTaggedAnon struct {
    InnerTagged `json:"inner"` // タグで名前を指定
}

実行結果:

{
  "inner": { "a": 1 }
}

例外2:インターフェース型では型名がフィールド名となる

インターフェース型の匿名フィールドも特有の挙動を示します。この場合、型名がそのままフィールド名として使用されます。

// インターフェース定義(メソッドを1つ持たせて実装を明示)
type Named interface{ IsNamed() }

// 具体型の実装
type NamedImpl struct { A int `json:"a"` }
func (NamedImpl) IsNamed() {}

type OuterWithIfaceAnon struct {
    Named // 匿名だけど interface 型(フィールド名は型名の "Named" になる)
}

実行結果(エンコード):

{
  "Named": { "a": 3 }
}

例えば、json.Marshal(OuterWithIfaceAnon{Named: NamedImpl{A: 3}}) を呼ぶと、上記のような出力になります。

デコードで具体型の値として受け取りたい場合は、事前にフィールドを具体型で初期化してから json.Unmarshal を呼びます。

var out OuterWithIfaceAnon
out.Named = &NamedImpl{} // 具体型で初期化
_ = json.Unmarshal([]byte(`{"Named":{"a":4}}`), &out)

競合時のルール:選択の優先順位

同一レベルでフィールドが競合する場合の優先順位についても確認しておきましょう。

  • タグ付きとタグなしが同名で競合した場合、タグ付きのフィールドが優先的に選択されます。
  • 候補となるフィールドが1つだけの場合、そのフィールドが採用されます。
  • 複数のタグ付きフィールドが同名で競合する場合は、すべての候補が無視され、エラーも発生しません。

タグ付きが優先される例:

type PrefTag struct   { S string `json:"S"` }
type PrefNoTag struct { S string }

type OutPreferTagged struct {
    PrefTag
    PrefNoTag
}

// 出力: {"S": "tagged"}

複数競合で無視される例:

type Tag1 struct { V int `json:"dup"` }
type Tag2 struct { V int `json:"dup"` }

type OutAmbig struct {
    Tag1
    Tag2
}

// 出力: {} ("dup" は出てこない)

公式ドキュメントでの記述

この挙動について、Goの公式ドキュメントを確認したところ、encoding/jsonパッケージの説明に明記されていました。

  • 基本ルールとして、埋め込み構造体のフィールドは外側の構造体のフィールドと同様に扱われると記述されています("Embedded struct fields are usually marshaled as if their inner exported fields were fields in the outer struct...")。
  • タグ付きの場合、指定したタグ名でフィールドが扱われるとされています("An anonymous struct field with a name given in its JSON tag is treated as having that name...")。
  • インターフェース型については、型名がフィールド名として使用されると記されています("An anonymous struct field of interface type is treated the same as having that type as its name...")。
  • 競合時の優先順位については、タグ付きが優先され、候補が1つなら採用、複数なら無視(エラーなし)というルールが明示されています。

出典:https://pkg.go.dev/encoding/json

かなり昔にinlineタグを追加する提案(https://github.com/golang/go/issues/6213)がありましたが、採用されていません。

フラットにエンコードされる動作についての考察

Goで埋め込まれた構造体のフィールド変数は「フィールド昇格」が発生するので、JSONエンコード時にはフラットに展開されるものと推測されます。
競合時の「タグ優先」「複数は無視」という規則は、曖昧さの排除が目的と考えられます。

TypeScriptなど他言語との連携時の注意点

Go同士で連携するときは問題になりませんが、GoのJSONをTypeScriptや他言語で扱う際には、構造の違いに留意する必要があります。以下に主なポイントを整理します。

フラットな型定義を前提とする

GoのPersonValはフラットなJSONとして出力されるため、TSでもネスト構造ではなくフラットな型定義を採用します。

type PersonValJson = {
  name: string;
  city: string; // ネストではなくフラット
  zip: number;
};

タグ付きの場合はネスト構造を反映

OuterWithTaggedAnonのようにタグで名前が指定されるとネストされるため、型定義もネスト構造に合わせます。

type OuterWithTaggedAnonJson = {
  inner: { a: number };
};

あとがき

Goを書き始めて6年くらいになるのですが初めて気づきました。
埋め込まれた構造体がフラットにアクセスできるのは、Goの構造体の埋め込みの基本的な挙動なので、JSONの入出力としても現在の仕様が適切なのだと思いますが、個人的にはやや驚きがあったので小ネタとしてまとめました。
フラット化される点は他の言語とやり取りする際に注意が必要ですが、特段問題になることはないと思います。
個人的にGoで嫌なことトップ5に入ってくる構造体の埋め込みがJSONの入出力にも影響してるのがわかったので、構造体の埋め込みがより嫌いになりました笑

株式会社エスマット

Discussion