Wybór alternatywy dla stosu technologicznego — wzloty i upadki

Opublikowany: 2022-03-11

Jeśli aplikacja internetowa jest wystarczająco duża i stara, może nadejść czas, kiedy trzeba będzie podzielić ją na mniejsze, odizolowane części i wyodrębnić z niej usługi, z których niektóre będą bardziej niezależne niż inne. Niektóre z powodów, które mogą skłonić do takiej decyzji, to: skrócenie czasu uruchamiania testów, możliwość niezależnego wdrażania różnych części aplikacji lub wymuszenie granic między podsystemami. Ekstrakcja usług wymaga od inżynierów oprogramowania podejmowania wielu ważnych decyzji, a jedną z nich jest to, jaki stos technologiczny należy zastosować w nowej usłudze.

W tym poście dzielimy się historią o wyodrębnieniu nowej usługi z monolitycznej aplikacji – Platformy Toptal . Wyjaśniamy, jaki stos techniczny wybraliśmy i dlaczego, oraz przedstawiamy kilka problemów, które napotkaliśmy podczas wdrażania usługi.

Usługa Kroniki Toptal to aplikacja, która obsługuje wszystkie działania użytkownika wykonywane na platformie Toptal. Akcje są zasadniczo wpisami dziennika. Kiedy użytkownik coś robi (np. publikuje post na blogu, zatwierdza zadanie itp.), tworzony jest nowy wpis w dzienniku.

Chociaż pochodzi z naszej Platformy, zasadniczo nie zależy od niej i może być używany z dowolną inną aplikacją. Dlatego publikujemy szczegółowy opis procesu i omawiamy szereg wyzwań, z jakimi musiał się zmierzyć nasz zespół inżynierów podczas przechodzenia na nowy stos.

Istnieje wiele powodów, dla których zdecydowaliśmy się wyodrębnić usługę i poprawić stos:

  • Chcieliśmy, aby inne usługi mogły rejestrować zdarzenia, które mogłyby być wyświetlane i używane gdzie indziej.
  • Rozmiar tabel bazy danych przechowujących rekordy historii rósł szybko i nieliniowo, co wiązało się z wysokimi kosztami operacyjnymi.
  • Uznaliśmy, że dotychczasowe wdrożenie było obciążone długiem technicznym.

Tabela działań - tabele bazy danych

Na pierwszy rzut oka wydawało się to prostą inicjatywą. Jednak radzenie sobie z alternatywnymi stosami technologii ma tendencję do tworzenia nieoczekiwanych wad i właśnie to ma na celu rozwiązanie dzisiejszego artykułu.

Przegląd architektury

Aplikacja Chronicles składa się z trzech części, które mogą być mniej lub bardziej niezależne i są uruchamiane w osobnych kontenerach Dockera.

  • Konsument Kafki jest bardzo chudym, opartym na Karafce konsumentem przekazów tworzenia wpisów. Wszystkie otrzymane wiadomości umieszcza w kolejce do Sidekiq.
  • Pracownik Sidekiq to pracownik przetwarzający wiadomości Kafki i tworzący wpisy w tabeli bazy danych.
  • Punkty końcowe GraphQL:
    • Publiczny punkt końcowy udostępnia interfejs API wyszukiwania wpisów, który jest używany do różnych funkcji platformy (np. do renderowania etykietek narzędzi komentarzy na przyciskach ekranowania lub wyświetlania historii zmian zadań).
    • Wewnętrzny punkt końcowy zapewnia możliwość tworzenia reguł tagów i szablonów na podstawie migracji danych.

Kroniki używane do łączenia się z dwiema różnymi bazami danych:

  • Własna baza danych (gdzie przechowujemy reguły i szablony tagów)
  • Baza danych Platformy (gdzie przechowujemy akcje wykonywane przez użytkowników oraz ich tagi i tagi)

W procesie wyodrębniania aplikacji dokonaliśmy migracji danych z bazy danych Platformy i zamknęliśmy połączenie Platformy.

Początkowy plan

Początkowo zdecydowaliśmy się na Hanami i cały ekosystem, który zapewnia domyślnie (model hanami wspierany przez ROM.rb, dry-rb, hanami-newrelic itp.). Podążanie za „standardowym” sposobem działania obiecało nam niskie tarcie, dużą szybkość implementacji i bardzo dobrą „googleability” wszelkich problemów, z którymi możemy się spotkać. Ponadto ekosystem hanami jest dojrzały i popularny, a biblioteka jest starannie utrzymywana przez szanowanych członków społeczności Ruby.

