Protobuffers están equivocados

Durante la mayor parte de mi vida profesional, me opongo al uso de Protocol Buffers. Están claramente escritos por aficionados, increíblemente altamente especializados, sufren muchas dificultades, son difíciles de compilar y resuelven un problema que nadie más que Google realmente tiene. Si estos problemas de prototipos se mantuvieran en la cuarentena de las abstracciones de serialización, entonces mis reclamos terminarían allí. Pero desafortunadamente, el diseño deficiente de Protobuffers es tan intrusivo que estos problemas pueden filtrarse en su código.

Especialización estrecha y desarrollo por aficionados

Para Cierra tu cliente de correo electrónico donde ya me escribiste una carta diciendo que "los mejores ingenieros del mundo trabajan en Google", que "sus diseños, por definición, no pueden ser creados por aficionados". No quiero escuchar eso.

Simplemente no discutamos este tema. Divulgación completa: solía trabajar en Google. Este fue el primer lugar (pero desafortunadamente no el último) en el que utilicé Protobuffers. Todos los problemas de los que quiero hablar existen en la base de código de Google; no se trata solo de "mal uso de protofobres" y cosas por el estilo.

De lejos, el mayor problema con los Protobuffers es el horrible sistema de tipos. Los fanáticos de Java deberían sentirse como en casa aquí, pero desafortunadamente, literalmente, nadie piensa que Java es un sistema de tipos bien diseñado. Los chicos del campo de escritura dinámico se quejan de restricciones innecesarias, mientras que los representantes del campo de escritura estático, como yo, se quejan de restricciones innecesarias y de la falta de todo lo que realmente quieres del sistema de escritura. Perder en ambos casos.

La estrecha especialización y el desarrollo por parte de aficionados van de la mano. Gran parte de las especificaciones parecían estar atornilladas en el último momento, y obviamente estaban atornilladas en el último momento. Algunas restricciones lo obligarán a detenerse, rascarse la cabeza y preguntar: "¿Qué demonios?" Pero estos son solo síntomas de un problema más profundo:

Obviamente, los protobuffers son creados por aficionados porque ofrecen soluciones deficientes a problemas conocidos y ya resueltos.

Falta de composición


Los Protobuffers ofrecen varias características que no funcionan entre sí. Por ejemplo, mire la lista de funciones de escritura ortogonales, pero al mismo tiempo limitadas que encontré en la documentación.

  • oneof campos no se puede repeated .
  • Los campos map<k,v> tienen una sintaxis especial para claves y valores, pero no se utilizan en ningún otro tipo.
  • Aunque map campos del map se pueden parametrizar, ya no se permite ningún tipo definido por el usuario. Esto significa que tiene que especificar manualmente sus propias especializaciones en estructuras de datos comunes.
  • map campos del map no se pueden repeated .
  • map claves de map pueden ser string , pero no bytes . Enum también está prohibido, aunque estos últimos se consideran equivalentes a los enteros en todas las demás partes de la especificación de Protobuffers.
  • map valores del map no pueden ser otro map .

Esta lista loca de restricciones es el resultado de una elección sin principios de diseño y funciones de atornillado en el último momento. Por ejemplo, uno de los campos no puede repeated , porque en lugar de un tipo lateral, el generador de código producirá campos opcionales mutuamente excluyentes. Tal transformación es válida solo para un campo singular (y, como veremos más adelante, ni siquiera funciona para él).

La restricción de map campos del map , que no se puede repeated , es aproximadamente de la misma ópera, pero muestra una restricción diferente del sistema de tipos. Detrás de escena, el map<k,v> transforma en algo similar al repeated Pair<k,v> . Y dado que se repeated la palabra clave mágica del idioma, y ​​no el tipo normal, no se combina consigo misma.

Sus conjeturas sobre el problema con la enum son tan ciertas como las mías.

Lo que es tan frustrante de todo esto es una mala comprensión de cómo funcionan los sistemas de tipos modernos. Esta comprensión simplificaría dramáticamente la especificación de Protobuffers y al mismo tiempo eliminaría todas las restricciones arbitrarias .

