GoSuda

Análisis de los cambios de comportamiento de encoding/json/v2 en Go 1.25 y de la capa de compatibilidad con v1

By lemonmint views ...

El paquete encoding/json v2 de Go es una nueva implementación destinada a mejorar varias deficiencias de la v1 (falta de consistencia, comportamientos sorprendentes y problemas de rendimiento). Se activa mediante la etiqueta de compilación goexperiment.jsonv2, lo que la convierte en una característica experimental.

Lo más importante es que cuando v2 está activado, v1 funciona como una capa de compatibilidad que emula el comportamiento de v1 sobre la implementación de v2. Esto se logra mediante la función DefaultOptionsV1() del archivo v2_v1.go. Es decir, v2 ofrece opciones que permiten reproducir por completo el comportamiento de v1, al mismo tiempo que presenta un nuevo comportamiento predeterminado más estricto y predecible.

Los objetivos principales de v2 son los siguientes:

  1. Mejora de la corrección y la previsibilidad: aplica por defecto reglas más estrictas (por ejemplo, distinción entre mayúsculas y minúsculas y prohibición de claves duplicadas) para reducir los comportamientos inesperados.
  2. Mejora del rendimiento: se rediseñaron los motores de análisis y codificación para aumentar la eficiencia.
  3. Mayor flexibilidad y control: se introduce un sistema detallado de opciones que permite al desarrollador controlar con precisión el procesamiento de JSON.

Principales diferencias de semántica y comportamiento

Se resumen por elementos las diferencias de comportamiento entre v1 y v2, tomando como referencia el archivo v2_test.go.

1. Coincidencia de nombres de campo (distinción entre mayúsculas y minúsculas)

  • Comportamiento de v1: al deserializar (unmarshal) los miembros de un objeto JSON en los campos de una estructura de Go, la coincidencia ignora las mayúsculas y minúsculas (case-insensitive). Tanto "FirstName" como "firstname" se asignan al campo FirstName.
  • Comportamiento de v2: por defecto distingue entre mayúsculas y minúsculas (case-sensitive) y solo asigna los campos que coinciden exactamente.
  • Motivo del cambio: la coincidencia sin distinción de mayúsculas puede ser causa de comportamientos inesperados y provoca una degradación del rendimiento al procesar campos que no coinciden. v2 adopta como comportamiento predeterminado uno más claro y predecible.
  • Opciones relacionadas: en v2 se puede activar explícitamente la coincidencia sin distinción de mayúsculas para un campo concreto mediante la opción de etiqueta json:"...,case:ignore", o aplicarla de forma global con json.MatchCaseInsensitiveNames(true).

2. Cambio de significado de la opción de etiqueta omitempty

  • Comportamiento de v1: omite el campo según el "estado vacío" del valor de Go. En este caso, el "estado vacío" significa false, 0, punteros o interfaces nil, y arreglos, slices, mapas o cadenas de longitud 0.
  • Comportamiento de v2: omite el campo según el "estado vacío" del valor JSON codificado. Es decir, se omite cuando se codifica como null, "", {} o [].
  • Motivo del cambio: la definición de v1 depende del sistema de tipos de Go. v2 se basa en el sistema de tipos de JSON, lo que proporciona un comportamiento más consistente. Por ejemplo, en v1 se omite el valor false de un tipo bool, mientras que en v2 false es un valor JSON no vacío y, por tanto, no se omite. En v2 se añade la opción omitzero, que puede reemplazar el comportamiento de omitempty de v1 aplicado a 0 o false.
  • Opciones relacionadas: para obtener en v2 el mismo comportamiento que en v1, se usa la opción json.OmitEmptyWithLegacyDefinition(true).

3. Cambio de comportamiento de la opción de etiqueta string

  • Comportamiento de v1: se aplica a campos de tipo numérico, booleano y cadena. Codifica de nuevo el valor dentro de una cadena JSON (por ejemplo, int(42) -> "42"). No se aplica de forma recursiva a los valores dentro de tipos compuestos (slices, mapas, etc.).
  • Comportamiento de v2: se aplica únicamente a tipos numéricos y lo hace de forma recursiva. Es decir, los números dentro de un slice como []int también se codifican como cadenas JSON.
  • Motivo del cambio: el uso principal de la opción string es representar enteros de 64 bits como cadenas para evitar la pérdida de precisión. El comportamiento de v1 era limitado e inconsistente. v2 se centra en este uso principal y amplía el comportamiento de forma recursiva para hacerlo más útil.
  • Opciones relacionadas: la opción json.StringifyWithLegacySemantics(true) permite imitar el comportamiento de v1.

4. Serialización (marshal) de slices y mapas nil

  • Comportamiento de v1: los slices nil y los mapas nil se serializan como null.
  • Comportamiento de v2: por defecto, los slices nil se serializan como [] (arreglo vacío) y los mapas nil como {} (objeto vacío).
  • Motivo del cambio: nil es un detalle de implementación del lenguaje Go, y no resulta conveniente exponerlo en un formato JSON independiente del lenguaje. [] y {} son representaciones más habituales de colecciones vacías.
  • Opciones relacionadas: en v2, las opciones json.FormatNilSliceAsNull(true) o json.FormatNilMapAsNull(true) permiten serializar como null, igual que en v1.

