Les protobuffers ont tort

Pendant la majeure partie de ma vie professionnelle, je suis opposé à l'utilisation de tampons de protocole. Ils sont clairement écrits par des amateurs, incroyablement hautement spécialisés, souffrent de nombreux pièges, sont difficiles à compiler et à résoudre un problème que personne d'autre que Google n'a réellement. Si ces problèmes de proto-tampons restaient dans la quarantaine des abstractions de sérialisation, alors mes revendications s'arrêteraient là. Mais malheureusement, la mauvaise conception des Protobuffers est si intrusive que ces problèmes peuvent s'infiltrer dans votre code.

Spécialisation étroite et développement par des amateurs

Arrête. Fermez votre client de messagerie, où vous m'avez déjà écrit une lettre disant que "les meilleurs ingénieurs du monde travaillent chez Google", que "leurs conceptions, par définition, ne peuvent pas être créées par des amateurs". Je ne veux pas entendre ça.

Ne discutons tout simplement pas de ce sujet. Divulgation complète: je travaillais chez Google. Ce fut le premier (mais malheureusement pas le dernier) endroit où j'ai jamais utilisé Protobuffers. Tous les problèmes dont je veux parler existent dans la base de code Google; il ne s’agit pas seulement de «mauvaise utilisation des protobuffers» et autres.

De loin, le plus gros problème avec Protobuffers est le système de type horrible. Les fans de Java devraient se sentir chez eux ici, mais malheureusement, personne ne pense que Java est un système de type bien conçu. Les gars du camp de frappe dynamique se plaignent de restrictions inutiles, tandis que les représentants du camp de frappe statique, comme moi, se plaignent de restrictions inutiles et du manque de tout ce que vous attendez vraiment du système de frappe. Perdre dans les deux cas.

La spécialisation et le développement étroits des amateurs vont de pair. Une grande partie des spécifications semblaient être boulonnées au dernier moment - et c'était évidemment boulonné au dernier moment. Certaines restrictions vous obligeront à vous arrêter, à vous gratter la tête et à vous demander: «Qu'est-ce qui se passe?» Mais ce ne sont que les symptômes d'un problème plus profond:

Évidemment, les protobuffers sont créés par des amateurs car ils offrent des solutions médiocres à des problèmes bien connus et déjà résolus.

Manque de composition


Les protobuffers offrent plusieurs fonctionnalités qui ne fonctionnent pas les unes avec les autres. Par exemple, regardez la liste des fonctions orthogonales, mais en même temps limitées que j'ai trouvées dans la documentation.

  • oneof champs ne peut pas être repeated .
  • Les champs map<k,v> ont une syntaxe spéciale pour les clés et les valeurs, mais elle n'est utilisée dans aucun autre type.
  • Bien que les champs de map puissent être paramétrés, aucun type défini par l'utilisateur n'est plus autorisé. Cela signifie que vous êtes obligé de spécifier manuellement vos propres spécialisations dans les structures de données communes.
  • map champs de la map ne peuvent pas être repeated .
  • map clés de mappage peuvent être des string , mais pas des bytes . L'énumération est également interdite, bien que ces derniers soient considérés comme équivalents aux entiers dans toutes les autres parties de la spécification Protobuffers.
  • map valeurs de map ne peuvent pas être une autre map .

Cette liste folle de restrictions est le résultat d'un choix sans principes de conception et de fonctions de vissage au dernier moment. Par exemple, l'un oneof champs ne peut pas être repeated , car au lieu d'un type latéral, le générateur de code produira des champs facultatifs mutuellement exclusifs. Une telle transformation n'est valable que pour un champ singulier (et, comme nous le verrons plus loin, elle ne fonctionne même pas pour lui).

La restriction des champs de map , qui ne peut pas être repeated , est approximativement du même opéra, mais montre une restriction différente du système de type. Dans les coulisses, la map<k,v> transforme en quelque chose de similaire à repeated Pair<k,v> . Et puisque repeated est le mot-clé magique de la langue, et non le type normal, il ne se combine pas avec lui-même.

Vos suppositions sur le problème avec enum sont aussi vraies que les miennes.

Ce qui est si frustrant dans tout cela, c'est une mauvaise compréhension du fonctionnement des systèmes de type modernes. Cette compréhension simplifierait considérablement la spécification des Protobuffers et en même temps supprimerait toutes les restrictions arbitraires .

