Die Technik zur Entwicklung hochzuverlässiger Server auf Go

Von Zeit zu Zeit stehen Webprogrammierer vor Aufgaben, die sogar Profis erschrecken können. Wir sprechen über die Entwicklung von Serveranwendungen, die nicht das Recht haben, Fehler zu machen, über Projekte, bei denen die Ausfallkosten extrem hoch sind. Der Autor des Materials, dessen Übersetzung wir heute veröffentlichen, wird darüber sprechen, wie solche Aufgaben angegangen werden sollen.



Welches Maß an Zuverlässigkeit benötigt Ihr Projekt?


Bevor Sie sich mit den Details der Entwicklung hochzuverlässiger Serveranwendungen befassen, sollten Sie sich fragen, ob Ihr Projekt wirklich das höchstmögliche Maß an Zuverlässigkeit benötigt. Der Prozess der Entwicklung von Systemen für Arbeitsszenarien, in denen der Fehler einer universellen Katastrophe ähnelt, kann für die meisten Projekte, in denen die Folgen möglicher Fehler nicht besonders beängstigend sind, unangemessen kompliziert sein.

Wenn sich die Kosten des Fehlers nicht als extrem hoch herausstellen, ist ein Ansatz akzeptabel, bei dessen Implementierung der Entwickler die vernünftigsten Anstrengungen unternimmt, um die Funktionsfähigkeit des Projekts sicherzustellen, und wenn Probleme auftreten, versteht er sie einfach. Moderne Überwachungstools und kontinuierliche Softwarebereitstellungsprozesse ermöglichen es Ihnen, Produktionsprobleme schnell zu identifizieren und fast sofort zu beheben. In vielen Fällen ist dies ausreichend.

In dem Projekt, an dem ich heute arbeite, ist das nicht so. Wir sprechen über die Implementierung von Blockchain - einer verteilten Serverinfrastruktur für die sichere Ausführung von Code in einer Umgebung mit geringem Vertrauen und Konsens. Eine Anwendung dieser Technologie sind digitale Währungen. Dies ist ein klassisches Beispiel für ein System mit extrem hohen Fehlerkosten. In diesem Fall müssen die Projektentwickler es wirklich sehr, sehr zuverlässig machen.

Bei einigen anderen Projekten ist das Streben nach höchster Codezuverlässigkeit jedoch sinnvoll, auch wenn sie nicht mit der Finanzierung zusammenhängen. Die Kosten für die Wartung einer häufig fehlerhaften Codebasis können sehr schnell astronomische Werte erreichen. Die Fähigkeit, Probleme in den frühen Phasen des Entwicklungsprozesses zu identifizieren, wenn die Kosten für ihre Behebung noch niedrig sind, scheint eine echte Belohnung für die rechtzeitige Investition von Zeit und Mühe in die Entwicklungsmethodik hochzuverlässiger Systeme zu sein.

Vielleicht ist die Lösung TDD?


Entwicklung durch Testen ( Test Driven Development , TDD) wird oft als das beste Heilmittel für schlechten Code angesehen. TDD ist eine puristische Entwicklungsmethode, bei deren Anwendung zuerst Tests geschrieben werden und erst dann - Code, der dem Projekt nur hinzugefügt wird, wenn die Tests, die es überprüfen, keine Fehler mehr generieren. Dieser Prozess garantiert eine 100% ige Abdeckung des Codes mit Tests und gibt oft die Illusion, dass der Code in allen möglichen Varianten seiner Verwendung getestet wird.

Dies ist jedoch nicht so. TDD ist eine großartige Methode, die in einigen Bereichen gut funktioniert, aber wirklich zuverlässigen Code zu entwickeln, reicht einfach nicht aus. Schlimmer noch, TDD inspiriert den Entwickler mit falschem Vertrauen und die Anwendung dieser Methodik kann dazu führen, dass er aus Faulheit einfach keine Tests schreibt, um das System auf Fehler in Situationen zu überprüfen, deren Auftreten aus Sicht des gesunden Menschenverstandes fast unmöglich ist. Wir werden später darüber sprechen.

