GoSuda

Análise das mudanças de comportamento do encoding/json/v2 no Go 1.25 e da camada de compatibilidade com v1

By lemonmint views ...

O pacote encoding/json v2 do Go é uma nova implementação destinada a melhorar várias desvantagens da v1 existente (falta de consistência, comportamentos surpreendentes, problemas de desempenho). Trata-se de um recurso experimental, ativado por meio da tag de build goexperiment.jsonv2.

O ponto mais importante é que , quando a v2 está ativada, a v1 passa a operar como uma camada de compatibilidade que emula o comportamento da v1 sobre a implementação da v2. Isso é feito por meio da função DefaultOptionsV1() no arquivo v2_v1.go. Ou seja, a v2 oferece opções capazes de reproduzir perfeitamente o comportamento da v1 e, ao mesmo tempo, apresenta novos comportamentos padrão mais rigorosos e previsíveis.

Os principais objetivos da v2 são os seguintes.

  1. Melhoria da correção e da previsibilidade: aplicar, por padrão, regras mais rigorosas (por exemplo, diferenciação de maiúsculas e minúsculas e proibição de chaves duplicadas) para reduzir comportamentos inesperados.
  2. Melhoria de desempenho: redesenho dos mecanismos de análise e codificação para aumentar a eficiência.
  3. Maior flexibilidade e controle: introdução de um sistema detalhado de opções que permite ao desenvolvedor controlar minuciosamente o processamento de JSON.

Principais diferenças de semântica e comportamento

Com base principalmente no arquivo v2_test.go, as diferenças de comportamento entre v1 e v2 são organizadas a seguir, item por item.

1. Correspondência de nomes de campos (diferenciação de maiúsculas e minúsculas)

  • Comportamento da v1: ao desserializar membros de objetos JSON em campos de structs Go, a correspondência ignora maiúsculas e minúsculas (case-insensitive). Tanto "FirstName" quanto "firstname" são mapeados para o campo FirstName.
  • Comportamento da v2: por padrão, a correspondência diferencia maiúsculas de minúsculas (case-sensitive), mapeando apenas campos que coincidem exatamente.
  • Motivo da mudança: a correspondência sem distinção de maiúsculas e minúsculas pode causar comportamentos inesperados e degradar o desempenho ao lidar com campos que não correspondem. A v2 adotou, por padrão, um comportamento mais claro e previsível.
  • Opções relacionadas: na v2, é possível habilitar explicitamente a desconsideração de maiúsculas e minúsculas por campo, usando a opção de tag json:"...,case:ignore", ou aplicá-la globalmente com json.MatchCaseInsensitiveNames(true).

2. Alteração do significado da opção de tag omitempty

  • Comportamento da v1: os campos são omitidos com base no "estado vazio" do valor Go. Nesse contexto, "estado vazio" significa false, 0, ponteiros ou interfaces nil, e arrays, slices, mapas ou strings com comprimento zero.
  • Comportamento da v2: os campos são omitidos com base no "estado vazio" do valor JSON codificado. Ou seja, são omitidos quando codificados como null, "", {} ou [].
  • Motivo da mudança: a definição da v1 depende do sistema de tipos do Go. A v2 toma como base o sistema de tipos do JSON, oferecendo um comportamento mais consistente. Por exemplo, na v1 o valor false de um campo do tipo bool é omitido, ao passo que na v2 false é um valor JSON não vazio e, portanto, não é omitido. A v2 acrescenta a opção omitzero, que pode substituir o comportamento do omitempty da v1 aplicado a 0 ou false.
  • Opções relacionadas: para obter na v2 o mesmo comportamento da v1, use a opção json.OmitEmptyWithLegacyDefinition(true).

3. Alteração do comportamento da opção de tag string

  • Comportamento da v1: aplica-se a campos dos tipos numérico, booleano e string. O valor é novamente codificado dentro de uma string JSON (por exemplo, int(42) -> "42"). Não se aplica recursivamente a valores dentro de tipos compostos (slices, mapas etc.).
  • Comportamento da v2: aplica-se apenas a tipos numéricos e é aplicada recursivamente. Ou seja, os números dentro de um slice como []int também são todos codificados como strings JSON.
  • Motivo da mudança: a principal finalidade da opção string é representar números como strings para evitar a perda de precisão em inteiros de 64 bits. O comportamento da v1 era limitado e pouco consistente. A v2 concentra-se nesse uso essencial e amplia o comportamento de forma recursiva, tornando-o mais útil.
  • Opções relacionadas: a opção json.StringifyWithLegacySemantics(true) permite imitar o comportamento da v1.

4. Desserialização (marshal) de slices e mapas nil

  • Comportamento da v1: slices nil e mapas nil são serializados como null.
  • Comportamento da v2: por padrão, slices nil são serializados como [] (array vazio) e mapas nil como {} (objeto vazio).
  • Motivo da mudança: nil é um detalhe de implementação da linguagem Go, e não é desejável expô-lo em um formato JSON independente de linguagem. [] e {} são representações mais universais de coleções vazias.
  • Opções relacionadas: na v2, é possível serializar como null, como na v1, com as opções json.FormatNilSliceAsNull(true) ou json.FormatNilMapAsNull(true).

