Go実践 #3 JSON入出力と入力検証

読了 6分

#2 ルーティングでルートの形を整えたら — この記事はその内側。データを受け取り処理してレスポンスを返す標準パターン

Marshal — Go → JSON #

基本のMarshal
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

u := User{ID: 1, Name: "イ・ドギョン"}
b, err := json.Marshal(u)
// {"id":1,"name":"イ・ドギョン"}

中核は2つ:

  • structタグjson:"name"でキーを指定
  • omitempty — zero valueならフィールド自体を省略

インデント #

b, _ := json.MarshalIndent(u, "", "  ")

レスポンスとして送るときは通常Marshalそのまま(トラフィック)、デバッグ用の出力はMarshalIndent

Unmarshal — JSON → Go #

基本のUnmarshal
input := []byte(`{"id":1,"name":"イ・ドギョン"}`)

var u User
if err := json.Unmarshal(input, &u); err != nil {
	// ...
}

ポインタを渡すのが要点。未知のフィールドは静かに無視されます。

HTTPハンドラでは — Encoder/Decoderの方が似合う #

ハンドラの標準パターン
func createUser(w http.ResponseWriter, r *http.Request) {
	var u User
	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	saved := saveUser(u)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(saved)
}

Decoder.Decodeは — io.Readerから直接読みます(全bytesを取得する必要なし)。メモリ効率的。

よくある落とし穴 #

1) zero valueと「キーなし」を区別できない #

type User struct {
	Active bool `json:"active"`
}

JSONに"active":falseが来ても、キーが完全に欠けている場合も — Goの立場ではどちらもfalse。区別が必要ならポインタを使います。

nilで不在を表現
type User struct {
	Active *bool `json:"active"`
}

if u.Active != nil && *u.Active {
	// 明示的にtrueが来た場合のみ
}

部分更新(PATCH) APIでよく登場。

2) 未知のフィールドを拒否したいとき #

デフォルトの動作は — 未知のフィールドを無視。明示的な拒否が必要なら:

DisallowUnknownFields
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&u); err != nil {
	// ...
}

タイポ発見に有用。特にPATCH APIで。

3) 大きな入力 — MaxBytesReader #

リクエストサイズの制限
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)    // 1 MiB

var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
	// 大きすぎるか不正なJSON
}

#1で見た道具。常に上限を設けるのが安全。

4) 時刻 — time.Timeのデフォルト形式 #

type Event struct {
	At time.Time `json:"at"`
}

time.TimeはRFC 3339 ("2026-02-12T10:00:00Z")でデフォルトマーシャル。別の形式が必要なら — カスタム型を作ってMarshalJSON/UnmarshalJSONを書きます。

カスタムマーシャル #

MarshalJSON
type Money int64    // cents

func (m Money) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf(`%.2f`, float64(m)/100)), nil
}
UnmarshalJSON
func (m *Money) UnmarshalJSON(b []byte) error {
	var f float64
	if err := json.Unmarshal(b, &f); err != nil {
		return err
	}
	*m = Money(f * 100)
	return nil
}

ドメイン型にシリアライゼーション規則をカプセル化する標準パターン。

入力検証 — コードで明示的に #

受け取ったデータが — 有効かを検査する部分。JSONのパースは形式だけをチェックし、ビジネスルールは検査しません。

単純な検証
type CreateUser struct {
	Name  string `json:"name"`
	Email string `json:"email"`
	Age   int    `json:"age"`
}

func (c CreateUser) Validate() error {
	if c.Name == "" {
		return errors.New("name required")
	}
	if !strings.Contains(c.Email, "@") {
		return errors.New("invalid email")
	}
	if c.Age < 0 || c.Age > 150 {
		return errors.New("invalid age")
	}
	return nil
}

func handler(w http.ResponseWriter, r *http.Request) {
	var c CreateUser
	if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	if err := c.Validate(); err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	// ...
}

小さなAPIでは — 手書きメソッドが一番明確。マニュアルが負担に感じる時点で道具を検討。

検証ライブラリ — go-playground/validator #

