Scegliere un'alternativa allo stack tecnologico: gli alti e bassi

Pubblicato: 2022-03-11

Se un'applicazione Web è abbastanza grande e vecchia, potrebbe arrivare il momento in cui è necessario scomporla in parti più piccole e isolate ed estrarre da essa servizi, alcuni dei quali saranno più indipendenti di altri. Alcuni dei motivi che potrebbero indurre a tale decisione includono: ridurre il tempo per eseguire i test, essere in grado di distribuire diverse parti dell'app in modo indipendente o imporre i confini tra i sottosistemi. L'estrazione del servizio richiede agli ingegneri del software di prendere molte decisioni vitali e una di queste è quale stack tecnologico utilizzare per il nuovo servizio.

In questo post, condividiamo una storia sull'estrazione di un nuovo servizio da un'applicazione monolitica: la piattaforma Toptal . Spieghiamo quale stack tecnico abbiamo scelto e perché, e delineiamo alcuni problemi che abbiamo riscontrato durante l'implementazione del servizio.

Il servizio Cronache di Toptal è un'app che gestisce tutte le azioni degli utenti eseguite sulla piattaforma Toptal. Le azioni sono essenzialmente voci di registro. Quando un utente fa qualcosa (ad es. pubblica un post sul blog, approva un lavoro, ecc.), viene creata una nuova voce di registro.

Sebbene estratta dalla nostra Piattaforma, fondamentalmente non dipende da essa e può essere utilizzata con qualsiasi altra app. Questo è il motivo per cui stiamo pubblicando un resoconto dettagliato del processo e discutendo una serie di sfide che il nostro team di ingegneri ha dovuto superare durante la transizione al nuovo stack.

Ci sono una serie di ragioni alla base della nostra decisione di estrarre il servizio e migliorare lo stack:

  • Volevamo che altri servizi fossero in grado di registrare eventi che potessero essere visualizzati e utilizzati altrove.
  • La dimensione delle tabelle del database in cui sono archiviati i record della cronologia è cresciuta rapidamente e in modo non lineare, comportando costi operativi elevati.
  • Abbiamo ritenuto che l'attuale attuazione fosse gravata da debiti tecnici.

Tabella delle azioni - tabelle del database

A prima vista, sembrava un'iniziativa semplice. Tuttavia, la gestione di stack tecnologici alternativi tende a creare svantaggi inaspettati, ed è ciò che l'articolo di oggi mira ad affrontare.

Panoramica dell'architettura

L'app Chronicles è composta da tre parti che possono essere più o meno indipendenti e vengono eseguite in contenitori Docker separati.

  • Il consumatore Kafka è un consumatore Kafka molto magro con sede a Karafka di messaggi di creazione di voci. Accoda tutti i messaggi ricevuti a Sidekiq.
  • Sidekiq worker è un lavoratore che elabora i messaggi Kafka e crea voci nella tabella del database.
  • Endpoint GraphQL:
    • L'endpoint pubblico espone l'API di ricerca delle voci, che viene utilizzata per varie funzioni della piattaforma (ad esempio, per visualizzare descrizioni comandi di commenti sui pulsanti di screening o visualizzare la cronologia delle modifiche al lavoro).
    • L'endpoint interno offre la possibilità di creare regole e modelli di tag dalle migrazioni dei dati.

Chronicles utilizzato per connettersi a due diversi database:

  • Il proprio database (dove archiviamo regole e modelli di tag)
  • Il database della piattaforma (dove memorizziamo le azioni eseguite dagli utenti e i loro tag e tagging)

Durante il processo di estrazione dell'app, abbiamo migrato i dati dal database della piattaforma e abbiamo interrotto la connessione alla piattaforma.

Piano iniziale

Inizialmente, abbiamo deciso di utilizzare Hanami e tutto l'ecosistema che fornisce per impostazione predefinita (un modello hanami supportato da ROM.rb, dry-rb, hanami-newrelic, ecc.). Seguire un modo "standard" di fare le cose ci ha promesso un basso attrito, una grande velocità di implementazione e un'ottima "googleability" di tutti i problemi che potremmo incontrare. Inoltre, l'ecosistema hanami è maturo e popolare e la biblioteca è curata con cura da membri rispettati della comunità di Ruby.

