Shopping cart

Subtotal $0.00

View cartCheckout

Building better devs

TnewsTnews
  • Home
  • Programação
  • Go 1.27 Reescreveu o JSON do Zero: 10x Mais Rápido e 6 Anos de Espera
Programação

Go 1.27 Reescreveu o JSON do Zero: 10x Mais Rápido e 6 Anos de Espera

Go 1.27 encoding/json/v2 - nova API JSON do Go
Email : 10

Go 1.27 Reescreveu o JSON do Zero: 10x Mais Rápido e 6 Anos de Espera

Seis anos. Foi esse o tempo que a equipe do Go levou para admitir que o encoding/json estava fundamentalmente quebrado e reescrever tudo do zero. O resultado, lançado oficialmente no Go 1.27, é o novo encoding/json/v2, um pacote que corrige falhas de segurança que existiam desde 2012, entrega unmarshal até 10x mais rápido e, de quebra, permite que você migre no seu ritmo sem quebrar uma linha sequer do código existente.

Se você programa em Go e já sofreu com aquele nil virando null no JSON, ou gastou horas debugando um campo que não batia porque o match era case-insensitive, esse artigo é pra você.

O Que Estava Errado com o encoding/json Original

Eu já vi gente defendendo o encoding/json do Go como “simples e funcional”. E ele era, até você precisar de qualquer coisa minimamente séria. O pacote original, presente desde o Go 1.0 em 2012, acumulou uma lista impressionante de problemas que a equipe core classificou em três categorias: falhas de comportamento, deficiências de API e limitações de performance.

Falhas de Comportamento (as piores)

A mais grave: o encoding/json aceitava UTF-8 inválido sem reclamar. Você podia enfiar bytes corrompidos num campo string e o marshal seguia feliz. A RFC 8259 exige UTF-8 válido, mas o Go simplesmente ignorava isso.

Outra bomba: nomes duplicados em objetos JSON. Se um JSON chegava com duas chaves iguais, o v1 aceitava silenciosamente e usava o último valor. Parece inofensivo? É uma brecha de segurança conhecida. Sistemas de validação leem a primeira ocorrência, mas a aplicação usa a segunda. Atacantes exploram exatamente essa inconsistência.

E tem mais:

Comportamento v1 (quebrado) v2 (correto)
— — —
Slice/map nil Serializa como null Serializa como [] ou {}
Match de campos Case-insensitive Case-sensitive
omitempty em structs Não verifica o valor Omite null, "", [], {}
time.Duration Representação padrão inconsistente Exige opção explícita

Aquele comportamento do nil slice virando null já causou bugs em produção em centenas de APIs. Eu mesmo já perdi uma tarde inteira porque um frontend em React quebrava ao receber null onde esperava []. O v2 finalmente faz o óbvio: nil slice vira [], nil map vira {}.

Deficiências de API

O v1 tinha limitações estruturais que nenhum workaround resolvia direito:

  • json.Decoder aceitava lixo depois do JSON válido sem avisar
  • Options como SetEscapeHTML só funcionavam no Encoder/Decoder, não no Marshal/Unmarshal
  • Funções como Compact e Indent só escreviam em bytes.Buffer, não em io.Writer genérico
  • Não existia forma limpa de customizar serialização sem implementar MarshalJSON na própria struct

Gargalos de Performance

Aqui é onde dói no bolso (ou no latency do seu serviço):


// v1: MarshalJSON OBRIGA alocação de []byte
func (t MyType) MarshalJSON() ([]byte, error) {
    // Tem que criar o slice, não tem como streamar
    return json.Marshal(t.internal)
}

Toda chamada ao MarshalJSON no v1 forçava uma alocação. Toda. E o UnmarshalJSON era pior: precisava do JSON completo em memória antes de começar a parsear. Se você tinha structs aninhadas, cada nível chamava Marshal/Unmarshal recursivamente, e o resultado era performance quadrática. O Go blog oficial cita esse problema textualmente.

A Arquitetura do v2: Duas Camadas, Zero Bagunça

A decisão de design mais inteligente do v2 foi separar o pacote em duas camadas:

encoding/json/jsontext: Lida com a gramática JSON pura. Tokens, valores, encoding, decoding. Não sabe nada sobre structs Go.

encoding/json/v2: Faz o mapeamento entre JSON e tipos Go. Usa o jsontext por baixo.


package jsontext

type Encoder struct { ... }
func NewEncoder(io.Writer, ...Options) *Encoder
func (*Encoder) WriteValue(Value) error
func (*Encoder) WriteToken(Token) error

type Decoder struct { ... }
func NewDecoder(io.Reader, ...Options) *Decoder
func (*Decoder) ReadValue() (Value, error)
func (*Decoder) ReadToken() (Token, error)

type Value []byte   // Unidade JSON completa (tipo RawMessage)
type Token struct{}  // Token individual, sem alocação

Essa separação resolve um monte de problemas de uma vez. Quer processar JSON sem mapear pra structs? Usa o jsontext direto. Quer streaming de verdade? O Token é allocation-free. Quer customizar parsing pra um tipo específico sem tocar na struct? Agora dá.

