La technique de développement de serveurs hautement fiables sur Go

De temps en temps, les programmeurs Web font face à des tâches qui peuvent même effrayer les professionnels. Nous parlons de développer des applications serveur qui n'ont pas le droit de faire des erreurs, de projets dans lesquels le coût de l'échec est extrêmement élevé. L'auteur du document, dont nous publions la traduction aujourd'hui, expliquera comment aborder ces tâches.



De quel niveau de fiabilité votre projet a-t-il besoin?


Avant de vous plonger dans les détails du développement d'applications serveur hautement fiables, vous devez vous demander si votre projet a vraiment besoin du niveau de fiabilité le plus élevé possible. Le processus de développement de systèmes conçus pour des scénarios de travail dans lesquels l'erreur s'apparente à une catastrophe universelle peut être excessivement compliqué pour la plupart des projets dans lesquels les conséquences d'erreurs possibles ne sont pas particulièrement effrayantes.

Si le coût de l'erreur ne s'avère pas extrêmement élevé, une approche est acceptable, dans la mise en œuvre de laquelle le développeur fait les efforts les plus raisonnables pour assurer l'opérabilité du projet, et si des problèmes surviennent, il les comprend simplement. Des outils de surveillance modernes et des processus de déploiement logiciel continu vous permettent d'identifier rapidement les problèmes de production et de les résoudre presque instantanément. Dans de nombreux cas, cela suffit.

Dans le projet sur lequel je travaille aujourd'hui, ce n'est pas le cas. Nous parlons de la mise en œuvre de la blockchain - une infrastructure de serveur distribuée pour l'exécution sûre du code dans un environnement avec un faible niveau de confiance, tout en atteignant un consensus. L'une des applications de cette technologie est la monnaie numérique. Il s'agit d'un exemple classique d'un système avec un coût d'erreur extrêmement élevé. Dans ce cas, les développeurs de projets doivent vraiment le rendre très, très fiable.

Cependant, dans certains autres projets, même s'ils ne sont pas liés à la finance, la recherche de la plus haute fiabilité du code est logique. Le coût de maintenance d'une base de code fréquemment cassée peut atteindre très rapidement des valeurs astronomiques. La capacité d'identifier les problèmes aux premiers stades du processus de développement, alors que le coût de leur résolution est encore faible, ressemble à une récompense très réelle pour l'investissement en temps et en efforts dans la méthodologie de développement de systèmes hautement fiables.

Peut-être que la solution est TDD?


Le développement par le biais de tests ( Test Driven Development , TDD) est souvent considéré comme le meilleur remède contre le mauvais code. TDD est une méthodologie de développement puriste, dans l'application de laquelle les tests sont écrits en premier, et seulement ensuite - du code qui n'est ajouté au projet que lorsque les tests qui le vérifient cessent de générer des erreurs. Ce processus garantit une couverture à 100% du code avec des tests et donne souvent l'illusion que le code est testé dans toutes les variantes possibles de son utilisation.

Mais ce n'est pas le cas. TDD est une excellente méthodologie qui fonctionne bien dans certains domaines, mais pour développer un code vraiment fiable, mais pas assez. Pire encore, TDD inspire au développeur une fausse confiance et l'application de cette méthodologie peut conduire au fait qu'il ne fera tout simplement pas, par paresse, des tests pour vérifier les défaillances du système dans des situations dont l'occurrence, du point de vue du bon sens, est presque impossible. Nous en reparlerons plus tard.

Les tests sont la clé de la fiabilité


En fait, peu importe que vous créiez des tests avant d’écrire du code ou après, que vous utilisiez ou non une méthodologie de développement comme TDD. L'essentiel est le fait d'avoir des tests. Les tests sont la meilleure fortification défensive qui protège votre code des problèmes de production.

Puisque nous allons exécuter nos tests très souvent, idéalement après avoir ajouté chaque nouvelle ligne au code, il est nécessaire que les tests soient automatisés. Notre confiance dans la qualité du code ne doit en aucun cas reposer sur ses vérifications manuelles. Le fait est que les gens ont tendance à faire des erreurs. L'attention portée aux détails par une personne est affaiblie après avoir effectué la même tâche intimidante plusieurs fois de suite.

Les tests doivent être rapides. Très vite.

S'il faut plus de quelques secondes pour terminer la suite de tests, les développeurs seront très probablement paresseux et ajouteront du code au projet sans le tester. La vitesse est l'une des plus grandes forces de Go. La boîte à outils de développement dans ce langage est l'une des plus rapides parmi celles existantes. La compilation, la reconstruction et le test des projets se font en quelques secondes.

