Alegerea unei alternative Tech Stack - Ușuri și coborâșuri

Publicat: 2022-03-11

Dacă o aplicație web este suficient de mare și veche, poate veni un moment când trebuie să o descompuneți în părți mai mici, izolate și să extrageți servicii din ea, dintre care unele vor fi mai independente decât altele. Unele dintre motivele care ar putea determina o astfel de decizie includ: reducerea timpului de rulare a testelor, posibilitatea de a implementa diferite părți ale aplicației în mod independent sau impunerea granițelor între subsisteme. Extragerea serviciului necesită inginerii software să ia multe decizii vitale, iar una dintre ele este ce stivă tehnologică să folosească pentru noul serviciu.

În această postare, împărtășim o poveste despre extragerea unui nou serviciu dintr-o aplicație monolitică – Platforma Toptal . Explicăm ce stivă tehnică am ales și de ce și prezentăm câteva probleme pe care le-am întâlnit în timpul implementării serviciului.

Serviciul Chronicles Toptal este o aplicație care gestionează toate acțiunile utilizatorului efectuate pe Platforma Toptal. Acțiunile sunt în esență intrări de jurnal. Când un utilizator face ceva (de exemplu, publică o postare pe blog, aprobă un job etc.), este creată o nouă intrare de jurnal.

Deși extras din Platforma noastră, în principiu nu depinde de aceasta și poate fi folosit cu orice altă aplicație. Acesta este motivul pentru care publicăm o prezentare detaliată a procesului și discutăm o serie de provocări pe care echipa noastră de ingineri a trebuit să le depășească în timpul tranziției la noua stivă.

Există o serie de motive în spatele deciziei noastre de a extrage serviciul și de a îmbunătăți stiva:

  • Am vrut ca alte servicii să poată înregistra evenimente care ar putea fi afișate și utilizate în altă parte.
  • Dimensiunea tabelelor bazei de date care stochează înregistrările istorice a crescut rapid și neliniar, implicând costuri de operare mari.
  • Am considerat că implementarea existentă a fost grevată de datoria tehnică.

Tabel de acțiuni - tabele baze de date

La prima vedere, mi s-a părut o inițiativă simplă. Cu toate acestea, a face față cu stive alternative de tehnologie tinde să creeze dezavantaje neașteptate și asta își propune să abordeze articolul de astăzi.

Privire de ansamblu asupra arhitecturii

Aplicația Chronicles constă din trei părți care pot fi mai mult sau mai puțin independente și sunt rulate în containere Docker separate.

  • Consumatorul Kafka este un consumator Kafka foarte subțire, bazat pe Karafka, de mesaje de creare a intrărilor. Pune în coadă toate mesajele primite către Sidekiq.
  • Lucrătorul Sidekiq este un lucrător care procesează mesajele Kafka și creează intrări în tabelul bazei de date.
  • Puncte finale GraphQL:
    • Punctul final public expune API-ul de căutare a intrărilor, care este utilizat pentru diferite funcții ale Platformei (de exemplu, pentru a reda sfaturile instrumente pentru comentarii pe butoanele de screening sau pentru a afișa istoricul modificărilor de locuri de muncă).
    • Punctul final intern oferă posibilitatea de a crea reguli de etichetă și șabloane din migrarea datelor.

Cronicile folosite pentru a se conecta la două baze de date diferite:

  • O bază de date proprie (unde stocăm regulile și șabloanele de etichete)
  • Baza de date Platformă (unde stocăm acțiunile efectuate de utilizator și etichetele și etichetele acestora)

În procesul de extragere a aplicației, am migrat datele din baza de date Platformă și am închis conexiunea Platformă.

Planul inițial

Inițial, am decis să mergem cu Hanami și tot ecosistemul pe care îl oferă implicit (un model hanami susținut de ROM.rb, dry-rb, hanami-newrelic etc). Urmând un mod „standard” de a face lucrurile ne-a promis o frecare redusă, o viteză mare de implementare și o „capacitate pe google” foarte bună a oricăror probleme cu care ne-am putea confrunta. În plus, ecosistemul hanami este matur și popular, iar biblioteca este întreținută cu grijă de membri respectați ai comunității Ruby.

