Stas Afanasyev. Juno. Pipelines basés sur io.Reader / io.Writer. Partie 1

Dans le rapport, nous parlerons du concept de io.Reader / io.Writer, pourquoi ils sont nécessaires, comment les implémenter correctement et quels pièges existent à cet égard, ainsi que de la construction de pipelines basés sur des implémentations standard et personnalisées de io.Reader / io.Writer .



Stanislav Afanasyev (ci-après - SA): - Bonjour! Je m'appelle Stas. Je venais de Minsk, de la société Juno. Merci d'être venu ce jour de pluie, d'avoir trouvé la force de quitter la maison.

Aujourd'hui, je veux vous parler d'un sujet tel que la construction de pipelines Go basés sur les interfaces io.Reader / io.Writer. Ce dont je vais parler aujourd'hui est, en général, le concept des interfaces io.Reader / io.Writer, pourquoi elles sont nécessaires, comment les utiliser correctement et, surtout, comment les implémenter correctement.

Nous parlerons également de la construction de pipelines basés sur diverses implémentations de ces interfaces. Nous parlerons des méthodes existantes, discuterons de leurs avantages et inconvénients. Je mentionnerai divers pièges (ce sera en abondance).

Avant de commencer, nous devons répondre à la question: pourquoi ces interfaces sont-elles nécessaires? Levez la main, qui travaille étroitement avec Go (tous les jours, tous les deux jours) ...



Génial! Nous avons toujours une communauté Go. Je pense que beaucoup d'entre vous ont travaillé avec ces interfaces, en ont entendu parler, au moins. Vous ne les connaissez peut-être même pas, mais vous auriez certainement dû en entendre parler.

Tout d'abord, ces interfaces sont une abstraction de l'opération entrée-sortie dans toutes ses manifestations. Deuxièmement, c'est une API très pratique qui vous permet de construire des pipelines, comme un constructeur à partir de cubes, sans vraiment penser aux détails internes de l'implémentation. Au moins, c'était initialement prévu.

io.Reader


Il s'agit d'une interface très simple. Il se compose d'une seule méthode - la méthode Read. Conceptuellement, l'implémentation de l'interface io.Reader peut être une connexion réseau - par exemple, où il n'y a pas encore de données, mais elles peuvent y apparaître:



il peut s'agir d'un tampon en mémoire où les données existent déjà et peuvent être lues entièrement. Il peut également s'agir d'un descripteur de fichier - nous pouvons lire ce fichier en morceaux s'il est très volumineux.

L'implémentation conceptuelle de l'interface io.Reader est l'accès à certaines données. Tous les cas que j'ai écrits sont pris en charge par la méthode Read. Il n'a qu'un seul argument - c'est l'octet de tranche.
Un point à souligner ici. Ceux qui sont venus récemment sur Go ou sont venus d'une autre technologie, où il n'y avait pas d'API similaire (je suis de ceux-là), cette signature est un peu déroutante. La méthode Read semble lire en quelque sorte cette tranche. En fait, l'inverse est vrai: l'implémentation de l'interface Reader lit les données à l'intérieur et remplit cette tranche avec les données que cette implémentation possède.

La quantité maximale de données pouvant être lues sur demande par la méthode Read est égale à la longueur de cette tranche. Une implémentation régulière renvoie autant de données qu'elle peut en renvoyer au moment de la demande, ou la quantité maximale qui correspond à cette tranche. Cela suggère que Reader peut être lu en morceaux: au moins par octet, au moins dix - comme vous le souhaitez. Et le client qui appelle Reader, selon les valeurs de retour de la méthode Read, pense comment vivre.

La méthode Read renvoie deux valeurs:

  • nombre d'octets soustraits;
  • une erreur si elle s'est produite.

Ces valeurs influencent le comportement futur du client. Il y a un gif sur la diapositive qui montre, affiche ce processus, que je viens de décrire:





Io.Reader - Comment faire?


Il existe exactement deux façons pour que vos données satisfassent l'interface du Reader.



Le premier est le plus simple. Si vous avez une sorte d'octet de tranche et que vous voulez qu'il satisfasse l'interface Reader, vous pouvez prendre l'implémentation d'une bibliothèque standard qui satisfait déjà cette interface. Par exemple, Reader à partir du package d'octets. Sur la diapositive ci-dessus, vous pouvez voir la signature de la façon dont ce Reader est créé.

