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:}
デフォルトの動作に注意してください:
- キーマッチングは大文字小文字を区別しません。
"Customer"はcustomerタグとマッチします。 "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の代わりに[]。nilsliceは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エラーのほとんどを避けられます。