Die Wahl einer Tech-Stack-Alternative - Die Höhen und Tiefen

Veröffentlicht: 2022-03-11

Wenn eine Webanwendung groß und alt genug ist, kann der Zeitpunkt kommen, an dem Sie sie in kleinere, isolierte Teile zerlegen und Dienste daraus extrahieren müssen, von denen einige unabhängiger sind als andere. Einige der Gründe, die zu einer solchen Entscheidung führen könnten, sind: Verkürzung der Zeit für die Durchführung von Tests, die Möglichkeit, verschiedene Teile der App unabhängig voneinander bereitzustellen oder die Durchsetzung von Grenzen zwischen Subsystemen. Die Serviceextraktion erfordert von Softwareingenieuren viele wichtige Entscheidungen, und eine davon ist, welcher Tech-Stack für den neuen Service verwendet werden soll.

In diesem Beitrag teilen wir eine Geschichte über das Extrahieren eines neuen Dienstes aus einer monolithischen Anwendung – der Toptal-Plattform . Wir erklären, welchen technischen Stack wir ausgewählt haben und warum, und skizzieren einige Probleme, auf die wir während der Service-Implementierung gestoßen sind.

Der Chronicles-Dienst von Toptal ist eine App, die alle Benutzeraktionen verarbeitet, die auf der Toptal-Plattform ausgeführt werden. Aktionen sind im Wesentlichen Protokolleinträge. Wenn ein Benutzer etwas tut (z. B. einen Blogbeitrag veröffentlicht, einen Job genehmigt usw.), wird ein neuer Protokolleintrag erstellt.

Obwohl es aus unserer Plattform extrahiert wurde, ist es grundsätzlich nicht davon abhängig und kann mit jeder anderen App verwendet werden. Aus diesem Grund veröffentlichen wir einen detaillierten Bericht über den Prozess und diskutieren eine Reihe von Herausforderungen, die unser Engineering-Team beim Übergang zum neuen Stack bewältigen musste.

Es gibt eine Reihe von Gründen für unsere Entscheidung, den Dienst zu extrahieren und den Stack zu verbessern:

  • Wir wollten, dass andere Dienste Ereignisse protokollieren können, die an anderer Stelle angezeigt und verwendet werden können.
  • Die Größe der Datenbanktabellen, in denen Verlaufsaufzeichnungen gespeichert sind, wuchs schnell und nicht linear, was zu hohen Betriebskosten führte.
  • Wir waren der Ansicht, dass die bestehende Implementierung durch technische Schulden belastet war.

Aktionstabelle - Datenbanktabellen

Auf den ersten Blick schien es eine einfache Initiative zu sein. Der Umgang mit alternativen Tech-Stacks führt jedoch tendenziell zu unerwarteten Nachteilen, und darauf zielt der heutige Artikel ab.

Architekturübersicht

Die Chronicles-App besteht aus drei Teilen, die mehr oder weniger unabhängig voneinander sein können und in separaten Docker-Containern ausgeführt werden.

  • Kafka-Verbraucher ist ein sehr dünner Karafka-basierter Kafka-Verbraucher von Nachrichten zur Erstellung von Einträgen. Es stellt alle empfangenen Nachrichten in Sidekiq ein.
  • Sidekiq-Worker ist ein Worker, der Kafka-Nachrichten verarbeitet und Einträge in der Datenbanktabelle erstellt.
  • GraphQL-Endpunkte:
    • Der öffentliche Endpunkt stellt die API für die Eintragssuche bereit, die für verschiedene Plattformfunktionen verwendet wird (z. B. zum Rendern von Kommentar-Tooltips auf Screening-Schaltflächen oder zum Anzeigen des Verlaufs von Jobänderungen).
    • Der interne Endpunkt bietet die Möglichkeit, Tag-Regeln und Vorlagen aus Datenmigrationen zu erstellen.

Chroniken verwendet, um eine Verbindung zu zwei verschiedenen Datenbanken herzustellen:

  • Eine eigene Datenbank (in der wir Tag-Regeln und Vorlagen speichern)
  • Die Plattformdatenbank (wo wir von Benutzern durchgeführte Aktionen und ihre Tags und Taggings speichern)

Beim Extrahieren der App haben wir Daten aus der Plattformdatenbank migriert und die Plattformverbindung beendet.

Ursprünglicher Plan

