作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Viktar是一位经验丰富的开发人员,具有很强的分析能力. 他拥有Ruby, JS, c#和Java的生产经验,并且擅长FP.
如果一个web应用程序足够大,足够老, 也许有一天你需要把它分解成更小的, 隔离部件并从中提取服务, 其中一些将比其他的更加独立. 可能促使做出此类决定的一些原因包括:减少运行测试的时间, 能够独立部署应用程序的不同部分, 或者加强子系统之间的边界. 服务提取需要软件工程师做出许多重要的决策, 其中之一就是这项新服务应该使用什么技术.
在这篇文章中,我们将分享一个关于从单片应用程序中提取新服务的故事 Toptal Platform. 我们解释了我们选择的技术堆栈及其原因, 并概述我们在服务实现过程中遇到的一些问题.
Toptal的Chronicles服务是一个应用程序,可以处理在Toptal平台上执行的所有用户操作. 操作本质上是日志条目. 当用户做某事时(e).g. 发布博客文章、批准作业等),创建一个新的日志条目.
虽然是从我们的平台上提取的, 它基本上不依赖于它,可以与任何其他应用程序一起使用. 这就是为什么我们要发布这个过程的详细描述,并讨论我们的工程团队在过渡到新堆栈时必须克服的一些挑战.
在我们决定提取服务并改进堆栈的背后有很多原因:
乍一看,这似乎是一个直截了当的倡议. However, 处理替代技术栈往往会产生意想不到的缺点, 这就是今天这篇文章的目的所在.
Chronicles应用程序由三个部分组成,它们或多或少是独立的,运行在不同的Docker容器中.
用于连接两个不同数据库的编年史:
在提取应用程序的过程中, 我们从Platform数据库中迁移了数据,并关闭了Platform连接.
最初,我们决定用 Hanami 以及它默认提供的所有生态系统(一个hanami模型) ROM.rb、干货、花式新文物等). 遵循“标准”的做事方式可以减少摩擦, 实现速度快, 对于我们可能面临的任何问题都有很好的“谷歌可搜索性”. In addition, 花见生态系统是成熟和流行的, 该库由Ruby社区中受人尊敬的成员精心维护.
此外,系统的很大一部分已经在平台端实现了.g., GraphQL条目搜索端点和CreateEntry操作), 所以我们计划将《欧博体育app下载》中的许多代码原样复制到《欧博体育app下载》中, 不做任何更改. 这也是我们没有选择《欧博体育app下载》的主要原因之一,因为《欧博体育app下载》不允许这么做.
我们决定不使用Rails,因为对于这样一个小项目来说,它有点太过了, 尤其是像activessupport这样的, 这不会为我们的需求提供很多切实的好处吗.
尽管我们尽了最大的努力坚持这个计划,但由于种种原因,它很快就脱轨了. 一个是我们缺乏使用所选堆栈的经验, 其次是堆栈本身的真正问题, 然后是我们的非标准设置(两个数据库). 最后,我们决定摆脱 hanami-model
,然后是花见本身,用 Sinatra.
我们选择Sinatra是因为它是12年前创建的一个积极维护的库, 因为它是最受欢迎的图书馆之一, 团队中的每个人都有丰富的实践经验.
编年史提取始于2019年6月, and back then, Hanami不兼容最新版本的dry-rb宝石. 也就是当时花见的最新版本(1).3.1)只支持干验证0.12,我们想要干验证.0.0. 我们计划使用在第1章中介绍的来自干验证的合同.0.0.
Also, Kafka 1.2与dry gems不兼容,所以我们使用了它的存储库版本. 目前我们使用的是1.3.0.Rc1,依赖于最新的干宝石.
Additionally, Hanami gem包含了太多我们不打算使用的依赖项, such as hanami-cli
, hanami-assets
, hanami-mailer
, hanami-view
, and even hanami-controller
. Also, looking at the hanami-model readme,很明显,它默认只支持一个数据库. 另一方面,ROM.rb, which the hanami-model
是基于,支持开箱即用的多数据库配置.
总之,花见一般和 hanami-model
特别是看起来像是一个不必要的抽象层次.
So, 在我们为《欧博体育app下载》创造了第一个有意义的PR后10天, 我们完全用Sinatra取代了hanami. 我们也可以使用纯Rack,因为我们不需要复杂的路由(我们有四个“静态”端点——两个GraphQL端点), the /ping endpoint, 和sidekiq网页界面), 但我们决定不要太硬核. 辛纳屈很适合我们. 如果您想了解更多信息,请查看我们的 Sinatra和Sequel教程.
我们花了一些时间和大量的试错来弄清楚如何正确地“烹饪”干验证.
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).填充(类型::可强迫的::字符串)
end
在上面的代码片段中, url
参数以几种稍有不同的方式定义. 有些定义是等价的,有些则没有任何意义. In the beginning, 我们无法真正分辨出所有这些定义之间的区别,因为我们没有完全理解它们. 因此,我们的第一版合同相当混乱. With time, 我们学习了如何正确地读取和编写DRY契约, 事实上,现在它们看起来一致而优雅, not only elegant, 它们简直美极了. 我们甚至用契约验证应用程序配置.
ROM.rb and Sequel 不同于ActiveRecord,这并不奇怪. 我们最初的想法是,我们将能够复制和粘贴大部分来自Platform的代码. 问题是平台部分非常重ar, 所以几乎所有内容都必须在ROM/Sequel中重写. 我们只复制了一小部分与框架无关的代码. 在此过程中,我们遇到了一些令人沮丧的问题和一些bug.
例如,我花了几个小时才弄清楚如何在ROM中进行子查询.rb/Sequel. 这是我不用在Rails中醒来就可以写的东西: scope.在哪里(sequence_code:子查询
). 但在《欧博体育app下载》中,事实证明是这样的 not that easy.
Def apply_subquery_filter(base_query, params)
Subquery = as_subquery(build_subquery(params))
base_query.where { Sequel.lit('sequence_code IN ?', subquery) }
end
#这是一个固定版本的http://github.com/rom-rb/rom-sql/blob/6fa344d7022b5cc9ad8e0d026448a32ca5b37f12/lib/rom/sql/relation/reading.rb#L998
#原始版本在子查询上有' unorder '.
#修复被合并:http://github.com/rom-rb/rom-sql/pull/342.
def as_subquery(关系)
attr = relation.schema.to_a[0]
subquery = relation.schema.project(attr).call(relation).dataset
罗::SQL: [attr:属性.type].元(sql_expr:子查询)
end
而不是简单的一行代码 base_query.在哪里(sequence_code: bild_subquery (params))
, 我们必须有十几行重要的代码, raw SQL fragments, 还有一条多行评论解释了是什么导致了这个不幸的肿胀.
The entry
relation (performed_actions
table) has a primary id
field. However, to join with *taggings
tables, it uses the sequence_code
column. 在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记载:实体
auto_struct true
Schema (: performanmed_actions, as::entries)执行
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记载:实体
auto_struct true
Schema (: performanmed_action_access_taggings, as::access_taggings, infer: false)执行
attribute: performanmed_action_sequence_code,::Types::UUID
associations do
Belongs_to:entry, foreign_key:: performanmed_action_sequence_code,
primary_key:: sequence_code,
null: false
end
end
end
不过它有个小问题. 它可以很好地编译,但当你真正尝试使用它时,它会在运行时失败.
[4] pry(main)> Chronicles::Persistence.关系(平台):(条目):.加入(access_taggings):.limit(1).to_a
E, [2019-09-05T15:54:16.[706292 #20153] ERROR——:PG::UndefinedFunction: ERROR: operator不存在:integer = uuid
LINE 1: ...ion_access_taggings" ON (" performanmed_actions ")."id" = "perform...
^
提示:没有操作符匹配给定的名称和参数类型. 您可能需要添加显式类型强制转换.: SELECT <..snip..> FROM "performed_actions" INNER JOIN "performed_action_access_taggings" ON (" performanmed_actions ")."id" = " performanmed_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 (" performanmed_actions ")."id" = "perform...
我们很幸运,身份证和 sequence_code
是不同的,所以PG抛出一个类型错误. 如果类型是相同的,谁知道我要花多少时间来调试它.
So, entries.加入(access_taggings):
doesn’t work. 如果我们显式地指定连接条件会怎样? As in entries.Join (:access_taggings, performed_action_sequence_code::sequence_code)
正如官方文件所示.
[8] pry(main)> Chronicles::Persistence.关系(平台):(条目):.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"不存在
LINE 1: ...."updated_at" FROM " performanmed_actions " INNER JOIN...
^: SELECT FROM "performed_actions" INNER JOIN "access_taggings" ON ("access_taggings"."performed_action_sequence_code" = "performed_actions"."sequence_code") ORDER BY " performanmed_actions "."id" LIMIT 1
Sequel::DatabaseError: PG::UndefinedTable: ERROR: relation "access_taggings" does not exist
Now it thinks that :access_taggings
是表名吗. 好,让我们把它换成实际的表名.
[10] pry(main)> data = Chronicles::Persistence.关系(平台):(条目):.Join (:performed_action_access_taggings, performed_action_sequence_code::sequence_code).limit(1).to_a
=> [#]
最后,它返回了一些东西,并且没有失败,尽管它最终产生了一个有漏洞的抽象. 表名不应该泄露给应用程序代码.
在编年史搜索中有一个功能,允许用户根据有效载荷进行搜索. 查询看起来像这样: {操作::情商,路径:“国旗”、“gid”,价值:“gid: / /平台/标志/ 1 "}
, where path
总是字符串数组,值是任何有效的JSON值.
在ActiveRecord中,它看起来像 this:
@scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)
在Sequel中,我没有设法正确地插入 :path
,所以我不得不求助于 that:
base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))
Luckily, path
这里经过适当验证,因此它只包含字母数字字符, 但是这段代码看起来仍然很有趣.
We used the 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)
不,期望不是失败,期望是好的.
问题是第二行出现了唯一的约束验证错误. The reason is that action
属性不是 Action
model has. The real name is action_name
,所以创建动作的正确方法应该是这样的:
RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']
由于忽略了输入错误的属性,它将恢复为工厂(Action_name {'created'}
),我们有一个独特的约束违反,因为我们试图创建两个相同的动作. 我们不得不多次处理这个问题,这证明是很费力的.
Luckily, it was fixed in 0.9.0. Dependabot 自动向我们发送库更新的拉取请求, 在我们的测试中修复了一些输入错误的属性后,我们合并了哪些属性.
This says it all:
# ActiveRecord
PerformedAction.count _# => 30232445_
# ROM
EntryRepository.new.root.count _# => 30232445_
在更复杂的例子中,差异甚至更大.
这并不全是痛苦、汗水和眼泪. There were many, 在我们的旅途中有许多美好的东西, 而且它们远远超过了新堆栈的负面影响. 如果不是这种情况,我们一开始就不会这么做.
在本地运行整个测试套件需要5-10秒,RuboCop也需要同样长的时间. CI时间更长(3-4分钟), 但这不是什么问题,因为我们可以在本地运行所有东西, thanks to which, 在CI上失败的可能性要小得多.
The guard gem 又变得可用了. 想象一下,您可以编写代码并对每次保存运行测试,从而获得非常快速的反馈. 这在使用平台时是很难想象的.
部署提取的Chronicles应用程序只需两分钟. 虽然没有闪电般快,但也还不错. 我们经常部署,所以即使是很小的改进也可以产生大量的节省.
编年史中性能最密集的部分是条目搜索. For now, 平台后端大约有20个地方可以从历代志中获取历史条目. 这意味着《欧博体育app下载》的响应时间将贡献给平台60秒的响应时间预算, 所以编年史必须要快, which it is.
尽管操作日志的大小很大(3000万行), and growing), 平均响应时间小于100ms. 请看这张漂亮的图表:
平均来说,80-90%的应用时间花在数据库上. 这就是一份合适的绩效表应该有的样子.
我们仍然有一些很慢的查询,可能需要几十秒, 但我们已经有了消灭它们的计划, 允许提取的应用程序变得更快.
For our purposes, dry-validation 是一个非常强大和灵活的工具吗. 我们通过契约传递来自外部世界的所有输入, 它使我们确信输入参数总是格式良好且类型定义良好.
没有必要再打电话了 .to_s.to_sym.to_i
在应用程序代码中,因为所有的数据都在应用程序的边界进行了清理和类型转换. 从某种意义上说,它为动态Ruby世界带来了强大的完整性. 我怎么推荐都不为过.
选择非标准堆栈并不像最初看起来那么简单. 在选择用于新服务的框架和库时,我们考虑了许多方面:单体应用程序的当前技术堆栈, 团队对新栈的熟悉程度, 所选择的堆栈是如何维护的, and so on.
尽管我们从一开始就试图做出非常仔细和计算的决定——我们选择使用标准的Hanami堆栈——但由于项目的非标准技术要求,我们不得不重新考虑我们的堆栈. 我们最终得到了Sinatra和基于dry的堆栈.
如果我们要提取一个新应用,我们还会选择花见吗? Probably yes. 我们现在对图书馆及其优缺点有了更多的了解, 因此,我们可以从任何新项目的一开始就做出更明智的决定. 然而,我们也会认真考虑使用普通的Sinatra/DRY.rb app.
All in all, 花在学习新框架上的时间, paradigms, 编程语言让我们对当前的技术栈有了全新的认识. 为了丰富你的工具箱,知道什么是可用的总是好的. 因此,每个工具都有自己独特的用例, 更好地了解它们意味着有更多的它们可供您使用,并将它们转化为更适合您的应用程序.
技术栈是一组工具, programming languages, architecture patterns, 以及团队在开发应用程序时遵循的通信协议.
为了选择一个技术栈进行web应用程序开发, 有许多因素需要考虑, 包括开发团队对技术栈的熟悉程度, 堆栈对应用程序功能需求的适用性, 以及使用所选堆栈构建的解决方案的长期可维护性.
我们的技术栈在后端依赖于Ruby,而在前端,我们使用React和Typescript. 前端通过GraphQL(有时是REST协议)与后端通信. 后端服务的异步通信通过Kafka实现, 或使用GraphQL/REST同步. 我们使用PostgreSQL和Redis作为我们的数据库.
Located in Warsaw, Poland
Member since November 26, 2015
Viktar是一位经验丰富的开发人员,具有很强的分析能力. 他拥有Ruby, JS, c#和Java的生产经验,并且擅长FP.
世界级的文章,每周发一次.
世界级的文章,每周发一次.
Join the Toptal® community.