Mai mult decât atât, o mare parte a sistemului fusese deja implementată în partea Platformei (de exemplu, punctul final GraphQL Entry Search și operațiunea CreateEntry), așa că am plănuit să copiem o mare parte din codul din Platformă în Chronicles așa cum este, fără a face nicio modificare. Acesta a fost, de asemenea, unul dintre motivele cheie pentru care nu am mers cu Elixir, deoarece Elixir nu ar permite asta.

Am decis să nu facem Rails pentru că a fost exagerat pentru un proiect atât de mic, în special lucruri precum ActiveSupport, care nu ar oferi multe beneficii tangibile pentru nevoile noastre.

Când planul merge spre sud

Deși am făcut tot posibilul să ne menținem planul, acesta a deraiat curând din mai multe motive. Una a fost lipsa noastră de experiență cu stiva aleasă, urmată de probleme reale cu stiva în sine, iar apoi a fost configurarea noastră non-standard (două baze de date). În cele din urmă, am decis să scăpăm de modelul hanami-model și apoi de Hanami însuși, înlocuindu-l cu Sinatra.

Am ales Sinatra pentru că este o bibliotecă întreținută activ, creată în urmă cu 12 ani și, deoarece este una dintre cele mai populare biblioteci, toți cei din echipă au avut o vastă experiență practică cu ea.

Dependențe incompatibile

Extragerea Chronicles a început în iunie 2019, iar pe atunci, Hanami nu era compatibil cu cele mai recente versiuni ale pietrelor dry-rb. Și anume, cea mai recentă versiune de Hanami la acea vreme (1.3.1) suporta doar validarea uscată 0.12, iar noi doream validarea uscată 1.0.0. Am plănuit să folosim contracte de la dry-validation care au fost introduse doar în 1.0.0.

De asemenea, Kafka 1.2 este incompatibil cu pietre prețioase uscate, așa că folosim versiunea de depozit a acestuia. În prezent, folosim 1.3.0.rc1, care depinde de cele mai noi pietre uscate.

Dependențe inutile

În plus, bijuteria Hanami includea prea multe dependențe pe care nu intenționam să le folosim, cum ar fi hanami-cli , hanami-assets , hanami-mailer , hanami-view și chiar hanami-controller . De asemenea, uitându-mă la modelul readme hanami, a devenit clar că acceptă o singură bază de date implicit. Pe de altă parte, ROM.rb, pe care se bazează hanami-model , acceptă configurații cu mai multe baze de date.

Una peste alta, Hanami în general și modelul hanami-model în special păreau un nivel inutil de abstractizare.

Deci, la 10 zile după ce am făcut primul PR semnificativ la Chronicles, am înlocuit complet hanami cu Sinatra. Am fi putut folosi și Rack pur pentru că nu avem nevoie de rutare complexă (avem patru puncte finale „statice” - două puncte finale GraphQL, punctul final /ping și interfața web sidekiq), dar am decis să nu mergem prea greu. Sinatra ni s-a potrivit foarte bine. Dacă doriți să aflați mai multe, consultați tutorialul nostru Sinatra și Sequel.

Schema uscată și neînțelegerile de validare uscată

Ne-a luat ceva timp și multe încercări și erori pentru a ne da seama cum să „gătim” corect validarea uscată.

 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

În fragmentul de mai sus, parametrul url este definit în mai multe moduri ușor diferite. Unele definiții sunt echivalente, iar altele nu au niciun sens. La început, nu am putut să facem diferența dintre toate aceste definiții, deoarece nu le-am înțeles pe deplin. Drept urmare, prima versiune a contractelor noastre a fost destul de dezordonată. Cu timpul, am învățat cum să citim și să scriem corect contractele DRY, iar acum ele arată consistent și elegante – de fapt, nu doar elegante, ci sunt chiar frumoase. Validăm chiar și configurația aplicației cu contractele.

Probleme cu ROM.rb și Sequel

ROM.rb și Sequel diferă de ActiveRecord, nicio surpriză. Ideea noastră inițială că vom putea copia și lipi majoritatea codului de pe platformă a eșuat. Problema este că partea Platformă a fost foarte intensă în AR, așa că aproape totul a trebuit rescris în ROM/Sequel. Am reușit să copiem doar porțiuni mici de cod care erau independente de cadru. Pe parcurs, ne-am confruntat cu câteva probleme frustrante și unele erori.