Anfangs entschieden wir uns für Hanami und das gesamte Ökosystem, das es standardmäßig bereitstellt (ein Hanami-Modell, das von ROM.rb, dry-rb, hanami-newrelic usw. unterstützt wird). Das Befolgen einer „Standard“-Vorgehensweise versprach uns eine geringe Reibung, eine hervorragende Implementierungsgeschwindigkeit und eine sehr gute „Googlebarkeit“ aller Probleme, mit denen wir möglicherweise konfrontiert sind. Darüber hinaus ist das Hanami-Ökosystem ausgereift und beliebt, und die Bibliothek wird von angesehenen Mitgliedern der Ruby-Community sorgfältig gepflegt.

Darüber hinaus war ein großer Teil des Systems bereits auf der Plattformseite implementiert (z. B. GraphQL Entry Search-Endpunkt und CreateEntry-Operation), sodass wir planten, einen Großteil des Codes unverändert von Platform nach Chronicles zu kopieren, ohne Änderungen vorzunehmen. Dies war auch einer der Hauptgründe, warum wir uns nicht für Elixir entschieden haben, da Elixir das nicht zulassen würde.

Wir entschieden uns, Rails nicht zu machen, weil es sich für ein so kleines Projekt wie ein Overkill anfühlte, besonders Dinge wie ActiveSupport, die nicht viele greifbare Vorteile für unsere Bedürfnisse bieten würden.

Wenn der Plan nach Süden geht

Obwohl wir unser Bestes taten, um an dem Plan festzuhalten, geriet er bald aus mehreren Gründen ins Wanken. Einer war unser Mangel an Erfahrung mit dem gewählten Stack, gefolgt von echten Problemen mit dem Stack selbst, und dann war da noch unser nicht standardmäßiges Setup (zwei Datenbanken). Am Ende haben wir uns entschieden, das hanami-model und dann Hanami selbst loszuwerden und es durch Sinatra zu ersetzen.

Wir haben uns für Sinatra entschieden, weil es sich um eine aktiv gepflegte Bibliothek handelt, die vor 12 Jahren erstellt wurde, und da es sich um eine der beliebtesten Bibliotheken handelt, hatte jeder im Team reichlich praktische Erfahrung damit.

Inkompatible Abhängigkeiten

Die Chronicles-Extraktion begann im Juni 2019, und damals war Hanami nicht mit den neuesten Versionen von Dry-Rb-Edelsteinen kompatibel. Die damals neueste Version von Hanami (1.3.1) unterstützte nämlich nur die Trockenvalidierung 0.12, und wir wollten die Trockenvalidierung 1.0.0. Wir planten, Verträge aus der Trockenvalidierung zu verwenden, die erst in 1.0.0 eingeführt wurden.

Außerdem ist Kafka 1.2 nicht mit Dry Gems kompatibel, daher haben wir die Repository-Version davon verwendet. Derzeit verwenden wir 1.3.0.rc1, das auf den neuesten Dry Gems basiert.

Unnötige Abhängigkeiten

Darüber hinaus enthielt das Hanami-Gem zu viele Abhängigkeiten, die wir nicht verwenden wollten, wie z. B. hanami-cli , hanami-assets , hanami-mailer , hanami-view und sogar hanami-controller . Außerdem wurde bei einem Blick in die Readme-Datei des Hanami-Modells klar, dass es standardmäßig nur eine Datenbank unterstützt. Andererseits unterstützt ROM.rb, auf dem das hanami-model basiert, Multi-Datenbank-Konfigurationen von Haus aus.

Alles in allem wirkte Hanami im Allgemeinen und das hanami-model im Besonderen wie eine unnötige Abstraktionsebene.

Zehn Tage nachdem wir die erste sinnvolle PR für Chronicles gemacht hatten, ersetzten wir Hanami komplett durch Sinatra. Wir hätten auch reines Rack verwenden können, da wir kein komplexes Routing benötigen (wir haben vier „statische“ Endpunkte – zwei GraphQL-Endpunkte, den /ping-Endpunkt und die Sidekiq-Webschnittstelle), aber wir entschieden uns, nicht zu hart zu werden. Sinatra hat uns gut gefallen. Wenn Sie mehr erfahren möchten, sehen Sie sich unser Sinatra- und Sequel-Tutorial an.

Missverständnisse bei Trockenschema und Trockenvalidierung

Wir brauchten einige Zeit und viel Trial-and-Error, um herauszufinden, wie man die Trockenvalidierung richtig „kocht“.

 params do required(:url).filled(:string) end params do required(:url).value(:string) end params do optional(:url).value(:string?) end params do optional(:url).filled(Types::String) end params do optional(:url).filled(Types::Coercible::String) end