De plus, les tests sont l'un des principaux moteurs des projets open source. Par exemple, cela s'applique à tout ce qui concerne la technologie blockchain. L'open source ici est presque une religion. La base de code afin de gagner la confiance de ceux qui l'utiliseront doit être ouverte. Cela permet, par exemple, de conduire son audit, cela crée une atmosphère de décentralisation, dans laquelle il n'y a pas certaines entités qui contrôlent le projet.

Cela n'a aucun sens d'attendre une contribution significative au projet open source de développeurs externes si ce projet ne comprend pas de tests de qualité. Les participants externes au projet ont besoin de mécanismes pour vérifier rapidement la compatibilité de ce qu'ils ont écrit avec ce qui est déjà ajouté au projet. En fait, l'ensemble des tests devrait être effectué automatiquement à la réception de chaque demande pour ajouter un nouveau code au projet. Si quelque chose qui est censé être ajouté au projet au moyen d'une telle demande casse quelque chose, le test doit le signaler immédiatement.

La couverture complète de la base de code avec des tests est une mesure trompeuse mais importante. L'objectif d'atteindre une couverture de code à 100% avec des tests peut sembler excessif, mais si vous y réfléchissez, il s'avère que si le code n'est pas entièrement couvert par les tests, une partie du code est envoyé en production sans vérification, ce qui n'a jamais été exécuté auparavant.

Une couverture complète du code avec des tests ne signifie pas nécessairement qu'il y a suffisamment de tests dans le projet et ne signifie pas que ce sont des tests qui fournissent absolument toutes les options pour utiliser le code. En toute confiance, nous pouvons seulement dire que si le projet n'est pas couvert à 100% par des tests, le développeur ne peut pas être sûr de la fiabilité absolue du code, car certaines parties du code ne sont jamais testées.

Malgré ce qui précède, il existe des situations où il y a trop de tests. Idéalement, chaque erreur possible devrait entraîner l'échec d'un test. Si le nombre de tests est excessif, c'est-à-dire que différents tests vérifient les mêmes fragments de code, puis modifier le code existant et changer le comportement du système existant conduira au fait que pour que les tests existants correspondent au nouveau code, cela prendra trop de temps pour les traiter .

Pourquoi Go est-il un excellent choix pour des projets hautement fiables?


Go est un langage tapé statique. Les types sont un contrat entre divers morceaux de code qui sont exécutés ensemble. Sans vérification de type automatique pendant le processus d'assemblage du projet, si vous devez respecter des règles strictes pour couvrir le code avec des tests, nous devons implémenter des tests qui vérifient nous-mêmes ces «contrats». Cela se produit, par exemple, dans les projets serveur et client basés sur JavaScript. L'écriture de tests complexes visant uniquement à vérifier les types signifie beaucoup de travail supplémentaire, qui, dans le cas de Go, peut être évité.

Go est un langage simple et dogmatique. Comme vous le savez, Go inclut de nombreuses idées traditionnelles pour les langages de programmation, comme l'héritage OOP classique. La complexité est le pire ennemi d'un code fiable. Les problèmes ont tendance à se cacher au niveau des articulations des structures complexes. Cela s'exprime dans le fait que bien que les options typiques pour l'utilisation d'une certaine conception soient faciles à tester, il existe des cas limites bizarres auxquels le développeur de test pourrait même ne pas penser. En fin de compte, le projet n'aboutira qu'à un seul de ces cas. En ce sens, le dogmatisme est également utile. Dans Go, il n'y a souvent qu'une seule façon d'effectuer une action. Cela peut sembler être un facteur qui freine l'esprit libre du programmeur, mais quand quelque chose ne peut être fait que d'une seule manière, il est difficile de faire quelque chose de mal.

Go est concis mais expressif. Le code lisible est plus facile à analyser et à auditer. Si le code est trop verbeux, son objectif principal peut se noyer dans le «bruit» des constructions auxiliaires. Si le code est trop concis, les programmes qu'il contient peuvent être difficiles à lire et à comprendre. Go maintient un équilibre entre concision et expressivité. Par exemple, il n'y a pas beaucoup de constructions auxiliaires, comme dans des langages tels que Java ou C ++. Dans le même temps, les constructions Go, relatives par exemple à des domaines tels que la gestion des erreurs, sont très claires et assez détaillées, ce qui simplifie le travail du programmeur, l'aidant à s'assurer, par exemple, qu'il a vérifié tout ce qui est possible.

