1. JSON と Go 型の対応を考える理由
JSON を初めて見ると、つい「かっこ付きの文字列」として扱いたくなります。小さな例なら、それでも動きます。ですが JSON がアプリケーションの入出力(ファイル、ネットワーク、API)になると、それは契約へと変わります。つまり、データの形は明確で安定していなければならず、そうでないとプログラムは最も都合の悪いタイミングで壊れます。たいていはデモの直前です。
Go には標準パッケージ encoding/json があり、JSON を Go の値へ、そしてその逆へ変換できます。しかし重要なのは次の点です。同じ JSON でも、異なる Go 型で「読む」ことができる のです。そして選んだ型によって、コードの安全性、分かりやすさ、後のロジックの扱いやすさが変わります。
「とりあえず全部 any にして、あとで考えよう」という流儀でアプリを書かないために、今日は地図を作ります。どの JSON の形が struct、[]T、map[string]any に最もよく対応するのか、そしてそれぞれの選択がどう違うのかを見ていきます。
JSON は 6 種類の型からなる
初心者はよく、JSON は「何でもあり」だと思いがちです。実際には JSON はかなり厳密で、たった 6 種類の型しかありません。これはうれしいニュースです。型が少なければ、Go 型との対応ルールも少ないからです。あとは組み合わせにすぎません。
以下は簡潔な対応表です(唯一の正解ではありませんが、入門時には最も実用的です)。
| JSON 型 | 例 | 典型的な Go 型(形が分かっている場合) | 典型的な Go 型(形が分からない場合) |
|---|---|---|---|
| object | |
|
|
| array | |
|
|
| string | |
|
|
| number | |
|
多くの場合は が内部に入る |
| boolean | |
|
|
| null | |
しばしば |
|
ここからは、主に 3 つの「コンテナ」型を見ていきます。JSON object → struct または map、JSON array → []T です。この選択が、データ読み取りの設計を決めます。つまり、型の世界で生きるか、毎回チェックし続ける世界で生きるか、という違いです。
2. JSON object → struct: データの形が分かっている場合
JSON オブジェクトの構造が明確なとき(たとえばチケット管理のタスクに id, title, done がある場合)、最も自然なのは struct に読むことです。アンケートのようなものだと考えると分かりやすいでしょう。最初から項目が決まっているのに、それを「キーと値の袋」として保存すると、自分で自分の首を絞めることになります。struct はコンパイラにあなたを守らせる手段です。
Go のリフレクションには 1 つ重要な性質があります。通常、エンコーダー/デコーダーは構造体の 公開されている フィールドだけを扱います。つまり大文字で始まるフィールドです。この考え方は他の標準シリアライズパッケージにもあり、たとえば encoding/gob の説明では、公開フィールドだけがエンコード・デコードされると明示されています。JSON でも同じルールが当てはまります。
では、学習用の小さなアプリ「ミニタスクトラッカー」を作り始めましょう。まだファイルも HTTP も使いません。まずはタスクをコードで正しく表現するところからです。
ミニモデル Task と JSON の構造体への読み込み
構造体を作って、入力 JSON をパースしてみましょう。JSON は id, title, done と書きますが、Go では ID, Title, Done を使います。基本的なケースでは encoding/json は名前をかなり柔軟に対応付けてくれます(ただし created_at のような複雑な名前は別問題です)。
package main
type Task struct {
ID int
Title string
Done bool
}
次は、JSON を Task に読み込む関数です。重要なのは、デコードは 変数のアドレスに対して 行うことです。ライブラリが結果を書き込む先を必要とするからです。
package main
import (
"encoding/json"
"fmt"
)
func parseTask(data []byte) (Task, error) {
var t Task
if err := json.Unmarshal(data, &t); err != nil {
return Task{}, fmt.Errorf("parse task: %v", err)
}
return t, nil
}
小さな使用例です。
package main
import "fmt"
func main() {
t, err := parseTask([]byte(`{"id":1,"title":"read docs","done":false}`))
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", t) // {ID:1 Title:read docs Done:false}
}
Unmarshal のあとに手元にあるのは、普通の Go 構造体 です。型の周りで「踊る」必要なく、そのまま扱えます。
ネストされたオブジェクト: struct の中の struct
実際の JSON ではオブジェクトが入れ子になることがよくあります。たとえばタスクに作者が付く場合です。Go では入れ子の構造体として自然に表現できます。
package main
type Author struct {
Name string
}
type Task struct {
ID int
Title string
Done bool
Author Author
}
入力が次のようなら:
{"id":2,"title":"write code","done":true,"author":{"name":"Sam"}}
Unmarshal のあとには Task{Author: Author{Name:"Sam"}} が得られます。これが、入力の構造が分かっているなら struct が最良の友だと言える理由の 1 つです。
部分読み込み: 必要なフィールドだけ取る
すべてが必要とは限りません。たとえば id だけ存在を確認したいが、他はまだ重要ではない、という場合です。そんなときは、最小限のフィールドだけを持つ受け取り用構造体を作れます。
package main
import "encoding/json"
type TaskIDOnly struct {
ID int
}
func parseTaskID(data []byte) (int, error) {
var x TaskIDOnly
if err := json.Unmarshal(data, &x); err != nil {
return 0, err
}
return x.ID, nil
}
これはとても実用的です。用途ごとの小さな構造体を使うと、すべてを map[string]any に読み込んで手作業で確認するより、ずっと読みやすくなります。
3. JSON array → []T: データが多く、同種の要素が並ぶ場合
JSON 配列は要素の並びです。Go における自然な並びの表現はスライス []T です。ルールは単純です。型 T を選ぶ と、配列の各要素をどう読むかが決まる のです。T が構造体なら、各要素は適切な形の JSON オブジェクトでなければなりません。
これは同じ部品が入った箱のようなものです。中身が「M6 ボルト」だと期待しているのに、突然「バナナの皮」が出てきてほしくはありません(理論上は JSON がそれをやってしまえますが)。[]T を使えば、デコーダーに形のチェックを任せられます。
タスクの配列を読む: []Task
たとえば、次のタスク一覧を受け取ったとします。
[
{"id":1,"title":"read docs","done":false},
{"id":2,"title":"write code","done":true}
]
Go では []Task として読みます。
package main
import (
"encoding/json"
"fmt"
)
func parseTaskList(data []byte) ([]Task, error) {
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, fmt.Errorf("parse task list: %v", err)
}
return tasks, nil
}
使用例:
package main
import "fmt"
func main() {
data := []byte(`[{"id":1,"title":"A","done":false},{"id":2,"title":"B","done":true}]`)
tasks, err := parseTaskList(data)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(len(tasks)) // 2
}
単純な値の配列: []string, []int
JSON 配列が文字列だけで構成されているなら、[]string に読むのが自然です。たとえばタスクのタグです。
["go","json","practice"]
package main
import "encoding/json"
func parseTags(data []byte) ([]string, error) {
var tags []string
err := json.Unmarshal(data, &tags)
return tags, err
}
この例はほとんど冗談みたいに簡単ですが、そこが良いところです。適切な型を選べば、コードは短くなり、エラーは早い段階で見つかります。
nil スライスと空スライス
スライスには 2 つの「空」状態があります。nil と []T{}(空だが nil ではない)です。日常的なロジックでは通常そこまで重要ではありませんが、シリアライズや比較では違いが表れることがあります。初心者は、関数が []T を返すなら、エラー時は nil と err を返し、成功時は通常のスライス(空でもよい)を返す、という考え方を覚えれば十分です。
学び始めたばかりなら、複雑なルールを作ろうとしないことです。大事なのは、選んだ契約を一貫して守ることだけです。「空リスト = 空スライス」にするのか、それとも「空リストは nil でもよい」のかを決め、場所によってバラバラにしないことです。
4. JSON object → map[string]any: 形が分からない場合
JSON の構造が事前に分からないこともあります。たとえば、どんな JSON でも受け取って中身を表示するデバッグ用ツールを作る場合です。あるいは、あらかじめキー一覧を固定せずに「ユーザー定義フィールド」を通すコードを書く場合です。
その場合は map[string]any を選びます(古いバージョンでは map[string]interface{} と書くことも多く、意味は同じです)。ただし柔軟性の代償として、値は any になり、その後は手動で型チェックや型変換をしなければなりません。
そしてもう 1 つの基本事実があります。JSON オブジェクトのキーは常に文字列 です。したがって map[int]... は定義上適しません。
JSON を map[string]any にデコードして型を見る
次の JSON を使います。
{"id":1,"title":"A","done":true}
デコードしてみましょう。
package main
import (
"encoding/json"
"fmt"
)
func main() {
var m map[string]any
_ = json.Unmarshal([]byte(`{"id":1,"title":"A","done":true}`), &m)
fmt.Printf("%T\n", m["title"]) // string
fmt.Printf("%T\n", m["done"]) // bool
fmt.Printf("%T\n", m["id"]) // float64
}
驚きましたか。id は float64 になっています。これはバグでも、学生への意地悪でもありません。JSON の数値は、それが int なのか float なのかを自分では示さないからです。動的な構造(any)にデコードする場合、パッケージは標準的な数値表現として float64 を選びます。
したがってルールは単純です。map[string]any は、手作業のチェックを受け入れる覚悟がある場合に使います。
map[string]any から安全に値を取り出す
それでもこの方法を使うなら、取り出しは慎重に行います。まずキーの存在を確認し、そのあとで型を確認します。
package main
import "fmt"
func getTitle(m map[string]any) (string, error) {
v, ok := m["title"]
if !ok {
return "", fmt.Errorf("title is missing")
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("title must be string")
}
return s, nil
}
確かに t.Title より長くなります。しかしそれは正直な実装です。入力データのルールを明示し、分かりやすいエラーを返せます。
map[string]any における入れ子オブジェクトと配列
JSON に入れ子のオブジェクトや配列が含まれる場合、動的表現では次の再帰的な構造になります。
- オブジェクトには map[string]any
- 配列には []any
- プリミティブには string, bool, float64, nil
ある意味では、これは any ノードを持つ「木」です。そしてコードは、たくさんのチェックを伴う木の走査に変わります。デバッグ用や「万能」ツールならそれでもかまいませんが、ビジネスロジックで本当に欲しいものとは普通は違います。
5. どう選ぶか: struct か map か、そして []T の役割
実際のプロジェクトでは、型の選択は「好み」ではなく、設計上の判断です。大事なのは、厳密さと可読性が欲しいのか、それとも「何でも受け入れる」柔軟性が欲しいのかを理解することです。
JSON を荷物、Go 型を倉庫での受け取り書類だと考えてみましょう。スマートフォンが届くと分かっているなら、「シリアル番号」「モデル」「色」といった欄を作ります。何が届くか分からないなら、「受領: 箱、内容確認中」と書きます。その先の分解はより難しくなります。
見やすい比較表はこちらです。
| 基準 | |
|
|---|---|---|
| 型安全性 | 高い | 低い(すべてチェック経由) |
| コードの読みやすさ | 通常は非常に良い | すぐに雑然としやすい |
| キーに対する柔軟性 | 固定された形 | 任意のキーを使える |
| エラー | 早い段階で見つかりやすい | 後で、実行時に見つかる |
| 向いている用途 | API 契約、DTO、モデル | デバッグ、「プラグイン」データ、未知スキーマ |
そして []T は、単に「同じものがたくさんある」という意味です。だから多くの場合、選ぶのは []Task か []map[string]any のどちらかで、要素の形が分かっているかどうかで決まります。
デコード時のエラー: 壊れた JSON は普通に起こる
学習中は、つい _ = json.Unmarshal(...) と書いて「悪いことは考えない」ようにしたくなります。しかし外部からの JSON はほとんど常に壊れている可能性があります。ユーザーが間違えることもあれば、ファイルが途中で切れることもある、ネットワークがくしゃみをすることもある、そもそも JSON ではなく「ほぼ JSON」のラブレターが届くこともあります。
encoding/json には、いくつかのケースに対する具体的なエラー型があります。たとえば構文上の問題は json.SyntaxError 型のエラーとして表され、どこで壊れたかを示す Offset フィールドがあります。この考え方(そして型そのもの)は、エラーメッセージをより役立つものにするためによく使われます。
ここでは行番号や列番号の取り出しまで深掘りする必要はありません。覚えておくべきなのは、デコードエラーは恥ではなく、通常のコード分岐だ ということです。err を確認し、その先でユーザーに伝えるか、処理を止めます。
6. JSON 用の Go 型を選ぶときによくあるミス
ミス 1: 既知の JSON オブジェクトを「念のため」で map[string]any に読むこと。
この選択は柔軟に見えますが、実際にはすぐに、チェック・型変換・意味不明なエラーだらけの沼になります。構造が分かっていて安定しているなら、struct の方がほぼ常に簡単で短く、そして信頼できます。フィールドはすでに型付けされているので、コンパイラが string と bool を取り違えないよう助けてくれます。
ミス 2: map[string]any が数値を int として保持すると期待すること。
動的デコードでは、数値は通常 float64 になります。そのため m["id"].(int) のような素朴な前提は壊れます。構造体として int で読むか、あるいは float64 → int を検証付きで意図的に変換する必要があります(なお 3.14 が id に入るなら、怒るべきです)。
ミス 3: JSON object に map[int]any を使うこと。
JSON のオブジェクトキーは文字列です。常にそうです。たとえ見た目が数字でも({"1":"a"} のように)、それでも文字列 "1" です。したがってオブジェクト用の正しいコンテナは map[string]... です。
ミス 4: Unmarshal にアドレスを渡し忘れること。
json.Unmarshal(data, t) では t を埋められません。書き込み先が必要だからです。正しくは json.Unmarshal(data, &t) です。エラーや不自然な挙動が出たら、まず & を忘れていないか確認しましょう。
ミス 5: 構造体のフィールドを非公開にしておきながら、埋まらないと驚くこと。
type Task struct { title string } と書くと、デコード時に title は埋まりません。公開されていないからです。その結果、いつも空の title
ができてしまい、JSON の陰謀を疑い始めます。実際には Title string にすれば十分です。フィールドを隠すのは、モデルのルールに従って意図的に行うべきで、偶然やってはいけません。
GO TO FULL VERSION