A Nova API: Três Níveis de Abstração

O v2 oferece três formas de serializar e deserializar, cada uma pra um caso de uso diferente:


package json

// Nível 1: Operação em memória (mais simples)
func Marshal(in any, opts ...Options) ([]byte, error)
func Unmarshal(in []byte, out any, opts ...Options) error

// Nível 2: Streaming com io.Reader/Writer
func MarshalWrite(out io.Writer, in any, opts ...Options) error
func UnmarshalRead(in io.Reader, out any, opts ...Options) error

// Nível 3: Controle total com Encoder/Decoder
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error
func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error

Repare que todas as funções aceitam Options. Essa foi uma das maiores reclamações do v1: você não conseguia passar opções pro Marshal/Unmarshal. Tinha que criar um Encoder, configurar, e aí usar. Agora tudo é uniforme.

Interfaces Novas: Streaming de Verdade

O v1 tinha MarshalJSON e UnmarshalJSON. Funcionavam, mas forçavam alocação. O v2 introduz duas interfaces novas que permitem streaming real:


// Nova: Marshal direto pro Encoder, sem alocar []byte
type MarshalerTo interface {
    MarshalJSONTo(*jsontext.Encoder) error
}

// Nova: Unmarshal direto do Decoder, sem buffer intermediário
type UnmarshalerFrom interface {
    UnmarshalJSONFrom(*jsontext.Decoder) error
}

Na prática, isso significa que tipos com parsing complexo (árvores, grafos, qualquer coisa recursiva) podem processar JSON em streaming, token por token, sem jamais bufferizar o valor inteiro na memória.

O Go blog cita o caso da spec OpenAPI do Kubernetes: ao trocar UnmarshalJSON por UnmarshalJSONFrom, a melhoria de performance foi “orders of magnitude”. Não 2x. Não 5x. Ordens de magnitude.

Customização Sem Tocar na Struct

Essa é provavelmente a feature mais pedida da história do encoding/json. No v1, se você queria customizar como um tipo era serializado, tinha que implementar MarshalJSON na struct. Se a struct era de um pacote externo, azar o seu.

No v2, você passa a customização no ponto de chamada:


// Customizar marshal de time.Time sem tocar no tipo
json.Marshal(data,
    json.WithMarshalers(
        json.MarshalFunc[time.Time](func(t time.Time) ([]byte, error) {
            return []byte(`"` + t.Format("02/01/2006") + `"`), nil
        }),
    ),
)

// Customizar unmarshal de qualquer tipo externo
json.Unmarshal(input, &result,
    json.WithUnmarshalers(
        json.UnmarshalFunc[ThirdPartyType](customParser),
    ),
)

Isso elimina dezenas de wrappers e tipos auxiliares que todo projeto Go acumula só pra customizar JSON.

Benchmarks: O Unmarshal Ficou Absurdamente Rápido

Vamos aos números, porque é aqui que o v2 realmente brilha.

Operação v1 v2 Diferença
— — — —
Marshal (twitter.json) 198 MB/s 374 MB/s ~1.9x mais rápido
Unmarshal (structs complexas) baseline até 10x depende da complexidade
Marshal (tipos concretos) baseline 1.4x mais rápido a 1.2x mais lento varia
Unmarshal com UnmarshalerFrom baseline “ordens de magnitude” streaming elimina alocações

O marshal ficou grosso modo no mesmo patamar, com ganhos em alguns cenários e perdas pequenas em outros. O unmarshal é onde a diferença aparece de verdade: o blog de Daniel Lemire mediu melhorias de 2x a 3x em structs complexas aninhadas, e cenários com as novas interfaces de streaming chegaram a 10x.

Se o seu serviço gasta tempo significativo parseando JSON (e qual API não gasta?), o upgrade pra v2 é basicamente performance grátis.

Migração: Faça No Seu Ritmo

A parte mais elegante do design do v2 é a estratégia de migração. Ninguém precisa reescrever nada:

  1. O encoding/json v1 continua funcionando para sempre. A equipe do Go garantiu isso.
  2. Internamente, o v1 do Go 1.27 já roda em cima do v2 com flags de compatibilidade.
  3. Pacotes usando v1 e v2 coexistem perfeitamente no mesmo binário.


// Migração gradual: use v2 mas mantenha comportamento v1 onde precisar
import jsonv2 "encoding/json/v2"

// Modo v2 completo
jsonv2.Marshal(data)

// Modo v2 mas aceitando nomes duplicados (comportamento v1)
jsonv2.Marshal(data,
    jsontext.AllowDuplicateNames(true),
)

// Modo v1 mas rejeitando nomes duplicados (upgrade parcial)
json.Marshal(data,  // v1!
    jsontext.AllowDuplicateNames(false),
)

Cada opção pode ser ligada ou desligada individualmente. Você pode migrar um endpoint por vez, uma struct por vez. E como o v1 roda em cima do v2 internamente, seus marshalers v1 funcionam com unmarshaling v2 e vice-versa.