5. Desserialização de arrays

  • Comportamento da v1: ao desserializar para arrays Go ([N]T), não é gerado erro quando o comprimento do array JSON difere do comprimento do array Go. Se o array JSON for menor, o espaço restante é preenchido com valores zero; se for maior, o excedente é descartado.
  • Comportamento da v2: o comprimento do array JSON deve coincidir exatamente com o comprimento do array Go. Caso contrário, ocorre um erro.
  • Motivo da mudança: em Go, arrays de tamanho fixo frequentemente têm o comprimento como informação semanticamente importante. O comportamento da v1 pode causar perda silenciosa de dados. A v2 aumenta a correção com regras mais rigorosas.
  • Opções relacionadas: a opção json.UnmarshalArrayFromAnyLength(true) permite imitar o comportamento da v1.

6. Tratamento de time.Duration

  • Comportamento da v1: time.Duration é tratado internamente como int64 e codificado como um número JSON em nanossegundos.
  • Comportamento da v2: é codificado como uma string JSON no formato "1h2m3s", usando o método time.Duration.String().
  • Motivo da mudança: números em nanossegundos são pouco legíveis, e a representação textual padrão de time.Duration é mais útil.
  • Opções relacionadas: é possível usar o comportamento da v1 com a opção de tag json:",format:nano" ou com a opção json.FormatTimeWithLegacySemantics(true).

7. Tratamento de UTF-8 inválido

  • Comportamento da v1: durante a serialização ou desserialização, bytes UTF-8 inválidos em strings são substituídos silenciosamente pelo caractere de substituição Unicode (\uFFFD).
  • Comportamento da v2: por padrão, ao encontrar UTF-8 inválido, retorna um erro.
  • Motivo da mudança: evitar a corrupção silenciosa de dados e seguir mais rigorosamente o padrão JSON (RFC 7493).
  • Opções relacionadas: a opção jsontext.AllowInvalidUTF8(true) permite imitar o comportamento da v1.

8. Tratamento de nomes de membros de objeto duplicados

  • Comportamento da v1: é permitido que um objeto JSON contenha membros com nomes duplicados. O último valor encontrado sobrescreve os anteriores.
  • Comportamento da v2: por padrão, se houver nomes de membros duplicados, retorna um erro.
  • Motivo da mudança: a RFC 8259 não define o comportamento para nomes duplicados, o que pode levar a comportamentos diferentes entre implementações. Isso pode ser uma causa de vulnerabilidades de segurança. A v2 rejeita explicitamente essa situação, aumentando a correção e a segurança.
  • Opções relacionadas: a opção jsontext.AllowDuplicateNames(true) permite imitar o comportamento da v1.

Diferenças de implementação e arquitetura

  • v1: depende fortemente de decodeState em decode.go e de uma máquina de estados (state machine) escrita manualmente em scanner.go. Trata-se de uma estrutura monolítica, com forte acoplamento entre a lógica de análise sintática e a análise semântica.
  • v2: a arquitetura é mais modular.
    • encoding/json/jsontext: fornece um tokenizador (Decoder) e um codificador (Encoder) JSON de baixo nível e alto desempenho. Este pacote se concentra exclusivamente nos aspectos sintáticos do JSON.
    • encoding/json/v2: com base em jsontext, trata a conversão semântica entre tipos Go e valores JSON.
    • Essa separação faz com que a análise sintática e a análise semântica sejam independentes, melhorando a clareza do código e o desempenho.

Novas APIs e recursos da v2

A v2 oferece controle muito flexível por meio do sistema json.Options.

  • json.Options: conjunto de opções que alteram o comportamento de serialização e desserialização.
  • json.JoinOptions(...): combina várias opções em uma única.
  • WithMarshalers / WithUnmarshalers: recurso poderoso que permite injetar lógica de serialização e desserialização para tipos específicos sem implementar as interfaces Marshaler / Unmarshaler. É especialmente útil ao lidar com tipos de pacotes externos.
  • Novas opções: RejectUnknownMembers, Deterministic(false), FormatNilSliceAsNull e outras, que possibilitam controles de comportamento impossíveis na v1.

Conclusão

O encoding/json v2 é uma implementação moderna que, com base na experiência da v1, melhora significativamente a correção, o desempenho e a flexibilidade. O comportamento padrão tornou-se mais rigoroso, mas, por meio do sofisticado sistema de Options, é possível reproduzir integralmente todos os comportamentos da v1, mantendo a compatibilidade com o código existente e permitindo a adoção gradual das vantagens da v2.

  • Para novos projetos, recomenda-se usar a v2 como padrão.
  • Projetos existentes podem continuar usando jsonv1 ou migrar para jsonv2, adotando gradualmente o comportamento mais rigoroso da v2 por meio de DefaultOptionsV1().