Im obigen Snippet wird der url Parameter auf mehrere leicht unterschiedliche Arten definiert. Einige Definitionen sind gleichwertig, andere ergeben keinen Sinn. Am Anfang konnten wir den Unterschied zwischen all diesen Definitionen nicht wirklich erkennen, da wir sie nicht vollständig verstanden haben. Infolgedessen war die erste Version unserer Verträge ziemlich chaotisch. Mit der Zeit haben wir gelernt, DRY-Verträge richtig zu lesen und zu schreiben, und jetzt sehen sie einheitlich und elegant aus – tatsächlich sind sie nicht nur elegant, sie sind geradezu schön. Wir validieren sogar die Anwendungskonfiguration mit den Verträgen.

Probleme mit ROM.rb und Sequel

ROM.rb und Sequel unterscheiden sich von ActiveRecord, keine Überraschung. Unsere anfängliche Idee, dass wir den größten Teil des Codes von Platform kopieren und einfügen können, schlug fehl. Das Problem ist, dass der Platform-Teil sehr AR-lastig war, sodass fast alles in ROM/Sequel neu geschrieben werden musste. Wir haben es geschafft, nur kleine Teile des Codes zu kopieren, die Framework-unabhängig waren. Auf dem Weg dorthin waren wir mit einigen frustrierenden Problemen und einigen Fehlern konfrontiert.

Filtern nach Unterabfrage

Ich habe zum Beispiel mehrere Stunden gebraucht, um herauszufinden, wie man eine Unterabfrage in ROM.rb/Sequel macht. Dies ist etwas, das ich schreiben würde, ohne in Rails aufzuwachen: scope.where(sequence_code: subquery ). In Sequel stellte sich jedoch heraus, dass dies nicht so einfach war.

 def apply_subquery_filter(base_query, params) subquery = as_subquery(build_subquery(params)) base_query.where { Sequel.lit('sequence_code IN ?', subquery) } end # This is a fixed version of https://github.com/rom-rb/rom-sql/blob/6fa344d7022b5cc9ad8e0d026448a32ca5b37f12/lib/rom/sql/relation/reading.rb#L998 # The original version has `unorder` on the subquery. # The fix was merged: https://github.com/rom-rb/rom-sql/pull/342. def as_subquery(relation) attr = relation.schema.to_a[0] subquery = relation.schema.project(attr).call(relation).dataset ROM::SQL::Attribute[attr.type].meta(sql_expr: subquery) end

Anstelle eines einfachen Einzeilers wie base_query.where(sequence_code: bild_subquery(params)) müssen wir also ein Dutzend Zeilen mit nicht trivialem Code, rohen SQL-Fragmenten und einem mehrzeiligen Kommentar haben, der erklärt, was diesen unglücklichen Fall verursacht hat aufblähen.

Assoziationen mit nicht-trivialen Join-Feldern

Die entry (Tabelle performed_actions ) hat ein primäres id -Feld. Um jedoch mit *taggings Tabellen zu verknüpfen, verwendet es die sequence_code Spalte. In ActiveRecord wird es ziemlich einfach ausgedrückt:

 class PerformedAction < ApplicationRecord has_many :feed_taggings, class_name: 'PerformedActionFeedTagging', foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code', end class PerformedActionFeedTagging < ApplicationRecord db_belongs_to :performed_action, foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code' end

Es ist auch möglich, dasselbe in ROM zu schreiben.

 module Chronicles::Persistence::Relations::Entries < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_actions, as: :entries) do attribute :id, ROM::Types::Integer attribute :sequence_code, ::Types::UUID primary_key :id associations do has_many :access_taggings, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code end end end module Chronicles::Persistence::Relations::AccessTaggings < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_action_access_taggings, as: :access_taggings, infer: false) do attribute :performed_action_sequence_code, ::Types::UUID associations do belongs_to :entry, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code, null: false end end end