La solución es la siguiente:

  • Realice todos los campos en el mensaje required . Esto hace que cada mensaje sea un tipo de producto.
  • Eleve el valor del campo oneof a tipos de datos independientes. Este será un tipo de coproducto.
  • Permitir la parametrización de tipos de productos y coproductos de otros tipos.

Eso es todo! Estos tres cambios son todo lo que necesita para determinar cualquier posible dato. Con este sistema simple, puede rehacer todas las demás especificaciones de Protobuffers.

Por ejemplo, puede rehacer los campos optional :

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

Crear campos repeated también es simple:

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

Por supuesto, la lógica real de la serialización le permite hacer algo más inteligente que enviar listas vinculadas a la red; después de todo, la implementación y la semántica no tienen que corresponder entre sí .

Elección dudosa


Los protobuffers de estilo Java distinguen entre tipos escalares y de mensajes . Los escalares corresponden más o menos a las primitivas de la máquina, como int32 , bool y string . Los tipos de mensajes, por otro lado, son todo lo demás. Todos los tipos de bibliotecas y usuarios son mensajes.

Por supuesto, los dos tipos de tipos tienen una semántica completamente diferente.

Los campos con tipos escalares siempre están presentes. Incluso si no los instaló. Ya dije eso (al menos en proto3 1 ) ¿todos los prototipos se inicializan a ceros, incluso si no tienen absolutamente ningún dato? Los campos escalares obtienen valores falsos: por ejemplo, uint32 inicializa a 0 y la string inicializa a "" .

No es posible distinguir un campo que no estaba en el proto-buffer de un campo que tiene asignado un valor predeterminado. Presumiblemente, esta decisión se tomó para la optimización a fin de no reenviar los valores predeterminados escalares. Esto es solo una suposición, porque la documentación no menciona esta optimización, por lo que su suposición no será peor que la mía.

Cuando analicemos las afirmaciones de Protobuffers de una solución ideal para la compatibilidad con API anteriores y futuras, veremos que esta incapacidad para distinguir entre valores indefinidos y predeterminados es una verdadera pesadilla. Especialmente si es realmente una decisión consciente guardar un bit (establecido o no) para el campo.

Compare este comportamiento con los tipos de mensajes. Mientras que los campos escalares son "tontos", el comportamiento de los campos de mensajes es completamente loco . Internamente, los campos de mensaje están allí o no, pero el comportamiento es una locura. Un pequeño pseudocódigo para su descriptor vale más que mil palabras. Imagina esto en Java o en otro 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; } } 

En teoría, si el campo foo no está configurado, verá una copia inicializada predeterminada, lo solicite o no, pero no puede cambiar el contenedor. Pero si cambias a foo , ¡también cambiará a su padre! Todo esto es solo para evitar el uso del tipo Maybe Foo y su "dolor de cabeza" asociado para descubrir qué debe significar un valor indefinido.

¡Tal comportamiento es particularmente atroz porque viola la ley! Esperamos el trabajo msg.foo = msg.foo; No funcionará. En cambio, la implementación en realidad cambia silenciosamente msg a una copia de foo con inicialización cero si no existía antes.

A diferencia de los campos escalares, al menos puede determinar que el campo del mensaje no está configurado. Los enlaces de idioma para protobuffers ofrecen algo como el método bool has_foo() generado. Si está presente, en el caso de una copia frecuente del campo de mensaje de un protobuffer a otro, debe escribir el siguiente código:

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

Tenga en cuenta que, al menos en los idiomas con escritura estática, esta plantilla no se puede abstraer debido a la relación nominal entre los has_foo() foo() , set_foo() y has_foo() . Dado que todas estas funciones son sus propios identificadores , no tenemos los medios para generarlas mediante programación, con la excepción de la macro del preprocesador:

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

(pero la guía de estilo de Google prohíbe las macros de preprocesador).

Si, en cambio, todos los campos adicionales se implementaron como Maybe , podría establecer de forma segura los pares de marcado abstraídos.