Il existe un moyen plus compliqué - d'implémenter l'interface Reader vous-même. Il y a environ 30 lignes dans la documentation avec des règles compliquées, des restrictions qui doivent être suivies. Avant de parler de chacun d'eux, il m'est devenu intéressant: «Et dans quels cas les implémentations standard (bibliothèque standard) ne sont-elles pas suffisantes? Quel est le moment où nous devons implémenter l'interface Reader nous-mêmes? »

Afin de répondre à cette question, j'ai pris les milliers de référentiels les plus populaires sur Github (par le nombre d'étoiles), les ai ajoutés et y ai trouvé toutes les implémentations de l'interface Reader. Sur la diapositive, j'ai quelques statistiques (catégorisées) sur le moment où les gens implémentent cette interface.

  • La catégorie la plus populaire est celle des connexions. Il s'agit d'une implémentation de protocoles propriétaires et d'encapsuleurs pour les types existants. Ainsi, Brad Fitzpatrick a un projet Camlistore - il y a un exemple sous la forme de statTrackingConn, qui, en général, est un wrapper ordinaire sur le type con du package net (ajoute des métriques à ce type).
  • La deuxième catégorie la plus populaire est celle des tampons personnalisés. Ici, j'ai aimé le seul et unique exemple: dataBuffer du package x / net. Sa particularité est qu'il stocke les données découpées en morceaux, et lors de la soustraction, il passe à travers ces morceaux. Si les données du bloc sont terminées, elles passent au bloc suivant. Dans le même temps, il prend en compte la longueur, la place qu'il peut remplir dans la tranche transmise.
  • Une autre catégorie comprend toutes sortes de barres de progression, comptant le nombre d'octets soustraits lors de l'envoi de métriques ...

Sur la base de ces données, nous pouvons dire que la nécessité de mettre en œuvre l'interface io.Reader se produit assez souvent. Commençons ensuite par parler des règles qui se trouvent dans la documentation.

Règles de documentation


Comme je l'ai dit, la liste des règles, et en général la documentation est assez grande, massive. 30 lignes suffisent pour une interface composée de seulement trois lignes.

La première règle, la plus importante, concerne le nombre d'octets renvoyés. Elle doit être strictement supérieure ou égale à zéro et inférieure ou égale à la longueur de la tranche envoyée. Pourquoi c'est important?



Comme il s'agit d'un contrat assez strict, le client peut faire confiance au montant provenant de la mise en œuvre. Il existe des wrappers dans la bibliothèque standard (par exemple, bytes.Buffer et bufio). Il y a un tel moment dans la bibliothèque standard: certaines implémentations font confiance aux lecteurs enveloppés, d'autres ne font pas confiance (nous en reparlerons plus tard).

Bufio ne fait confiance à rien - il vérifie absolument tout. Bytes.Buffer fait entièrement confiance à tout ce qui lui arrive. Je vais maintenant montrer ce qui se passe à ce sujet ...

Nous allons maintenant considérer trois cas possibles - ce sont trois lecteurs implémentés. Ils sont assez synthétiques, utiles à la compréhension. Nous lirons tous ces lecteurs à l'aide de l'assistant ReadAll. Sa signature est présentée en haut de la diapositive:



io.Reader # 1. Exemple 1


ReadAll est un assistant qui accepte une sorte d'implémentation de l'interface Reader, lit tout et retourne les données qu'il a lues, ainsi qu'une erreur.

Notre premier exemple est Reader, qui renverra toujours -1 et nil comme une erreur, c'est-à-dire un tel NegativeReader. Lançons-le et voyons ce qui se passe:



Comme vous le savez, la panique sans raison est un signe de folie. Mais qui dans ce cas est idiot - moi ou octet.Buffer - dépend du point de vue. Ceux qui écrivent ce paquet et qui le suivent ont des points de vue différents.

Que s'est-il passé ici? Bytes.Buffer a accepté un nombre négatif d'octets, n'a pas vérifié qu'il était négatif et a essayé de couper le tampon interne le long de la limite supérieure, qu'il a reçu - et nous sommes sortis des limites de tranche.