Es gab jedoch ein kleines Problem damit. Es würde gut kompilieren, aber zur Laufzeit fehlschlagen, wenn Sie tatsächlich versuchten, es zu verwenden.

 [4] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings).limit(1).to_a E, [2019-09-05T15:54:16.706292 #20153] ERROR -- : PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform... ^ HINT: No operator matches the given name and argument types. You might need to add explicit type casts.: SELECT <..snip..> FROM "performed_actions" INNER JOIN "performed_action_access_taggings" ON ("performed_actions"."id" = "performed_action_access_taggings"."performed_action_sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform...

Wir haben Glück, dass die Typen von id und sequence_code unterschiedlich sind, also wirft PG einen Typfehler aus. Wenn die Typen gleich wären, wer weiß, wie viele Stunden ich damit verbringen würde, dies zu debuggen.

entries.join(:access_taggings) funktioniert also nicht. Was ist, wenn wir die Join-Bedingung explizit angeben? Wie in entries.join(:access_taggings, performed_action_sequence_code: :sequence_code) , wie die offizielle Dokumentation vorschlägt.

 [8] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a E, [2019-09-05T16:02:16.952972 #20153] ERROR -- : PG::UndefinedTable: ERROR: relation "access_taggings" does not exist LINE 1: ...."updated_at" FROM "performed_actions" INNER JOIN "access_ta... ^: SELECT <snip> FROM "performed_actions" INNER JOIN "access_taggings" ON ("access_taggings"."performed_action_sequence_code" = "performed_actions"."sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedTable: ERROR: relation "access_taggings" does not exist

Jetzt denkt es, dass :access_taggings aus irgendeinem Grund ein Tabellenname ist. Gut, tauschen wir es gegen den tatsächlichen Tabellennamen aus.

 [10] pry(main)> data = Chronicles::Persistence.relations[:platform][:entries].join(:performed_action_access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a => [#<Chronicles::Entities::Entry id=22 subject_g ... updated_at=2012-05-10 08:46:43 UTC>]

Schließlich gab es etwas zurück und schlug nicht fehl, obwohl es mit einer undichten Abstraktion endete. Der Tabellenname sollte nicht an den Anwendungscode weitergegeben werden.

SQL-Parameter-Interpolation

Es gibt eine Funktion in der Chronicles-Suche, die es Benutzern ermöglicht, nach Payload zu suchen. Die Abfrage sieht folgendermaßen aus: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"} , wobei path immer ein Array aus Strings und value ist ist ein beliebiger gültiger JSON-Wert.

In ActiveRecord sieht das so aus:

 @scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)

In Sequel habe ich es nicht geschafft, :path richtig zu interpolieren, also musste ich darauf zurückgreifen:

 base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))

Glücklicherweise ist der path hier richtig validiert, sodass er nur alphanumerische Zeichen enthält, aber dieser Code sieht immer noch komisch aus.

Silent Magic der ROM-Fabrik

Wir haben das rom-factory Juwel verwendet, um die Erstellung unserer Modelle in Tests zu vereinfachen. Der Code funktionierte jedoch mehrmals nicht wie erwartet. Können Sie erraten, was an diesem Test falsch ist?

 action1 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'deleted'] action2 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'updated'] expect(action1.id).not_to eq(action2.id)

Nein, die Erwartung scheitert nicht, die Erwartung ist in Ordnung.

Das Problem besteht darin, dass die zweite Zeile mit einem eindeutigen Einschränkungsvalidierungsfehler fehlschlägt. Der Grund dafür ist, dass action nicht das Attribut ist, das das Action hat. Der richtige Name ist action_name , also sollte der richtige Weg zum Erstellen von Aktionen so aussehen:

 RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']

Da das falsch eingegebene Attribut ignoriert wurde, fällt es auf das in der Factory angegebene Standardattribut zurück ( action_name { 'created' } ), und wir haben eine eindeutige Einschränkungsverletzung, weil wir versuchen, zwei identische Aktionen zu erstellen. Wir mussten uns mehrmals mit diesem Problem befassen, was sich als anstrengend herausstellte.

Glücklicherweise wurde es in 0.9.0 behoben. Dependabot hat uns automatisch eine Pull-Anfrage mit dem Bibliotheks-Update gesendet, die wir zusammengeführt haben, nachdem wir einige falsch eingegebene Attribute behoben hatten, die wir in unseren Tests hatten.

Allgemeine Ergonomie

Das sagt alles:

 # ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_

Und der Unterschied ist bei komplizierteren Beispielen noch größer.

Die guten Teile

Es war nicht alles Schmerz, Schweiß und Tränen. Es gab viele, viele gute Dinge auf unserer Reise, und sie überwiegen bei weitem die negativen Aspekte des neuen Stacks. Wenn das nicht so gewesen wäre, hätten wir es gar nicht erst gemacht.

Testgeschwindigkeit