Co więcej, duża część systemu została już zaimplementowana po stronie Platformy (np. punkt końcowy GraphQL Entry Search i operacja CreateEntry), więc zaplanowaliśmy skopiowanie dużej części kodu z Platformy do Chronicles bez wprowadzania jakichkolwiek zmian. Był to również jeden z głównych powodów, dla których nie zdecydowaliśmy się na Elixir, ponieważ Elixir na to nie pozwolił.

Zdecydowaliśmy się nie robić Railsów, ponieważ wydawało się to przesadą dla tak małego projektu, zwłaszcza rzeczy takich jak ActiveSupport, które nie przyniosłyby wielu wymiernych korzyści dla naszych potrzeb.

Kiedy plan idzie na południe

Chociaż staraliśmy się trzymać tego planu, szybko został on wykolejony z wielu powodów. Jednym z nich był brak doświadczenia z wybranym stosem, a następnie prawdziwe problemy z samym stosem, a następnie nasza niestandardowa konfiguracja (dwie bazy danych). W końcu postanowiliśmy pozbyć się modelu hanami-model , a następnie samego Hanami, zastępując go Sinatrą.

Wybraliśmy Sinatrę, ponieważ jest to aktywnie utrzymywana biblioteka stworzona 12 lat temu, a ponieważ jest to jedna z najpopularniejszych bibliotek, wszyscy w zespole mieli z nią duże doświadczenie.

Niezgodne zależności

Ekstrakcja Chronicles rozpoczęła się w czerwcu 2019 roku i wtedy Hanami nie było kompatybilne z najnowszymi wersjami klejnotów dry-rb. Mianowicie, najnowsza wersja Hanami w tym czasie (1.3.1) obsługiwała tylko dry-validation 0.12, a my chcieliśmy dry-validation 1.0.0. Planowaliśmy wykorzystać kontrakty z walidacji na sucho, które zostały wprowadzone dopiero w wersji 1.0.0.

Ponadto Kafka 1.2 jest niekompatybilna z suchymi klejnotami, więc używaliśmy jej wersji repozytorium. Obecnie używamy wersji 1.3.0.rc1, która zależy od najnowszych suchych klejnotów.

Niepotrzebne zależności

Dodatkowo klejnot Hanami zawierał zbyt wiele zależności, których nie planowaliśmy używać, takich jak hanami-cli , hanami-assets , hanami-mailer , hanami-view , a nawet hanami-controller . Ponadto, patrząc na plik readme modelu hanami, stało się jasne, że domyślnie obsługuje on tylko jedną bazę danych. Z drugiej strony, ROM.rb, na którym oparty jest hanami-model , obsługuje konfiguracje z wieloma bazami danych od razu po wyjęciu z pudełka.

Podsumowując, Hanami w ogóle, a model hanami-model w szczególności, wyglądał jak niepotrzebny poziom abstrakcji.

Tak więc, 10 dni po tym, jak zrobiliśmy pierwszy sensowny PR do Chronicles, całkowicie zastąpiliśmy hanami Sinatrą. Mogliśmy również użyć czystego Racka, ponieważ nie potrzebujemy skomplikowanego routingu (mamy cztery „statyczne” punkty końcowe - dwa punkty końcowe GraphQL, punkt końcowy /ping i interfejs sieciowy sidekiq), ale postanowiliśmy nie iść zbyt hardkorowo. Sinatra bardzo nam odpowiadała. Jeśli chcesz dowiedzieć się więcej, zapoznaj się z naszym samouczkiem Sinatra i Sequel.

Nieporozumienia dotyczące schematów suchych i walidacji

Zajęło nam trochę czasu i wiele prób i błędów, aby dowiedzieć się, jak prawidłowo „ugotować” walidację na sucho.

 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

W powyższym fragmencie parametr url jest zdefiniowany na kilka nieco innych sposobów. Niektóre definicje są równoważne, a inne nie mają żadnego sensu. Na początku nie mogliśmy odróżnić wszystkich tych definicji, ponieważ nie w pełni ich rozumieliśmy. W rezultacie pierwsza wersja naszych kontraktów była dość chaotyczna. Z czasem nauczyliśmy się poprawnie czytać i pisać kontrakty SUCHE, a teraz wyglądają one konsekwentnie i elegancko – w rzeczywistości nie tylko elegancko, są po prostu piękne. Weryfikujemy nawet konfigurację aplikacji z umowami.

Problemy z ROM.rb i Sequel