Inoltre, gran parte del sistema era già stata implementata lato piattaforma (ad esempio, l'endpoint GraphQL Entry Search e l'operazione CreateEntry), quindi abbiamo pianificato di copiare gran parte del codice da Platform a Chronicles così com'è, senza apportare modifiche. Questo è stato anche uno dei motivi principali per cui non abbiamo scelto Elixir, poiché Elixir non lo avrebbe permesso.

Abbiamo deciso di non fare Rails perché sembrava eccessivo per un progetto così piccolo, in particolare cose come ActiveSupport, che non avrebbero fornito molti vantaggi tangibili per le nostre esigenze.

Quando il piano va a sud

Anche se abbiamo fatto del nostro meglio per attenerci al piano, è stato presto fatto deragliare per una serie di motivi. Uno era la nostra mancanza di esperienza con lo stack scelto, seguito da problemi reali con lo stack stesso, e poi c'era la nostra configurazione non standard (due database). Alla fine, abbiamo deciso di sbarazzarci hanami-model , e poi della stessa Hanami, sostituendolo con Sinatra.

Abbiamo scelto Sinatra perché è una libreria gestita attivamente creata 12 anni fa e, poiché è una delle librerie più popolari, tutti i membri del team hanno avuto un'ampia esperienza pratica con essa.

Dipendenze incompatibili

L'estrazione di Chronicles è iniziata a giugno 2019 e, all'epoca, Hanami non era compatibile con le ultime versioni di gemme dry-rb. Vale a dire, l'ultima versione di Hanami all'epoca (1.3.1) supportava solo la convalida a secco 0.12 e volevamo la convalida a secco 1.0.0. Abbiamo pianificato di utilizzare i contratti dalla convalida a secco che sono stati introdotti solo in 1.0.0.

Inoltre, Kafka 1.2 è incompatibile con le gemme dry, quindi ne stavamo usando la versione repository. Al momento, stiamo usando 1.3.0.rc1, che dipende dalle gemme asciutte più recenti.

Dipendenze inutili

Inoltre, la gemma Hanami includeva troppe dipendenze che non avevamo intenzione di utilizzare, come hanami-cli , hanami-assets , hanami-mailer , hanami-view e persino hanami-controller . Inoltre, guardando il readme del modello hanami, è diventato chiaro che supporta un solo database per impostazione predefinita. D'altra parte, ROM.rb, su cui si basa il hanami-model , supporta configurazioni multi-database pronte all'uso.

Tutto sommato, Hanami in generale e il hanami-model in particolare sembravano un livello di astrazione non necessario.

Quindi, 10 giorni dopo aver realizzato il primo PR significativo di Chronicles, abbiamo completamente sostituito hanami con Sinatra. Avremmo potuto usare anche Rack puro perché non abbiamo bisogno di un routing complesso (abbiamo quattro endpoint "statici" - due endpoint GraphQL, l'endpoint /ping e l'interfaccia web sidekiq), ma abbiamo deciso di non andare troppo all'estremo. Sinatra ci andava benissimo. Se vuoi saperne di più, dai un'occhiata al nostro tutorial Sinatra e Sequel.

Incomprensioni sullo schema a secco e sulla convalida a secco

Ci è voluto del tempo e molti tentativi ed errori per capire come "cucinare" correttamente la convalida a secco.

 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

Nello snippet sopra, il parametro url è definito in diversi modi leggermente diversi. Alcune definizioni sono equivalenti e altre non hanno alcun senso. All'inizio, non potevamo davvero dire la differenza tra tutte queste definizioni poiché non le capivamo completamente. Di conseguenza, la prima versione dei nostri contratti era piuttosto disordinata. Con il tempo, abbiamo imparato a leggere e scrivere correttamente i contratti DRY, e ora sembrano coerenti ed eleganti, infatti, non solo eleganti, sono a dir poco belli. Convalidiamo anche la configurazione dell'applicazione con i contratti.

Problemi con ROM.rb e Sequel