Es dauert 5-10 Sekunden, um die gesamte Testsuite lokal auszuführen, und ebenso lange für RuboCop. Die CI-Zeit ist viel länger (3-4 Minuten), aber das ist weniger ein Problem, weil wir sowieso alles lokal ausführen können, wodurch es viel weniger wahrscheinlich ist, dass irgendetwas auf CI fehlschlägt.

Der Wächterstein ist wieder verwendbar. Stellen Sie sich vor, Sie könnten Code schreiben und Tests für jede Speicherung durchführen, wodurch Sie sehr schnelles Feedback erhalten. Dies ist sehr schwer vorstellbar, wenn man mit der Plattform arbeitet.

Bereitstellungszeiten

Die Zeit zum Bereitstellen der extrahierten Chronicles-App beträgt nur zwei Minuten. Nicht blitzschnell, aber trotzdem nicht schlecht. Wir setzen sehr oft ein, sodass selbst geringfügige Verbesserungen zu erheblichen Einsparungen führen können.

Anwendungsleistung

Der leistungsintensivste Teil von Chronicles ist die Eintragssuche. Im Moment gibt es etwa 20 Stellen im Plattform-Backend, die Verlaufseinträge von Chronicles abrufen. Das bedeutet, dass die Antwortzeit von Chronicles zum 60-Sekunden-Budget der Plattform für die Antwortzeit beiträgt, also muss Chronicles schnell sein, was es auch ist.

Trotz der enormen Größe des Aktionsprotokolls (30 Millionen Zeilen, Tendenz steigend) beträgt die durchschnittliche Antwortzeit weniger als 100 ms. Schauen Sie sich dieses schöne Diagramm an:

Anwendungsleistungsdiagramm

Im Durchschnitt werden 80-90 % der App-Zeit in der Datenbank verbracht. So sollte ein ordentliches Leistungsdiagramm aussehen.

Wir haben immer noch einige langsame Abfragen, die mehrere zehn Sekunden dauern können, aber wir haben bereits einen Plan, wie wir sie beseitigen können, damit die extrahierte App noch schneller wird.

Struktur

Für unsere Zwecke ist die Trockenvalidierung ein sehr leistungsfähiges und flexibles Werkzeug. Wir leiten alle Eingaben von außen durch Verträge weiter, und das gibt uns die Gewissheit, dass die Eingabeparameter immer wohlgeformt und von wohldefinierten Typen sind.

Es ist nicht mehr erforderlich, .to_s.to_sym.to_i im Anwendungscode aufzurufen, da alle Daten bereinigt und an den Rändern der App typisiert werden. In gewisser Weise bringt es starke Arten von geistiger Gesundheit in die dynamische Welt von Ruby. Ich kann es nicht genug empfehlen.

Letzte Worte

Die Wahl eines Nicht-Standard-Stacks war nicht so einfach, wie es zunächst schien. Bei der Auswahl des Frameworks und der Bibliotheken für den neuen Service haben wir viele Aspekte berücksichtigt: den aktuellen Tech-Stack der Monolith-Anwendung, die Vertrautheit des Teams mit dem neuen Stack, wie gepflegt der ausgewählte Stack ist und so weiter.

Obwohl wir von Anfang an versucht haben, sehr sorgfältige und kalkulierte Entscheidungen zu treffen – wir haben uns für den Standard-Hanami-Stack entschieden – mussten wir unseren Stack im Laufe der Zeit aufgrund nicht standardmäßiger technischer Anforderungen des Projekts überdenken. Wir landeten bei Sinatra und einem DRY-basierten Stack.

Würden wir uns wieder für Hanami entscheiden, wenn wir eine neue App extrahieren würden? Wahrscheinlich ja. Wir wissen jetzt mehr über die Bibliothek und ihre Vor- und Nachteile, sodass wir gleich zu Beginn eines neuen Projekts fundiertere Entscheidungen treffen können. Wir würden jedoch auch ernsthaft in Betracht ziehen, eine einfache Sinatra/DRY.rb-App zu verwenden.

Alles in allem gibt uns die Zeit, die in das Erlernen neuer Frameworks, Paradigmen oder Programmiersprachen investiert wird, eine neue Perspektive auf unseren aktuellen Tech-Stack. Es ist immer gut zu wissen, was da draußen verfügbar ist, um Ihre Toolbox zu bereichern. Jedes Tool hat seinen eigenen, einzigartigen Anwendungsfall. Um sie besser kennenzulernen, müssen Sie mehr davon zur Verfügung haben und sie besser für Ihre Anwendung geeignet machen.