GoSuda

Analyse der Verhaltensänderungen in encoding/json/v2 unter Go 1.25 und der Kompatibilitätsschicht zu v1

By lemonmint views ...

Das Paket encoding/json in Version 2 von Go ist eine neue Implementierung, die mehrere Nachteile der bisherigen Version 1 (mangelnde Konsistenz, überraschendes Verhalten, Leistungsprobleme) beheben soll. Sie wird über den Build-Tag goexperiment.jsonv2 aktiviert und stellt ein experimentelles Feature dar.

Am wichtigsten ist, dass bei aktiviertem v2 die v1-API als Kompatibilitätsschicht auf der v2-Implementierung arbeitet, die das Verhalten von v1 emuliert. Dies geschieht über die Funktion DefaultOptionsV1() in der Datei v2_v1.go. Somit bietet v2 Optionen, mit denen sich das Verhalten von v1 vollständig reproduzieren lässt, und führt gleichzeitig strengere und vorhersehbarere neue Standardverhaltensweisen ein.

Die Hauptziele von v2 sind die folgenden:

  1. Verbesserte Korrektheit und Vorhersehbarkeit: Durch standardmäßig strengere Regeln (z. B. Groß-/Kleinschreibung beachten, keine doppelten Schlüssel) wird unerwartetes Verhalten reduziert.
  2. Leistungsverbesserung: Die Parsing- und Encoding-Engine wurde neu entworfen, um die Effizienz zu steigern.
  3. Mehr Flexibilität und Kontrolle: Ein detailliertes Optionssystem ermöglicht es Entwicklern, die JSON-Verarbeitung präzise zu steuern.

Wesentliche Unterschiede in Bedeutung und Verhalten

Die Unterschiede zwischen v1 und v2 werden im Folgenden anhand der Datei v2_test.go nach Punkten geordnet dargestellt.

1. Abgleich von Feldnamen (Groß-/Kleinschreibung)

  • Verhalten in v1: Beim Unmarshaling von JSON-Objektmitgliedern in Go-Struct-Felder wird die Groß-/Kleinschreibung ignoriert (case-insensitive). Sowohl "FirstName" als auch "firstname" werden dem Feld FirstName zugeordnet.
  • Verhalten in v2: Standardmäßig wird die Groß-/Kleinschreibung berücksichtigt (case-sensitive), und nur exakt übereinstimmende Felder werden zugeordnet.
  • Grund der Änderung: Ein Abgleich ohne Berücksichtigung der Groß-/Kleinschreibung kann unerwartetes Verhalten verursachen und führt beim Verarbeiten nicht passender Felder zu Leistungseinbußen. v2 übernimmt daher als Standard ein klareres und vorhersehbareres Verhalten.
  • Zugehörige Optionen: In v2 kann die Groß-/Kleinschreibung-Ignorierung feldweise über das Tag-Attribut json:"...,case:ignore" explizit aktiviert werden oder global über die Option json.MatchCaseInsensitiveNames(true) angewendet werden.

2. Änderung der Bedeutung der Tag-Option omitempty

  • Verhalten in v1: Felder werden anhand des „leeren Zustands" des Go-Wertes ausgelassen. Dabei bedeutet „leerer Zustand" false, 0, nil-Zeiger bzw. nil-Interfaces sowie Arrays, Slices, Maps und Strings mit der Länge 0.
  • Verhalten in v2: Felder werden anhand des „leeren Zustands" des kodierten JSON-Wertes ausgelassen. Das heißt, Felder werden ausgelassen, wenn sie zu null, "", {} oder [] kodiert werden.
  • Grund der Änderung: Die Definition in v1 ist vom Typsystem von Go abhängig. v2 orientiert sich am JSON-Typsystem und bietet dadurch ein konsistenteres Verhalten. Beispielsweise wird in v1 der Wert false eines bool-Typs ausgelassen, in v2 dagegen nicht, da false ein nicht leerer JSON-Wert ist. In v2 kann die Option omitzero hinzugefügt werden, um das Verhalten zu ersetzen, bei dem omitempty in v1 auf 0 oder false angewendet wurde.
  • Zugehörige Optionen: Wenn in v2 dasselbe Verhalten wie in v1 gewünscht ist, wird die Option json.OmitEmptyWithLegacyDefinition(true) verwendet.

