選擇技術堆棧替代方案 - 起起落落
已發表: 2022-03-11如果一個 Web 應用程序足夠大且足夠老,那麼有時您可能需要將其分解為更小的、獨立的部分並從中提取服務,其中一些會比其他的更獨立。 可能促使做出此類決定的一些原因包括:減少運行測試的時間、能夠獨立部署應用程序的不同部分或強制子系統之間的邊界。 服務提取需要軟件工程師做出許多重要決定,其中之一就是為新服務使用什麼技術堆棧。
在這篇文章中,我們分享了一個關於從單一應用程序( Toptal 平台)中提取新服務的故事。 我們解釋了我們選擇了哪種技術棧以及為什麼選擇,並概述了我們在服務實現過程中遇到的一些問題。
Toptal 的 Chronicles 服務是處理在 Toptal 平台上執行的所有用戶操作的應用程序。 操作本質上是日誌條目。 當用戶做某事(例如發布博客文章、批准工作等)時,會創建一個新的日誌條目。
儘管從我們的平台中提取,但它基本上不依賴於它,並且可以與任何其他應用程序一起使用。 這就是為什麼我們要發布該過程的詳細說明,並討論我們的工程團隊在過渡到新堆棧時必須克服的一些挑戰。
我們決定提取服務並改進堆棧的原因有很多:
- 我們希望其他服務能夠記錄可以在其他地方顯示和使用的事件。
- 存儲歷史記錄的數據庫表的大小快速且非線性地增長,從而產生了高昂的運營成本。
- 我們認為現有的實施背負著技術債務。
乍一看,這似乎是一個簡單的舉措。 然而,處理替代技術堆棧往往會產生意想不到的缺點,這就是今天的文章旨在解決的問題。
架構概述
Chronicles 應用程序由三個部分組成,它們或多或少是獨立的,並且在單獨的 Docker 容器中運行。
- Kafka 消費者是一個非常瘦的基於 Karafka 的條目創建消息的 Kafka 消費者。 它將所有收到的消息排隊到 Sidekiq。
- Sidekiq worker是一個處理 Kafka 消息並在數據庫表中創建條目的 worker。
- GraphQL 端點:
- 公共端點公開條目搜索 API,用於各種平台功能(例如,在篩選按鈕上呈現評論工具提示,或顯示工作更改的歷史記錄)。
- 內部端點提供了從數據遷移創建標籤規則和模板的能力。
Chronicles 用於連接到兩個不同的數據庫:
- 它自己的數據庫(我們存儲標籤規則和模板的地方)
- 平台數據庫(我們存儲用戶執行的操作及其標籤和標記的地方)
在提取應用程序的過程中,我們從 Platform 數據庫中遷移了數據並關閉了 Platform 連接。
初步計劃
最初,我們決定使用 Hanami 及其默認提供的所有生態系統(由 ROM.rb、dry-rb、hanami-newrelic 等支持的 hanami 模型)。 遵循“標準”的做事方式向我們保證了低摩擦、高執行速度,以及我們可能面臨的任何問題的非常好的“googleability”。 此外,hanami 生態系統成熟且受歡迎,該庫由 Ruby 社區的受人尊敬的成員精心維護。
此外,系統的很大一部分已經在 Platform 端實現(例如,GraphQL Entry Search 端點和 CreateEntry 操作),因此我們計劃將大量代碼從 Platform 複製到 Chronicles,而不做任何更改。 這也是我們不使用 Elixir 的關鍵原因之一,因為 Elixir 不允許這樣做。
我們決定不使用 Rails,因為對於這樣一個小項目來說,它感覺有點矯枉過正,尤其是像 ActiveSupport 這樣的東西,它不會為我們的需求提供很多切實的好處。
當計劃南下
儘管我們盡最大努力堅持該計劃,但由於多種原因,它很快就脫軌了。 一是我們對所選堆棧缺乏經驗,其次是堆棧本身的真正問題,然後是我們的非標准設置(兩個數據庫)。 最後,我們決定去掉hanami-model
,然後去掉 Hanami 本身,用 Sinatra 取而代之。
我們選擇 Sinatra 是因為它是 12 年前創建的一個積極維護的庫,而且由於它是最受歡迎的庫之一,團隊中的每個人都有豐富的實踐經驗。
不兼容的依賴項
Chronicles 提取於 2019 年 6 月開始,當時 Hanami 與最新版本的 dry-rb 寶石不兼容。 即當時最新的 Hanami 版本(1.3.1)只支持幹驗證 0.12,而我們想要幹驗證 1.0.0。 我們計劃使用僅在 1.0.0 中引入的干驗證合約。
此外,Kafka 1.2 與乾 gem 不兼容,因此我們使用的是它的存儲庫版本。 目前我們使用的是1.3.0.rc1,依賴於最新的干寶石。
不必要的依賴
此外,Hanami gem 包含太多我們不打算使用的依賴項,例如hanami-cli
、 hanami-assets
、 hanami-mailer
、 hanami-view
,甚至hanami-controller
。 此外,查看 hanami-model 自述文件,很明顯它默認只支持一個數據庫。 另一方面, hanami-model
所基於的 ROM.rb 支持開箱即用的多數據庫配置。
總而言之,Hanami 和特別是hanami-model
看起來像是一個不必要的抽象級別。
因此,在我們對 Chronicles 進行第一次有意義的公關 10 天后,我們用 Sinatra 完全取代了 hanami。 我們也可以使用純 Rack,因為我們不需要復雜的路由(我們有四個“靜態”端點 - 兩個 GraphQL 端點、/ping 端點和 sidekiq Web 界面),但我們決定不要太硬核。 Sinatra 非常適合我們。 如果您想了解更多信息,請查看我們的 Sinatra 和 Sequel 教程。
Dry-schema 和 Dry-validation 誤解
我們花了一些時間和大量的反複試驗來弄清楚如何正確地“煮”幹驗證。
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
在上面的代碼片段中, url
參數的定義方式略有不同。 有些定義是等價的,有些則沒有任何意義。 一開始,我們無法真正區分所有這些定義,因為我們沒有完全理解它們。 結果,我們的合約的第一個版本非常混亂。 隨著時間的推移,我們學會瞭如何正確讀寫 DRY 合約,現在它們看起來一致且優雅——事實上,不僅優雅,而且非常漂亮。 我們甚至使用合約驗證應用程序配置。
ROM.rb 和 Sequel 的問題
ROM.rb 和 Sequel 與 ActiveRecord 不同,這不足為奇。 我們最初的想法是,我們將能夠從 Platform 複製和粘貼大部分代碼,但這個想法失敗了。 問題是平台部分非常依賴 AR,因此幾乎所有內容都必須在 ROM/Sequel 中重寫。 我們設法只複製了與框架無關的一小部分代碼。 在此過程中,我們遇到了一些令人沮喪的問題和一些錯誤。
按子查詢過濾
例如,我花了幾個小時才弄清楚如何在 ROM.rb/Sequel 中創建子查詢。 這是我什至不用在 Rails 中醒來就可以寫的東西: scope.where(sequence_code: subquery
)。 然而,在續集中,事實證明並不那麼容易。
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
因此,不是像base_query.where(sequence_code: bild_subquery(params))
這樣簡單的單行代碼,我們必須有十幾行包含非平凡代碼、原始 SQL 片段和多行註釋來解釋導致這種不幸情況的原因膨脹。
與非平凡連接字段的關聯
entry
關係( performed_actions
表)有一個主要的id
字段。 但是,要與*taggings
表連接,它使用sequence_code
列。 在 ActiveRecord 中,它的表達相當簡單:
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
也可以在 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
不過,它有一個小問題。 當您實際嘗試使用它時,它會編譯得很好,但在運行時會失敗。