Go a des mécanismes de gestion des erreurs et de récupération clairs après les plantages. Des mécanismes de gestion des erreurs d'exécution bien réglés sont la pierre angulaire d'un code très fiable. Go a des règles strictes pour retourner et distribuer les erreurs. Dans des environnements comme Node.js, mélanger les approches de contrôle du flux d'un programme, telles que les rappels, les promesses et les fonctions asynchrones, conduit souvent à des erreurs non gérées, comme un rejet non géré d'une promesse . La restauration du programme après des événements similaires est presque impossible .

Go a une bibliothèque standard étendue. Les dépendances sont un risque, en particulier lorsque leur source est des projets dans lesquels une attention insuffisante est accordée à la fiabilité du code. Une application serveur qui entre en production contient toutes les dépendances. De plus, en cas de problème, le développeur de l'application finie en sera responsable, et non celui qui a créé l'une des bibliothèques utilisées par lui. Par conséquent, dans des environnements où les projets écrits sont surchargés de petites dépendances, il est plus difficile de créer des applications fiables.

Les dépendances sont également un risque pour la sécurité, car le niveau de vulnérabilité d'un projet correspond au niveau de vulnérabilité de sa dépendance la plus dangereuse . La vaste bibliothèque standard Go est maintenue par ses développeurs en très bon état, son existence réduit le besoin de dépendances externes.

Vitesse de développement élevée. Une caractéristique clé d'environnements comme Node.js est son cycle de développement extrêmement court. L'écriture de code prend moins de temps, par conséquent, le programmeur devient plus productif.

Go a également une vitesse de développement élevée. Un ensemble d'outils pour la construction de projets est assez rapide pour pouvoir regarder instantanément le code en action. Le temps de compilation est extrêmement court; par conséquent, l'exécution de code sur Go est perçue comme si elle n'avait pas été compilée, mais interprétée. De plus, le langage possède suffisamment d'abstractions, comme un système de collecte des ordures, qui permet aux développeurs de diriger les efforts pour implémenter les fonctionnalités de leur projet, et non de résoudre des tâches auxiliaires.

Expérience pratique


Maintenant que nous avons exprimé suffisamment de points généraux, il est temps de jeter un œil au code. Nous avons besoin d'un exemple assez simple pour que, tout en l'étudiant, nous puissions nous concentrer sur la méthodologie de développement, mais en même temps, il devrait être suffisamment avancé pour que nous, en l'explorant, ayons quelque chose à dire. J'ai décidé qu'il serait plus facile de prendre quelque chose de ce que je fais quotidiennement. Par conséquent, je propose d'analyser la création d'un serveur qui traite quelque chose qui ressemble à des transactions financières. Les utilisateurs de ce serveur pourront vérifier les soldes des comptes associés à leurs comptes. De plus, ils pourront transférer des fonds d'un compte à un autre.

Nous allons essayer de ne pas compliquer cet exemple. Notre système aura un serveur. Nous ne contacterons pas les systèmes d'authentification et de cryptographie. Ce sont des parties intégrantes des projets de travail. Mais nous devons nous concentrer sur le cœur d'un tel projet, pour montrer comment le rendre aussi fiable que possible.

▍Division d'un projet complexe en parties faciles à gérer


La complexité est le pire ennemi de la fiabilité. L'une des meilleures approches lorsque l'on travaille avec des systèmes complexes consiste à appliquer le principe bien connu de «diviser pour mieux régner». La tâche doit être divisée en petites sous-tâches et résoudre chacune d'elles séparément. De quel côté aborder la partition de notre tâche? Nous suivrons le principe de la responsabilité partagée . Chaque partie de notre projet devrait avoir son propre domaine de responsabilité.

Cette idée correspond parfaitement à l'architecture de microservices populaire. Notre serveur sera composé de services distincts. Chaque service aura un domaine de responsabilité clairement défini et une interface clairement décrite pour interagir avec d'autres services.

Après avoir structuré le serveur de cette manière, nous serons en mesure de prendre des décisions sur le fonctionnement de chacun des services. Tous les services peuvent être exécutés ensemble, dans le même processus, à partir de chacun d'eux, vous pouvez créer un serveur distinct et établir leur interaction à l'aide de RPC, vous pouvez séparer les services et exécuter chacun d'eux sur un ordinateur distinct.