Para cambiar de tema, hablemos de otra decisión dudosa. Aunque puede definir uno de los campos en oneof , ¡su semántica no coincide con el tipo de coproducto! Newbie error chicos! En cambio, obtienes un campo opcional para cada caso y código mágico en los setters, que simplemente deshacerá cualquier otro campo si está configurado.

A primera vista, parece que esto debería ser semánticamente equivalente al tipo correcto de unión. ¡Pero en cambio, obtenemos una fuente de error repugnante e indescriptible! Cuando este comportamiento se combina con una implementación ilegal msg.foo = msg.foo; , ¡una tarea aparentemente normal elimina silenciosamente cantidades arbitrarias de datos!

Como resultado, esto significa que uno de los campos no forma un Prism respetuoso de la ley, y los mensajes no forman una Lens respetuosa de la ley. Así que buena suerte con tus intentos de escribir manipulaciones de protobuffer no triviales sin errores. Es literalmente imposible escribir un código polimórfico universal, libre de errores en protobuffers .

Esto no es muy agradable de escuchar, especialmente para aquellos de nosotros que amamos el polimorfismo paramétrico, que promete exactamente lo contrario .

La compatibilidad con versiones anteriores y futuras radica


Una de las "características asesinas" mencionadas a menudo de los Protobuffers es su "capacidad sin problemas para escribir API compatibles con versiones anteriores y posteriores". Esta declaración fue colgada ante tus ojos para oscurecer la verdad.

Que los protobuffers son permisivos . Se las arreglan para hacer frente a los mensajes del pasado o del futuro, porque no hacen absolutamente ninguna promesa sobre cómo se verán sus datos. ¡Todo es opcional! Pero si lo necesita, Protobuffers estará encantado de prepararse y darle algo con la verificación de tipo, independientemente de si tiene sentido.

Esto significa que los Protobuffers llevan a cabo el "viaje en el tiempo" prometido mientras hacen silenciosamente lo incorrecto por defecto . Por supuesto, un programador cuidadoso puede (y debe) escribir código que verifique la corrección de los protobuffers recibidos. Pero si escribe verificaciones de corrección de protección en cada sitio, tal vez solo significa que el paso de deserialización fue demasiado permisivo. Todo lo que logró hacer fue descentralizar la lógica de validación desde un límite bien definido y difuminarla en toda la base del código.

Uno de los posibles argumentos es que los protobuffers guardarán cualquier información que no entiendan en el mensaje. En principio, esto significa una transmisión no destructiva del mensaje a través de un intermediario que no comprende esta versión del esquema. Esta es una victoria clara, ¿no?

Por supuesto, en papel esta es una característica genial. Pero nunca he visto una aplicación donde esta propiedad esté realmente almacenada. Con la excepción del software de enrutamiento, ningún programa quiere verificar solo ciertos bits de un mensaje y luego reenviarlo sin cambios. La gran mayoría de los programas en protobuffers decodificarán el mensaje, lo transformarán en otro y lo enviarán a otro lugar. Por desgracia, estas conversiones se hacen por encargo y se codifican manualmente. Y las conversiones manuales de un protobuffer a otro no conservan campos desconocidos, porque es literalmente inútil.

Esta actitud ubicua hacia los protobuffers como universalmente compatible también se manifiesta de otras maneras feas. Las guías de estilo para Protobuffers se oponen activamente a DRY y sugieren insertar definiciones en el código siempre que sea posible. Argumentan que esto permitirá el uso de mensajes separados en el futuro si las definiciones divergen. Insisto en que ofrecen abandonar la práctica de 60 años de una buena programación por si acaso , de repente, en algún momento en el futuro tendrá que cambiar algo.

La raíz del problema es que Google combina el significado de los datos con su representación física. Cuando estás en una escala de Google, eso tiene sentido. Al final, tienen una herramienta interna que compara el pago por hora del programador usando la red, el costo de almacenar X bytes y otras cosas. A diferencia de la mayoría de las compañías de tecnología, el salario de los programadores es uno de los gastos más pequeños de Google. Financieramente, tiene sentido que pasen el tiempo de los programadores para ahorrar un par de bytes.

