Go実践 #3 JSON入出力と入力検証
#2 ルーティングでルートの形を整えたら — この記事はその内側。データを受け取り処理してレスポンスを返す標準パターン。
Marshal — Go → JSON #
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 #
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。区別が必要ならポインタを使います。
type User struct {
Active *bool `json:"active"`
}
if u.Active != nil && *u.Active {
// 明示的にtrueが来た場合のみ
}部分更新(PATCH) APIでよく登場。
2) 未知のフィールドを拒否したいとき #
デフォルトの動作は — 未知のフィールドを無視。明示的な拒否が必要なら:
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を書きます。
カスタムマーシャル #
type Money int64 // cents
func (m Money) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`%.2f`, float64(m)/100)), nil
}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を分離。
// 内部ドメイン
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分離 — 内部型をそのまま公開しない
- 空slice →
nullの落とし穴、明示的な[]Item{} - JSON Linesで大きなレスポンスのストリーミング
次の記事(#4 DB連携)では — database/sql、prepared statement、トランザクションなどデータ永続化の標準を整理します。