Bir Teknoloji Yığını Alternatifi Seçme - İnişler ve Çıkışlar
Yayınlanan: 2022-03-11Bir web uygulaması yeterince büyük ve eskiyse, onu daha küçük, izole parçalara ayırmanız ve ondan bazısı diğerlerinden daha bağımsız olacak hizmetler çıkarmanız gereken bir zaman gelebilir. Böyle bir kararı gerektirebilecek nedenlerden bazıları şunlardır: testleri çalıştırma süresini kısaltmak, uygulamanın farklı bölümlerini bağımsız olarak dağıtabilmek veya alt sistemler arasında sınırları zorlamak. Hizmet çıkarma, yazılım mühendislerinin birçok hayati karar vermesini gerektirir ve bunlardan biri de yeni hizmet için hangi teknoloji yığınının kullanılacağıdır.
Bu gönderide, monolitik bir uygulama olan Toptal Platform'dan yeni bir hizmet çıkarma hakkında bir hikaye paylaşıyoruz. Hangi teknik yığını seçtiğimizi ve neden seçtiğimizi açıklıyoruz ve hizmetin uygulanması sırasında karşılaştığımız birkaç sorunu özetliyoruz.
Toptal'ın Chronicles hizmeti, Toptal Platformunda gerçekleştirilen tüm kullanıcı eylemlerini yöneten bir uygulamadır. Eylemler aslında günlük girişleridir. Bir kullanıcı bir şey yaptığında (ör. bir blog gönderisi yayınladığında, bir işi onayladığında, vb.), yeni bir günlük girişi oluşturulur.
Platformumuzdan çıkarılmış olmasına rağmen, temelde ona bağlı değildir ve başka herhangi bir uygulama ile kullanılabilir. Bu nedenle, sürecin ayrıntılı bir hesabını yayınlıyoruz ve mühendislik ekibimizin yeni yığına geçerken üstesinden gelmek zorunda olduğu bir dizi zorluğu tartışıyoruz.
Hizmeti çıkarma ve yığını iyileştirme kararımızın arkasında birkaç neden var:
- Diğer hizmetlerin, başka yerlerde görüntülenebilecek ve kullanılabilecek olayları günlüğe kaydedebilmesini istedik.
- Geçmiş kayıtlarını depolayan veritabanı tablolarının boyutu hızlı ve doğrusal olmayan bir şekilde büyüyerek yüksek işletme maliyetlerine neden oldu.
- Mevcut uygulamanın teknik borç yükü olduğunu düşündük.
İlk bakışta, basit bir girişim gibi görünüyordu. Bununla birlikte, alternatif teknoloji yığınlarıyla uğraşmak beklenmedik dezavantajlar yaratma eğilimindedir ve bugünün makalesinin ele almayı amaçladığı şey budur.
Mimariye Genel Bakış
Chronicles uygulaması, az çok bağımsız olabilen ve ayrı Docker kapsayıcılarında çalıştırılan üç bölümden oluşur.
- Kafka tüketicisi , giriş oluşturma mesajlarının çok ince bir Karafka tabanlı Kafka tüketicisidir. Alınan tüm mesajları Sidekiq'e sıralar.
- Sidekiq çalışanı , Kafka mesajlarını işleyen ve veritabanı tablosunda girişler oluşturan bir çalışandır.
- GraphQL uç noktaları:
- Genel uç nokta , çeşitli Platform işlevleri için kullanılan giriş arama API'sini ortaya çıkarır (örneğin, tarama düğmelerinde yorum araç ipuçlarını işlemek veya iş değişikliklerinin geçmişini görüntülemek için).
- Dahili uç nokta , veri geçişlerinden etiket kuralları ve şablonlar oluşturma yeteneği sağlar.
İki farklı veritabanına bağlanmak için kullanılan günlükler:
- Kendi veritabanı (etiket kurallarını ve şablonlarını sakladığımız yer)
- Platform veritabanı (kullanıcı tarafından gerçekleştirilen eylemleri ve bunların etiketlerini ve etiketlemelerini depoladığımız yer)
Uygulamayı çıkarma sürecinde, Platform veritabanından veri taşıdık ve Platform bağlantısını kapattık.
Başlangıç planı
Başlangıçta, Hanami'yi ve varsayılan olarak sağladığı tüm ekosistemi (ROM.rb, dry-rb, hanami-newrelic, vb. tarafından desteklenen bir hanami modeli) kullanmaya karar verdik. İşleri yapmanın "standart" bir yolunu takip etmek, bize düşük sürtüşme, harika uygulama hızı ve karşılaşabileceğimiz herhangi bir sorun için çok iyi bir "googlelanabilirlik" vaat etti. Ayrıca, hanami ekosistemi olgun ve popülerdir ve kütüphane, Ruby topluluğunun saygın üyeleri tarafından özenle korunur.
Ayrıca, sistemin büyük bir kısmı Platform tarafında zaten uygulanmıştı (örneğin, GraphQL Entry Search uç noktası ve CreateEntry işlemi), bu yüzden birçok kodu Platform'dan Chronicles'a olduğu gibi, herhangi bir değişiklik yapmadan kopyalamayı planladık. Elixir buna izin vermeyeceğinden, Elixir'i kullanmamamızın temel nedenlerinden biri de buydu.
Rails yapmamaya karar verdik çünkü bu kadar küçük bir proje için, özellikle de ihtiyaçlarımız için pek çok somut fayda sağlamayan ActiveSupport gibi şeyler için fazla abartı gibi geldi.
Plan Güneye Gittiğinde
Plana bağlı kalmak için elimizden gelenin en iyisini yapmamıza rağmen, birkaç nedenden dolayı kısa sürede raydan çıktı. Biri, seçilen yığınla ilgili deneyim eksikliğimiz, ardından yığının kendisiyle ilgili gerçek sorunlar ve ardından standart olmayan kurulumumuz (iki veritabanı) vardı. Sonunda, hanami-model
ve ardından Hanami'nin kendisinden kurtulmaya karar verdik ve yerine Sinatra koyduk.
Sinatra'yı seçtik çünkü 12 yıl önce oluşturulmuş, aktif olarak bakımı yapılan bir kütüphane ve en popüler kütüphanelerden biri olduğu için, ekipteki herkesin onunla bolca uygulamalı deneyimi oldu.
Uyumsuz Bağımlılıklar
Chronicles çıkarma Haziran 2019'da başladı ve o zamanlar Hanami, kuru rb taşlarının en son sürümleriyle uyumlu değildi. Yani, o zamanki Hanami'nin en son sürümü (1.3.1) yalnızca kuru doğrulama 0.12'yi destekliyordu ve biz de kuru doğrulama 1.0.0 istedik. Yalnızca 1.0.0'da tanıtılan kuru doğrulamadan gelen sözleşmeleri kullanmayı planladık.
Ayrıca, Kafka 1.2 kuru taşlarla uyumlu değil, bu yüzden onun depo versiyonunu kullanıyorduk. Şu anda, en yeni kuru taşlara bağlı olan 1.3.0.rc1 kullanıyoruz.
Gereksiz Bağımlılıklar
Ek olarak, Hanami gem, hanami-cli
, hanami-assets
, hanami-mailer
, hanami-view
ve hatta hanami-controller
gibi kullanmayı planlamadığımız çok fazla bağımlılık içeriyordu. Ayrıca, hanami modeli benioku dosyasına bakıldığında, varsayılan olarak yalnızca bir veritabanını desteklediği ortaya çıktı. Öte yandan, hanami hanami-model
dayandığı ROM.rb, kutudan çıktığı gibi çoklu veritabanı yapılandırmalarını destekler.
Sonuç olarak, genel olarak Hanami ve özel olarak hanami-model
gereksiz bir soyutlama düzeyi gibi görünüyordu.
Chronicles'a ilk anlamlı PR'ı yaptıktan 10 gün sonra, hanami'yi tamamen Sinatra ile değiştirdik. Saf Rack'i de kullanabilirdik çünkü karmaşık yönlendirmeye ihtiyacımız yok (dört “statik” uç noktamız var - iki GraphQL uç noktası, /ping uç noktası ve sidekiq web arayüzü), ancak fazla sert olmamaya karar verdik. Sinatra bize çok yakıştı. Daha fazlasını öğrenmek isterseniz, Sinatra ve Sequel eğitimimize göz atın.
Kuru Şema ve Kuru Doğrulama Yanlış Anlamaları
Kuru doğrulamayı doğru bir şekilde nasıl "pişireceğimizi" bulmak biraz zamanımızı ve çok fazla deneme yanılmamızı aldı.
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
Yukarıdaki snippet'te, url
parametresi birkaç farklı şekilde tanımlanmıştır. Bazı tanımlar eşdeğerdir ve diğerleri hiçbir anlam ifade etmez. Başlangıçta, tam olarak anlamadığımız için tüm bu tanımlar arasındaki farkı gerçekten söyleyemedik. Sonuç olarak, sözleşmelerimizin ilk versiyonu oldukça dağınıktı. Zamanla, DRY sözleşmelerini nasıl düzgün bir şekilde okuyup yazacağımızı öğrendik ve şimdi tutarlı ve zarif görünüyorlar – aslında sadece zarif değiller, aynı zamanda güzelden başka bir şey değiller. Hatta uygulama yapılandırmasını sözleşmelerle doğrularız.
ROM.rb ve Sequel ile ilgili sorunlar
ROM.rb ve Sequel, ActiveRecord'dan farklıdır, sürpriz değil. Kodun çoğunu Platform'dan kopyalayıp yapıştırabileceğimize dair ilk fikrimiz başarısız oldu. Sorun şu ki, Platform kısmı çok AR ağırlıklıydı, bu yüzden neredeyse her şeyin ROM/Sequel'de yeniden yazılması gerekiyordu. Çerçeveden bağımsız olan kodun yalnızca küçük bölümlerini kopyalamayı başardık. Yol boyunca, birkaç sinir bozucu sorunla ve bazı hatalarla karşılaştık.
Alt sorguya göre filtreleme
Örneğin, ROM.rb/Sequel'de nasıl alt sorgu yapılacağını bulmam birkaç saatimi aldı. Bu, Rails'de uyanmadan bile yazacağım bir şey: scope.where(sequence_code: subquery
). Ancak Sequel'de bunun o kadar kolay olmadığı ortaya çıktı.
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
Bu nedenle, base_query.where(sequence_code: bild_subquery(params))
gibi basit bir satır yerine, önemsiz kod, ham SQL parçaları ve bu talihsiz duruma neyin neden olduğunu açıklayan çok satırlı bir yorum içeren bir düzine satıra sahip olmamız gerekir. kabartmak.
Önemsiz Birleştirme Alanlarıyla İlişkilendirmeler
entry
ilişkisinin ( performed_actions
tablosu) bir birincil id
alanı vardır. Ancak *taggings
sequence_code
kullanır. ActiveRecord'da oldukça basit bir şekilde ifade edilir:
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
Aynısını ROM'da da yazmak mümkündür.
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
Bununla birlikte küçük bir sorun vardı. İyi bir şekilde derlenir, ancak gerçekten kullanmaya çalıştığınızda çalışma zamanında başarısız olur.