Además de las cinco compañías tecnológicas líderes, nadie más está dentro de los cinco órdenes de magnitud de Google. Su startup no puede permitirse el lujo de pasar horas de ingeniería ahorrando bytes. Pero ahorrar bytes y perder el tiempo de los programadores en el proceso es exactamente para lo que están optimizados los Protobuffers.

Seamos realistas. No se ajusta a la escala de Google y nunca se ajustará. Deje de usar el culto a la carga de la tecnología solo porque "Google la usa" y porque "estas son las mejores prácticas de la industria".

Protobuffers contamina las bases de código


Si fuera posible limitar el uso de Protobuffers solo a la red, no hablaría tan duramente sobre esta tecnología. Desafortunadamente, aunque en principio hay varias soluciones, ninguna de ellas es lo suficientemente buena como para ser utilizada en un software real.

Los protobuffers corresponden a los datos que desea enviar a través del canal de comunicación. A menudo son consistentes , pero no idénticos , con los datos reales con los que la aplicación quisiera trabajar. Esto nos coloca en una posición incómoda, debe elegir entre una de las tres malas opciones:

  1. Mantenga un tipo separado que describa los datos que realmente necesita y asegúrese de que ambos tipos sean compatibles simultáneamente.
  2. Empaquete los datos completos en un formato para su transmisión y uso por la aplicación.
  3. Recupere datos completos cada vez que sea necesario del formato corto para la transmisión.

La opción 1 es claramente la solución "correcta", pero no es adecuada para Protobuffers. El lenguaje no es lo suficientemente potente como para codificar tipos que pueden hacer doble trabajo en dos formatos. Esto significa que debe escribir un tipo de datos completamente separado, desarrollarlo sincrónicamente con Protobuffers y escribir específicamente un código de serialización para ellos . Pero dado que la mayoría de las personas parecen usar Protobuffers para no escribir código de serialización, esta opción obviamente nunca se implementa.

En cambio, el código que usa protobuffers les permite distribuirse a través de la base de código. Es una realidad. Mi proyecto principal en Google fue un compilador que tomó un "programa" escrito en una variación de Protobuffers y produjo un "programa" equivalente en otra. Los formatos de entrada y salida eran bastante diferentes, por lo que sus versiones paralelas correctas de C ++ nunca funcionaron. Como resultado, mi código no pudo usar ninguna de las técnicas de escritura del compilador, porque los datos de Protobuffers (y el código generado) eran demasiado difíciles de hacer algo interesante con ellos.

Como resultado, en lugar de 50 líneas de esquemas de recursión , se usaron 10,000 líneas de barajado de buffer especial. El código que quería escribir era literalmente imposible con los proto-buffers.

Aunque este es un caso, no es único. Debido a la naturaleza áspera de la generación de código, las manifestaciones de los prototipos de buffers en los idiomas nunca serán idiomáticas, y no pueden hacerse así, a menos que vuelva a escribir el generador de códigos.

Pero incluso entonces, todavía tiene un problema para incorporar un sistema de tipo malo en su idioma de destino. Como la mayoría de las funciones de los Protobuffers están mal pensadas, estas dudosas propiedades se filtran en nuestras bases de código. Esto significa que no solo estamos obligados a implementar, sino también a usar estas malas ideas en cualquier proyecto que espere interactuar con Protobuffers.

Sobre una base sólida, es fácil darse cuenta de cosas sin sentido, pero si vas en una dirección diferente, en el mejor de los casos encontrarás dificultades, y en el peor de los casos, con verdadero horror antiguo.

En general, pierda la esperanza de cualquiera que implemente Protobuffers en sus proyectos.



1. Hasta el día de hoy, hay una acalorada discusión en Google sobre proto2 y si los campos deberían marcarse como required . Los manifiestos “ optional se considera dañino” y “los required consideran dañinos” se distribuyen al mismo tiempo. Buena suerte, descifren chicos.

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


All Articles