Tests sind der Schlüssel zur Zuverlässigkeit


Tatsächlich spielt es keine Rolle, ob Sie Tests vor dem Schreiben von Code erstellen oder danach, ob Sie eine Entwicklungsmethode wie TDD verwenden oder nicht. Die Hauptsache ist die Tatsache, Tests zu haben. Tests sind die beste defensive Verstärkung, die Ihren Code vor Produktionsproblemen schützt.

Da wir unsere Tests sehr oft ausführen werden, idealerweise nach dem Hinzufügen jeder neuen Zeile zum Code, ist es erforderlich, dass die Tests automatisiert werden. Unser Vertrauen in die Qualität des Codes sollte in keiner Weise auf seinen manuellen Überprüfungen beruhen. Die Sache ist, dass Menschen dazu neigen, Fehler zu machen. Die Liebe zum Detail einer Person wird geschwächt, nachdem sie dieselbe entmutigende Aufgabe viele Male hintereinander erledigt hat.

Tests sollten schnell sein. Sehr schnell.

Wenn die Fertigstellung der Testsuite länger als ein paar Sekunden dauert, sind Entwickler höchstwahrscheinlich faul und fügen dem Projekt Code hinzu, ohne es zu testen. Geschwindigkeit ist eine der größten Stärken von Go. Das Entwicklungs-Toolkit in dieser Sprache ist eines der schnellsten unter den vorhandenen. Das Kompilieren, Neuerstellen und Testen von Projekten erfolgt in Sekundenschnelle.

Darüber hinaus sind Tests eine der wichtigsten Triebkräfte von Open Source-Projekten. Dies gilt beispielsweise für alles, was mit Blockchain-Technologie zu tun hat. Open Source ist hier fast eine Religion. Die Codebasis muss offen sein, um Vertrauen in diejenigen zu gewinnen, die sie verwenden werden. Dies ermöglicht beispielsweise die Durchführung der Prüfung und schafft eine Atmosphäre der Dezentralisierung, in der es keine bestimmten Stellen gibt, die das Projekt kontrollieren.

Es macht keinen Sinn, auf einen signifikanten Beitrag externer Entwickler zum Open Source-Projekt zu warten, wenn dieses Projekt keine Qualitätstests enthält. Externe Projektteilnehmer benötigen Mechanismen, um schnell die Kompatibilität ihrer Texte mit den bereits zum Projekt hinzugefügten Informationen zu überprüfen. Tatsächlich sollte der gesamte Testsatz nach Eingang jeder Anforderung zum Hinzufügen neuen Codes zum Projekt automatisch durchgeführt werden. Wenn etwas, das mittels einer solchen Anfrage zum Projekt hinzugefügt werden soll, etwas kaputt macht, sollte der Test dies sofort melden.

Die vollständige Abdeckung der Codebasis mit Tests ist eine trügerische, aber wichtige Metrik. Das Ziel, eine 100% ige Codeabdeckung mit Tests zu erreichen, mag übertrieben erscheinen. Wenn Sie jedoch darüber nachdenken, stellt sich heraus, dass ein Teil des Codes ohne Überprüfung an die Produktion gesendet wird, wenn der Code nicht vollständig durch Tests abgedeckt ist, was noch nie zuvor ausgeführt wurde.

Die vollständige Abdeckung des Codes mit Tests bedeutet nicht unbedingt, dass das Projekt genügend Tests enthält, und bedeutet nicht, dass es sich um Tests handelt, die absolut alle Optionen für die Verwendung des Codes bieten. Mit Sicherheit können wir nur sagen, dass der Entwickler sich der absoluten Zuverlässigkeit des Codes nicht sicher sein kann, wenn das Projekt nicht zu 100% in Tests behandelt wird, da einige Teile des Codes niemals getestet werden.