タグベースの検証
import "github.com/go-playground/validator/v10"

type CreateUser struct {
	Name  string `json:"name" validate:"required,min=2,max=50"`
	Email string `json:"email" validate:"required,email"`
	Age   int    `json:"age" validate:"gte=0,lte=150"`
}

var v = validator.New()

if err := v.Struct(c); err != nil {
	// validator.ValidationErrorsで具体的な違反項目を抽出可能
}

利点 — タグでの表現が短い。欠点 — 動作が魔法のようで、エラー処理がやや煩雑(複数フィールド違反の整理)。大きなAPIでよく採用。

レスポンス — 一貫したエラーの形 #

エラーレスポンスの標準化
type ErrorResponse struct {
	Error   string            `json:"error"`
	Details map[string]string `json:"details,omitempty"`
}

func writeError(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(ErrorResponse{Error: msg})
}

APIユーザーが — エラーの形を一度学習すれば済むように。

フィールド別のディテール
writeError(w, 400, "validation failed")
// or with details
writeError(w, 400, "validation failed",
	map[string]string{"email": "invalid format"})

Bool/Intレスポンスでよくある落とし穴 — nil slice #

type Resp struct {
	Items []Item `json:"items"`
}

var r Resp
json.Marshal(r)
// {"items":null}

空のスライスではなくnullとしてシリアライズ。クライアントがnullを扱えないと壊れます。

解決 — 明示的に空のスライスを作る。

r.Items = []Item{}
// {"items":[]}

パターン — DTOの分離 #

ドメイン型をそのままシリアライズせず — API公開用のDTOを分離。

DTOの分離
// 内部ドメイン
type User struct {
	ID           int
	Email        string
	PasswordHash string    // ← APIに公開してはダメ
	CreatedAt    time.Time
}

// レスポンス用DTO
type UserDTO struct {
	ID    int    `json:"id"`
	Email string `json:"email"`
}

func toDTO(u User) UserDTO {
	return UserDTO{ID: u.ID, Email: u.Email}
}

json:"-"でそのフィールドだけ除外する方法もありますが — DTO分離が意図がより明確で、内部構造の変更がAPIを壊さないように保護します。

レスポンスヘルパー #

頻繁に使うとハンドラが短くなります。

レスポンスヘルパー
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func decodeJSON(r *http.Request, v any) error {
	r.Body = http.MaxBytesReader(nil, r.Body, 1<<20)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	return dec.Decode(v)
}

この2つの関数があれば — ハンドラ本体に本質だけ残ります。

落とし穴 — 再利用時のzero value残留 #

よくある落とし穴
var u User
json.Unmarshal(input, &u)    // 一部のフィールドだけ埋まる
// 他のフィールドはzero valueのまま

パースが欠けたフィールドを空にしません — 以前の値がそのまま残ります。常に新しい変数で開始するか、明示的にu = User{}でリセット。

大きなレスポンス — ストリーミング #

大きな結果のストリーミング
func handler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	enc := json.NewEncoder(w)

	for row := range queryRows() {    // チャネル/イテレータ
		if err := enc.Encode(row); err != nil {
			return
		}
	}
}

これはJSON Lines(1行に1つずつ)形式。大きな結果を — メモリに一度にロードせず流して送ります。ndjsonとして広く知られているパターン。

まとめ #

この記事で整理した内容:

  • json.Marshal / Unmarshal — 小さなデータ
  • Encoder / Decoder — ハンドラで自然な形
  • タグjson:"key,omitempty"
  • ポインタフィールド — zero valueと不在の区別
  • DisallowUnknownFields — タイポ捕捉
  • MaxBytesReader — 常に入力サイズを制限
  • 検証 — ハンドラ内で明示的に分離、大きなAPIはvalidatorを検討
  • DTO分離 — 内部型をそのまま公開しない
  • 空slicenullの落とし穴、明示的な[]Item{}
  • JSON Linesで大きなレスポンスのストリーミング

次の記事(#4 DB連携)では — database/sql、prepared statement、トランザクションなどデータ永続化の標準を整理します。

X