跳到正文
DEV Community· SensorFlow·· 4 小時前AI 評分58

Go JSON 事件契約:未知欄位檢查不會拒絕重複欄位

Go JSON Event Contracts: Unknown Fields Are Not Duplicate Fields

AI 導讀

Go 的 `encoding/json` 中,`DisallowUnknownFields` 只檢查輸入名稱能否對應目的 struct 欄位,不會因此拒絕重複 JSON 成員。

正文

A JSON event can decode successfully while containing two competing values for the same member. Adding DisallowUnknownFields does not make those duplicates an error. It checks a different boundary: whether an incoming name can be mapped to a field in the destination struct.

This matters when an event identifier, event name, or monetary property crosses multiple parsers. A validator and an ingestion service may accept the same bytes but assign different meanings to them. Before calling a contract strict, test both member uniqueness and field-name matching.

Disclosure: this is an AI-generated technical article published by the SensorFlow project account. The examples are synthetic, were executed locally, and do not describe a customer incident or a completed production security assessment. There is no product integration or SDK compatibility claim.

What the standard actually says

RFC 8259, section 4 says object member names SHOULD be unique. It explains that implementations differ when names are repeated. Do not translate that into a claim that every JSON parser must reject duplicates, or that every parser keeps the last value.

The encoding/json documentation describes Go's legacy behavior: duplicate members are processed in order, with later values replacing or merging with prior values depending on the destination. Struct field matching also accepts case-insensitive matches. A map with string keys does not perform that struct-field matching.

These are two separate questions. The exact duplicate event/event is a duplicate JSON member. The pair event/EVENT contains distinct JSON names, but can still target the same Go struct field under legacy matching.

A small regression test

Save the following as duplicate_test.go in an empty scratch directory. Run go test -v duplicate_test.go. This uses the standard encoding/json import, not encoding/json/v2, and makes no HTTP requests.

package contract

import (
    "encoding/json"
    "strings"
    "testing"
)

type event struct {
    Name string `json:"event"`
}

func TestMemberBoundaries(t *testing.T) {
    cases := []struct {
        name, body, want string
        wantErr bool
    }{
        {"ordinary", `{"event":"paid"}`, "paid", false},
        {"duplicate", `{"event":"first","event":"second"}`, "second", false},
        {"case variant", `{"event":"first","EVENT":"second"}`, "second", false},
        {"unknown", `{"event":"paid","extra":1}`, "", true},
        {"wrong type", `{"event":7}`, "", true},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            var got event
            dec := json.NewDecoder(strings.NewReader(tc.body))
            dec.DisallowUnknownFields()
            err := dec.Decode(&got)
            if (err != nil) != tc.wantErr {
                t.Fatalf("error = %v, wantErr = %v", err, tc.wantErr)
            }
            if !tc.wantErr && got.Name != tc.want {
                t.Fatalf("name = %q, want %q", got.Name, tc.want)
            }
        })
    }
    t.Run("map preserves case distinction", func(t *testing.T) {
        var got map[string]string
        err := json.Unmarshal([]byte(`{"event":"first","EVENT":"second"}`), &got)
        if err != nil || len(got) != 2 || got["event"] != "first" || got["EVENT"] != "second" {
            t.Fatalf("map = %#v, error = %v", got, err)
        }
    })
}

All six synthetic subtests passed locally using Go 1.26.4. The duplicate and case-variant cases are expected successes in this legacy-behavior test. A passing test therefore does not mean the input satisfies a strict event contract; it means the observed parser behavior matches the expectations encoded here.

The test structure follows Go's testing package, while strings.NewReader supplies the input reader. The exact toolchain and import path belong in the evidence when comparing parsers.

What to enforce before materializing a map

If the event contract requires unique member names, enforce that requirement at a parser boundary that can still observe every member. Once a normal map has kept a single value for an exact duplicate, checking the map cannot recover the discarded member from thin air.

Also decide whether field names are case-sensitive. Rejecting exact duplicate names alone does not prevent two distinct names from mapping to one legacy struct field. Keep those two policies explicit rather than relying on a switch whose name sounds broadly strict.

Do not copy this test into production as a full validator. It does not test nested duplicate objects, payload size, invalid UTF-8, multiple top-level values, authorization, or application-level field constraints. Add samples at the raw JSON boundary and compare the result at every parser your real event traverses.

Version changes need contract tests

The Go team's 2025 introduction to JSON v2 discusses duplicate names and case-insensitive matching among the motivations for changing defaults. It describes an experiment introduced in Go 1.25; that historical article is not evidence of the current availability or defaults in every deployed Go version.

Before a migration, run a fixed corpus against the actual old and new toolchains. Include duplicates with string values, case variants, nested members, and unknown properties. Record accepted/rejected results and materialized values. Treat a formerly accepted payload becoming rejected as a compatibility decision to review, rather than silently dropping it from ingestion statistics.

The useful outcome is a documented contract and reproducible evidence. “JSON decoded” and “unknown fields rejected” each prove a narrow fact. Neither proves that every participant interpreted the original event identically.

來源:DEV Community · dev.to