Trotzdem gibt es Situationen, in denen zu viele Tests durchgeführt werden. Im Idealfall sollte jeder mögliche Fehler zum Scheitern eines Tests führen. Wenn die Anzahl der Tests zu hoch ist, dh verschiedene Tests dieselben Codefragmente prüfen, führt das Ändern des vorhandenen Codes und das Ändern des vorhandenen Systemverhaltens dazu, dass die Verarbeitung der vorhandenen Tests zu dem neuen Code zu lange dauert .

Warum ist Go eine gute Wahl für hochzuverlässige Projekte?


Go ist eine statisch typisierte Sprache. Typen sind ein Vertrag zwischen verschiedenen Codeteilen, die zusammen ausgeführt werden. Ohne automatische Typprüfung während des Projektzusammenstellungsprozesses müssten wir Tests implementieren, die diese „Verträge“ selbst überprüfen, wenn Sie strenge Regeln für das Abdecken von Code mit Tests einhalten müssen. Dies geschieht beispielsweise in Server- und Clientprojekten, die auf JavaScript basieren. Das Schreiben komplexer Tests, die nur auf die Überprüfung von Typen abzielen, bedeutet viel zusätzlichen Aufwand, der im Fall von Go vermieden werden kann.

Go ist eine einfache und dogmatische Sprache. Wie Sie wissen, enthält Go viele traditionelle Ideen für Programmiersprachen, wie beispielsweise die klassische OOP-Vererbung. Komplexität ist der schlimmste Feind zuverlässigen Codes. Probleme neigen dazu, sich an den Fugen komplexer Strukturen zu verstecken. Dies drückt sich in der Tatsache aus, dass typische Optionen für die Verwendung eines bestimmten Designs zwar leicht zu testen sind, es jedoch bizarre Grenzfälle gibt, über die der Testentwickler möglicherweise nicht einmal nachdenkt. Das Projekt wird am Ende nur einen dieser Fälle zum Erliegen bringen. In diesem Sinne ist auch Dogmatismus nützlich. In Go gibt es oft nur einen Weg, eine Aktion auszuführen. Dies mag wie ein Faktor erscheinen, der den freien Geist des Programmierers zurückhält, aber wenn etwas nur auf eine Weise getan werden kann, ist es schwierig, etwas falsch zu machen.

Go ist prägnant, aber ausdrucksstark. Lesbarer Code ist einfacher zu analysieren und zu prüfen. Wenn der Code zu ausführlich ist, kann sein Hauptzweck im "Rauschen" von Hilfskonstruktionen ertrinken. Wenn der Code zu präzise ist, können die darauf enthaltenen Programme schwer zu lesen und zu verstehen sein. Go hält ein Gleichgewicht zwischen Prägnanz und Ausdruckskraft. Zum Beispiel enthält es nicht viele Hilfskonstrukte, wie in Sprachen wie Java oder C ++. Gleichzeitig sind Go-Konstruktionen, die sich beispielsweise auf Bereiche wie die Fehlerbehandlung beziehen, sehr klar und sehr detailliert, was die Arbeit des Programmierers vereinfacht und ihm hilft, beispielsweise sicherzustellen, dass er alles überprüft hat, was möglich ist.

Go verfügt über eindeutige Fehlerbehandlungs- und Wiederherstellungsmechanismen nach Abstürzen. Gut abgestimmte Mechanismen zur Behandlung von Laufzeitfehlern sind der Grundstein für hochzuverlässigen Code. Go hat strenge Regeln für die Rückgabe und Verteilung von Fehlern. In Umgebungen wie Node.js führt das Verwechseln von Ansätzen zur Steuerung des Programmflusses wie Rückrufen, Versprechungen und asynchronen Funktionen häufig zu unbehandelten Fehlern, z. B. einer unbehandelten Ablehnung eines Versprechens . Das Wiederherstellen des Programms nach ähnlichen Ereignissen ist fast unmöglich .

