Comparação de desempenho em geração de JSON em Ruby
Nos últimos dias tenho otimizado uma aplicação Ruby on Rails de uma API que responde requisições nos formatos JSON ou XML.
Identifiquei ocorrências de memory bloat na geração de XML em alguns casos raros e por isso estou investigando opções mais eficientes de gerar JSON e XML. Atualmente essa API utiliza a gem RABL para renderizar os documentos de resposta.
Minha primeira missão é encontrar a solução de renderização de JSON em Ruby com o melhor desempenho possível. Estou disposto a sacrificar simplicidade, estética, padronização ou manutenabilidade de código.
Essa API que está sendo otimizada recebe algumas dezenas de milhões de requisições por mês, então desempenho é a palavra-chave aqui.
TL;DR
A melhor solução que encontrei foi usar a biblioteca Oj para manipular JSONs e não usar nenhuma DSL de renderização, ou seja, para renderizar basta construir um Hash
a partir do objeto Ruby e convertê-lo para JSON.
Desempenho para converter um Hash para JSON
A primeira análise de desempenho será entre algumas bibliotecas supostamente eficientes que permitem converter um Hash
de Ruby para um JSON em texto:
Para facilitar essa análise, a gem MultiJSON será usada. Ela oferece uma interface comum para acessar as bibliotecas de transformação de JSON.
Na análise serão usados 3 tipos de Hash
para a conversão.
- hash1 - muito simples com poucas chaves e tipos básicos.
- hash2 - quantidade razoável de chaves, "árvore" de até 3 níveis, valores do tipo
Array
deHashes
, valor comString
muito longa (simulando o conteúdo de um arquivo, por exemplo). Suponho ser oHash
mais comum de se encontrar em aplicações Ruby. - hash3 - mil chaves, "árvore" de até 10 níveis, valores do tipo hash1 e hash2. Exemplo de um caso extremo de
Hash
.
Resultados
Para análise dos resultados, um script foi executado para medir tempo de execução de conversões de Hash
para JSON e também para medir quanta memória é alocada durante uma conversão.
╔════════════╦═════════╦═════════╗
║ BIBLIOTECA ║ TEMPO ║ MEMÓRIA ║
╔════════╬════════════╬═════════╬═════════╣
║ hash1 ║ ║ ║ ║
║ ║ JSON ║ 0,01 ms ║ 1,5 K ║
║ ║ Yajl ║ 0,02 ms ║ 1,0 K ║
║ ║ Oj ║ 0,01 ms ║ 1,0 K ║
╠════════╬════════════╬═════════╬═════════╣
║ hash2 ║ ║ ║ ║
║ ║ JSON ║ 0,57 ms ║ 12,2 K ║
║ ║ Yajl ║ 0,39 ms ║ 11,8 K ║
║ ║ Oj ║ 0,33 ms ║ 11,8 K ║
╠════════╬════════════╬═════════╬═════════╣
║ hash3 ║ ║ ║ ║
║ ║ JSON ║ 327 ms ║ 6,62 M ║
║ ║ Yajl ║ 235 ms ║ 6,62 M ║
║ ║ Oj ║ 235 ms ║ 6,62 M ║
╚════════╩════════════╩═════════╩═════════╝
Em todos os casos, a biblioteca Oj teve o melhor desempenho no tempo para converter um Hash
para JSON. Nenhuma biblioteca apresentou alocação de memória significativamente diferente das outras.
Desempenho para renderização de JSON
A renderização de um JSON tipicamente envolve a interpretação de regras de conversão (template) de um objeto Ruby de entrada para um JSON de saída.
O renderizador de JSON mais popular em Ruby é o JBuilder. Um que foi popular no passado é o RABL. E uma abordagem menos convencional que também foi avaliada consiste na construção de um Hash
a partir de um objeto Ruby seguida da conversão direta desse Hash
para JSON.
Na análise, o objeto Ruby a ser renderizado representa um Artigo (post) com múltiplos Comentários. Foram feitas análises de 3 tipos de artigos:
- post1 - artigo com aproximadamente mil caracteres e 10 comentários simples.
- post2 — artigo com aproximadamente 1 milhão de caracteres, 200 comentários, e cada comentário com mais de mil caracteres.
- post3 — artigo com aproximadamente 10 milhões de caracteres, 5 mil comentários, e cada comentário com mais de 10 mil caracteres.
Resultados
Para análise dos resultados, um script foi executado para medir tempo de execução das renderizações de cada artigo para JSON e também para medir quanta memória é alocada durante uma renderização.
╔═════════════╦═════════╦═════════╗
║ Render ║ TEMPO ║ MEMÓRIA ║
╔════════╬═════════════╬═════════╬═════════╣
║ post1 ║ ║ ║ ║
║ ║ RABL ║ 0,96 ms ║ 82,7 K ║
║ ║ Jbuilder ║ 0,22 ms ║ 30,1 K ║
║ ║ Hash > JSON ║ 0,09 ms ║ 10,5 K ║
╠════════╬═════════════╬═════════╬═════════╣
║ post2 ║ ║ ║ ║
║ ║ RABL ║ 14,3 ms ║ 1,20 M ║
║ ║ Jbuilder ║ 6,8 ms ║ 0,53 M ║
║ ║ Hash > JSON ║ 4,6 ms ║ 0,19 M ║
╠════════╬═════════════╬═════════╬═════════╣
║ post3 ║ ║ ║ ║
║ ║ RABL ║ 475 ms ║ 29,4 M ║
║ ║ Jbuilder ║ 321 ms ║ 13,2 M ║
║ ║ Hash > JSON ║ 259 ms ║ 4,7 M ║
╚════════╩═════════════╩═════════╩═════════╝
Em todos os casos, a abordagem Hash > JSON (converter o objeto Ruby para um Hash
e em seguida converter o Hash
para JSON) teve o melhor desempenho tanto em tempo quanto em alocação de memória.
O que fica de mais relevante para mim é que a abordagem Hash > JSON utiliza cerca de 6 vezes menos memória que o RABL e 3 vezes menos memória que o Jbuilder.
Código final
Ainda estou refletindo sobre a organização do código final. Por ora, uma sugestão é:
Controller
# app/controllers/posts_controller.rbrespond_to do |format|
format.json { render json: Renderer::Post.json(@post) }
format.xml { ... }
end
Renderizador
# app/views-api/renderer/post.rbclass Renderer::Post
def self.json(post)
MultiJson.dump({
'content' => post.content,
'created_at' => post.created_at,
'published' => post.published,
'author' => {
'name' => post.author.name,
'age' => post.author.age,
'email' => post.author.email,
},
'comments' => post.comments.map do |comment|
{
'created_at' => comment.created_at,
'message' => comment.message,
'user' => {
'name' => comment.user.name,
'age' => comment.user.age,
'email' => comment.user.email,
},
'attachment' => comment.attachment,
}
end
})
end
end
Ambiente utilizado nos testes
O tempo de execução de cada teste é uma média dos tempos de execução de mil execuções de cada cenário.
O código fonte necessário para reproduzir os códigos está presente neste gist. O ambiente utilizado nos testes está descrito a seguir:
- Computador: MacBook Pro 15" 2017