Filtrarea după subinterogare

De exemplu, mi-a luat câteva ore să-mi dau seama cum să fac o subinterogare în ROM.rb/Sequel. Acesta este ceva pe care l-aș scrie fără măcar să mă trezesc în Rails: scope.where(sequence_code: subquery ). În Sequel, însă, s-a dovedit a nu fi atât de ușor.

 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

Deci, în loc de o linie simplă, cum ar fi base_query.where(sequence_code: bild_subquery(params)) , trebuie să avem o duzină de linii cu cod non-trivial, fragmente SQL brute și un comentariu pe mai multe linii care explică ce a cauzat acest caz nefericit de balonare.

Asociații cu câmpuri de alăturare non-triviale

Relația de entry (tabelul performed_actions ) are un câmp de id primar. Cu toate acestea, pentru a se alătura cu tabelele *taggings , folosește coloana sequence_code . În ActiveRecord, este exprimat destul de simplu:

 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

Este posibil să scrieți același lucru și în ROM.

 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

Totuși, a fost o mică problemă cu el. S-ar compila foarte bine, dar eșuează în timpul de execuție atunci când ați încercat să îl utilizați.

 [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...

Suntem norocoși că tipurile de id și sequence_code sunt diferite, așa că PG aruncă o eroare de tip. Dacă tipurile ar fi aceleași, cine știe câte ore aș petrece depanând asta.

Deci, entries.join(:access_taggings) nu funcționează. Ce se întâmplă dacă specificăm explicit condiția de unire? Ca și în entries.join(:access_taggings, performed_action_sequence_code: :sequence_code) , așa cum sugerează documentația oficială.

 [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

Acum crede că :access_taggings este un nume de tabel dintr-un anumit motiv. Bine, să-l schimbăm cu numele real al tabelului.

 [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>]

În cele din urmă, a returnat ceva și nu a eșuat, deși s-a terminat cu o abstracție scursă. Numele tabelului nu trebuie să se scurgă în codul aplicației.

Interpolarea parametrilor SQL

Există o funcție în căutarea Chronicles care permite utilizatorilor să caute după sarcină utilă. Interogarea arată astfel: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"} , unde path este întotdeauna o matrice de șiruri și valoare este orice valoare JSON validă.

În ActiveRecord, arată astfel:

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

În Sequel, nu am reușit să interpolez corect :path , așa că a trebuit să recurg la asta:

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

Din fericire, path aici este validată corespunzător, astfel încât să conțină doar caractere alfanumerice, dar acest cod încă arată amuzant.

Magia tăcută a fabricii de ROM

Am folosit bijuteria rom-factory pentru a simplifica crearea modelelor noastre în teste. De mai multe ori, însă, codul nu a funcționat așa cum era de așteptat. Poți ghici ce este în neregulă cu acest test?

 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)

Nu, așteptarea nu eșuează, așteptarea este în regulă.

Problema este că a doua linie eșuează cu o eroare unică de validare a constrângerii. Motivul este că action nu este atributul pe care îl are modelul Action . Numele real este action_name , așa că modul corect de a crea acțiuni ar trebui să arate astfel:

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

Deoarece atributul greșit a fost ignorat, acesta revine la cel implicit specificat în fabrică ( action_name { 'created' } ) și avem o încălcare unică a constrângerii deoarece încercăm să creăm două acțiuni identice. A trebuit să ne confruntăm de mai multe ori cu această problemă, ceea ce s-a dovedit a fi o taxă.

Din fericire, a fost remediat în 0.9.0. Dependabot ne-a trimis automat o cerere de extragere cu actualizarea bibliotecii, pe care am fuzionat-o după ce am remediat câteva atribute greșite pe care le-am avut în testele noastre.

Ergonomie generală

Asta spune totul:

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

Iar diferența este și mai mare în exemplele mai complicate.

Părțile bune

Nu totul a fost durere, transpirație și lacrimi. Au fost multe, multe lucruri bune în călătoria noastră și ele depășesc cu mult aspectele negative ale noii stive. Dacă nu ar fi fost așa, nu am fi făcut-o de la început.

Test de viteză

Este nevoie de 5-10 secunde pentru a rula întreaga suită de testare la nivel local și atât timp pentru RuboCop. Timpul CI este mult mai lung (3-4 minute), dar aceasta este o problemă mai mică, deoarece oricum putem rula totul local, datorită căruia, orice defecțiune pe CI este mult mai puțin probabil.

Bijuteria de gardă a devenit din nou utilizabilă. Imaginează-ți că poți scrie cod și rula teste la fiecare salvare, oferindu-ți feedback foarte rapid. Acest lucru este foarte greu de imaginat când lucrezi cu Platforma.

Timp de implementare

Timpul de implementare a aplicației extrase Chronicles este de doar două minute. Nu fulgerător, dar tot nu e rău. Implementăm foarte des, astfel încât chiar și îmbunătățirile minore pot genera economii substanțiale.

Performanța aplicației

Partea cea mai intensă de performanță din Chronicles este căutarea intrărilor. Deocamdată, există aproximativ 20 de locuri în back-end-ul Platformei care preiau intrări de istorie din Chronicles. Aceasta înseamnă că timpul de răspuns al Chronicles contribuie la bugetul de 60 de secunde al Platformei pentru timpul de răspuns, așa că Chronicles trebuie să fie rapid, ceea ce este.

În ciuda dimensiunii uriașe a jurnalului de acțiuni (30 de milioane de rânduri și în creștere), timpul mediu de răspuns este mai mic de 100 ms. Aruncă o privire la acest grafic frumos:

Diagrama de performanță a aplicației

În medie, 80-90% din timpul aplicației este petrecut în baza de date. Așa ar trebui să arate o diagramă de performanță adecvată.

Mai avem câteva interogări lente care pot dura zeci de secunde, dar avem deja un plan cum să le eliminăm, permițând aplicației extrase să devină și mai rapid.

Structura

Pentru scopurile noastre, validarea uscată este un instrument foarte puternic și flexibil. Trecem toate inputurile din lumea exterioară prin contracte și ne face încrezători că parametrii de intrare sunt întotdeauna bine formați și de tipuri bine definite.

Nu mai este nevoie să apelați .to_s.to_sym.to_i în codul aplicației, deoarece toate datele sunt curățate și tipărite la granițele aplicației. Într-un fel, aduce tipuri puternice de sănătate mintală în lumea dinamică Ruby. Nu o pot recomanda suficient.

Cuvinte finale

Alegerea unei stive non-standard nu a fost atât de simplă cum părea inițial. Am luat în considerare multe aspecte atunci când am selectat cadrul și bibliotecile de utilizat pentru noul serviciu: stiva tehnologică actuală a aplicației monolit, familiaritatea echipei cu noua stivă, cât de menținută este stiva aleasă și așa mai departe.

Chiar dacă am încercat să luăm decizii foarte atente și calculate încă de la început - am ales să folosim stiva standard Hanami - a trebuit să ne reconsiderăm stiva pe parcurs din cauza cerințelor tehnice nestandard ale proiectului. Am ajuns să avem Sinatra și un stack bazat pe DRY.

Am alege din nou Hanami dacă ar fi să extragem o nouă aplicație? Probabil da. Acum știm mai multe despre bibliotecă și despre avantajele și dezavantajele acesteia, astfel încât să putem lua decizii mai informate chiar de la începutul oricărui proiect nou. Cu toate acestea, ne-am gândi serios să folosim o aplicație simplă Sinatra/DRY.rb.

Una peste alta, timpul investit în învățarea de noi cadre, paradigme sau limbaje de programare ne oferă o perspectivă nouă asupra stivei noastre tehnologice actuale. Este întotdeauna bine să știți ce este disponibil acolo pentru a vă îmbogăți cutia de instrumente. Fiecare instrument are propriul său caz de utilizare unic – prin urmare, să le cunoașteți mai bine înseamnă să aveți mai multe dintre ele la dispoziția dumneavoastră și să le transformați într-o potrivire mai bună pentru aplicația dvs.