Go verfügt über eine umfangreiche Standardbibliothek. Abhängigkeiten sind ein Risiko, insbesondere wenn ihre Quelle Projekte sind, bei denen der Zuverlässigkeit des Codes nicht genügend Aufmerksamkeit geschenkt wird. Eine Serveranwendung, die in Produktion geht, enthält alle Abhängigkeiten. Wenn etwas schief geht, ist der Entwickler der fertigen Anwendung dafür verantwortlich und nicht derjenige, der eine der von ihm verwendeten Bibliotheken erstellt hat. In Umgebungen, in denen Projekte geschrieben wurden, für die kleine Abhängigkeiten bestehen, ist es daher schwieriger, zuverlässige Anwendungen zu erstellen.

Abhängigkeiten stellen auch ein Sicherheitsrisiko dar, da die Schwachstellenstufe eines Projekts der Schwachstellenstufe seiner unsichersten Abhängigkeit entspricht . Die umfangreiche Standardbibliothek Go wird von ihren Entwicklern in einem sehr guten Zustand gehalten, ihre Existenz reduziert den Bedarf an externen Abhängigkeiten.

Hohe Entwicklungsgeschwindigkeit. Ein wesentliches Merkmal von Umgebungen wie Node.js ist der extrem kurze Entwicklungszyklus. Das Schreiben von Code nimmt weniger Zeit in Anspruch, wodurch der Programmierer produktiver wird.

Go hat auch eine hohe Entwicklungsgeschwindigkeit. Eine Reihe von Tools zum Erstellen von Projekten ist schnell genug, um den Code sofort in Aktion anzeigen zu können. Die Kompilierungszeit ist extrem kurz. Daher wird das Ausführen von Code auf Go so wahrgenommen, als ob er nicht kompiliert, sondern interpretiert wurde. Darüber hinaus verfügt die Sprache über genügend Abstraktionen, z. B. ein Garbage Collection-System, mit dem Entwickler die Bemühungen zur Implementierung der Funktionalität ihres Projekts lenken und keine Hilfsaufgaben lösen können.

Praktisches Experiment


Nachdem wir genug allgemeine Punkte geäußert haben, ist es Zeit, einen Blick auf den Code zu werfen. Wir brauchen ein Beispiel, das einfach genug ist, damit wir uns während des Studiums auf die Entwicklungsmethodik konzentrieren können, aber gleichzeitig sollte es so weit fortgeschritten sein, dass wir bei der Erforschung etwas zu besprechen haben. Ich entschied, dass es am einfachsten wäre, etwas von dem zu nehmen, was ich täglich mache. Daher schlage ich vor, die Erstellung eines Servers zu analysieren, der etwas verarbeitet, das Finanztransaktionen ähnelt. Benutzer dieses Servers können den mit ihren Konten verknüpften Kontostand überprüfen. Darüber hinaus können sie Geld von einem Konto auf ein anderes überweisen.

Wir werden versuchen, dieses Beispiel nicht zu komplizieren. Unser System wird einen Server haben. Wir werden keine Authentifizierungs- und Kryptografiesysteme kontaktieren. Dies sind integrale Bestandteile von Arbeitsprojekten. Wir müssen uns jedoch auf den Kern eines solchen Projekts konzentrieren, um zu zeigen, wie es so zuverlässig wie möglich gemacht werden kann.

▍Aufteilen eines komplexen Projekts in Teile, die bequem zu verwalten sind


Komplexität ist der schlimmste Feind der Zuverlässigkeit. Einer der besten Ansätze bei der Arbeit mit komplexen Systemen ist die Anwendung des seit langem bekannten Prinzips "Teilen und Erobern". Die Aufgabe muss in kleine Unteraufgaben unterteilt und einzeln gelöst werden. Welche Seite soll sich der Aufteilung unserer Aufgabe nähern? Wir werden dem Prinzip der geteilten Verantwortung folgen. Jeder Teil unseres Projekts sollte seinen eigenen Verantwortungsbereich haben.

Diese Idee passt perfekt zur beliebten Microservice- Architektur. Unser Server wird aus separaten Diensten bestehen. Jeder Dienst verfügt über einen klar definierten Verantwortungsbereich und eine klar beschriebene Schnittstelle für die Interaktion mit anderen Diensten.