La solution est la suivante:

  • Faites tous les champs dans le message required . Cela fait de chaque message un type de produit.
  • Augmentez la valeur du champ oneof en types de données autonomes. Ce sera un type de coproduit.
  • Permettre le paramétrage de types de produits et coproduits d'autres types.

C'est tout! Ces trois changements sont tout ce dont vous avez besoin pour déterminer les données possibles. Avec ce système simple, vous pouvez refaire toutes les autres spécifications de Protobuffers.

Par exemple, vous pouvez refaire les champs optional :

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

La création de champs repeated est également simple:

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

Bien sûr, la vraie logique de la sérialisation vous permet de faire quelque chose de plus intelligent que de pousser des listes liées sur le réseau - après tout, l' implémentation et la sémantique n'ont pas à correspondre .

Choix douteux


Les tampons de style Java distinguent les types scalaires et les types de messages . Les scalaires correspondent plus ou moins aux primitives de la machine - des choses comme int32 , bool et string . Les types de messages, en revanche, sont tout le reste. Tous les types de bibliothèques et d'utilisateurs sont des messages.

Bien sûr, les deux types de types ont une sémantique complètement différente.

Les champs avec des types scalaires sont toujours présents. Même si vous ne les avez pas installés. J'ai déjà dit que (au moins dans proto3 1 ) tous les proto-tampons sont-ils initialisés à zéro, même s'ils n'ont absolument aucune donnée? Les champs scalaires obtiennent de fausses valeurs: par exemple, uint32 initialisé à 0 et la string initialisée à "" .

Il n'est pas possible de distinguer un champ qui n'était pas dans le proto-tampon d'un champ auquel une valeur par défaut est affectée. Vraisemblablement, cette décision a été prise pour l'optimisation afin de ne pas transmettre les valeurs par défaut scalaires. Ce n'est qu'une hypothèse, car la documentation ne mentionne pas cette optimisation, donc votre hypothèse ne sera pas pire que la mienne.

Lorsque nous discuterons des revendications de Protobuffers pour une solution idéale pour la compatibilité en amont et en aval des API, nous verrons que cette incapacité à distinguer les valeurs non définies des valeurs par défaut est un véritable cauchemar. Surtout si c'est vraiment une décision consciente d'enregistrer un bit (défini ou non) pour le champ.

Comparez ce comportement avec les types de message. Alors que les champs scalaires sont «stupides», le comportement des champs de message est complètement fou . En interne, les champs de message sont là ou non, mais le comportement est fou. Un petit pseudo-code pour leur accesseur vaut mille mots. Imaginez cela en Java ou ailleurs:

 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 théorie, si le champ foo n'est pas défini, vous verrez une copie initialisée par défaut, que vous le demandiez ou non, mais vous ne pouvez pas changer le conteneur. Mais si vous changez foo , il changera également son parent! Tout cela est juste pour éviter d'utiliser le type Maybe Foo et son «mal de tête» associé pour comprendre ce que devrait signifier une valeur non définie.

Un tel comportement est particulièrement flagrant car il viole la loi! Nous attendons le travail msg.foo = msg.foo; ne fonctionnera pas. Au lieu de cela, l'implémentation modifie discrètement msg en une copie de foo avec une initialisation nulle si elle n'existait pas auparavant.

Contrairement aux champs scalaires, vous pouvez au moins déterminer que le champ de message n'est pas défini. Les liaisons de langage pour les protobuffers offrent quelque chose comme la bool has_foo() générée. S'il est présent, dans le cas d'une copie fréquente du champ de message d'un proto-tampon à un autre, vous devez écrire le code suivant:

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

Veuillez noter que, au moins dans les langues avec typage statique, ce modèle ne peut pas être abstrait en raison de la relation nominale entre les has_foo() foo() , set_foo() et has_foo() . Étant donné que toutes ces fonctions sont leurs propres identifiants , nous n'avons pas les moyens de les générer par programmation, à l'exception de la macro du préprocesseur:

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

(mais les macros de préprocesseur sont interdites par le guide de style Google).

Si, à la place, tous les champs supplémentaires étaient implémentés comme Maybe - Maybe , vous pourriez définir en toute sécurité les homologues de numérotation abstraits.

