Protobuffers estão errados

Na maior parte da minha vida profissional, sou contra o uso de buffers de protocolo. Eles são claramente escritos por amadores, incrivelmente altamente especializados, sofrem de muitas armadilhas, são difíceis de compilar e resolver um problema que ninguém além do Google realmente tem. Se esses problemas dos proto-buffers permanecessem na quarentena das abstrações de serialização, minhas reivindicações terminariam aí. Infelizmente, porém, o design inadequado dos protobuffers é tão intrusivo que esses problemas podem vazar para o seu código.

Estreita especialização e desenvolvimento por amadores

Pare. Feche o seu cliente de e-mail onde você já me escreveu uma carta dizendo que "os melhores engenheiros do mundo trabalham no Google", que "seus projetos, por definição, não podem ser criados por amadores". Eu não quero ouvir isso.

Vamos apenas não discutir este tópico. Divulgação completa: eu trabalhava no Google. Este foi o primeiro (mas infelizmente não o último) lugar que eu já usei Protobuffers. Todos os problemas sobre os quais eu quero falar existem na base de código do Google; não é apenas "uso indevido de protobuffers" e coisas do gênero.

De longe, o maior problema com os Protobuffers é o terrível sistema de tipos. Os fãs de Java devem se sentir em casa aqui, mas infelizmente, literalmente, ninguém acha que Java é um sistema de tipo bem projetado. Os caras do campo de digitação dinâmico reclamam de restrições desnecessárias, enquanto os representantes do campo de digitação estático, como eu, reclamam de restrições desnecessárias e da falta de tudo o que você realmente deseja do sistema de tipos. Perder nos dois casos.

A especialização e o desenvolvimento restritos de amadores andam de mãos dadas. Muitas das especificações pareciam aparafusadas no último momento - e obviamente estavam aparafusadas no último momento. Algumas restrições o forçarão a parar, coçar a cabeça e perguntar: "Que diabos?" Mas estes são apenas sintomas de um problema mais profundo:

Obviamente, protobuffers são criados por amadores porque oferecem soluções ruins para problemas conhecidos e já resolvidos.

Falta de composição


Os protobuffers oferecem vários recursos que não funcionam entre si. Por exemplo, veja a lista de funções de digitação ortogonais, mas ao mesmo tempo limitadas que encontrei na documentação.

  • oneof campos não pode ser repeated .
  • O map<k,v> campos map<k,v> possui uma sintaxe especial para chaves e valores, mas não é usado em nenhum outro tipo.
  • Embora os campos do map possam ser parametrizados, nenhum tipo definido pelo usuário é permitido mais. Isso significa que você está impedido de especificar manualmente suas próprias especializações em estruturas de dados comuns.
  • map campos do map não podem ser repeated .
  • map chaves do map podem ser string , mas não bytes . O enum também é proibido, embora este último seja considerado equivalente a números inteiros em todas as outras partes da especificação Protobuffers.
  • map valores do map não podem ser outro map .

Esta lista maluca de restrições é o resultado de uma escolha sem princípios de funções de projeto e aparafusamento no último momento. Por exemplo, oneof campos não pode ser repeated , porque, em vez de um tipo lateral, o gerador de código produzirá campos opcionais mutuamente exclusivos. Essa transformação é válida apenas para um campo singular (e, como veremos mais adiante, não funciona nem para ele).

A restrição dos campos do map , que não pode ser repeated , é aproximadamente da mesma ópera, mas mostra uma restrição diferente do sistema de tipos. Nos bastidores, o map<k,v> transforma em algo semelhante ao repeated Pair<k,v> . E como repeated é a palavra-chave mágica do idioma, e não o tipo normal, ela não se combina consigo mesma.

Suas suposições sobre o problema com enum são tão verdadeiras quanto as minhas.

O que é tão frustrante sobre tudo isso é uma compreensão insuficiente de como os sistemas de tipos modernos funcionam. Esse entendimento simplificaria drasticamente a especificação dos Protobuffers e ao mesmo tempo removeria todas as restrições arbitrárias .