3. Änderung des Verhaltens der Tag-Option string

  • Verhalten in v1: Die Option wird auf Felder vom Typ Zahl, Boolean oder String angewendet. Der entsprechende Wert wird erneut innerhalb eines JSON-Strings kodiert (z. B. int(42) -> "42"). Auf Werte innerhalb zusammengesetzter Typen (Slices, Maps usw.) wird die Option nicht rekursiv angewendet.
  • Verhalten in v2: Die Option wird ausschließlich auf Zahlentypen angewendet und rekursiv angewendet. Das heißt, auch Zahlen innerhalb eines Slices wie []int werden alle als JSON-Strings kodiert.
  • Grund der Änderung: Der Hauptzweck der Option string besteht darin, Zahlen als Strings darzustellen, um Präzisionsverluste bei 64-Bit-Ganzzahlen zu verhindern. Das Verhalten in v1 war eingeschränkt und wenig konsistent. v2 konzentriert sich auf diesen zentralen Zweck und erweitert das Verhalten rekursiv, um es nützlicher zu machen.
  • Zugehörige Optionen: Mit der Option json.StringifyWithLegacySemantics(true) kann das Verhalten von v1 nachgeahmt werden.

4. Marshaling von nil-Slices und -Maps

  • Verhalten in v1: nil-Slices und nil-Maps werden zu null marshaled.
  • Verhalten in v2: Standardmäßig werden nil-Slices zu [] (leeres Array) und nil-Maps zu {} (leeres Objekt) marshaled.
  • Grund der Änderung: nil ist ein Implementierungsdetail der Sprache Go, und es ist nicht wünschenswert, dies in einem sprachunabhängigen JSON-Format offenzulegen. Die Darstellungen [] und {} für leere Sammlungen sind allgemeiner verbreitet.
  • Zugehörige Optionen: In v2 kann über die Optionen json.FormatNilSliceAsNull(true) oder json.FormatNilMapAsNull(true) ein Marshaling zu null wie in v1 erreicht werden.

5. Unmarshaling von Arrays

  • Verhalten in v1: Beim Unmarshaling in ein Go-Array ([N]T) wird kein Fehler ausgelöst, selbst wenn die Länge des JSON-Arrays von der Länge des Go-Arrays abweicht. Ist das JSON-Array kürzer, werden die übrigen Elemente mit Null-Werten aufgefüllt; ist es länger, wird der Überschuss verworfen.
  • Verhalten in v2: Die Länge des JSON-Arrays muss genau mit der Länge des Go-Arrays übereinstimmen. Andernfalls wird ein Fehler ausgelöst.
  • Grund der Änderung: In Go ist die Länge eines Arrays fester Größe häufig von wesentlicher Bedeutung. Das Verhalten in v1 kann zu stillem Datenverlust führen. v2 erhöht die Korrektheit durch strengere Regeln.
  • Zugehörige Optionen: Mit der Option json.UnmarshalArrayFromAnyLength(true) kann das Verhalten von v1 nachgeahmt werden.

6. Verarbeitung von time.Duration

  • Verhalten in v1: time.Duration wird intern als int64 behandelt und als JSON-Zahl in Nanosekunden kodiert.
  • Verhalten in v2: Mithilfe der Methode time.Duration.String() wird als JSON-String in einem Format wie "1h2m3s" kodiert.
  • Grund der Änderung: Nanosekunden als Zahlen sind schwer lesbar, während die standardisierte String-Darstellung von time.Duration nützlicher ist.
  • Zugehörige Optionen: Über das Tag-Attribut json:",format:nano" oder die Option json.FormatTimeWithLegacySemantics(true) kann das Verhalten von v1 verwendet werden.