ROM.rb i Sequel różnią się od ActiveRecord, bez niespodzianki. Nasz początkowy pomysł, że będziemy mogli skopiować i wkleić większość kodu z Platformy, nie powiódł się. Problem polega na tym, że część Platformy była bardzo ciężka w AR, więc prawie wszystko musiało zostać przepisane w ROM/Sequel. Udało nam się skopiować tylko małe fragmenty kodu, które były niezależne od frameworka. Po drodze napotkaliśmy kilka frustrujących problemów i kilka błędów.

Filtrowanie według podzapytania

Na przykład zajęło mi kilka godzin, aby dowiedzieć się, jak utworzyć podzapytanie w ROM.rb/Sequel. To jest coś, co napisałbym nawet bez budzenia się w Railsach: scope.where(sequence_code: subquery ). W Sequelu okazało się jednak, że nie jest to takie proste.

 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

Więc zamiast prostego jednolinijkowego kodu, takiego jak base_query.where(sequence_code: bild_subquery(params)) , musimy mieć tuzin wierszy z nietrywialnym kodem, surowe fragmenty SQL i wielowierszowy komentarz wyjaśniający, co spowodowało ten niefortunny przypadek nadąć.

Powiązania z nietrywialnymi polami łączenia

Relacja entry (tabela performed_actions ) ma podstawowe pole id . Jednak aby połączyć się z tabelami *taggings , używa kolumny sequence_code . W ActiveRecord wyraża się to dość prosto:

 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

Możliwe jest również napisanie tego samego w ROM-ie.

 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

Był jednak z tym mały problem. Kompilowałby się dobrze, ale zawodził w czasie wykonywania, gdy faktycznie próbowałeś go użyć.

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

Mamy szczęście, że typy id i sequence_code są różne, więc PG zgłasza błąd typu. Gdyby typy były takie same, kto wie, ile godzin poświęciłbym na debugowanie tego.

Tak więc entries.join(:access_taggings) nie działa. Co się stanie, jeśli wyraźnie określimy warunek złączenia? Jak we entries.join(:access_taggings, performed_action_sequence_code: :sequence_code) , jak sugeruje oficjalna dokumentacja.

 [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

Teraz myśli, że :access_taggings jest z jakiegoś powodu nazwą tabeli. Dobrze, zamieńmy to na rzeczywistą nazwę tabeli.

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

W końcu coś zwróciło i nie zawiodło, chociaż skończyło się na nieszczelnej abstrakcji. Nazwa tabeli nie powinna przeciekać do kodu aplikacji.

Interpolacja parametrów SQL

W wyszukiwaniu Chronicles dostępna jest funkcja, która umożliwia użytkownikom wyszukiwanie według ładunku. Zapytanie wygląda tak: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"} , gdzie path jest zawsze tablicą ciągów, a wartość to dowolna prawidłowa wartość JSON.

W ActiveRecord wygląda to tak:

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

W Sequelu nie udało mi się poprawnie interpolować :path , więc musiałem się do tego odwołać:

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

Na szczęście path tutaj jest poprawnie zweryfikowana, więc zawiera tylko znaki alfanumeryczne, ale ten kod nadal wygląda śmiesznie.

Cicha magia fabryki ROM

Wykorzystaliśmy klejnot rom-factory aby uprościć tworzenie naszych modeli w testach. Jednak kilka razy kod nie działał zgodnie z oczekiwaniami. Czy wiesz, co jest nie tak z tym testem?

 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)

Nie, oczekiwanie nie zawodzi, oczekiwanie jest w porządku.

Problem polega na tym, że druga linia kończy się niepowodzeniem z unikalnym błędem walidacji ograniczenia. Powodem jest to, że action nie jest atrybutem, który posiada model Action . Prawdziwa nazwa to action_name , więc właściwy sposób tworzenia akcji powinien wyglądać tak:

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

Ponieważ błędnie wpisany atrybut został zignorowany, powraca do domyślnego atrybutu określonego w fabryce ( action_name { 'created' } ) i mamy unikatowe naruszenie ograniczenia, ponieważ próbujemy utworzyć dwie identyczne akcje. Kilka razy mieliśmy do czynienia z tym problemem, co okazało się bardzo trudne.

Na szczęście został naprawiony w wersji 0.9.0. Dependabot automatycznie wysłał nam pull request z aktualizacją biblioteki, którą połączyliśmy po naprawieniu kilku błędnie wpisanych atrybutów, które mieliśmy w naszych testach.

Ergonomia ogólna

To mówi wszystko:

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

A różnica jest jeszcze większa w bardziej skomplikowanych przykładach.