Nous ne compliquerons pas la tâche, nous choisirons l'option la plus simple. À savoir, tous les services seront exécutés dans le même processus, ils échangeront directement des informations, comme les bibliothèques. Si nécessaire, cette solution architecturale pourra à l'avenir être facilement révisée et modifiée.

Alors de quels services avons-nous besoin? Notre serveur est peut-être trop simple pour le diviser en parties, mais, à des fins pédagogiques, nous le diviserons néanmoins. Nous devons répondre aux demandes HTTP des clients visant à vérifier les soldes et à exécuter les transactions. L'un des services peut fonctionner avec une interface HTTP pour les clients. PublicApi cela PublicApi . Un autre service détiendra des informations sur l'état du système - le bilan. StateStorage cela StateStorage . Le troisième service combinera les deux décrits ci-dessus et mettra en œuvre la logique des «contrats» visant à modifier les soldes. La tâche du troisième service sera l'exécution des contrats. VirtualMachine cela VirtualMachine .


Architecture du serveur d'applications

Placez le code de ces services dans les dossiers de projet /services/publicapi , /services/virtualmachine et /services/statestorage .

▍ Définition claire des responsabilités de service


Lors de la mise en place des services, nous souhaitons pouvoir travailler avec chacun d'eux individuellement. Il est même possible de répartir le développement de ces services entre différents programmeurs. Étant donné que les services sont interdépendants et que nous voulons paralléliser leur développement, nous devons commencer à travailler avec une définition claire des interfaces qu'ils utilisent pour interagir les uns avec les autres. À l'aide de ces interfaces, nous pouvons tester les services de manière autonome en préparant des talons pour tout ce qui se trouve en dehors de chacun d'eux.

Comment décrire l'interface? L'une des options est de tout documenter, mais la documentation a la propriété de devenir obsolète, au cours du travail sur un projet, des différences commencent à s'accumuler entre la documentation et le code. De plus, nous pouvons utiliser des déclarations d'interface Go. C'est une option intéressante, mais il vaut mieux décrire l'interface pour que cette description ne dépende pas d'un langage de programmation spécifique. Cela nous sera utile dans une situation très réelle, si au cours du travail sur un projet, il sera décidé de mettre en œuvre certains de ses services dans d'autres langues dont les capacités sont mieux adaptées pour résoudre leurs problèmes.

Une option pour décrire les interfaces est d'utiliser protobuf . Il s'agit d'un langage simple et d'un protocole indépendant du langage pour décrire les messages et les points de terminaison de service.

Commençons par l'interface du service StateStorage . Nous présenterons l'état de l'application sous la forme d'une structure de vue clé-valeur. Voici le code du fichier statestorage.proto :

 syntax = "proto3"; package statestorage; service StateStorage { rpc WriteKey (WriteKeyInput) returns (WriteKeyOutput); rpc ReadKey (ReadKeyInput) returns (ReadKeyOutput); } message WriteKeyInput { string key = 1; int32 value = 2; } message WriteKeyOutput { } message ReadKeyInput { string key = 1; } message ReadKeyOutput { int32 value = 1; } 

Bien que les clients utilisent HTTP via le service PublicApi , cela n'interfère pas non plus avec l'interface claire décrite par les mêmes moyens que ci-dessus (le fichier publicapi.proto ):

 syntax = "proto3"; package publicapi; import "protocol/transactions.proto"; service PublicApi { rpc Transfer (TransferInput) returns (TransferOutput); rpc GetBalance (GetBalanceInput) returns (GetBalanceOutput); } message TransferInput { protocol.Transaction transaction = 1; } message TransferOutput { string success = 1; int32 result = 2; } message GetBalanceInput { protocol.Address from = 1; } message GetBalanceOutput { string success = 1; int32 result = 2; } 

Maintenant, nous devons décrire les structures de données de Transaction et d' Address (fichier transactions.proto ):

 syntax = "proto3"; package protocol; message Address { string username = 1; } message Transaction { Address from = 1; Address to = 2; int32 amount = 3; } 

Dans le projet, les proto-descriptions des services sont placées dans le dossier /types/services et les descriptions des structures de données à usage général se trouvent dans le dossier /types/protocol .

Une fois que les descriptions d'interface sont prêtes, elles peuvent être compilées en code Go.

Les avantages de cette approche sont que le code qui ne correspond pas à la description de l'interface n'apparaît tout simplement pas dans les résultats de la compilation. L'utilisation de méthodes alternatives nous obligerait à écrire des tests spéciaux pour vérifier que le code correspond aux descriptions de l'interface.