Nachdem wir den Server auf diese Weise strukturiert haben, können wir Entscheidungen darüber treffen, wie die einzelnen Dienste funktionieren sollen. Alle Dienste können zusammen im selben Prozess ausgeführt werden. Von jedem von ihnen können Sie einen separaten Server erstellen und ihre Interaktion mithilfe von RPC herstellen. Sie können die Dienste trennen und jeden von ihnen auf einem separaten Computer ausführen.

Wir werden die Aufgabe nicht erneut verkomplizieren, sondern die einfachste Option wählen. Alle Dienste werden nämlich im selben Prozess ausgeführt, sie tauschen Informationen wie Bibliotheken direkt aus. Bei Bedarf kann diese Architekturlösung in Zukunft leicht überprüft und geändert werden.

Welche Dienstleistungen brauchen wir also? Unser Server ist vielleicht zu einfach, um ihn in Teile zu unterteilen, aber zu Bildungszwecken werden wir ihn dennoch aufteilen. Wir müssen auf Client-HTTP-Anfragen antworten, um Salden zu überprüfen und Transaktionen auszuführen. Einer der Dienste kann mit einer HTTP-Schnittstelle für Clients arbeiten. Nennen PublicApi es PublicApi . Ein anderer Dienst besitzt Informationen über den Zustand des Systems - die Bilanz. Nennen StateStorage es StateStorage . Der dritte Dienst wird die beiden oben beschriebenen kombinieren und die Logik von „Verträgen“ implementieren, die darauf abzielen, die Salden zu ändern. Die Aufgabe des dritten Dienstes wird die Ausführung von Verträgen sein. Nennen VirtualMachine es VirtualMachine .


Anwendungsserver-Architektur

Platzieren Sie den Code dieser Dienste in den Projektordnern /services/publicapi , /services/virtualmachine und /services/statestorage .

▍ Klare Definition der Serviceverantwortlichkeiten


Während der Implementierung von Diensten möchten wir in der Lage sein, mit jedem von ihnen individuell zu arbeiten. Es ist sogar möglich, die Entwicklung dieser Dienste auf verschiedene Programmierer aufzuteilen. Da die Dienste voneinander abhängig sind und wir ihre Entwicklung parallelisieren möchten, müssen wir mit einer klaren Definition der Schnittstellen arbeiten, über die sie miteinander interagieren. Mithilfe dieser Schnittstellen können wir Dienste autonom testen, indem wir Stubs für alles vorbereiten, was sich außerhalb der einzelnen Schnittstellen befindet.

Wie beschreibe ich die Schnittstelle? Eine der Optionen besteht darin, alles zu dokumentieren, aber die Dokumentation hat die Eigenschaft, veraltet zu werden. Während der Arbeit an einem Projekt häufen sich Unterschiede zwischen der Dokumentation und dem Code. Darüber hinaus können wir Go-Schnittstellendeklarationen verwenden. Dies ist eine interessante Option, aber es ist besser, die Schnittstelle zu beschreiben, damit diese Beschreibung nicht von einer bestimmten Programmiersprache abhängt. Dies ist für uns in einer sehr realen Situation nützlich, wenn bei der Arbeit an einem Projekt beschlossen wird, einige seiner Dienste in anderen Sprachen zu implementieren, deren Fähigkeiten besser zur Lösung ihrer Probleme geeignet sind.

Eine Möglichkeit zur Beschreibung von Schnittstellen ist die Verwendung von Protobuf . Dies ist ein einfaches sprach- und sprachunabhängiges Protokoll zur Beschreibung von Nachrichten und Dienstendpunkten.

Beginnen wir mit der Schnittstelle für den StateStorage Dienst. Wir werden den Status der Anwendung in Form einer Schlüsselwertansichtsstruktur darstellen. Hier ist der Code für die Datei 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; } 

Obwohl Clients HTTP über den PublicApi Dienst verwenden, beeinträchtigt es auch nicht die übersichtliche Schnittstelle, die auf die gleiche Weise wie oben beschrieben wurde (die Datei 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; } 