[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...
Kimlik ve sequence_code
türleri farklı olduğu için şanslıyız, bu nedenle PG bir tür hatası veriyor. Tipler aynı olsaydı, bunu hata ayıklamak için kim bilir kaç saat harcardım.
Dolayısıyla, entries.join(:access_taggings)
çalışmıyor. Birleştirme koşulunu açıkça belirtirsek ne olur? Resmi belgelerin önerdiği gibi entries.join(:access_taggings, performed_action_sequence_code: :sequence_code)
olduğu gibi.
[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
Şimdi :access_taggings
bir nedenden dolayı bir tablo adı olduğunu düşünüyor. Pekala, gerçek tablo adıyla değiştirelim.
[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>]
Sonunda, bir şey döndürdü ve sızdıran bir soyutlama ile sonuçlanmasına rağmen başarısız olmadı. Tablo adı uygulama koduna sızmamalıdır.
SQL Parametre İnterpolasyonu
Chronicles aramasında, kullanıcıların yüke göre arama yapmasına izin veren bir özellik vardır. Sorgu şöyle görünür: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"}
, burada path
her zaman bir dize dizisidir ve değer herhangi bir geçerli JSON değeridir.
ActiveRecord'da şöyle görünür:
@scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)
Sequel'de :path
'u düzgün bir şekilde enterpolasyon yapmayı başaramadım, bu yüzden buna başvurmak zorunda kaldım:
base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))
Neyse ki, buradaki path
, yalnızca alfasayısal karakterler içerecek şekilde doğru bir şekilde doğrulanmıştır, ancak bu kod yine de komik görünmektedir.
ROM-fabrikasının Sessiz Büyüsü
Testlerde modellerimizin oluşturulmasını basitleştirmek için rom-factory
mücevherini kullandık. Ancak birkaç kez kod beklendiği gibi çalışmadı. Bu testte neyin yanlış olduğunu tahmin edebilir misiniz?
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)
Hayır, beklenti boşa gitmiyor, beklenti iyi.
Sorun, ikinci satırın benzersiz bir kısıtlama doğrulama hatasıyla başarısız olmasıdır. Bunun nedeni, action
Action
modelinin sahip olduğu nitelik olmamasıdır. Gerçek ad action_name
, bu nedenle eylemler oluşturmanın doğru yolu şöyle görünmelidir:
RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']
Yanlış yazılan öznitelik yok sayıldığından, fabrikada belirtilen varsayılan değere geri döner ( action_name { 'created' }
) ve iki özdeş eylem oluşturmaya çalıştığımız için benzersiz bir kısıtlama ihlalimiz var. Bu konuyla birkaç kez uğraşmak zorunda kaldık, bu da vergiyi kanıtladı.
Neyse ki, 0.9.0'da düzeltildi. Dependabot, testlerimizde sahip olduğumuz birkaç yanlış yazılmış özelliği düzelttikten sonra birleştirdiğimiz kitaplık güncellemesiyle otomatik olarak bize bir çekme isteği gönderdi.
Genel Ergonomi
Bu her şeyi söylüyor:
# ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_
Ve daha karmaşık örneklerde fark daha da büyüktür.
İyi Parçalar
Hepsi acı, ter ve gözyaşı değildi. Yolculuğumuzda pek çok iyi şey vardı ve bunlar yeni yığının olumsuz yönlerinden çok daha ağır basıyor. Eğer durum böyle olmasaydı, ilk etapta bunu yapmazdık.
Hız testi
Tüm test takımını yerel olarak çalıştırmak 5-10 saniye sürer ve RuboCop için o kadar uzun sürer. CI süresi çok daha uzundur (3-4 dakika), ancak bu daha az sorun yaratır çünkü her şeyi yerel olarak zaten çalıştırabiliriz, bu sayede CI'de başarısız olan herhangi bir şey çok daha az olasıdır.
Muhafız mücevheri tekrar kullanılabilir hale geldi. Her kayıtta kod yazabileceğinizi ve testler yapabileceğinizi ve size çok hızlı geri bildirim sağlayabileceğinizi hayal edin. Platform ile çalışırken bunu hayal etmek çok zor.
Dağıtım Süreleri
Çıkarılan Chronicles uygulamasını dağıtma süresi sadece iki dakikadır. Şimşek hızında değil ama yine de fena değil. Çok sık dağıtım yapıyoruz, bu nedenle küçük iyileştirmeler bile önemli tasarruflar sağlayabilir.
Uygulama Performansı
Chronicles'ın en yoğun performans gerektiren kısmı Giriş aramasıdır. Şimdilik, Platform arka ucunda Chronicles'dan geçmiş girdileri getiren yaklaşık 20 yer var. Bu, Chronicles'ın yanıt süresinin, Platformun yanıt süresi için 60 saniyelik bütçesine katkıda bulunduğu anlamına gelir, bu nedenle Chronicles'ın hızlı olması gerekir, ki öyle.
Eylem günlüğünün devasa boyutuna (30 milyon satır ve artan) rağmen, ortalama yanıt süresi 100 ms'den azdır. Bu güzel tabloya bir göz atın:
Ortalama olarak, uygulama süresinin %80-90'ı veritabanında geçirilir. Doğru bir performans tablosu böyle görünmelidir.
Hala onlarca saniye sürebilecek bazı yavaş sorgularımız var, ancak bunları nasıl ortadan kaldıracağımıza dair bir planımız var, böylece ayıklanan uygulamanın daha da hızlı olmasını sağlıyoruz.
Yapı
Amaçlarımız için, kuru doğrulama çok güçlü ve esnek bir araçtır. Dış dünyadan gelen tüm girdileri sözleşmeler aracılığıyla iletiyoruz ve bu, girdi parametrelerinin her zaman iyi biçimlendirilmiş ve iyi tanımlanmış türlerde olduğundan emin olmamızı sağlıyor.
Artık tüm veriler temizlendiğinden ve uygulamanın sınırlarına yazıldığından, uygulama kodunda .to_s.to_sym.to_i
çağrısına gerek yoktur. Bir anlamda, dinamik Ruby dünyasına güçlü akıl sağlığı türleri getiriyor. Yeterince tavsiye edemem.
Son sözler
Standart olmayan bir yığın seçmek başlangıçta göründüğü kadar kolay değildi. Yeni hizmet için kullanılacak çerçeveyi ve kitaplıkları seçerken birçok yönü göz önünde bulundurduk: monolit uygulamasının mevcut teknoloji yığını, ekibin yeni yığına aşinalığı, seçilen yığının bakımının nasıl olduğu vb.
En başından beri çok dikkatli ve hesaplı kararlar vermeye çalışsak da - standart Hanami yığını kullanmayı seçtik - projenin standart dışı teknik gereksinimleri nedeniyle yol boyunca yığınımızı yeniden gözden geçirmek zorunda kaldık. Sinatra ve DRY tabanlı bir yığınla sonuçlandık.
Yeni bir uygulama çıkarsaydık tekrar Hanami'yi seçer miydik? Muhtemelen evet. Artık kütüphane ve artıları ve eksileri hakkında daha fazla şey biliyoruz, böylece herhangi bir yeni projenin başlangıcından itibaren daha bilinçli kararlar verebiliriz. Ancak, düz bir Sinatra/DRY.rb uygulaması kullanmayı da ciddi olarak düşünürüz.
Sonuç olarak, yeni çerçeveler, paradigmalar veya programlama dilleri öğrenmeye harcanan zaman bize mevcut teknoloji yığınımız hakkında yeni bir bakış açısı sağlıyor. Araç kutunuzu zenginleştirmek için orada nelerin mevcut olduğunu bilmek her zaman iyidir. Her aracın kendine özgü bir kullanım durumu vardır; bu nedenle, onları daha iyi tanımak, daha fazlasına sahip olmak ve bunları uygulamanız için daha uygun hale getirmek anlamına gelir.