7. Verarbeitung ungültiger UTF-8-Sequenzen

  • Verhalten in v1: Beim Marshaling und Unmarshaling werden ungültige UTF-8-Bytes innerhalb eines Strings stillschweigend durch das Unicode-Ersatzzeichen (\uFFFD) ersetzt.
  • Verhalten in v2: Standardmäßig wird beim Auftreten ungültigen UTF-8 ein Fehler zurückgegeben.
  • Grund der Änderung: Ziel ist es, stille Datenbeschädigung zu verhindern und sich an den strengeren JSON-Standard (RFC 7493) zu halten.
  • Zugehörige Optionen: Mit der Option jsontext.AllowInvalidUTF8(true) kann das Verhalten von v1 nachgeahmt werden.

8. Verarbeitung doppelter Objektmitgliedsnamen

  • Verhalten in v1: Innerhalb eines JSON-Objekts ist es erlaubt, dass Mitglieder mit demselben Namen mehrfach vorkommen. Der zuletzt auftretende Wert überschreibt die vorherigen.
  • Verhalten in v2: Standardmäßig wird bei doppelten Mitgliedsnamen ein Fehler zurückgegeben.
  • Grund der Änderung: Der Standard RFC 8259 definiert das Verhalten bei doppelten Namen nicht, sodass sich Implementierungen unterschiedlich verhalten können. Dies kann eine Ursache für Sicherheitslücken sein. v2 lehnt doppelte Namen ausdrücklich ab, um Korrektheit und Sicherheit zu erhöhen.
  • Zugehörige Optionen: Mit der Option jsontext.AllowDuplicateNames(true) kann das Verhalten von v1 nachgeahmt werden.

Unterschiede in Implementierung und Architektur

  • v1: Stützt sich stark auf decodeState in decode.go und den manuell geschriebenen Zustandsautomaten (State Machine) in scanner.go. Dies ist eine monolithische Struktur, in der Parsing-Logik und semantische Analyse stark miteinander gekoppelt sind.
  • v2: Die Architektur ist stärker modularisiert.
    • encoding/json/jsontext: Stellt einen leistungsstarken, low-level JSON-Tokenizer (Decoder) und Encoder (Encoder) bereit. Dieses Paket konzentriert sich ausschließlich auf die syntaktischen Aspekte von JSON.
    • encoding/json/v2: Baut auf jsontext auf und übernimmt die semantische Umwandlung zwischen Go-Typen und JSON-Werten.
    • Durch diese Trennung sind syntaktische und semantische Analyse voneinander getrennt, was Klarheit und Leistung des Codes verbessert.

Neue API und Funktionen in v2

v2 bietet über das System json.Options eine sehr flexible Steuerung.

  • json.Options: Eine Menge von Optionen, die das Marshaling- bzw. Unmarshaling-Verhalten verändern.
  • json.JoinOptions(...): Führt mehrere Optionen zu einer einzigen zusammen.
  • WithMarshalers / WithUnmarshalers: Eine leistungsstarke Funktion, mit der Serialisierungs- und Deserialisierungslogik für bestimmte Typen eingefügt werden kann, ohne dass die Interfaces Marshaler bzw. Unmarshaler implementiert werden müssen. Dies ist besonders nützlich bei der Verarbeitung von Typen aus externen Paketen.
  • Neue Optionen: RejectUnknownMembers, Deterministic(false), FormatNilSliceAsNull und weitere ermöglichen eine Verhaltenssteuerung, die in v1 nicht möglich war.

Fazit

encoding/json v2 ist eine moderne Implementierung, die auf den Erfahrungen mit v1 aufbaut und Korrektheit, Leistung und Flexibilität deutlich verbessert. Das Standardverhalten ist strenger geworden, doch durch das ausgefeilte System der Options lässt sich das gesamte Verhalten von v1 vollständig nachbilden. Dadurch kann die Kompatibilität mit bestehendem Code gewahrt bleiben, während die Vorteile von v2 schrittweise übernommen werden.

  • Für neue Projekte empfiehlt es sich, v2 als Standard zu verwenden.
  • Bestehende Projekte können jsonv1 unverändert weiterverwenden oder zu jsonv2 migrieren und dabei über DefaultOptionsV1() das strengere Verhalten von v2 schrittweise einführen.