Nun müssen wir die Transaction und Address ( transactions.proto Datei) beschreiben:

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

Im Projekt werden Protobeschreibungen für Dienste im Ordner /types/services und Beschreibungen allgemeiner Datenstrukturen im Ordner /types/protocol abgelegt.

Sobald die Schnittstellenbeschreibungen fertig sind, können sie in Go-Code kompiliert werden.

Die Vorteile dieses Ansatzes bestehen darin, dass Code, der nicht mit der Schnittstellenbeschreibung übereinstimmt, einfach nicht in den Kompilierungsergebnissen angezeigt wird. Die Verwendung alternativer Methoden würde erfordern, dass wir spezielle Tests schreiben, um zu überprüfen, ob der Code mit den Schnittstellenbeschreibungen übereinstimmt.

Vollständige Definitionen, generierte Go-Dateien und Kompilierungsanweisungen finden Sie hier . Dies ist dank Square Engineering und der Entwicklung von Goprotowrap möglich .

Bitte beachten Sie, dass in unserem Projekt die Transportschicht-RPC nicht implementiert ist und der Datenaustausch zwischen Diensten wie normale Bibliotheksaufrufe aussieht. Wenn wir bereit sind, Dienste auf verschiedenen Servern zu verteilen, können wir dem System eine Transportschicht wie gRPC hinzufügen.

▍ Arten von Tests, die im Projekt verwendet werden


Da Tests der Schlüssel zu hochzuverlässigem Code sind, empfehle ich, zunächst darüber zu sprechen, welche Tests wir für unser Projekt schreiben werden.

Unit-Tests


Unit-Tests bilden den Kern der Testpyramide . Wir werden jedes Modul einzeln testen. Was ist ein Modul? In Go können wir Module als separate Dateien in einem Paket wahrnehmen. Wenn wir beispielsweise die Datei /services/publicapi/handlers.go , platzieren wir den /services/publicapi/handlers.go im selben Paket unter /services/publicapi/handlers_test.go .

Es ist am besten, Komponententests im selben Paket wie den Testcode zu platzieren, damit die Tests auf nicht exportierte Variablen und Funktionen zugreifen können.

Servicetests


Die folgende Art von Test ist unter verschiedenen Namen bekannt. Dies sind die sogenannten Service-, Integrations- oder Komponententests. Ihre Essenz besteht darin, mehrere Module zu belegen und ihre gemeinsame Arbeit zu testen. Diese Tests sind eine Stufe höher als die Einheitentests in der Testpyramide. In unserem Fall verwenden wir Integrationstests, um den gesamten Service zu testen. Diese Tests bestimmen die Spezifikationen für den Service. Beispielsweise werden Tests für den StateStorage Dienst im Ordner /services/statestorage/spec .

Es ist am besten, diese Tests in einem Paket zu platzieren, das sich von dem unterscheidet, in dem sich der getestete Code befindet, damit der Zugriff auf die Funktionen dieses Codes nur über exportierte Schnittstellen erfolgt.

End-to-End-Tests


Diese Tests stehen ganz oben in der Testpyramide und helfen dabei, das gesamte System und alle seine Dienste zu überprüfen. Solche Tests beschreiben die End-to-End-e2e-Spezifikation für das System, daher werden sie im Ordner /e2e/spec .

End-to-End-Tests sowie Servicetests müssen in einem anderen Paket als dem, in dem sich der getestete Code befindet, abgelegt werden, damit das System nur über exportierte Schnittstellen betrieben werden kann.

Welche Tests sollten zuerst geschrieben werden? Beginnen Sie mit dem Fundament der "Pyramide" und bewegen Sie sich nach oben? Oder oben anfangen und runter gehen? Jeder dieser Ansätze hat das Recht auf Leben. Die Vorteile eines Top-Down-Ansatzes liegen darin, dass zuerst die Spezifikation für das gesamte System erstellt wird. Es ist normalerweise am einfachsten, zu Beginn der Arbeit über die Merkmale des Gesamtsystems zu diskutieren. Auch wenn wir das System falsch in separate Dienste aufteilen, bleiben die Systemspezifikationen unverändert. Dies hilft uns außerdem zu verstehen, dass etwas auf einer niedrigeren Ebene falsch gemacht wird.