[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...
我們很幸運 id 和sequence_code
的類型不同,所以 PG 拋出了類型錯誤。 如果類型相同,誰知道我會花多少時間來調試它。
因此, entries.join(:access_taggings)
不起作用。 如果我們明確指定連接條件怎麼辦? 正如官方文檔所建議的那樣,在entries.join(:access_taggings, performed_action_sequence_code: :sequence_code)
中。
[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
現在它認為:access_taggings
出於某種原因是一個表名。 好吧,讓我們用實際的表名交換它。
[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>]
最後,它返回了一些東西並且沒有失敗,儘管它最終導致了一個洩漏的抽象。 表名不應洩露給應用程序代碼。
SQL 參數插值
Chronicles 搜索中有一項功能允許用戶按有效負載進行搜索。 查詢如下所示: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"}
,其中path
始終是字符串數組,而 value是任何有效的 JSON 值。
在 ActiveRecord 中,它看起來像這樣:
@scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)
在 Sequel 中,我沒有設法正確插入:path
,所以我不得不求助於:
base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))
幸運的是,此處的path
經過了正確驗證,因此它只包含字母數字字符,但這段代碼看起來仍然很有趣。
ROM工廠的無聲魔法
我們使用rom-factory
gem 來簡化測試中模型的創建。 然而,有幾次,代碼沒有按預期工作。 你能猜出這個測試有什麼問題嗎?
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)
不,期望沒有失敗,期望很好。
問題是第二行因唯一約束驗證錯誤而失敗。 原因是action
不是Action
模型所具有的屬性。 真實名稱是action_name
,所以創建動作的正確方法應該是這樣的:
RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']
由於錯誤類型的屬性被忽略,它回退到工廠中指定的默認屬性 ( action_name { 'created' }
),並且我們有一個唯一的約束違規,因為我們試圖創建兩個相同的操作。 我們不得不多次處理這個問題,這被證明是很費力的。
幸運的是,它已在 0.9.0 中修復。 Dependabot 自動向我們發送了一個包含庫更新的拉取請求,我們在修復了我們在測試中遇到的一些錯誤輸入的屬性後將其合併。
一般人體工程學
這說明了一切:
# ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_
在更複雜的示例中,差異甚至更大。
好的部分
不全是痛苦、汗水和眼淚。 我們的旅程中有很多很多的好東西,它們遠遠超過了新堆棧的負面影響。 如果不是這樣,我們一開始就不會這樣做。
測試速度
在本地運行整個測試套件需要 5 到 10 秒,而 RuboCop 則需要這麼長的時間。 CI 時間要長得多(3-4 分鐘),但這不是什麼問題,因為我們無論如何都可以在本地運行所有內容,因此,任何在 CI 上失敗的可能性都小得多。
守護寶石再次變得可用。 想像一下,您可以在每次保存時編寫代碼並運行測試,從而為您提供非常快速的反饋。 在使用平台時,這是很難想像的。
部署時間
部署提取的 Chronicles 應用程序只需兩分鐘。 不是閃電般快速,但仍然不錯。 我們經常部署,因此即使是很小的改進也可以節省大量資金。
應用程序性能
Chronicles 中性能最密集的部分是條目搜索。 目前,平台後端大約有 20 個地方從 Chronicles 中獲取歷史條目。 這意味著 Chronicles 的響應時間有助於平台的 60 秒響應時間預算,因此 Chronicles 必須快速,事實就是如此。
儘管操作日誌的大小很大(3000 萬行,並且還在增長),但平均響應時間不到 100 毫秒。 看看這張漂亮的圖表:
平均而言,80-90% 的應用程序時間花在數據庫中。 這就是正確的性能圖表應該是什麼樣子。
我們仍然有一些可能需要幾十秒的慢查詢,但我們已經制定瞭如何消除它們的計劃,從而使提取的應用程序變得更快。
結構
就我們的目的而言,乾式驗證是一個非常強大且靈活的工具。 我們通過合約傳遞來自外部世界的所有輸入,這讓我們確信輸入參數總是格式良好且類型明確。
不再需要在應用程序代碼中調用.to_s.to_sym.to_i
,因為所有數據都在應用程序的邊界進行了清理和類型轉換。 從某種意義上說,它為動態的 Ruby 世界帶來了強烈的理智。 我不能推薦它。
最後的話
選擇非標準堆棧並不像最初看起來那麼簡單。 在選擇用於新服務的框架和庫時,我們考慮了許多方面:單體應用程序的當前技術堆棧、團隊對新堆棧的熟悉程度、所選堆棧的維護方式等等。
儘管我們從一開始就嘗試做出非常謹慎和經過計算的決定——我們選擇使用標準 Hanami 堆棧——但由於項目的非標準技術要求,我們不得不在此過程中重新考慮我們的堆棧。 我們最終得到了 Sinatra 和基於 DRY 的堆棧。
如果我們要提取一個新的應用程序,我們會再次選擇 Hanami 嗎? 可能是。 我們現在對圖書館及其優缺點有了更多的了解,因此我們可以從任何新項目的一開始就做出更明智的決定。 但是,我們也會認真考慮使用普通的 Sinatra/DRY.rb 應用程序。
總而言之,花在學習新框架、範式或編程語言上的時間讓我們對當前的技術堆棧有了全新的認識。 了解可用的內容以豐富您的工具箱總是很好的。 每個工具都有自己獨特的用例——因此,更好地了解它們意味著擁有更多的工具供您使用,並將它們變成更適合您的應用程序的工具。