ROM.rb e Sequel differiscono da ActiveRecord, nessuna sorpresa. La nostra idea iniziale che saremo in grado di copiare e incollare la maggior parte del codice dalla piattaforma non è riuscita. Il problema è che la parte della piattaforma era molto pesante in AR, quindi quasi tutto doveva essere riscritto in ROM/Sequel. Siamo riusciti a copiare solo piccole porzioni di codice indipendenti dal framework. Lungo la strada, abbiamo affrontato alcuni problemi frustranti e alcuni bug.

Filtraggio per sottoquery

Ad esempio, mi ci sono volute diverse ore per capire come creare una sottoquery in ROM.rb/Sequel. Questo è qualcosa che scriverei senza nemmeno svegliarmi in Rails: scope.where(sequence_code: subquery ). In Sequel, però, non è stato così facile.

 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

Quindi, invece di una semplice riga come base_query.where(sequence_code: bild_subquery(params)) , dobbiamo avere una dozzina di righe con codice non banale, frammenti SQL grezzi e un commento su più righe che spieghi cosa ha causato questo sfortunato caso di gonfiare.

Associazioni con campi di unione non banali

La relazione di entry ( tabella performed_actions ) ha un campo id primario. Tuttavia, per unire con le tabelle *taggings , utilizza la colonna sequence_code . In ActiveRecord, si esprime piuttosto semplicemente:

 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

È possibile scrivere lo stesso anche in 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

C'era un piccolo problema, però. Verrebbe compilato bene ma fallirebbe in runtime quando hai effettivamente provato a usarlo.

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

Siamo fortunati che i tipi di id e sequence_code siano diversi, quindi PG genera un errore di tipo. Se i tipi fossero gli stessi, chissà quante ore passerei a eseguire il debug di questo.