O Que Vai Quebrar Se Você Migrar Pro v2

Nem tudo são flores. Se você trocar pro encoding/json/v2 direto, alguns comportamentos mudam e podem quebrar seu código:

1. UTF-8 inválido agora gera erro

Se algum sistema manda bytes corrompidos nos seus JSONs, o v2 vai rejeitar. Solução: limpe os dados na entrada ou use a opção de compatibilidade.

2. Nomes duplicados agora geram erro

JSONs mal-formados com chaves repetidas vão falhar. Isso é bom pra segurança, mas pode quebrar integrações com APIs bagunçadas de terceiros.

3. Match de campos é case-sensitive

Se sua API retorna UserName e sua struct tem Username, o v1 fazia match. O v2 não. Use tags json:"UserName" explícitas.

4. Nil slices agora viram [] em vez de null

Se algum consumer depende de receber null pra representar “lista vazia”, vai precisar de ajuste. Mas convenhamos: isso era um bug, não uma feature.


// Se precisar do comportamento antigo pra algum campo específico:
type Response struct {
    Items []string `json:"items,omitempty"` // omitempty agora funciona direito
}

Antes e Depois: Um Exemplo Real

Pra deixar claro o tamanho da diferença, olha um cenário comum: você tem uma API que recebe um JSON com campos opcionais e precisa customizar a serialização de datas.

Com v1 (o jeito velho):


// Wrapper só pra customizar o formato de data
type CustomTime struct {
    time.Time
}

func (ct CustomTime) MarshalJSON() ([]byte, error) {
    return []byte(`"` + ct.Format("02/01/2006") + `"`), nil
}

func (ct *CustomTime) UnmarshalJSON(b []byte) error {
    t, err := time.Parse(`"02/01/2006"`, string(b))
    if err != nil {
        return err
    }
    ct.Time = t
    return nil
}

type User struct {
    Name      string      `json:"name"`
    CreatedAt CustomTime  `json:"created_at"`  // tipo wrapper
    Tags      []string    `json:"tags"`
}

Com v2 (o jeito novo):


type User struct {
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at"`  // tipo original!
    Tags      []string  `json:"tags"`
}

// Customização no ponto de chamada, sem wrappers
out, _ := jsonv2.Marshal(user,
    json.WithMarshalers(
        json.MarshalFunc[time.Time](func(t time.Time) ([]byte, error) {
            return []byte(`"` + t.Format("02/01/2006") + `"`), nil
        }),
    ),
)

Menos código, menos tipos auxiliares, menos indireção. E a struct User continua limpa, sem wrappers que poluem o domínio.

Como Começar Hoje

Se você já está no Go 1.27, é só importar:


import (
    jsonv2 "encoding/json/v2"
    "encoding/json/jsontext"
)

func main() {
    data := map[string]any{
        "nome":  "CodeInsider",
        "posts": 500,
        "tags":  []string{"go", "json", "performance"},
    }

    // Marshal com v2 (defaults seguros)
    out, err := jsonv2.Marshal(data)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(out))

    // Unmarshal com streaming
    reader := strings.NewReader(string(out))
    var result map[string]any
    if err := jsonv2.UnmarshalRead(reader, &result); err != nil {
        log.Fatal(err)
    }
}

Pra projetos existentes, a recomendação da equipe do Go é:

  1. Atualize pro Go 1.27 (o v1 já ganha performance de graça)
  2. Identifique os endpoints com mais tráfego JSON
  3. Migre esses endpoints pro v2 usando as options de compatibilidade
  4. Remova as flags de compatibilidade uma por vez, testando cada mudança

6 Anos Bem Gastos?

O primeiro rascunho do v2 surgiu no final de 2020, quando Daniel Marti publicou uma proposta inicial. Joe Tsai e outros membros da equipe core construíram um protótipo experimental. A discussão pública veio no final de 2023 (issue #63397), a proposta formal no início de 2025 (issue #71497), o pacote entrou como experimento no Go 1.25, e finalmente graduou pra estável no Go 1.27.

Seis anos. Pra um pacote de JSON.

Parece absurdo? Talvez. Mas quando você olha a quantidade de decisões de design envolvidas, a retrocompatibilidade perfeita com bilhões de linhas de código Go existente, e o fato de que o v1 interno agora roda em cima do v2 sem que ninguém precise mudar nada, o tempo começa a fazer sentido.

A equipe do Go poderia ter lançado um v2 rápido e sujo em 2021. Em vez disso, esperou até ter uma solução que resolve o problema de verdade, sem criar novos. Em um mundo onde todo framework JavaScript lança uma versão major a cada 6 meses e depreca a anterior, tem algo refrescante nisso.

O encoding/json/v2 está pronto, estável e disponível agora no Go 1.27. Se você programa em Go, não tem motivo pra não começar a usar. E se você não programa em Go, talvez essa seja uma boa hora pra considerar uma linguagem que leva 6 anos pra acertar um pacote em vez de 6 meses pra quebrar tudo.

Fonte de inspiração: A new Go API for JSON, publicado no blog oficial do Go.

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Posts