Das Minus des Top-Down-Ansatzes ist, dass End-to-End-Tests diejenigen Tests sind, die nach allen anderen verwendet werden, wenn das gesamte zu entwickelnde System erstellt wird. Dies bedeutet, dass sie lange Zeit Fehler erzeugen. Wenn wir Tests für unser Projekt schreiben, werden wir genau diesen Ansatz verwenden.

EstTestentwicklung


End-to-End-Testentwicklung


Bevor wir Tests erstellen, müssen wir entscheiden, ob wir sie schreiben, ohne zusätzliche Tools oder ein Framework zu verwenden. Sich auf das Framework zu verlassen und es als Entwicklungsabhängigkeit zu verwenden, ist weniger gefährlich als sich auf das Framework im Code zu verlassen, der in die Produktion kommt. In unserem Fall wählen wir eine Arbeitsoption, die die Verwendung eines Frameworks umfasst, da die Standard-Go-Bibliothek keine angemessene BDD- Unterstützung bietet und dieses Format sich hervorragend zur Beschreibung von Spezifikationen eignet.

Es gibt viele großartige Frameworks, die das geben, was wir brauchen. Unter ihnen sind GoConvey und Ginkgo .

Persönlich verwende ich gerne eine Kombination aus Ginkgo und Gomega (schreckliche Namen, aber was zu tun ist), die syntaktische Konstrukte wie Describe() und It() .

Wie werden unsere Tests aussehen? Hier ist zum Beispiel ein Test für den Mechanismus zur Überprüfung des Benutzerguthaben (Datei 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")) }) }) 

Da der Server von außen über HTTP zugänglich ist, werden wir mit seiner Web-API über http.Get arbeiten . Was ist mit Transaktionstests? Hier ist der Code für den entsprechenden Test:

 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")) }) 

Der Testcode beschreibt ihre Essenz perfekt und kann sogar die Dokumentation ersetzen. Wie Sie sehen können, geben wir das Vorhandensein negativer Benutzerkontostände zu. Dies ist ein Merkmal unseres Projekts. Wenn es verboten wäre, würde sich diese Entscheidung im Test widerspiegeln.

Hier ist der vollständige Testcode

Service Test Entwicklung


Nachdem wir End-to-End-Tests entwickelt haben, gehen wir die Testpyramide entlang und erstellen Servicetests. Solche Tests werden für jeden einzelnen Dienst entwickelt. Wir wählen einen Dienst, der von einem anderen Dienst abhängig ist, da dieser Fall interessanter ist als die Entwicklung von Tests für einen unabhängigen Dienst.

Beginnen wir mit dem VirtualMachine Dienst. Hier finden Sie die Schnittstelle mit Protobeschreibungen für diesen Dienst. Da der VirtualMachine Dienst auf dem StateStorage Dienst StateStorage und ihn aufruft, müssen wir ein StateStorage für den StateStorage Dienst erstellen, um den VirtualMachine Dienst isoliert zu testen. Mit dem Stub-Objekt können wir die StateStorage Antworten während des Tests steuern.

Wie implementiere ich ein Stub-Objekt in Go? Dies kann ausschließlich über die Sprache ohne Hilfsmittel erfolgen, oder Sie können auf die entsprechende Bibliothek zurückgreifen, die es zusätzlich ermöglicht, mit den Anweisungen im Testprozess zu arbeiten. Zu diesem Zweck bevorzuge ich die Go-Mock- Bibliothek.

Wir werden den Stub-Code in die Datei /services/statestorage/mock.go . Es ist am besten, Stub-Objekte an derselben Stelle wie die von ihnen nachgeahmten Entitäten zu platzieren, um ihnen Zugriff auf nicht exportierte Variablen und Funktionen zu gewähren. Der Stub in dieser Phase ist eine schematische Implementierung des Dienstes, aber während sich der Dienst entwickelt, müssen wir möglicherweise die Implementierung des Stubs entwickeln. Hier ist der Code für das Stub-Objekt ( mock.go Datei):

 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) } 