Les définitions complètes, les fichiers Go générés et les instructions de compilation peuvent être trouvés ici . Ceci est possible grâce à Square Engineering et à leur développement de goprotowrap .

Veuillez noter que dans notre projet, la couche de transport RPC n'est pas implémentée et l'échange de données entre les services ressemble à des appels de bibliothèque ordinaires. Lorsque nous sommes prêts à distribuer des services sur différents serveurs, nous pouvons ajouter une couche de transport telle que gRPC au système.

▍ Types de tests utilisés dans le projet


Étant donné que les tests sont la clé d'un code hautement fiable, je suggère que nous parlions d'abord des tests que nous allons écrire pour notre projet.

Tests unitaires


Les tests unitaires sont au cœur de la pyramide des tests . Nous testerons chaque module isolément. Qu'est-ce qu'un module? Dans Go, nous pouvons percevoir les modules comme des fichiers séparés dans un package. Par exemple, si nous avons le fichier /services/publicapi/handlers.go , nous /services/publicapi/handlers.go le test unitaire pour celui-ci dans le même package à /services/publicapi/handlers_test.go .

Il est préférable de placer les tests unitaires dans le même package que le code de test, ce qui permet aux tests d'avoir accès aux variables et fonctions non exportées.

Tests de service


Le type de test suivant est connu sous différents noms. Il s'agit des tests dits de service, d'intégration ou de composants. Leur essence est de prendre plusieurs modules et de tester leur travail commun. Ces tests sont supérieurs d'un niveau aux tests unitaires de la pyramide des tests. Dans notre cas, nous utiliserons des tests d'intégration pour tester l'ensemble du service. Ces tests déterminent les spécifications du service. Par exemple, les tests du service StateStorage seront placés dans le dossier /services/statestorage/spec .

Il est préférable de placer ces tests dans un package qui diffère de celui dans lequel se trouve le code testé afin que l'accès aux capacités de ce code se fasse uniquement via des interfaces exportées.

Tests de bout en bout


Ces tests sont au sommet de la pyramide des tests, avec leur aide pour vérifier l'ensemble du système et tous ses services sont effectués. Ces tests décrivent les spécifications e2e de bout en bout du système, nous les /e2e/spec dans le /e2e/spec .

Les tests de bout en bout, ainsi que les tests de service, doivent être placés dans un package différent de celui dans lequel se trouve le code testé afin que le système ne puisse fonctionner que via des interfaces exportées.

Quels tests doivent être écrits en premier? Commencer par la fondation de la "pyramide" et remonter? Ou commencer par le haut et descendre? Chacune de ces approches a droit à la vie. Les avantages d'une approche descendante résident dans la création de la spécification d'abord pour l'ensemble du système. Il est généralement plus facile de discuter au tout début des travaux sur les caractéristiques du système dans son ensemble. Même si nous divisons le système en services séparés de manière incorrecte, les spécifications du système resteront inchangées. De plus, cela nous aidera à comprendre que quelque chose, à un niveau inférieur, est mal fait.

L'inconvénient de l'approche descendante est que les tests de bout en bout sont les tests qui sont utilisés après tous les autres, lorsque tout le système en cours de développement est créé. Cela signifie qu'ils généreront des erreurs pendant longtemps. Lors de la rédaction des tests pour notre projet, nous utiliserons cette approche même.

▍Développement des tests


Développement de test de bout en bout


Avant de créer des tests, nous devons décider si nous allons les écrire sans utiliser d'outils auxiliaires ou utiliser une sorte de framework. S'appuyer sur le framework, l'utiliser comme dépendance de développement, est moins dangereux que de s'appuyer sur le framework dans le code qui entre en production. Dans notre cas, étant donné que la bibliothèque Go standard n'a pas de prise en charge BDD décente et que ce format est idéal pour décrire les spécifications, nous choisirons une option de travail qui inclut l'utilisation d'un cadre.

Il existe de nombreux cadres formidables qui donnent ce dont nous avons besoin. Parmi eux, GoConvey et Ginkgo .

Personnellement, j'aime utiliser une combinaison de Ginkgo et Gomega (noms terribles, mais que faire) qui utilisent des constructions syntaxiques comme Describe() et It() .