Quindi, entries.join(:access_taggings) non funziona. Cosa succede se specifichiamo esplicitamente la condizione di join? Come in entries.join(:access_taggings, performed_action_sequence_code: :sequence_code) , come suggerisce la documentazione ufficiale.

 [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

Ora pensa che :access_taggings sia un nome di tabella per qualche motivo. Bene, sostituiamolo con il nome effettivo della tabella.

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

Alla fine, ha restituito qualcosa e non ha fallito, anche se ha finito con un'astrazione che perde. Il nome della tabella non dovrebbe perdere il codice dell'applicazione.

Interpolazione dei parametri SQL

C'è una funzione nella ricerca di Chronicles che consente agli utenti di cercare per carico utile. La query è simile a questa: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"} , dove path è sempre un array di stringhe e valore è un valore JSON valido.

In ActiveRecord, si presenta così:

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

In Sequel, non sono riuscito a interpolare correttamente :path , quindi ho dovuto ricorrere a quello:

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

Fortunatamente, il path qui è convalidato correttamente in modo che contenga solo caratteri alfanumerici, ma questo codice sembra comunque divertente.

Magia silenziosa della fabbrica di ROM

Abbiamo utilizzato la gemma rom-factory per semplificare la creazione dei nostri modelli nei test. Diverse volte, tuttavia, il codice non ha funzionato come previsto. Riuscite a indovinare cosa c'è che non va in questo 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)

No, l'aspettativa non viene meno, l'aspettativa va bene.

Il problema è che la seconda riga non riesce con un errore di convalida del vincolo univoco. Il motivo è che action non è l'attributo del modello Action . Il vero nome è action_name , quindi il modo giusto per creare azioni dovrebbe assomigliare a questo:

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

Poiché l'attributo digitato in modo errato è stato ignorato, ritorna a quello predefinito specificato nella fabbrica ( action_name { 'created' } ) e abbiamo una violazione del vincolo univoca perché stiamo cercando di creare due azioni identiche. Abbiamo dovuto affrontare questo problema più volte, il che si è rivelato gravoso.

Fortunatamente, è stato risolto in 0.9.0. Dependabot ci ha inviato automaticamente una richiesta pull con l'aggiornamento della libreria, che abbiamo unito dopo aver corretto alcuni attributi digitati in modo errato che avevamo nei nostri test.

Ergonomia generale

Questo dice tutto:

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

E la differenza è ancora maggiore negli esempi più complicati.

Le parti buone

Non era tutto dolore, sudore e lacrime. Ci sono state molte, molte cose buone nel nostro viaggio e superano di gran lunga gli aspetti negativi del nuovo stack. Se non fosse stato così, non l'avremmo fatto in primo luogo.

Test di velocità

Occorrono 5-10 secondi per eseguire l'intera suite di test in locale e tutto il tempo per RuboCop. Il tempo di CI è molto più lungo (3-4 minuti), ma questo è un problema minore perché possiamo comunque eseguire tutto localmente, grazie al quale è molto meno probabile che qualcosa non funzioni su CI.

La gemma di guardia è tornata utilizzabile. Immagina di poter scrivere codice ed eseguire test su ogni salvataggio, fornendoti un feedback molto veloce. Questo è molto difficile da immaginare quando si lavora con la piattaforma.

Tempi di distribuzione

Il tempo per distribuire l'app Chronicles estratta è di soli due minuti. Non fulmineo, ma comunque non male. Distribuiamo molto spesso, quindi anche piccoli miglioramenti possono generare risparmi sostanziali.

Prestazioni dell'applicazione

La parte più ad alta intensità di prestazioni di Chronicles è la ricerca di voci. Per ora, ci sono circa 20 posti nel back-end della piattaforma che recuperano le voci della cronologia da Chronicles. Ciò significa che il tempo di risposta di Chronicles contribuisce al budget di 60 secondi della piattaforma per il tempo di risposta, quindi Chronicles deve essere veloce, ed è così.

Nonostante le enormi dimensioni del registro delle azioni (30 milioni di righe e in crescita), il tempo medio di risposta è inferiore a 100 ms. Dai un'occhiata a questo bellissimo grafico:

Grafico delle prestazioni dell'applicazione

In media, l'80-90% del tempo dell'app viene speso nel database. Ecco come dovrebbe essere un grafico delle prestazioni corretto.

Abbiamo ancora alcune query lente che potrebbero richiedere decine di secondi, ma abbiamo già un piano su come eliminarle, consentendo all'app estratta di diventare ancora più veloce.

Struttura

Per i nostri scopi, la convalida a secco è uno strumento molto potente e flessibile. Passiamo tutti gli input dal mondo esterno attraverso i contratti, e questo ci rende fiduciosi che i parametri di input siano sempre ben formati e di tipi ben definiti.

Non è più necessario chiamare .to_s.to_sym.to_i nel codice dell'applicazione, poiché tutti i dati vengono ripuliti e digitati ai bordi dell'app. In un certo senso, porta forti tipi di sanità mentale nel dinamico mondo di Ruby. Non posso raccomandarlo abbastanza.

Parole finali

La scelta di uno stack non standard non è stata così semplice come sembrava inizialmente. Abbiamo considerato molti aspetti quando abbiamo selezionato il framework e le librerie da utilizzare per il nuovo servizio: lo stack tecnologico attuale dell'applicazione monolith, la familiarità del team con il nuovo stack, il modo in cui viene mantenuto lo stack scelto e così via.

Anche se abbiamo cercato di prendere decisioni molto attente e calcolate fin dall'inizio - abbiamo scelto di utilizzare lo stack Hanami standard - abbiamo dovuto riconsiderare il nostro stack lungo il percorso a causa dei requisiti tecnici non standard del progetto. Abbiamo finito con Sinatra e uno stack basato su DRY.

Sceglieremmo di nuovo Hanami se dovessimo estrarre una nuova app? Probabilmente sì. Ora sappiamo di più sulla biblioteca e sui suoi pro e contro, quindi potremmo prendere decisioni più informate fin dall'inizio di qualsiasi nuovo progetto. Tuttavia, prenderemmo seriamente in considerazione l'utilizzo di una semplice app Sinatra/DRY.rb.

Tutto sommato, il tempo investito nell'apprendimento di nuovi framework, paradigmi o linguaggi di programmazione ci offre una nuova prospettiva sul nostro attuale stack tecnologico. È sempre bene sapere cosa è disponibile là fuori per arricchire la tua cassetta degli attrezzi. Ogni strumento ha il suo caso d'uso unico, quindi conoscerli meglio significa averne di più a tua disposizione e trasformarli in una soluzione migliore per la tua applicazione.