5. Deserialización (unmarshal) de arreglos

  • Comportamiento de v1: al deserializar en un arreglo de Go ([N]T), no se produce error aunque la longitud del arreglo JSON sea distinta de la del arreglo de Go. Si el arreglo JSON es más corto, el espacio sobrante se rellena con valores cero; si es más largo, el excedente se descarta.
  • Comportamiento de v2: la longitud del arreglo JSON debe coincidir exactamente con la del arreglo de Go. En caso contrario, se produce un error.
  • Motivo del cambio: en Go, la longitud de los arreglos de tamaño fijo suele tener un significado importante. El comportamiento de v1 puede provocar pérdidas silenciosas de datos. v2 aplica reglas más estrictas para mejorar la corrección.
  • Opciones relacionadas: la opción json.UnmarshalArrayFromAnyLength(true) permite imitar el comportamiento de v1.

6. Tratamiento de time.Duration

  • Comportamiento de v1: time.Duration se trata internamente como int64 y se codifica como un número JSON en nanosegundos.
  • Comportamiento de v2: se codifica como una cadena JSON con un formato como "1h2m3s", mediante el método time.Duration.String().
  • Motivo del cambio: los nanosegundos numéricos son poco legibles, y la representación textual estándar de time.Duration resulta más útil.
  • Opciones relacionadas: se puede usar el comportamiento de v1 mediante la opción de etiqueta json:",format:nano" o la opción json.FormatTimeWithLegacySemantics(true).

7. Tratamiento de UTF-8 no válido

  • Comportamiento de v1: durante la serialización o deserialización, si una cadena contiene bytes UTF-8 no válidos, estos se reemplazan silenciosamente por el carácter de reemplazo Unicode (\uFFFD).
  • Comportamiento de v2: por defecto, al encontrar UTF-8 no válido devuelve un error.
  • Motivo del cambio: evitar la corrupción silenciosa de datos y cumplir con el estándar JSON más estricto (RFC 7493).
  • Opciones relacionadas: la opción jsontext.AllowInvalidUTF8(true) permite imitar el comportamiento de v1.

8. Tratamiento de nombres de miembros duplicados en objetos

  • Comportamiento de v1: permite que aparezcan miembros con el mismo nombre dentro de un objeto JSON. Prevalece el último valor.
  • Comportamiento de v2: por defecto, si existen nombres de miembros duplicados, devuelve un error.
  • Motivo del cambio: el estándar RFC 8259 no define el comportamiento para nombres duplicados, por lo que las implementaciones pueden comportarse de forma distinta. Esto puede ser una causa de vulnerabilidades de seguridad. v2 rechaza explícitamente estos casos para mejorar la corrección y la seguridad.
  • Opciones relacionadas: la opción jsontext.AllowDuplicateNames(true) permite imitar el comportamiento de v1.

Diferencias de implementación y arquitectura

  • v1: depende en gran medida de decodeState en decode.go y de la máquina de estados escrita manualmente en scanner.go. Se trata de una estructura monolítica en la que el análisis sintáctico y el análisis semántico están fuertemente acoplados.
  • v2: la arquitectura es más modular.
    • encoding/json/jsontext: proporciona un tokenizador Decoder y un codificador Encoder de JSON de bajo nivel y alto rendimiento. Este paquete se centra únicamente en los aspectos sintácticos de JSON.
    • encoding/json/v2: se basa en jsontext para gestionar la conversión semántica entre los tipos de Go y los valores JSON.
    • Gracias a esta separación, el análisis sintáctico y el semántico quedan desacoplados, lo que mejora la claridad del código y el rendimiento.

Nuevas API y funciones de v2

v2 ofrece un control muy flexible mediante el sistema json.Options.

  • json.Options: conjunto de opciones que modifican el comportamiento de la serialización y deserialización.
  • json.JoinOptions(...): combina varias opciones en una sola.
  • WithMarshalers / WithUnmarshalers: función potente que permite inyectar la lógica de serialización y deserialización para tipos concretos sin implementar las interfaces Marshaler o Unmarshaler. Resulta especialmente útil al tratar tipos de paquetes externos.
  • Nuevas opciones: RejectUnknownMembers, Deterministic(false), FormatNilSliceAsNull, entre otras, permiten controlar comportamientos que en v1 no eran posibles.

Conclusión

encoding/json v2 es una implementación moderna que, a partir de la experiencia con v1, mejora notablemente la corrección, el rendimiento y la flexibilidad. Su comportamiento predeterminado es más estricto, pero gracias a un sistema de Options detallado es posible reproducir por completo el comportamiento de v1, lo que permite mantener la compatibilidad con el código existente e incorporar gradualmente las ventajas de v2.

  • En proyectos nuevos, se recomienda usar v2 como opción predeterminada.
  • En proyectos existentes, es posible seguir usando jsonv1 tal cual, o migrar a jsonv2 e incorporar gradualmente el comportamiento más estricto de v2 mediante DefaultOptionsV1().