À quoi ressembleront nos tests? Par exemple, voici un test du mécanisme de vérification du solde utilisateur (fichier sanity.go ):

 package spec import ... var _ = Describe("Sanity", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should show balances with GET /api/balance", func() { resp, err := http.Get("http://localhost:8080/api/balance?from=user1") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("0")) }) }) 

Étant donné que le serveur est accessible depuis le monde extérieur via HTTP, nous travaillerons avec son API Web en utilisant http.Get . Qu'en est-il des tests transactionnels? Voici le code du test correspondant:

 It("should transfer funds with POST /api/transfer", func() { resp, err := http.Get("http://localhost:8080/api/transfer?from=user1&to=user2&amount=17") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("-17")) resp, err = http.Post("http://localhost:8080/api/balance?from=user2", "text/plain", nil) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("17")) }) 

Le code de test décrit parfaitement leur essence, il peut même remplacer la documentation. Comme vous pouvez le constater, nous admettons la présence de soldes de comptes d'utilisateurs négatifs. C'est une caractéristique de notre projet. Si elle était interdite, cette décision serait reflétée dans le test.

Voici le code de test complet

Développement de tests de service


Maintenant, après avoir développé des tests de bout en bout, nous descendons la pyramide des tests et procédons à la création de tests de service. Ces tests sont développés pour chaque service individuel. Nous choisissons un service qui dépend d'un autre service, car ce cas est plus intéressant que de développer des tests pour un service indépendant.

Commençons par le service VirtualMachine . Ici vous pouvez trouver l'interface avec des proto-descriptions pour ce service. Étant donné que le service VirtualMachine s'appuie sur le service StateStorage et lui fait des appels, nous devrons créer un objet StateStorage pour le service StateStorage afin de tester le service VirtualMachine de manière isolée. L'objet stub nous permet de contrôler les réponses StateStorage pendant les tests.

Comment implémenter un objet stub dans Go? Cela peut être fait exclusivement au moyen du langage, sans outils auxiliaires, ou vous pouvez recourir à la bibliothèque appropriée, qui, en plus, permettra de travailler avec les instructions dans le processus de test. À cette fin, je préfère utiliser la bibliothèque go-mock .

Nous /services/statestorage/mock.go le code de /services/statestorage/mock.go dans le fichier /services/statestorage/mock.go . Il est préférable de placer les objets stub au même endroit que les entités qu'ils imitent afin de leur donner accès aux variables et fonctions non exportées. Le stub à ce stade est une implémentation schématique du service, mais, à mesure que le service se développe, nous devrons peut-être développer l'implémentation du stub. Voici le code de l'objet stub (fichier mock.go ):

 package statestorage import ... type MockService struct { mock.Mock } func (s *MockService) Start() { s.Called() } func (s *MockService) Stop() { s.Called() } func (s *MockService) IsStarted() bool { return s.Called().Bool(0) } func (s *MockService) WriteKey(input *statestorage.WriteKeyInput) (*statestorage.WriteKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.WriteKeyOutput), ret.Error(1) } func (s *MockService) ReadKey(input *statestorage.ReadKeyInput) (*statestorage.ReadKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.ReadKeyOutput), ret.Error(1) } 

Si vous donnez le développement de services individuels à différents programmeurs, il est logique de créer d'abord des talons et de les transmettre à l'équipe.

