Serialização: JSON vs Protobuf
Este exercício visa facilitar o entendimento do método de serialização binária do protobuf através da comparação de densidade de armazenamento da informação com o formato text/json.
TL;TR
O conteúdo serializado em text/json será em média 1.3 à 4 vezes maior que o mesmo dado serializado de forma binária com protobuf. Isto se refletirá em IO e maior uso de cpu na hora da de/serialização. Este é um dos fatores pelos quais protobuf vem sendo adotado em muitos cenários.
Mas, antes dos testes...
Além de um exercício para entendimento do protobuf, talvez seja válido alguns comentários sobre informação vs representação. Algo que já notei muitas vezes, principalmente com desenvolvedores iniciantes, é uma certa falta de compreenção sobre as diferenças entre a informação e suas formas de representação. E como diferentes formas de representação possuem diferentes densidades (ou seja, a quantidade de informação por espaço de armazenamento).
Por exemplo um inteiro entre 0 e 255 (como o valor 100 por exemplo) pode ser armazenado como um uint8 (8-bit unsigned integer) ocupando 1 byte, como int32 (int do java) ocupará 4 bytes, já sua representação decimal em caracteres legiveis '100' (serializado em um formato texto por exemplo) ocupará pelo menos 3 bytes (podendo chegar à 12 bytes em alguns casos dependendo do encode).
Um outro exemplo são UUIDs [RFC4122], que em uma densidade máxima ocupam 16 bytes (128 bits), mas no formato de representação mais conhecido, uma string de caracteres hexadecimais de 32 posições, ocupa pelo menos 32 bytes (36 com '-').
Apesar de MySQL permitir colunas binary (ex: binary(16)) e PostgreSQL possuir o data type UUID que permitem o uso eficiênte de 128 bits. Um dev desavisado poderia inicialmente pensar em armazenar um UUID como texto em um varchar(36), o que impacta significativamento o IO principalmente quando se trata de uma primary key.
É claro que há situações onde você precisa usar uma representação ineficiente, principalmente quando se trata de apresentar de forma legivel para humanos. Mas é importante compreender estes aspectos para saber quando e como representar uma informação da melhor maneira.
Metadados
Além da representação binária, um outro fator que torna o protobuf mais eficiente e com uma maior densidade é que no JSON os metadados (que descrevem os dados) são transportados junto com os dados.
Enquanto que no protobuf os metadados são previamente conhecidos através do contrato .proto e estes não serão transportados junto ao dado serializado. Neste sentido o protobuf se assemelha mais à um formato posicional.
No exemplo de JSON abaixo as keys "id" e "name" são metadados que descrevem os dados correspondêntes:
{
"id": 1,
"name": "James Wilson"
}
Ok, agora chega de papo e vamos aos testes...
Para realizar este teste/comparação vamos usar dados de 1000 usuários fakes da API https://randomuser.me/api/?format=json&results=1000&seed=teste_proto_vs_json.
Nem todos os dados obtidos desta API serão utilizados, pois passaremos por um mapeamento para o .proto que utilizará alguns dos campos, enquanto outros dados serão descartados no processo. O json será gerado à partir do objeto mapeado para proto, para que ambos reflitam a mesma informação.
O arquivo proto é este:
syntax="proto3";
package my.system.person;
message User {
string gender = 1;
Name name = 2;
string email = 3;
Login login = 4;
Picture picture = 5;
Location location = 6;
bool isactive = 7;
}
message Name {
string title = 1;
string first = 2;
string last = 3;
}
message Location {
Street street = 1;
string city = 2;
string state = 3;
string country = 4;
string postcode = 5;
Geo coordinates = 6;
TZ timezone = 7;
}
message Street {
int32 number = 1;
string name = 2;
}
message Geo {
float latitude = 1;
float longitude = 2;
}
message TZ {
string offset = 1;
string description = 2;
}
message Login {
string username = 1;
bytes uuid = 2;
bool isloggedin = 3;
}
message Picture {
string large = 1;
string medium = 2;
string thumbnail = 3;
}
Um objeto mapeado para este contrato proto fica com esta cara:
{
gender: 'male',
name: { title: 'Mr', first: 'Sean', last: 'Perkins' },
email: 'sean.perkins@example.com',
login: {
username: 'someperson84',
uuid: '25eAaALBTHa5Ixo+RLtSZQ==',
isloggedin: true
},
picture: {
large: 'https://randomuser.me/api/portraits/men/1.jpg',
medium: 'https://randomuser.me/api/portraits/med/men/1.jpg',
thumbnail: 'https://randomuser.me/api/portraits/thumb/men/1.jpg'
},
location: {
street: { number: 4481, name: 'Northaven Rd' },
city: 'Adelaide',
state: 'Queensland',
country: 'Australia',
postcode: 7056,
coordinates: { latitude: -86.0805, longitude: -24.3252 },
timezone: { offset: '+3:30', description: 'Tehran' }
},
isactive: false
}
O codigo que fará o mapeamento dos dados, a serialização e escrita dos arquivos será feito em js com node, mas poderia ser com qualquer outra linguagem.
As dependências (package.json):
{
"name": "proto_vs_json",
"version": "0.1.0",
"description": "Testing serialization with protobuf and json",
"scripts": {
"test-user": "node users/user_serializer.js",
"build-user-proto": "protoc --js_out=import_style=commonjs,binary:./ --plugin=protoc-gen-grpc=node_modules/grpc-tools/bin/grpc_node_plugin users/user.proto"
},
"keywords": [],
"author": "Artus Rocha",
"dependencies": {
"google-protobuf": "^3.15.6",
"grpc": "^1.24.6"
},
"devDependencies": {
"grpc-tools": "^1.11.1"
}
}
Comando para gerar o códifo à partir do arquivo .proto:
protoc --js_out=import_style=commonjs,binary:./ --plugin=protoc-gen-grpc=node_modules/grpc-tools/bin/grpc_node_plugin user.proto
Eu não falarei de todo o código, porque não é o foco aqui, mas o código completo pode ser visto aqui. Destacarei apenas poucos trechos.
Serializando para binary/protobuf e escrevendo arquivo:
function writeProtobuf(user, i) {
const filepath = './data/user-' + zeroPad(i, 3) + '.pb'
const content = user.serializeBinary()
fs.writeFile(filepath, content, "binary", function (err) {
if (err) console.log("Error bin", err);
})
}
Serializando para text/json e escrevendo arquivo:
function writeJson(user, i) {
const filepath = './data/user-' + zeroPad(i, 3) + '.json'
const content = JSON.stringify( user.toObject() )
fs.writeFile(filepath, content, function (err) {
if (err) console.log("Error json", err);
})
}
Os arquivos com dados serializados em formato string json, ficaram com um tamanho médio de 688 bytes.
$> wc -c data/*.json
# ...
686 data/user-991.json
662 data/user-992.json
684 data/user-993.json
682 data/user-994.json
663 data/user-995.json
729 data/user-996.json
669 data/user-997.json
699 data/user-998.json
698 data/user-999.json
687943 total
Os arquivos com dados serializados em formato binário protobuf, que eu salvei com a extensão '.pb', ficaram com um tamanho médio de 362 bytes
$> wc -c data/*.pb
# ...
360 data/user-991.pb
338 data/user-992.pb
357 data/user-993.pb
358 data/user-994.pb
333 data/user-995.pb
406 data/user-996.pb
341 data/user-997.pb
371 data/user-998.pb
378 data/user-999.pb
362108 total
Vemos aqui que a versão serializada com um formato binário protobuf neste caso foi em média 47% menor que o mesmo dado serializado em formato text/json.
Mas... aqui não estamos utilizando compressão e quando trafegamos estes dados as boas práticas orientam a utilização de uma compressão como a gzip.
Então vamos compactar os arquivos e verificarmos como fica esta relação:
$> gzip -6 data/*
$> wc -c data/*.json.gz
# ...
429 data/user-991.json.gz
413 data/user-992.json.gz
433 data/user-993.json.gz
431 data/user-994.json.gz
413 data/user-995.json.gz
455 data/user-996.json.gz
416 data/user-997.json.gz
442 data/user-998.json.gz
438 data/user-999.json.gz
432815 total
$ wc -c data/*.pb.gz
# ...
285 data/user-991.pb.gz
274 data/user-992.pb.gz
290 data/user-993.pb.gz
287 data/user-994.pb.gz
266 data/user-995.pb.gz
320 data/user-996.pb.gz
271 data/user-997.pb.gz
303 data/user-998.pb.gz
310 data/user-999.pb.gz
291953 total
Utilizando uma compressão gzip com fator de compressão 6, ficamos uma média de 433 bytes para arquivos com json. E uma média de 292 bytes para os arquivos com dados em formato binário protobuf.
A proporção entre protobuf e json diminui, e agora o protobuf é em média 32% menor que o json.
A compactação da versão text/json ser maior é algo esperado pois, por já ser bem mais otimizado e ter menos dados repetidos a margem para compactação do protobuf diminui.
Mas por que tantas strings?
O teste com o objeto User é interessante pois é um cenário bem tipico e nos ajuda à tirar alguns insights. Mas este tipo de objeto é composto basicamente por strings utf8.
Por isto resolvi realizar um segundo teste, seguindo a mesma metodologia, mas agora com um objeto composto apenas de valores escalares, booleans, integers e floats.
O arquivo proto:
syntax="proto3";
package my.system.scalar;
message Scalar {
bool boolean1 = 1;
bool boolean2 = 2;
float float1 = 3;
float float2 = 4;
uint32 uint1 = 5;
uint32 uint2 = 6;
int32 int1 = 7;
int32 int2 = 8;
}
$> wc -c data/scalar*.json
# ...
164 data/scalar-991.json
165 data/scalar-992.json
161 data/scalar-993.json
164 data/scalar-994.json
163 data/scalar-995.json
162 data/scalar-996.json
162 data/scalar-997.json
161 data/scalar-998.json
163 data/scalar-999.json
162527 total
$> wc -c data/scalar*.pb
# ...
38 data/scalar-991.pb
33 data/scalar-992.pb
37 data/scalar-993.pb
34 data/scalar-994.pb
33 data/scalar-995.pb
36 data/scalar-996.pb
35 data/scalar-997.pb
35 data/scalar-998.pb
33 data/scalar-999.pb
35395 total
Tivemos neste novo cenário uma diferença bem maior entre json e protobuf. Na média a serialização json gerou arquivos com 163 bytes (144 bytes com gzip), enquanto que a média do mesmo dado em protobuf foi 35 bytes, ficando o json 4 vezes maior que o protobuf em média.
Isto se deve principalmente à diferença de tamanho entre o dado e as suas representações em caracteres legiveis. Por exemplo qualquer valor int32 ocupará 4 bytes, mas a representação de um int32 em texto (por exemplo quando serializamos para json) ocupará uma quantidade maior de bytes (ex: o inteiro 2000000000 serializado com esta representaçao em text/json ocupará pelo menos 10 bytes).
O código com estes testes
por hoje é isto, até a próxima
Artus