Wenn Sie die Entwicklung einzelner Dienste an verschiedene Programmierer weitergeben, ist es sinnvoll, zuerst Stubs zu erstellen und diese an das Team weiterzugeben.

VirtualMachine wir zurück zur Entwicklung eines Servicetests für VirtualMachine . Welches Szenario soll ich hier überprüfen? Am besten konzentrieren Sie sich auf die Serviceschnittstelle und die Designtests für jeden Endpunkt. Wir implementieren einen Test für den CallContract() mit einem Argument, das die "GetBalance" Methode darstellt. Hier ist der entsprechende Code ( contracts.go Datei):

 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()) }) }) 

Beachten Sie, dass der von uns getestete Dienst StateStorage in der Start() -Methode über einen einfachen Abhängigkeitsinjektionsmechanismus einen Zeiger auf seine Abhängigkeit VirtualMachine erhält. Hier übergeben wir die Instanz des Stub-Objekts. stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key… die Zeile stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key… , in der wir dem Stub-Objekt mitteilen, wie es sich beim Zugriff verhalten soll. Wenn die ReadKey Methode ReadKey , sollte sie einen Wert zurückgeben 100. Dann überprüfen wir in der Zeile Expect(stateStorage).To(ExecuteAsPlanned()) , ob dieser Befehl genau einmal aufgerufen wird.

Ähnliche Tests werden zu Spezifikationen für den Dienst. Die vollständigen Tests für den VirtualMachine Dienst finden Sie hier . Testsuiten für andere Dienstleistungen unseres Projekts finden Sie hier und hier .

Unit Test Entwicklung


Möglicherweise ist die Implementierung des Vertrags für die "GetBalance" Methode zu einfach. "GetBalance" über die Implementierung einer etwas komplexeren "Transfer" sprechen. Der Vertrag für die Überweisung von Geldern von einem Konto auf ein anderes, dargestellt durch diese Methode, muss Daten über die Guthaben des Absenders und Empfängers von Geldern lesen, neue Guthaben berechnen und aufzeichnen, was im Antragsstatus passiert ist. Der Servicetest für all dies ist dem gerade implementierten sehr ähnlich ( transactions.go Datei):

 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()) }) 

Während der Arbeit an dem Projekt können wir endlich seine internen Mechanismen erstellen und ein Modul in der Datei processor.go erstellen, das die Implementierung des Vertrags enthält. Hier ist die Originalversion ( processor.go Datei):

 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 } 

Dieses Design erfüllt den Servicetest, in unserem Fall enthält der Integrationstest jedoch nur einen Test des Basisszenarios. Was ist mit Grenzfällen und möglichen Fehlern? Wie Sie sehen, kann jeder StateStorage von StateStorage fehlschlagen. Wenn eine 100% ige Abdeckung des Codes mit Tests erforderlich ist, müssen alle diese Situationen überprüft werden. Der Unit-Test eignet sich hervorragend zur Durchführung solcher Tests.

Da wir die Funktion mehrmals mit unterschiedlichen Eingabedaten aufrufen und die Parameter zum Erreichen aller Zweige des Codes simulieren werden, können wir auf tabellenbasierte Tests zurückgreifen, um diesen Prozess effizienter zu gestalten. Go neigt dazu, exotische Unit-Test-Frameworks zu vermeiden. Wir können Ginkgo ablehnen, aber wahrscheinlich sollten wir Gomega verlassen. Infolgedessen ähneln die hier durchgeführten Überprüfungen denen, die wir in früheren Tests durchgeführt haben. Hier ist der Testcode (Datei 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 

Was ist passiert? 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 . Wie ist das möglich? , 100%?! , — . processTransfer() , , .

. , , . .

Zusammenfassung


, , , -, , , ? ? — .

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

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

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

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

Liebe Leser! ?

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


All Articles