选择技术堆栈替代方案 - 起起落落
已发表: 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 应用程序。
总而言之,花在学习新框架、范式或编程语言上的时间让我们对当前的技术堆栈有了全新的认识。 了解可用的内容以丰富您的工具箱总是很好的。 每个工具都有自己独特的用例——因此,更好地了解它们意味着拥有更多的工具供您使用,并将它们变成更适合您的应用程序的工具。