Executando verificação de segurança...
29
kht
10 min de leitura ·

Protobuf, um formato binário bem compacto (ou: "devo usar JSON pra tudo?")

Antes de chegar ao Protobuf propriamente dito, uma reflexão: hoje em dia JSON tem sido o formato mais popular para trocas de dados, praticamente toda API Web que se vê por aí retorna um JSON por padrão. Mas será que ele é a única opção? Mais ainda, será que ele sempre é a melhor opção?

A resposta é não.


Entre as principais características do JSON, estão a sintaxe familiar (muito similar à do JavaScript, tanto que a sigla significa "JavaScript Object Notation") e o fato de ser human readable (facilmente lido por humanos). Além, é claro, da popularidade e do amplo suporte das linguagens e frameworks.

Mas nem sempre você precisa dessas características, em especial da segunda. Um exemplo claro foi dado neste outro post, que cita o caso do GTA V. O jogo precisava ler um JSON de 10 megabytes, e por isso o tempo de carregamento estava alto demais.

Eu comentei ali questionando se esta tinha sido a melhor escolha. Afinal, era um arquivo que somente o jogo lia, ou seja, não precisava necessariamente ser lido por humanos. Exceto, talvez, os próprios desenvolvedores, em caso de precisar debugar algo. Mas ainda sim, eles poderiam muito bem usar um formato binário mais compacto, e para debugar poderiam usar alguma ferramenta para traduzir o binário para algo legível por humanos.

O problema é que JSON é um formato verboso. E se estivermos falando de uma lista de vários itens (como era o caso do GTA), essa verbosidade se agrava ainda mais, devido à redundância nos nomes das chaves. Por exemplo, no post já citado é dito que o arquivo tinha 63 mil itens com este formato:

{
    "key": "WP_WCT_TINT_21_t2_v9_n2",
    "price": 45000,
    "statName": "CHAR_KIT_FM_PURCHASE20",
    "storageType": "BITFIELD",
    "bitShift": 7,
    "bitSize": 1,
    "category": ["CATEGORY_WEAPON_MOD"]
}

Ou seja, as strings "key", "price", "statName" etc se repetiam 63 mil vezes, o que é uma enorme redundância. Claro que poderia até zipar o arquivo, mas a questão não é essa: isso não diminuiria o tempo de carregamento, pois no fim o parser precisaria processar todo o JSON de qualquer jeito.

Para mim, este é um caso em que JSON não parece ser a melhor opção. Acho que poderia usar algum formato binário, como o Protocol Buffers (também conhecido como Protobuf).


Usando Protobuf

Vamos usar o mesmo exemplo do post do GTA. Primeiro eu crio um arquivo com a definição dos dados, que o Protobuf chama de "mensagem". No caso, vou criar o arquivo inventario.proto, contendo o seguinte:

syntax = "proto3";

package protobuf.exemplo;

option java_multiple_files = true;
option java_package = "protobuf.exemplo";
option java_outer_classname = "InventarioJogo";

message Item {
  optional string key = 1;
  optional int32 price = 2;
  optional string statName = 3;
  optional string storageType = 4;
  optional int32 bitShift = 5;
  optional int32 bitSize = 6;
  repeated string category = 7;
}

message Inventario {
  repeated Item item = 1;
}

Basicamente, defini uma mensagem do tipo Item com os campos já citados acima, e uma mensagem do tipo Inventario, que contém zero ou mais itens. Os números servem para identificar unicamente os campos de uma mensagem - para mais detalhes, consulte a documentação, que também explica em detalhes todos os tipos existentes.

Agora vou gerar o código para Java e Python, pois a ideia é criar o arquivo em uma linguagem e ler em outra. Na documentação existem tutoriais para várias linguagens. Mas basicamente, basta baixar o compilador protoc e rodar o comando:

protoc --java_out=diretorio/fontes/java --python_out=diretorio/fontes/python caminho/do/arquivo/inventario.proto 