Il y a deux problèmes dans cet exemple. La première est que la signature n'est pas interdite de renvoyer des nombres négatifs, et la documentation est interdite. Si la signature avait Uint, nous obtiendrions un débordement classique (lorsqu'un numéro signé est interprété comme non signé). Et c'est un bug très délicat, qui se produira certainement vendredi soir, lorsque vous serez déjà assemblé à la maison. Par conséquent, la panique dans ce cas est l'option préférée.

Le deuxième «point» est que la trace de la pile ne comprend pas du tout ce qui s'est passé. Il est clair que nous avons dépassé les limites de la tranche - alors quoi? Lorsque vous disposez d'un tel tuyau multicouche et qu'une telle erreur se produit, il n'est pas immédiatement clair ce qui s'est passé. Le bufio de la bibliothèque standard «panique» également dans cette situation, mais il le fait plus magnifiquement. Il écrit immédiatement: «J'ai soustrait un nombre négatif d'octets. Je ne ferai rien d'autre - je ne sais pas quoi en faire. "

Et octets. Buffer panique du mieux qu'il peut. J'ai posté un problème à Golang me demandant d'ajouter une erreur humaine. Le troisième jour, nous avons discuté des perspectives de cette décision. La raison en est la suivante: historiquement, il arrivait que différentes personnes à différents moments prenaient différentes décisions non coordonnées. Et maintenant, nous avons ce qui suit: dans un cas, nous ne faisons pas du tout confiance à la mise en œuvre (nous vérifions tout), et dans l'autre, nous faisons entièrement confiance, nous n'obtenons pas ce qui en découle. Il s'agit d'un problème non résolu, et nous en parlerons davantage.

io.Reader # 1. Exemple 2


La situation suivante: notre Reader retournera toujours 0 et nil comme résultat. Du point de vue des contrats, tout est légal ici - il n'y a pas de problèmes. Seul bémol: la documentation indique que les implémentations ne sont pas recommandées pour renvoyer les valeurs 0 et nil, en plus du cas, lorsque la longueur de la tranche envoyée est nulle.

Dans la vraie vie, un tel lecteur peut causer beaucoup de problèmes. Donc, revenons à la question, faut-il faire confiance à Reader? Par exemple, une vérification est intégrée à bufio: elle lit séquentiellement Reader exactement 100 fois - si une telle paire de valeurs est retournée 100 fois, elle renvoie simplement NoProgress.

Il n'y a rien de tel dans bytes.Buffer. Si nous exécutons cet exemple, nous obtenons juste une boucle sans fin (ReadAll utilise bytes.Buffer sous le capot, pas Reader lui-même):



io.Reader # 1. Exemple 2


Un autre exemple. Il est également assez synthétique, mais utile à la compréhension:



ici, nous renvoyons toujours 1 et nil. Il semblerait qu'il n'y ait pas de problème ici non plus - tout est légal du point de vue du contrat. Il y a une nuance: si j'exécute cet exemple sur mon ordinateur, il se fige après 30 secondes ...

Cela est dû au fait que le client qui lit ce Reader (c'est-à-dire octets.Buffer) ne reçoit jamais un signe de la fin des données - il lit, soustrait ... De plus, il obtient un octet soustrait à chaque fois. Pour lui, cela signifie qu'à un moment donné, le tampon repositionné se termine, il s'exécute toujours - la situation se répète et il s'exécute à l'infini jusqu'à ce qu'il éclate.

io.Lecteur # 2. Retour d'erreur


Nous arrivons à la deuxième règle importante pour la mise en œuvre de l'interface Reader - il s'agit d'un retour d'erreur. La documentation indique trois erreurs que l'implémentation doit renvoyer. Le plus important d'entre eux est l'EOF.

EOF est le signe même de la fin des données, que l'implémentation doit retourner chaque fois qu'il manque de données. Sur le plan conceptuel, il ne s'agit pas, en général, d'une erreur, mais d'une erreur.

Il y a une autre erreur appelée UnexpectedEOF. Si soudainement, pendant la lecture, Reader ne peut plus lire les données, on pensait que cela retournerait UneattenduEOF. Mais en fait, cette erreur n'est utilisée qu'à un seul endroit de la bibliothèque standard - dans la fonction ReadAtLeast.



Une autre erreur est NoProgress, dont nous avons déjà parlé. La documentation le dit: c'est un signe que l'interface est implémentée, c'est nul.

Lecteur n ° 3


La documentation stipule un ensemble de cas sur la façon de renvoyer correctement l'erreur. Ci-dessous, vous pouvez voir trois cas possibles:



Nous pouvons renvoyer une erreur à la fois avec le nombre d'octets soustraits et séparément. Mais si tout d'un coup vos données s'épuisent dans votre Reader et que vous ne pouvez pas retourner le EOF [signe de fin] pour le moment (de nombreuses implémentations de la bibliothèque standard fonctionnent exactement comme ça), alors on suppose que vous retournerez EOF au prochain appel consécutif (c'est-à-dire, vous devez laisser aller client).

