Resumo comentado: Designing Data-Intensive Applications — Parte 3
Olá!
Esse artigo faz parte de uma série. Se tiver interesse em acompanhar os anteriores, segue os links:
No capítulo 4 do livro, temos uma aprofundamento em conceituações relacionadas a Codificação de Caracteres (Encoding).
O conceito de Encoding nada mais do que codificar algum dado em outro formato. É possível tanto adicionar expressividade, com um custo adicional de verbosidade (como XML), como fazer algo que parece mais enxuto, usando um formato com objetos e atributos (como JSON), ou, até mesmo tentar simplificar as coisas e usar um formato que é extremamente simples de ler, porém, complicado de escrever (como YAML). Ao comparar esses 3, temos:
Nesses três casos, estamos discutindo formatos que ainda são relativamente legíveis para humanos.
Porém, caso queiramos ir além em termos de performance e sacrificar a legibilidade, o que é possível fazer? Uma solução comum na computação (e que geralmente funciona) é: eliminar o que é desnecessário.
Em se tratado de representação de dados, podemos ir no formato mais enxuto possível que um computador consegue trabalhar: formatos binários. O trade-off aqui é sacrificar legibilidade em troca de performance de processamento. Nesse caso, vou comentar sobre os formatos mencionados no livro: Apache Thrift, Protocol Buffers (também conhecido como protobuf), MessagePack e Apache Avro.
Vamos aos exemplos de Encoding do livro. Para começar, temos o seguinte arquivo em JSON:
Nesse caso, percebam que os dados, ordem dos campos e tipos estão contidos nesse arquivo. Além disso, não há a necessidade de uma estrutura prévia indicando onde começa cada campo e demais detalhes de interpretação do arquivo.
A estrutura desse arquivo pode ser "forçada" usando a seguinte estrutura, também chamada de schema:
Caso seja necessário representar esses dados usando um formato um pouco mais enxuto, podemos codificar esses mesmos dados em MessagePack, que irá gerar o seguinte resultado:
Aqui, a leitura correta dos bytes requer entender a representação em hexadecimal desses dados binários (vale visitar o link se você não conhece ou não lembra dessa representação).
Nesse output, cada dígito (ou bit) indica alguma coisa no formato do arquivo. Em formatos binários, é comum encontrar caracteres de controle que dizem o que vem a seguir no arquivo (lembrando que é um formato de arquivo sequencial que tem a necessidade de alguma estrutura de controle dessa natureza para fazer sentido para quem lê).
Com isso dito, temos o seguinte detalhamento da imagem:
O primeiro byte (0x83, em notação hexadecimal) indica que o que vem a seguir é um objeto de 3 registros. O primeiro bit indica que é um objeto e o seguinte, o tamanho.
O segundo byte (0xa8), indica que o vem a seguir é uma string de 8 bytes de comprimento. Aqui ocorre a mesma coisa, o bit a indica que é uma string e o próximo digito/bit indica o tamanho.
Os próximos 8 bytes (0x75 até 0x65) representam a string UserName em ASCII. Como o tamanho é previamente conhecido, não há necessidade do uso de caracteres de terminação ou separadores (como a vírgula em JSON, quebra de linha em YAML ou uso de tags em XML).
Após isso, temos novamente a marcação de string, porém, com 6 bytes de comprimento (0xa6) e assim por diante.
Todos os formatos mencionados até aqui operam dessa forma: codificação de caracteres de forma binária + algum tipo de schema previamente conhecido que indica onde começa e termina cada campo. Lembrando que tanto a parte que escreve nesse formato quanto quem lê precisam conhecer essa estrutura para que o formato seja legível (sistemicamente falando).
No caso de JSON, geralmente o schema é opcional. Em formatos binários, é obrigatório para o correto processamento do arquivo, afinal, como quem lê saberá onde começa e termina cada campo?
Para ilustrar um pouco como os schemas podem variar entre os formatos e o quanto isso pode ficar complexo, temos o seguinte exemplo do mesmos dados anteriores em Thrift:
Ao representar o mesmo arquivo usando esse schema, é possível obter o seguinte output:
Não vou detalhar esse output que nem o binário do MessagePack para fins de brevidade, porém, os mesmos padrões se repetem: caracteres que indicam o que vem a seguir, bytes em ASCII e nesse caso há uma novidade: adição de um caracter terminador (0x00) que indica o final da struct.
Diferenças na prática: tamanho final um pouco menor que o MessagePack e só. Lembrando que o intuito de se utilizar esse formato geralmente é para performance, então, cada byte a menos importa.
Em protobuf, temos o seguinte schema (lembrando que são os mesmos dados em todos os exemplos):
Perceba que assim como em Thrift, os nome dos campos, tipos e ordem dos campos ficam no schema (justamente para poupar espaço no arquivo final e ganhar performance na leitura).
No output binário, fica assim:
Diferença na prática: protobuf é mais eficiente que Thrift (quase 50% de redução de tamanho). Na prática, foi o formato que acabou se tornando mais popular e é no momento que eu escrevo esse artigo (Janeiro/2022), é o protocolo multi-linguagem mais popular quando é necessário mais performance do que API REST usando JSON na comunicação entre sistemas. Se você já ouviu falar de gRPC, um detalhe que vale mencionar é que o protobuf é o formato usado debaixo do capô dessa tecnologia.
O Avro funciona bastante parecido (com schemas e output binário), porém, como surgiu no ecossistema do Hadoop (que é um sistema de armazenamento baseado em arquivos voltado para big data geralmente usado para manipular bases de dados muito grandes), ele suporta algumas features interessantes, como evolução de schema. Para tal coisa acontecer, o Avro possui um schema de leitura e outro de escrita.
O ganho aqui ocorre quando o lado de leitura tem uma versão do schema e o schema de escrita acaba evoluindo com o tempo (mudando ou removendo algum campo, por exemplo). Nesse caso, o schema de leitura continua lendo o arquivo do "jeito antigo", não gerando quebra de compatibilidade. Isso não acontece com Thrift e protobuf, pois são protocolos que precisam que ambos os lados envolvidos na transmissão de dados enxerguem o mesmo schema ao mesmo tempo.
Um outro caso de uso mencionado é caso seja usado Avro para delimitar um schema de exportação de dados de um banco de dados e a estrutura do banco mude com o tempo, é possível gerar um schema versionado dinamicamente, sem quebrar eventuais clientes do esquema anterior (lembrando que para uma ferramenta que basicamente manipula bases de dados o tempo todo como o Hadoop, faz muito sentido ser resiliente nesse ponto).
Resumindo o que eu entendi das recomendações do livro: use REST e JSON para o comum. gRPC para casos que precisa de performance na comunicação entre sistemas… e Avro é legal e diferentão, mas só é encontrado em nichos.
Com isso dito, eu paro por aqui para não deixar esse texto muito grande.
E você? O que achou dos formatos apresentados? Já usou alguns deles?
Até o próximo artigo!
Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.