Obviamente, mudando os nomes das pastas para o que você estiver usando.

Com isso, serão gerados arquivos em cada uma das linguagens, nas respectivas pastas. A recomendação é que estes arquivos não sejam editados, sob o risco de quebrar algo.

Agora vamos usá-los. Em Java, estou usando o Maven, então basta adicionar as dependências do Protobuf:

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>4.27.1</version>
</dependency>
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java-util</artifactId>
    <version>4.27.1</version>
</dependency>

E depois criei uma classe simples que gera 63 mil itens e grava em dois arquivos: um no formato do Protobuf, e outro em JSON. Para os campos, usei valores aleatórios mesmo:

import com.google.protobuf.util.JsonFormat;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Writer;
import java.util.Random;

// estas são as classes que o compilador protoc gerou
import protobuf.exemplo.Inventario;
import protobuf.exemplo.Item;

public class TesteInventario {
    private static final Random RAND = new Random();
    private static final String ALL_SYMBOLS;
    static {
        String upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
        ALL_SYMBOLS = upper + upper.toLowerCase() + "0123456789_!@#$%\"'&*()-+=[]{},.;";
    }
    static String randomString(int size) {
        char[] chars = new char[size];
        for (int i = 0; i < size; i++) {
            chars[i] = ALL_SYMBOLS.charAt(RAND.nextInt(ALL_SYMBOLS.length()));
        }
        return new String(chars);
    }

    public static void main(String[] args) {
        Inventario.Builder inventarioBuilder = Inventario.newBuilder();
        for (int i = 0; i < 63000; i++) { // gera 63 mil itens
            inventarioBuilder.addItem(Item.newBuilder()
                    .setKey(randomString(23))
                    .setPrice(RAND.nextInt(100000))
                    .setStatName(randomString(22))
                    .setStorageType(randomString(10))
                    .setBitShift(RAND.nextInt(8))
                    .setBitSize(RAND.nextInt(8))
                    .addCategory(randomString(20))
                    .build());
        }
        Inventario inventario = inventarioBuilder.build();

        // salva um arquivo no formato protobuf e outro em JSON
        try (OutputStream out = new FileOutputStream("inventario.binpb")) {
            inventario.writeTo(out);
        } catch (IOException e) {
            // só loga o erro, mas em uma aplicação mais séria poderia exibir mensagem de erro e algum outro tratamento mais adequado
            e.printStackTrace();
        }
        try (Writer writer = new FileWriter("inventario.json")) {
            writer.write(JsonFormat.printer().omittingInsignificantWhitespace().print(inventario));
        } catch (IOException e) {
            // só loga o erro, mas em uma aplicação mais séria poderia exibir mensagem de erro e algum outro tratamento mais adequado
            e.printStackTrace();
        }
    }
}

Para o arquivo no formato do Protobuf, usei a extensão .binpb, que é a recomendada na documentação.

Após rodar o programa, o arquivo Protobuf ficou com 5.848.669 bytes (pouco mais de 5 MB), enquanto o JSON ficou com 11.727.874 (o dobro do tamanho). E isso porque eu gerei o JSON em uma única linha e sem espaços, para tentar deixá-lo menor. Segue o começo dele, para termos uma ideia:

{"item":[{"key":"tt-P]ILhna;lob\u0026QrM#9Rp\u003d","price":24333,"statName":"%zJq,0CQ]\u003dn}w0sh7K*)Jx","storageType":"T\"a9w,V!xS","bitShift":1,"bitSize":6,"category":["86GsdS%P\"o3BmswwTyU\u0027"]},{"key":"_FeU@ScbqEJbSsZQB!{fhHT","price":78146,"statName":"]!Nz%7eK(L%)]QfPm[qciN","storageType":"IreS_Kx],U","bitShift":6,"bitSize":4,"category":["CboAL5g#p4V@58{TFx1M"]},{"key":"nq4Qt-\u003d\"NDFRXUGF!JQ}YNJ","price":92929,"statName":"MlA!tYaF3pD..5!B+CvjtC","storageType":"jj\u00273*QFUzU","bitShift":0,"bitSize":7,"category":["0N(a\u0027cYTCFjV5Zmpb9O6"]},{"key":"J[9C@,j\u0027r;7F,I_;31O(Dc{","price":85444,"statName":"+NLd1f0\u0026Ej#3\u0027TeA3k\u003do1a","storageType":...

E para efeito de comparação, o início do arquivo binário (usei o comando hexdump para mostrar os bytes em um formato mais "amigável"):

$ hexdump -C inventario.binpb | head
00000000  0a 5b 0a 17 74 74 2d 50  5d 49 4c 68 6e 61 3b 6c  |.[..tt-P]ILhna;l|
00000010  6f 62 26 51 72 4d 23 39  52 70 3d 10 8d be 01 1a  |ob&QrM#9Rp=.....|
00000020  16 25 7a 4a 71 2c 30 43  51 5d 3d 6e 7d 77 30 73  |.%zJq,0CQ]=n}w0s|
00000030  68 37 4b 2a 29 4a 78 22  0a 54 22 61 39 77 2c 56  |h7K*)Jx".T"a9w,V|
00000040  21 78 53 28 01 30 06 3a  14 38 36 47 73 64 53 25  |!xS(.0.:.86GsdS%|
00000050  50 22 6f 33 42 6d 73 77  77 54 79 55 27 0a 5b 0a  |P"o3BmswwTyU'.[.|
00000060  17 5f 46 65 55 40 53 63  62 71 45 4a 62 53 73 5a  |._FeU@ScbqEJbSsZ|
00000070  51 42 21 7b 66 68 48 54  10 c2 e2 04 1a 16 5d 21  |QB!{fhHT......]!|
00000080  4e 7a 25 37 65 4b 28 4c  25 29 5d 51 66 50 6d 5b  |Nz%7eK(L%)]QfPm[|
00000090  71 63 69 4e 22 0a 49 72  65 53 5f 4b 78 5d 2c 55  |qciN".IreS_Kx],U|

Agora vamos escrever o código Python que lê os arquivos. No caso, o comando protoc que rodamos acima gerou o arquivo inventario_pb2.py, então basta adicioná-lo ao seu projeto. A seguir, instale o módulo protobuf usando o comando pip install protobuf. Aí sim podemos fazer o programa que lê os arquivos:

import inventario_pb2

# lê o arquivo protobuf
inventario = inventario_pb2.Inventario()
with open('inventario.binpb', 'rb') as f:
    inventario.ParseFromString(f.read())
    # mostra somente os 3 primeiros
    it = iter(inventario.item)
    for _ in range(3):
        print(next(it))

import json

# lê o JSON
with open('inventario.json') as f:
    inventario = json.load(f)
    it = iter(inventario['item'])
    # mostra somente os 3 primeiros
    for _ in range(3):
        print(next(it))

Bom, isso foi só para mostrar que é tranquilo e transparente para os programas lerem e escreverem, desde que os códigos gerados pelo compilador protoc tenham usado o mesmo arquivo .proto que contém a definição das mensagens.

Aproveitando, vamos verificar quanto tempo demora para cada parsing ser executado, usando o módulo timeit:

import inventario_pb2, json

def ler_protobuf():
    inventario = inventario_pb2.Inventario()
    with open('inventario.binpb', 'rb') as f:
        inventario.ParseFromString(f.read())

def ler_json():
    with open('inventario.json') as f:
        inventario = json.load(f)

from timeit import timeit

# executa 10 vezes cada teste
params = { 'number': 10, 'globals': globals() }
# imprime os tempos em segundos
print(timeit('ler_json()', **params))
print(timeit('ler_protobuf()', **params))

Ou seja, o código só lê o arquivo e mais nada. Lembrando que os resultados podem variar de uma máquina para outra, mas na minha foram:

1.0223600070021348
0.11911176600551698

Os tempos estão em segundos, e mostram que a leitura do JSON demorou quase 10 vezes mais. Testei várias vezes, e os resultados não variaram muito.

Isso se deve ao fato do formato Protobuf ser mais compacto, e segundo a própria documentação, otimizado para velocidade. Se ficou curioso quanto ao formato, vale uma lida na documentação, que explica em detalhes a "escovação de bits" que é feita para deixar o arquivo menor, se comparado a JSON e outros formatos de texto.

Outro ponto é que enquanto no JSON os nomes dos campos ficam no próprio arquivo, no Protobuf esta informação é externa, separada dos dados (ela fica nas classes que foram geradas pelo compilador protoc). Esse é outro motivo pelo qual os arquivos ficam menores, pois eles só contêm os dados, enquanto que o JSON precisa ter os dados e os nomes dos campos.

Mas claro que em computação tudo é trade-off, ou em outras palavras, "cada escolha, uma renúncia". Um formato binário não é legível por humanos, então vai precisar de ferramentas de debug adicionais (embora o simples fato de imprimir os objetos gerados já resultem em um formato legível, mas enfim, não é tão direto quanto JSON).

E claro, cada alteração no arquivo .proto tem que ser devidamente propagada para todos os clients, e as respectivas classes devem ser geradas novamente. Na documentação há vários guias que explicam como conduzir mudanças no formato das mensagens (se bem que isso também acontece com JSON e qualquer outro formato, afinal, se algum campo mudar, todo mundo precisará atualizar seus códigos).


Ah, então vou trocar todos os meus JSON's por Protobuf!

Calma.

É claro que por gerar arquivos menores e ter um parsing mais rápido, você pode ficar tentado a sair mudando tudo para Protobuf. Mas tem outros fatores para levar em conta.

Por exemplo, se forem poucos dados, aí eu diria que tanto faz. Se a quantidade de dados e/ou tempo de parsing não é um fator crítico, não teria problema "desperdiçar" um pouco usando JSON, ainda mais se todos os sistemas que consomem os dados já conseguem lê-los - que aliás é outro fator a se pensar, se vale a pena mudar todos os lugares que lêem o JSON.

A necessidade de ser ou não ser human readable também tem que ser repensada: nem tudo precisa ser lido por humanos. No caso do GTA, apenas o programa lia o arquivo, e o usuário só via o resultado final (os dados são exibidos em algum lugar, numa interface mais amigável, etc). Talvez somente os programadores precisem ver ao debugar, mas aí eles podem usar ferramentas adicionais para isso, já que neste caso o tempo de parsing era um fator crítico e - na minha opinião - justifica este trabalho adicional.

A curva de aprendizado do time que vai implementar também deve ser considerada (se o prazo estiver apertado, por exemplo, pode ser que não dê tempo).

Como sempre, no final prevalece a mesma regra que deveria valer para tudo: o importante é conhecer as alternativas, saber seus prós e contras, situações em que uma é mais adequada que outra, analisar caso a caso e então decidir qual usar. E claro, testar com dados reais, que é o que importa na prática (dependendo dos dados, a diferença pode não ser tão significativa, por exemplo).

E nunca se limitar a duas escolhas, existem outros formatos menos conhecidos por aí. Por exemplo, com FlatBuffers você pode ler apenas uma parte dos dados, sem precisar fazer parsing do arquivo inteiro (pode ser útil e fazer diferença quando há muitos dados e você só precisa de um pedaço). E tem o Cap'n Proto, que afirma ser mais rápido que o Protobuf (mas não cheguei a testar). E por aí vai...


Leitura complementar:

1

Irado! Obrigado por compartilhar seu conhecimento.
A forma com que identificamos problemas e resolvemos me deixa fascinado pela programação e me deixa com medo de ficar pra trás kkkk