Pour le client, cela signifie qu'il n'y a plus de données - ne me revenez plus. Si vous retournez zéro et que le client a besoin de données, il devrait revenir vers vous.

io.Reader. Erreurs


En général, selon Reader, ce sont les principales règles importantes. Il existe encore un ensemble de petits, mais ils ne sont pas si importants et ne conduisent pas à une telle situation:



Avant de passer en revue tout ce qui concerne Reader, nous devons répondre à la question: est-ce important, des erreurs se produisent-elles souvent dans les implémentations personnalisées? Pour répondre à cette question, je me suis tourné vers mon spool pour 1000 référentiels (et là, nous avons obtenu environ 550 implémentations personnalisées). J'ai regardé à travers les cent premiers avec mes yeux. Bien sûr, ce n'est pas une super-analyse, mais ce que c'est ... J'ai

identifié les deux erreurs les plus courantes:
  • ne renvoie jamais EOF;
  • trop de confiance dans le Reader encapsulé.

Encore une fois, c'est un problème de mon point de vue. Et pour ceux qui regardent le package io, ce n'est pas un problème. Nous en reparlerons.

Je voudrais revenir sur une nuance. Voir:



Le client ne doit jamais interpréter la paire 0 et nil comme EOF. C'est une erreur! Pour Reader, cette valeur n'est qu'une opportunité de laisser aller le client. Donc, les deux erreurs que j'ai mentionnées semblent insignifiantes, mais il suffit d'imaginer que vous avez un pipeline multicouche dans la prod et un petit "bagul" sournois glissé au milieu, alors le "coup souterrain" ne prendra pas longtemps - c'est garanti!

Selon Reader, pratiquement tout. Telles étaient les règles de mise en œuvre de base.

io.Writer


À l'autre bout des pipelines, nous avons io.Writer, qui est l'endroit où nous écrivons habituellement les données. Une interface très similaire: elle se compose également d'une méthode (Write), leur signature est similaire. Du point de vue de la sémantique, l'interface Writer est plus compréhensible: je dirais que telle qu'elle est entendue, elle est écrite.



La méthode Write prend un octet de tranche et l'écrit dans son intégralité. Il a également un ensemble de règles qui doivent être suivies.

  1. Le premier concerne le nombre d'octets renvoyés écrits. Je dirais que ce n'est pas si strict, parce que je n'ai pas trouvé un seul exemple où cela conduirait à des conséquences critiques - par exemple, à la panique. Ce n'est pas très strict car il y a la règle suivante ...
  2. L'implémentation Writer est requise pour renvoyer une erreur lorsque la quantité de données écrites est inférieure à ce qui a été envoyé. Autrement dit, l'enregistrement partiel n'est pas pris en charge. Cela signifie qu'il n'est pas très important de savoir combien d'octets ont été écrits.
  3. Une règle de plus: Writer ne doit en aucun cas modifier la tranche envoyée, car le client continuera de travailler avec cette tranche.
  4. Writer ne doit pas contenir cette tranche (Reader a la même règle). Si vous avez besoin de données dans votre implémentation pour certaines opérations, il vous suffit de copier cette diapositive, et c'est tout.



Par Reader et Writer, c'est tout.

Dendrogramme


Surtout pour ce rapport, j'ai généré un graphe d'implémentation et l'ai conçu sous forme de dendrogramme. Ceux qui veulent en ce moment peuvent suivre ce code QR:



Ce dendrogramme a toutes les implémentations de toutes les interfaces du paquet io. Ce dendrogramme est nécessaire pour comprendre simplement: quoi et avec quoi vous pouvez rester ensemble dans les pipelines, où et ce que vous pouvez lire, où vous pouvez écrire. Je vais toujours y faire référence au cours de mon rapport, veuillez donc vous référer au code QR.

Pipelines


Nous avons parlé de ce qu'est Reader, io.Writer. Parlons maintenant de l'API qui existe dans la bibliothèque standard pour la construction de pipelines. Commençons par les bases. Peut-être que cela ne sera même intéressant pour personne. Cependant, c'est très important.

Nous lirons les données du flux d'entrée standard (de Stdin):



Stdin est représenté dans Go par une variable globale de type file du package os. Si vous regardez le dendrogramme, vous remarquerez que le type de fichier implémente également les interfaces Reader et Writer.

En ce moment, nous sommes intéressés par Reader. Nous lirons Stdin en utilisant le même assistant ReadAll que nous avons déjà utilisé.

Une nuance concernant cet assistant mérite d'être notée: ReadAll lit Reader jusqu'à la fin, mais il détermine la fin par EOF, selon le signe de la fin dont nous avons parlé.
Nous allons maintenant limiter la quantité de données que nous lisons depuis Stdin. Pour ce faire, il existe une implémentation de LimitedReader dans la bibliothèque standard:



je voudrais que vous fassiez attention à la façon dont LimitedReader limite le nombre d'octets à lire. On pourrait penser que cette implémentation, ce Wrapper, soustrait tout ce qui se trouve dans le Reader, qu'il enveloppe, puis donne autant que nous voulons. Mais tout fonctionne un peu différemment ...

LimitedReader coupe la tranche qui lui est donnée comme argument le long de la limite supérieure. Et il passe cette tranche recadrée à Reader, qui l'enveloppe. Il s'agit d'une démonstration claire de la façon dont la longueur des données lues est régulée dans les implémentations de l'interface io.Reader.

Erreur de retour de fin de fichier


Autre point intéressant: notez comment cette implémentation renvoie une erreur EOF! Les valeurs nommées renvoyées sont utilisées ici, et elles sont affectées par les valeurs que nous obtenons du Reader encapsulé.

Et s'il arrive qu'il y ait plus de données dans le lecteur encapsulé que nous n'en avons besoin, nous attribuons les valeurs du lecteur encapsulé - disons, 10 octets et zéro - car il y a encore des données dans le lecteur encapsulé. Mais la variable n, qui diminue (dans l'avant-dernière ligne), dit que nous avons atteint le «bas» - la fin de ce dont nous avons besoin.

Dans la prochaine itération, le client devrait revenir - à la première condition, il recevra EOF. C'est le cas que j'ai mentionné.

A suivre très prochainement ...


Un peu de publicité :)


Merci de rester avec nous. Aimez-vous nos articles? Vous voulez voir des matériaux plus intéressants? Soutenez-nous en passant une commande ou en recommandant à vos amis des VPS basés sur le cloud pour les développeurs à partir de 4,99 $ , un analogue unique de serveurs d'entrée de gamme que nous avons inventés pour vous: Toute la vérité sur les VPS (KVM) E5-2697 v3 (6 cœurs) 10 Go DDR4 480 Go SSD 1 Gbit / s à partir de 19 $ ou comment diviser le serveur? (les options sont disponibles avec RAID1 et RAID10, jusqu'à 24 cœurs et jusqu'à 40 Go de DDR4).

Dell R730xd 2 fois moins cher au centre de données Equinix Tier IV à Amsterdam? Nous avons seulement 2 x Intel TetraDeca-Core Xeon 2x E5-2697v3 2.6GHz 14C 64GB DDR4 4x960GB SSD 1Gbps 100 TV à partir de 199 $ aux Pays-Bas!Dell R420 - 2x E5-2430 2.2Ghz 6C 128GB DDR3 2x960GB SSD 1Gbps 100TB - à partir de 99 $! En savoir plus sur la création d'un bâtiment d'infrastructure. classe c utilisant des serveurs Dell R730xd E5-2650 v4 coûtant 9 000 euros pour un sou?

All Articles