Pour changer de sujet, parlons d'une autre décision douteuse. Bien que vous puissiez définir l'un des champs dans les oneof , leur sémantique ne correspond pas au type de coproduit! Les débutants se trompent les gars! Au lieu de cela, vous obtenez un champ facultatif pour chaque cas et code magique dans les setters, qui annulera simplement tout autre champ s'il est défini.

À première vue, il semble que cela devrait être sémantiquement équivalent au type d'union correct. Mais à la place, nous obtenons une source d'erreur dégoûtante et indescriptible! Lorsque ce comportement est combiné avec une implémentation illégale msg.foo = msg.foo; , une telle affectation apparemment normale supprime silencieusement des quantités arbitraires de données!

En conséquence, cela signifie que l'un oneof champs oneof forme pas un Prism respectueux des lois et que les messages ne forment pas une Lens respectueuse des lois. Alors bonne chance avec vos tentatives d'écrire des manipulations de tampons non triviaux sans bugs. Il est littéralement impossible d'écrire un code polymorphe universel et sans erreur sur les tampons tampons .

Ce n'est pas très agréable à entendre, surtout pour ceux d'entre nous qui aiment le polymorphisme paramétrique, ce qui promet exactement le contraire .

La compatibilité ascendante et future réside


L'une des «caractéristiques tueuses» souvent mentionnées des Protobuffers est leur «capacité sans problème à écrire des API compatibles en amont et en aval». Cette déclaration a été suspendue sous vos yeux pour obscurcir la vérité.

Que les protobuffers sont permissifs . Ils parviennent à faire face aux messages du passé ou du futur, car ils ne font absolument aucune promesse sur l'apparence de vos données. Tout est facultatif! Mais si vous en avez besoin, Protobuffers se fera un plaisir de préparer et de vous donner quelque chose avec une vérification de type, que cela ait du sens.

Cela signifie que les Protobuffers effectuent le "voyage dans le temps" promis tout en faisant tranquillement la mauvaise chose par défaut . Bien sûr, un programmeur prudent peut (et devrait) écrire du code qui vérifie l'exactitude des protobuffers reçus. Mais si vous effectuez des contrôles d'exactitude de protection sur chaque site, cela signifie peut-être simplement que l'étape de désérialisation était trop permissive. Tout ce que vous avez réussi à faire était de décentraliser la logique de validation à partir d'une frontière bien définie et de l'étaler sur toute la base de code.

L'un des arguments possibles est que les protobuffers enregistreront toutes les informations qu'ils ne comprennent pas dans le message. En principe, cela signifie une transmission non destructive du message par un intermédiaire qui ne comprend pas cette version du schéma. C'est une nette victoire, non?

Bien sûr, sur le papier, c'est une fonctionnalité intéressante. Mais je n'ai jamais vu d'application où cette propriété est vraiment stockée. À l'exception du logiciel de routage, aucun programme ne veut vérifier uniquement certains bits d'un message, puis le transmettre tel quel. La grande majorité des programmes sur les protobuffers décodera le message, le transformera en un autre et l'enverra à un autre endroit. Hélas, ces conversions sont faites sur commande et encodées manuellement. Et les conversions manuelles d'un tampon tampon à un autre ne préservent pas les champs inconnus, car elles sont littéralement inutiles.

Cette attitude omniprésente envers les proto-tampons comme universellement compatibles se manifeste également sous d'autres formes laides. Les guides de style pour Protobuffers s'opposent activement à DRY et suggèrent d'incorporer des définitions dans le code chaque fois que possible. Ils soutiennent que cela permettra l'utilisation de messages distincts à l'avenir si les définitions divergent. J'insiste sur le fait qu'ils proposent d'abandonner la pratique de 60 ans d'une bonne programmation au cas où , tout à coup, dans le futur, vous auriez besoin de changer quelque chose.

La racine du problème est que Google combine la signification des données avec leur représentation physique. Lorsque vous êtes à l'échelle de Google, cela a du sens. En fin de compte, ils ont un outil interne qui compare le salaire horaire du programmeur utilisant le réseau, le coût de stockage de X octets et d'autres choses. Contrairement à la plupart des entreprises technologiques, le salaire des programmeurs est l'un des plus petits postes de dépenses de Google. Financièrement, il est logique pour eux de passer le temps des programmeurs pour économiser quelques octets.