A solução é a seguinte:

  • Faça todos os campos na mensagem required . Isso torna cada mensagem um tipo de produto.
  • Aumente o valor do campo oneof para tipos de dados independentes. Este será um tipo de coproduto.
  • Permitir a parametrização de tipos de produtos e coprodutos de outros tipos.

Isso é tudo! Essas três alterações são tudo o que você precisa para determinar quaisquer dados possíveis. Com este sistema simples, você pode refazer todas as outras especificações dos Protobuffers.

Por exemplo, você pode refazer os campos optional :

 product Unit { // no fields } coproduct Optional<t> { t value = 0; Unit unset = 1; } 

Criar campos repeated também é simples:

 coproduct List<t> { Unit empty = 0; Pair<t, List<t>> cons = 1; } 

Obviamente, a lógica real da serialização permite que você faça algo mais inteligente do que enviar listas vinculadas pela rede - afinal, a implementação e a semântica não precisam corresponder uma à outra .

Escolha duvidosa


Os protobuffers no estilo Java distinguem entre os tipos escalar e de mensagem . Os escalares correspondem mais ou menos às primitivas da máquina - coisas como int32 , bool e string . Por outro lado, os tipos de mensagens são o restante. Todos os tipos de biblioteca e usuário são mensagens.

Obviamente, os dois tipos de tipos têm semânticas completamente diferentes.

Campos com tipos escalares estão sempre presentes. Mesmo se você não os instalou. Eu já disse isso (pelo menos no proto3 1 ) são todos os proto-buffers inicializados em zeros, mesmo se eles não tiverem absolutamente nenhum dado? Os campos escalares obtêm valores falsos: por exemplo, uint32 inicializado com 0 e string inicializada com "" .

Não é possível distinguir um campo que não estava no proto-buffer de um campo ao qual é atribuído um valor padrão. Presumivelmente, essa decisão foi tomada para otimização, a fim de não encaminhar padrões escalares. Isso é apenas uma suposição, porque a documentação não menciona essa otimização, portanto, sua suposição não será pior que a minha.

Quando discutirmos as reivindicações do Protobuffers de uma solução ideal para compatibilidade com versões anteriores e futuras da API, veremos que essa incapacidade de distinguir entre valores indefinidos e padrão é um pesadelo real. Especialmente se for realmente uma decisão consciente de economizar um bit (definido ou não) para o campo.

Compare esse comportamento com os tipos de mensagem. Enquanto os campos escalares são "burros", o comportamento dos campos de mensagens é completamente insano . Internamente, os campos da mensagem estão lá ou não, mas o comportamento é louco. Um pequeno pseudocódigo para seu acessador vale mais que mil palavras. Imagine isso em Java ou em outro lugar:

 private Foo m_foo; public Foo foo { // only if `foo` is used as an expression get { if (m_foo != null) return m_foo; else return new Foo(); } // instead if `foo` is used as an lvalue mutable get { if (m_foo = null) m_foo = new Foo(); return m_foo; } } 

Em teoria, se o campo foo não estiver definido, você verá uma cópia inicializada padrão, solicitada ou não, mas não poderá alterar o contêiner. Mas se você mudar de posição, também mudará seu pai! Tudo isso é apenas para evitar o uso do tipo Maybe Foo e sua "dor de cabeça" associada para descobrir o que um valor indefinido deve significar.

Esse comportamento é particularmente flagrante porque viola a lei! Esperamos o trabalho msg.foo = msg.foo; não vai funcionar. Em vez disso, a implementação muda silenciosamente a msg para uma cópia do foo com inicialização zero, se ela não existia antes.

Ao contrário dos campos escalares, pelo menos você pode determinar que o campo da mensagem não está definido. As ligações de idioma para protobuffers oferecem algo como o método bool has_foo() gerado. Se estiver presente, no caso de cópias frequentes do campo de mensagem de um protobuffer para outro, você deve escrever o seguinte código:

 if (src.has_foo(src)) { dst.set_foo(src.foo()); } 

Observe que, pelo menos em idiomas com digitação estática, este modelo não pode ser abstraído devido à relação nominal entre os has_foo() foo() , set_foo() e has_foo() . Como todas essas funções são seus próprios identificadores , não temos como gerá-las programaticamente, com exceção da macro do pré-processador:

 #define COPY_IFF_SET(src, dst, field) \ if (src.has_##field(src)) { \ dst.set_##field(src.field()); \ } 

(mas as macros de pré-processador são proibidas pelo guia de estilo do Google).

Se, em vez disso, todos os campos adicionais foram implementados como Maybe , você pode definir com segurança os pontos de discagem abstratos.

Para mudar de assunto, vamos falar sobre outra decisão duvidosa. Embora você possa definir um dos campos nos oneof , a semântica deles não corresponde ao tipo de co-produto! Novato erro pessoal! Em vez disso, você obtém um campo opcional para cada caso e código mágico nos setters, que simplesmente desfarão qualquer outro campo, se definido.

À primeira vista, parece que isso deve ser semanticamente equivalente ao tipo correto de união. Mas, em vez disso, temos uma fonte de erro nojenta e indescritível! Quando esse comportamento é combinado com uma implementação ilegal msg.foo = msg.foo; , uma atribuição aparentemente normal exclui silenciosamente quantidades arbitrárias de dados!

Como resultado, isso significa que oneof campos não forma Prism cumpridor da lei e as mensagens não formam Lens cumpridor da lei. Boa sorte com suas tentativas de escrever manipulações não triviais de protobuffer sem erros. É literalmente impossível escrever um código polimórfico universal, livre de erros em protobuffers .

Isso não é muito agradável de ouvir, especialmente para aqueles que amam o polimorfismo paramétrico, que promete exatamente o oposto .

Compatibilidade com versões anteriores e futuras


Um dos "recursos matadores" frequentemente mencionados dos Protobuffers é sua "capacidade sem problemas de escrever APIs compatíveis com versões anteriores e posteriores". Esta declaração foi colocada diante de seus olhos para obscurecer a verdade.

Que protobuffers são permissivos . Eles conseguem lidar com mensagens do passado ou do futuro, porque não fazem absolutamente nenhuma promessa sobre a aparência dos seus dados. Tudo é opcional! Mas se você precisar, os Protobuffers terão prazer em preparar e fornecer algo com a verificação de tipo, independentemente de fazer sentido.

Isso significa que os Protobuffers realizam a "viagem no tempo" prometida, enquanto fazem silenciosamente a coisa errada por padrão . Obviamente, um programador cuidadoso pode (e deve) escrever um código que verifique a correção dos protobuffers recebidos. Mas se você fizer verificações de correção de proteção em todos os sites, talvez isso signifique apenas que a etapa de desserialização foi muito permissiva. Tudo o que você conseguiu fazer foi descentralizar a lógica da validação de um limite bem definido e borrá-la em toda a base de código.

Um dos argumentos possíveis é que os protobuffers salvam qualquer informação que eles não entendam na mensagem. Em princípio, isso significa transmissão não destrutiva da mensagem através de um intermediário que não entende esta versão do esquema. Esta é uma vitória clara, não é?

Obviamente, no papel, esse é um recurso interessante. Mas nunca vi um aplicativo em que essa propriedade esteja realmente armazenada. Com exceção do software de roteamento, nenhum programa deseja verificar apenas determinados bits de uma mensagem e encaminhá-la inalterada. A grande maioria dos programas em protobuffers decodificará a mensagem, a transformará em outra e a enviará para outro local. Infelizmente, essas conversões são feitas por ordem e codificadas manualmente. E as conversões manuais de um protobuffer para outro não preservam campos desconhecidos, porque é literalmente inútil.

Essa atitude onipresente em relação aos proto-bufferes como universalmente compatível também se manifesta de outras maneiras feias. Os guias de estilo para protobuffers se opõem ativamente a DRY e sugerem incorporar definições no código sempre que possível. Eles argumentam que isso permitirá o uso de mensagens separadas no futuro se as definições divergirem. Enfatizo que eles oferecem abandonar a prática de 60 anos de boa programação para o caso de , de repente, em algum momento no futuro, você precisar alterar alguma coisa.

A raiz do problema é que o Google combina o significado dos dados com sua representação física. Quando você está em uma escala do Google, isso faz sentido. No final, eles têm uma ferramenta interna que compara o pagamento por hora do programador usando a rede, o custo de armazenamento de bytes X e outras coisas. Diferentemente da maioria das empresas de tecnologia, o salário dos programadores é um dos menores itens de despesas do Google. Financeiramente, faz sentido que eles gastem o tempo dos programadores para economizar alguns bytes.

Além das cinco principais empresas de tecnologia, ninguém mais está dentro das cinco ordens de grandeza do Google. Sua inicialização não pode gastar horas de engenharia economizando bytes. Mas economizar bytes e desperdiçar o tempo dos programadores no processo é exatamente para o que os protobuffers são otimizados.

Vamos enfrentá-lo. Você não se ajusta à escala do Google e nunca se encaixa. Pare de usar o culto à carga da tecnologia apenas porque "o Google usa" e porque "essas são as melhores práticas do setor".

Protobuffers poluem bases de código


Se fosse possível limitar o uso de protobuffers apenas à rede, eu não falaria tão severamente sobre essa tecnologia. Infelizmente, embora em princípio existam várias soluções, nenhuma delas é boa o suficiente para ser usada em software real.

Os protobuffers correspondem aos dados que você deseja enviar pelo canal de comunicação. Eles geralmente são consistentes , mas não idênticos , com os dados reais com os quais o aplicativo gostaria de trabalhar. Isso nos coloca em uma posição desconfortável; você deve escolher entre uma das três más opções:

  1. Mantenha um tipo separado que descreva os dados realmente necessários e garanta que ambos os tipos sejam suportados simultaneamente.
  2. Empacote os dados completos em um formato para transmissão e uso pelo aplicativo.
  3. Recupere dados completos sempre que necessário, no formato curto de transmissão.

A opção 1 é claramente a solução "certa", mas não é adequada para protobuffers. O idioma não é poderoso o suficiente para codificar tipos que podem funcionar duas vezes em dois formatos. Isso significa que você precisa escrever um tipo de dados completamente separado, desenvolvê-lo de forma síncrona com os Protobuffers e escrever especificamente um código de serialização para eles . Mas como a maioria das pessoas parece usar Protobuffers para não escrever código de serialização, essa opção obviamente nunca é implementada.

Em vez disso, o código usando protobuffers permite que eles sejam distribuídos por toda a base de código. É uma realidade. Meu projeto principal no Google foi um compilador que pegou um "programa" escrito em uma variação de Protobuffers e produziu um "programa" equivalente em outra. Os formatos de entrada e saída eram bem diferentes para que suas versões paralelas corretas do C ++ nunca funcionassem. Como resultado, meu código não pôde usar nenhuma das técnicas avançadas de escrita do compilador, porque os dados do Protobuffers (e o código gerado) eram muito difíceis de fazer algo interessante com eles.

Como resultado, em vez de 50 linhas de esquemas de recursão , 10.000 linhas de embaralhamento de buffer especial foram usadas. O código que eu queria escrever era literalmente impossível com proto-buffers.

Embora este seja um caso, não é único. Devido à natureza severa da geração de código, as manifestações de proto-buffers em idiomas nunca serão idiomáticas e não podem ser feitas - a menos que você reescreva o gerador de código.

Mas, mesmo assim, você ainda tem um problema ao incorporar um sistema de baixa qualidade no idioma de destino. Como a maioria das funções dos Protobuffers é mal pensada, essas propriedades duvidosas vazam para nossas bases de código. Isso significa que somos forçados a não apenas implementar, mas também usar essas más idéias em qualquer projeto que espere interagir com os Protobuffers.

Em uma base sólida, é fácil perceber coisas sem sentido, mas se você for em outra direção, na melhor das hipóteses, encontrará dificuldades e, na pior das hipóteses, com verdadeiro horror antigo.

Em geral, desista da esperança de quem implementa Protobuffers em seus projetos.



1. Até hoje, há uma discussão acalorada no Google sobre proto2 e se os campos devem ser marcados conforme required . Os manifestos “ optional é considerado prejudicial” erequired considerados prejudiciais” são distribuídos ao mesmo tempo. Boa sorte, descubra, pessoal.

Source: https://habr.com/ru/post/pt427265/


All Articles