Dobre części

To nie był tylko ból, pot i łzy. W naszej podróży było wiele dobrych rzeczy, które znacznie przewyższają negatywne aspekty nowego stacka. Gdyby tak nie było, nie zrobilibyśmy tego w pierwszej kolejności.

Test prędkości

Uruchomienie całego zestawu testów lokalnie zajmuje 5-10 sekund, podobnie jak RuboCop. Czas CI jest znacznie dłuższy (3-4 minuty), ale jest to mniejszy problem, ponieważ i tak wszystko możemy uruchomić lokalnie, dzięki czemu wszystko, co zawiedzie na CI, jest znacznie mniej prawdopodobne.

Klejnot straży znów stał się użyteczny. Wyobraź sobie, że możesz pisać kod i uruchamiać testy przy każdym zapisie, dając bardzo szybką informację zwrotną. Bardzo trudno to sobie wyobrazić podczas pracy z Platformą.

Czasy wdrażania

Czas wdrożenia wyodrębnionej aplikacji Chronicles to zaledwie dwie minuty. Nie błyskawicznie, ale nadal nieźle. Wdrażamy bardzo często, więc nawet drobne usprawnienia mogą generować spore oszczędności.

Wydajność aplikacji

Najbardziej wymagającą wydajnością częścią Chronicles jest wyszukiwanie wpisów. Na razie w zapleczu Platformy jest około 20 miejsc, które pobierają wpisy historii z Kronik. Oznacza to, że czas reakcji Chronicles przyczynia się do 60-sekundowego budżetu Platformy na czas reakcji, więc Chronicles musi być szybki i tak właśnie jest.

Pomimo ogromnego rozmiaru dziennika działań (30 milionów wierszy i rośnie), średni czas odpowiedzi wynosi mniej niż 100 ms. Spójrz na ten piękny wykres:

Wykres wydajności aplikacji

W bazie danych spędza się średnio 80-90% czasu aplikacji. Tak powinien wyglądać właściwy wykres wydajności.

Nadal mamy kilka powolnych zapytań, które mogą zająć dziesiątki sekund, ale mamy już plan, jak je wyeliminować, dzięki czemu wyodrębniona aplikacja będzie jeszcze szybsza.

Struktura

Dla naszych celów walidacja na sucho jest bardzo potężnym i elastycznym narzędziem. Wszystkie dane wejściowe ze świata zewnętrznego przekazujemy za pomocą umów, co daje nam pewność, że parametry wejściowe są zawsze dobrze uformowane i mają dobrze zdefiniowane typy.

Nie ma już potrzeby wywoływania .to_s.to_sym.to_i w kodzie aplikacji, ponieważ wszystkie dane są czyszczone i typowane na granicach aplikacji. W pewnym sensie wprowadza silne rodzaje zdrowego rozsądku do dynamicznego świata Ruby. Nie mogę go wystarczająco polecić.

Ostatnie słowa

Wybór niestandardowego stosu nie był tak prosty, jak się początkowo wydawało. Rozważaliśmy wiele aspektów przy wyborze frameworka i bibliotek do użycia w nowej usłudze: aktualny stos technologiczny aplikacji monolitowej, znajomość nowego stosu przez zespół, sposób utrzymania wybranego stosu i tak dalej.

Mimo że od samego początku staraliśmy się podejmować bardzo ostrożne i wyrachowane decyzje – zdecydowaliśmy się na użycie standardowego stosu Hanami – po drodze musieliśmy ponownie przemyśleć nasz stos ze względu na niestandardowe wymagania techniczne projektu. Skończyło się na Sinatrze i stacku opartym na DRY.

Czy wybralibyśmy ponownie Hanami, gdybyśmy mieli wyodrębnić nową aplikację? Prawdopodobnie tak. Teraz wiemy więcej o bibliotece i jej zaletach i wadach, dzięki czemu możemy podejmować bardziej świadome decyzje od samego początku każdego nowego projektu. Jednak poważnie rozważylibyśmy użycie zwykłej aplikacji Sinatra/DRY.rb.

Podsumowując, czas zainwestowany w naukę nowych frameworków, paradygmatów lub języków programowania daje nam świeże spojrzenie na nasz obecny stos technologiczny. Zawsze dobrze jest wiedzieć, co jest dostępne, aby wzbogacić swój zestaw narzędzi. Każde narzędzie ma swój własny, unikalny przypadek użycia — dlatego lepsze ich poznanie oznacza posiadanie ich większej liczby i przekształcenie ich w lepiej dopasowane do aplikacji.