En plus des cinq principales sociétés technologiques, personne d'autre ne fait partie des cinq ordres de grandeur de Google. Votre startup ne peut pas se permettre de passer des heures d'ingénierie à économiser des octets. Mais économiser des octets et perdre du temps aux programmeurs dans le processus est exactement ce pour quoi les Protobuffers sont optimisés.

Avouons-le. Vous ne correspondez pas à l'échelle de Google et vous ne vous adapterez jamais. Cessez d'utiliser le culte du fret de la technologie simplement parce que "Google l'utilise" et parce que "ce sont les meilleures pratiques de l'industrie".

Protobuffers pollue les bases de code


S'il était possible de limiter l'utilisation des Protobuffers au seul réseau, je ne parlerais pas aussi durement de cette technologie. Malheureusement, bien qu'il existe en principe plusieurs solutions, aucune d'entre elles n'est suffisamment bonne pour être réellement utilisée dans de vrais logiciels.

Les protobuffers correspondent aux données que vous souhaitez envoyer sur le canal de communication. Ils sont souvent cohérents , mais pas identiques , avec les données réelles avec lesquelles l'application aimerait travailler. Cela nous met dans une position inconfortable, vous devez choisir entre l'une des trois mauvaises options:

  1. Gérez un type distinct qui décrit les données dont vous avez réellement besoin et assurez-vous que les deux types sont pris en charge simultanément.
  2. Emballez les données complètes dans un format pour la transmission et l'utilisation par l'application.
  3. Récupérez des données complètes chaque fois que vous en avez besoin à partir du format court pour la transmission.

L'option 1 est clairement la «bonne» solution, mais elle ne convient pas aux Protobuffers. Le langage n'est pas assez puissant pour encoder des types qui peuvent faire un double travail dans deux formats. Cela signifie que vous devez écrire un type de données complètement séparé, le développer de manière synchrone avec Protobuffers et écrire spécifiquement le code de sérialisation pour eux . Mais comme la plupart des gens semblent utiliser Protobuffers pour ne pas écrire de code de sérialisation, cette option n'est évidemment jamais implémentée.

Au lieu de cela, le code utilisant des protobuffers permet de les distribuer à travers la base de code. C'est une réalité. Mon projet principal chez Google était un compilateur qui prenait un «programme» écrit dans une variante de Protobuffers et produisait un «programme» équivalent sur une autre. Les formats d'entrée et de sortie étaient très différents, de sorte que leurs versions parallèles correctes de C ++ n'ont jamais fonctionné. En conséquence, mon code ne pouvait utiliser aucune des techniques d'écriture de compilateur riche, car les données Protobuffers (et le code généré) étaient trop difficiles à faire avec elles.

En conséquence, au lieu de 50 lignes de schémas de récursivité , 10 000 lignes de réarrangement de tampon spécial ont été utilisées. Le code que je voulais écrire était littéralement impossible avec les proto-tampons.

Bien qu'il s'agisse d'un cas, il n'est pas unique. En raison de la nature sévère de la génération de code, les manifestations des proto-tampons dans les langages ne seront jamais idiomatiques, et elles ne peuvent pas l'être, à moins que vous ne réécriviez le générateur de code.

Mais même dans ce cas, vous avez toujours un problème d'intégration d'un système de type merdique dans votre langue cible. Comme la plupart des fonctions des Protobuffers sont mal pensées, ces propriétés douteuses s'infiltrent dans nos bases de code. Cela signifie que nous sommes obligés non seulement de mettre en œuvre, mais également d'utiliser ces mauvaises idées dans tout projet qui espère interagir avec Protobuffers.

Sur une base solide, il est facile de réaliser des choses dénuées de sens, mais si vous allez dans une direction différente, au mieux vous rencontrerez des difficultés, et au pire, avec une véritable horreur ancienne.

En général, abandonnez tout espoir à quiconque implémente des Protobuffers dans ses projets.



1. À ce jour, il y a une discussion animée sur Google à propos de proto2 et si les champs doivent jamais être marqués comme required . Les manifestes « optional est considéré comme nuisible» et « required considérés comme nuisibles» sont distribués en même temps. Bonne chance, comprenez-le.

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


All Articles