Encoding e Dataflow
Tags: database, architecture, distributedsystems
Published: 2020-06-24 18:58:04

Parte da [série](https://dev.to/brenoferreira/designing-data-intensive-apps-um-resumo-2df9) sobre o resumo do livro [Designing Data Intensive Apps](https://amzn.to/2YaKEhB).

No [Capítulo 1](https://dev.to/brenoferreira/designing-data-intensive-apps-um-resumo-390c) foi falado sobre características de sistemas de dados, no [Capítulo 2](https://dev.to/brenoferreira/designing-data-intensive-apps-resumo-cap-2-4b75) sobre modelos de dados. No [Capítulo 3](https://dev.to/brenoferreira/designing-data-intensive-apps-capitulo-3-1693), sobre o tema de armazenamento de dados. Agora iremos abordar o tema de encoding de dados.

O banco de dados, como qualquer aplicação, escreve e lê dados. Escreve dados em memória e disco, e lê esses dados e os envia pela rede para clientes remotos. Em memória, esses dados ficam em estruturas de dados como Hashmaps e/ou Árvores. Porém, tanto no processo de escrita em disco quanto no processo de envio desses dados para os clientes remotos, é necessário converter esses dados em memória para algum formato mais apropriado, num processo conhecido como encoding. Quem lê esses dados por sua vez faz o decoding para converter novamente em alguma estrutura em memória (não necessariamente a mesma estrutura original, podendo ser, por exemplo, uma simples lista ao invés de uma árvore).

Esse encoding/decoding (também chamado de serialização) pode ser feito de várias formas, e existem vários formatos que podem ser usados.

Muitas linguagens de programação possuem seu próprio mecanismo de encode/decode de dados, como as classes java.io.Serializable. Porém não é recomendado usar essas bibliotecas pois não são compatíveis com outros ambientes. Então se um código Java serializa os dados usando a biblioteca nativa do Java e envia para uma aplicação rodando em Python, os dados provavelmente não vão poder ser deserializados. Por isso precisamos usar formatos padrões que qualquer linguagem e ambiente de programação entenda e consiga ler e escrever.

Alguns dos formatos mais comuns são: CSV, XML e JSON que são formatos de texto e legíveis tanto por pessoas quanto por máquinas. Thrift e Protocol Buffers (ProtoBuf) são formatos de serialização binária, logo, legíveis somente por máquinas. Existe também o format Avro, que usa JSON para definição de Schema mas serializa os dados de forma binária. Recomendo ler a documentação de como esses formatos funcionam para definição de Schemas e serialização antes de continuar a leitura.

Destes formatos, o único que não possui uma linguagem para definição de schema é CSV. Por isso é comum dados serializados nesse formato contarem com uma documentação complementar para definição dos tipos de dados. Ou simplesmente deixarem por conta de quem lê os dados a tarefa de interpretar os tipos utilizados. Porém, isso não é muito recomendável.

Tanto JSON quanto XML contam com ferramentas para definição de schemas (XML Schema e JSON Schema), apesar de também ser razoavelmente comum dados transmitidos em formato JSON não terem também disponíveis um JSON Schema associado e contarem mais com documentação suplementar.

Dados serializados de forma binária porém, sem um schema, são somente uma sequencia aparentemente aleatória de bytes. Thrift, ProtoBuf e Avro todos tem suas definições próprias de schema.

## Evolução de Schema

Os dados vão inevitavelmente mudar com o tempo. Com mudança nos dados, obviamente o schema também muda. Nessas mudanças de schema, devemos nos preocupar com compatibilidade:

* *backward compatibility*: código novo consegue ler dados antigos
* *forward compatibility*: código antigo consegue ler dados novos

Não mude seu schema com campos novos obrigatórios, mas sim opcionais. Assim, um código que desconhece o schema mais atualizado ainda consegue manipular os dados, pois os novos campos podem conter um valor padrão (0, uma string vazia ou NULL por exemplo).

Caso use formatos binários como Thrift ou ProtoBuf, que fazem o encoding dos dados em uma ordem definida no schema, essa ordem deverá ser mantida.

Mudanças de tipos de dados podem ser complicadas. Em alguns casos, pode ser que um tipo seja automaticamente convertido durante leitura ou escrita (ex: converter um int de 32bits para um int de 64bits, mas não o contrário). ProtoBuf permite também que valores opcionais sejam convertidos para um array de valores, pois na sequencia de bytes final um valor opcional e um array vazio são serializados da mesma forma, e durante deserialização, código que ainda acha que o dado é um valor opcional pode ler somente o último elemento da lista.

## Fluxo de dados

Aplicações uma hora vão ter que ler os dados do banco e, em alguns casos, serializar esses dados e enviar para outra aplicação. Por isso devemos entender esses formatos de serialização, evolução de schema e manutenção de compatibilidade.

### Fluxo de dados via Banco de Dados

É comum aplicações diferentes acessarem o mesmo banco de dados. E nem sempre precisa ser aplicação X e Y. Pode ser aplicação X v1.0 e aplicação X v2.0 rodando ao mesmo tempo, como por exemplo para manter versões antigas de uma API web funcionando, ou durante um rolling upgrade, onde é feito de forma gradual o deploy de versões novas nos nós do cluster da aplicação e duas versões coexistem por um tempo.

Por isso é importante se preocupar com a evolução do schema de dados para que uma schema migration quebre a compatibilidade entre as versões diferentes das aplicações que acessam o banco de dados. Durante schema migrations também é importante se preocupar como isso irá impactar a disponibilidade do servidor do banco de dados. Adicionar campos novos opcionais, novas tabelas ou views geralmente é bem rápido e não tem muito impacto. Já mudança de tipos de dados pode impactar a disponibilidade do servidor pois terá que ser feita a conversão ou re-escrita de todos os dados na tabela.

### Fluxo de dados via serviços REST ou RPC

Não vou entrar nos detalhes técnicos de cada um dos estilos de comunicação, até por que cada um é bem complexo por si só. Mas alguns pontos de atenção ao transmitir dados com esses diferentes tipos de serviços:

#### REST

Geralmente serviços REST usam XML ou JSON como formato de encoding de dados. Então é importante ficar atento a como manter compatibilidade com esses formatos de dados.

Um ponto interessante é tentar manter um schema para os dados, usando XML Schema ou JSON Schema. Muitas APIs REST que usam esses formatos não possuem um schema associado, e dependem de documentação auxiliar onde os tipos de dados são definidos para ajudar os clientes da API. Ter uma ferramenta que gere um XML/JSON Schema automaticamente é bem útil, pois manter esse schema em dois lugares diferentes, no código, mesmo q implicitamente, e na documentação, não é mistério nenhum que facilmente a documentação fica desatualizada.

#### Remote Procedure Calls

Uma parte boa de alguns protocolos de Remote Procedure Call (RPC) é que o schema costuma ser obrigatório, como é o caso de tecnologias meio defasadas como SOAP Web Services, RMI, DCOM, e também com tecnologias mais recentes como Thrift e gRPC (que usa ProtoBuf).

Alguns dos problemas dessa abordagem é que ela tenta criar uma abstração em cima de chamadas remotas parecida com chamadas a funções locais, que são conceitos fundamentalmente diferentes.

Uma função local retorna um valor com sucesso ou retorna um erro, dependendo dos parametros. Uma chamada remota é imprevisível por natureza, pois a rede nem o servidor remoto são confiáveis e podem falhar por razões adversas.

Uma função local pode entrar em um loop infinito ou um deadlock, mas daí a aplicação inteira trava. Uma requisição remota pode não retornar por causa de um timeout, seja por falha na rede ou um servidor não responsivo, e não dá pra saber a priori por que a requisição falhou.

Uma requisição remota pode chegar ao servidor, ser processada, mas a resposta pode falhar em chegar ao cliente. Requisições repetidas podem ser problemáticas.

Enfim, são paradigmas completamente diferentes e, filosoficamente falando, tratar chamadas remotas como chamada à funções é tratar conceitos diferentes de forma parecida.

Apesar de que esses problemas de chamadas remotas são tratados por tecnologias modernas de RPC, com uso de Promises por exemplo. Mas uma "vantagem" conceitual do REST é que ele não tenta mascarar a existencia de uma chamada remota.

Importante deixar claro que não tem certo ou errado e que esses problemas são só conceituais e que na prática é perfeitamente possível lidar com problemas de rede em chamadas RPC.

### Fluxo de dados via envio de mensagens

Uma outra maneira de ter fluxo de dados é via envio de mensagens (message-passing). Dessa forma, a comunicação é indireta, através de um intermediário conhecido como Message Queue ou Message Broker. Ferramentas conhecidas são RabbitMQ, ActiveMQ e Apache Kafka. Outra maneira de se trabalhar com message-passing é com algum framework de Actor Model. Frameworks como Akka e Orleans são alguns exemplos.

Algumas vantagens desse modelo de message passing é que há desacoplamento entre o cliente que envia a mensagem e o processo que executa a requisição. Nesse modelo algumas coisas ficam mais fáceis de fazer do que nos outros modelos:

* O Message Broker funciona como um buffer entre request e response, assim se acontecer dos processos executando as requisições estarem sobrecarregados, as requisições são enfileiradas pelo broker até que os processos fiquem livres para continuar processando
* Os processos que executam as mensagens podem ser escalados independentemente
* Uma mensagem pode ser enviada à vários processos diferentes
* Se um processo falhar durante o processamento de uma mensagem, a mesma pode ser reenviada a outro processo redundante.

Importante lembrar que as mesmas preocupações com versionamento de schema das mensagens vale nesse modelo, para que processos que executam as mensagens continuem mantendo compatibilidade.