Revenons au développement d'un test de service pour VirtualMachine . Quel scénario dois-je vérifier ici? Il est préférable de se concentrer sur l'interface de service et les tests de conception pour chaque point de terminaison. Nous implémentons un test pour le point de terminaison CallContract() avec un argument représentant la méthode "GetBalance" . Voici le code correspondant (fichier contracts.go ):

 package spec import ... var _ = Describe("Contracts", func() { var ( service uut.Service stateStorage *_statestorage.MockService ) BeforeEach(func() { service = uut.NewService() stateStorage = &_statestorage.MockService{} service.Start(stateStorage) }) AfterEach(func() { service.Stop() }) It("should support 'GetBalance' contract method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) addr := protocol.Address{Username: "user1"} out, err := service.CallContract(&virtualmachine.CallContractInput{Method: "GetBalance", Arg: &addr}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(100)) Expect(stateStorage).To(ExecuteAsPlanned()) }) }) 

Veuillez noter que le service que nous testons, VirtualMachine , obtient un pointeur sur sa dépendance, StateStorage , dans la méthode Start() via un mécanisme d'injection de dépendance simple. C'est là que nous passons l'instance de l'objet stub. stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key… également attention à la ligne stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key… , où nous indiquons à l'objet stub comment il doit se comporter lorsqu'il y accède. Lorsque la méthode ReadKey est ReadKey , elle doit renvoyer une valeur 100. Ensuite, dans la ligne Expect(stateStorage).To(ExecuteAsPlanned()) , nous vérifions que cette commande est appelée exactement une fois.

Des tests similaires deviennent des spécifications pour le service. L'ensemble complet de tests pour le service VirtualMachine peut être trouvé ici . Des suites de tests pour d'autres services de notre projet peuvent être trouvées ici et ici .

Développement de tests unitaires


Peut-être que l'implémentation du contrat pour la méthode "GetBalance" est trop simple, alors parlons de l'implémentation d'une méthode de "Transfer" légèrement plus complexe. Le contrat de transfert de fonds d'un compte à un autre représenté par cette méthode doit lire les données sur les soldes de l'expéditeur et du destinataire des fonds, calculer les nouveaux soldes et enregistrer ce qui s'est passé dans l'état d'application. Le test de service pour tout cela est très similaire à celui que nous venons de mettre en place (fichier transactions.go ):

 It("should support 'Transfer' transaction method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) t := protocol.Transaction{From: &protocol.Address{Username: "user1"}, To: &protocol.Address{Username: "user2"}, Amount: 10} out, err := service.ProcessTransaction(&virtualmachine.ProcessTransactionInput{Method: "Transfer", Arg: &t}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(90)) Expect(stateStorage).To(ExecuteAsPlanned()) }) 

Dans le processus de travail sur le projet, nous arrivons enfin à créer ses mécanismes internes et à créer un module situé dans le fichier processor.go , qui contient la mise en œuvre du contrat. Voici la version originale (fichier processor.go ):

 package virtualmachine import ... func (s *service) processTransfer(fromUsername string, toUsername string, amount int32) (int32, error) { fromBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: fromUsername}) if err != nil { return 0, err } toBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: toUsername}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: fromUsername, Value: fromBalance.Value - amount}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: toUsername, Value: toBalance.Value + amount}) if err != nil { return 0, err } return fromBalance.Value - amount, nil } 

Cette conception satisfait le test de service, mais dans notre cas, le test d'intégration ne contient qu'un test du scénario de base. Qu'en est-il des cas limites et des échecs potentiels? Comme vous pouvez le voir, l'un des appels que nous faisons à StateStorage peut échouer. Si une couverture à 100% du code avec des tests est requise, nous devons vérifier toutes ces situations. Le test unitaire est idéal pour implémenter de tels tests.

Comme nous allons appeler la fonction plusieurs fois avec différentes données d'entrée et simuler les paramètres pour atteindre toutes les branches du code, afin de rendre ce processus plus efficace, nous pouvons recourir à des tests basés sur des tables. Go a tendance à éviter les cadres de tests unitaires exotiques. Nous pouvons refuser Ginkgo , mais nous devrions probablement quitter Gomega . Par conséquent, les contrôles effectués ici seront similaires à ceux que nous avons effectués lors des tests précédents. Voici le code de test (fichier processor_test.go ):

 package virtualmachine import ... var transferTable = []struct{ to string //  ,    read1Err error //       read2Err error //       write1Err error //       write2Err error //       output int32 //   errs bool //        }{ {"user2", errors.New("a"), nil, nil, nil, 0, true}, {"user2", nil, errors.New("a"), nil, nil, 0, true}, {"user2", nil, nil, errors.New("a"), nil, 0, true}, {"user2", nil, nil, nil, errors.New("a"), 0, true}, {"user2", nil, nil, nil, nil, 90, false}, } func TestTransfer(t *testing.T) { Ω := NewGomegaWithT(t) for _, tt := range transferTable { s := NewService() ss := &_statestorage.MockService{} s.Start(ss) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, tt.read1Err) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, tt.read2Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, tt.write1Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, tt.write2Err) output, err := s.(*service).processTransfer("user1", tt.to, 10) if tt.errs { Ω.Expect(err).To(HaveOccurred()) } else { Ω.Expect(err).ToNot(HaveOccurred()) Ω.Expect(output).To(BeEquivalentTo(tt.output)) } } } 

«Ω» — , — ( Gomega ). .

, TDD, , , . processTransfer() .

VirtualMachine . .

100% . , . .

, ? . , , , .

▍ -


. ? HTTP- Go (goroutine). , — , . , , , .

- . , , , , . - /e2e/stress . - ( stress.go ):

 package stress import ... const NUM_TRANSACTIONS = 20000 const NUM_USERS = 100 const TRANSACTIONS_PER_BATCH = 200 const BATCHES_PER_SEC = 40 var _ = Describe("Transaction Stress Test", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should handle lots and lots of transactions", func() { //  HTTP-     transport := http.Transport{ IdleConnTimeout: time.Second*20, MaxIdleConns: TRANSACTIONS_PER_BATCH*10, MaxIdleConnsPerHost: TRANSACTIONS_PER_BATCH*10, } client := &http.Client{Transport: &transport} //      ledger := map[string]int32{} for i := 0; i < NUM_USERS; i++ { ledger[fmt.Sprintf("user%d", i+1)] = 0 } //     HTTP   rand.Seed(42) done := make(chan error, TRANSACTIONS_PER_BATCH) for i := 0; i < NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH; i++ { log.Printf("Sending %d transactions... (batch %d out of %d)", TRANSACTIONS_PER_BATCH, i+1, NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH) time.Sleep(time.Second / BATCHES_PER_SEC) for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { from := randomizeUser() to := randomizeUser() amount := randomizeAmount() ledger[from] -= amount ledger[to] += amount go sendTransaction(client, from, to, amount, &done) } for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { err := <- done Expect(err).ToNot(HaveOccurred()) } } //   for i := 0; i < NUM_USERS; i++ { user := fmt.Sprintf("user%d", i+1) resp, err := client.Get(fmt.Sprintf("http://localhost:8080/api/balance?from=%s", user)) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal(fmt.Sprintf("%d", ledger[user]))) } }) }) func randomizeUser() string { return fmt.Sprintf("user%d", rand.Intn(NUM_USERS)+1) } func randomizeAmount() int32 { return rand.Int31n(1000)+1 } func sendTransaction(client *http.Client, from string, to string, amount int32, done *chan error) { url := fmt.Sprintf("http://localhost:8080/api/transfer?from=%s&to=%s&amount=%d", from, to, amount) resp, err := client.Post(url, "text/plain", nil) if err == nil { ioutil.ReadAll(resp.Body) resp.Body.Close() } *done <- err } 

, - . ( rand.Seed(42) ) , . . , , — , .

- HTTP , TCP- ( , , ). , , 200 IdleConnection TCP- . , 100.

… :

 fatal error: concurrent map writes goroutine 539 [running]: runtime.throw(0x147bf60, 0x15) /usr/local/go/src/runtime/panic.go:616 +0x81 fp=0xc4207159d8 sp=0xc4207159b8 pc=0x102ca01 runtime.mapassign_faststr(0x13f5140, 0xc4201ca0c0, 0xc4203a8097, 0x6, 0x1012001) /usr/local/go/src/runtime/hashmap_fast.go:703 +0x3e9 fp=0xc420715a48 sp=0xc4207159d8 pc=0x100d879 services/statestorage.(*service).WriteKey(0xc42000c060, 0xc4209e6800, 0xc4206491a0, 0x0, 0x0) services/statestorage/methods.go:15 +0x10c fp=0xc420715a88 sp=0xc420715a48 pc=0x138339c services/virtualmachine.(*service).processTransfer(0xc4201ca090, 0xc4203a8097, 0x6, 0xc4203a80a1, 0x6, 0x2a4, 0xc420715b30, 0x1012928, 0x40) services/virtualmachine/processor.go:19 +0x16e fp=0xc420715ad0 sp=0xc420715a88 pc=0x13840ee services/virtualmachine.(*service).ProcessTransaction(0xc4201ca090, 0xc4209e67c0, 0x30, 0x1433660, 0x12a1d01) Ginkgo ran 1 suite in 1.288879763s Test Suite Failed 

? StateStorage ( map ), . , , . , map sync.map . .

processTransfer() . , — . , , , , . , processTransfer() . .

, . , , .

 e2e/stress/transactions.go:44 Expected <string>: -7498 to equal <string>: -7551 e2e/stress/transactions.go:82 ------------------------------ Ginkgo ran 1 suite in 5.251593179s Test Suite Failed 

, . , , ( , ). , , .

— . TDD . ? , 100%?! , — . processTransfer() , , .

. , , . .

Résumé


, , , -, , , ? ? — .

, -. , «» processTransfer() . , , . , — . , - . , , .

. , . , StateStorage WriteKey , , , , WriteKeys , , .

, : . « ». -, , , , , . — . , , — .

, — GitHub. . , , , , , , .

Chers lecteurs! ?

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


All Articles