作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Viktar Basharymau的头像

Viktar Basharymau

Viktar是一位经验丰富的开发人员,具有很强的分析能力. 他拥有Ruby, JS, c#和Java的生产经验,并且擅长FP.

Previously At

epam systems
Share

如果一个web应用程序足够大,足够老, 也许有一天你需要把它分解成更小的, 隔离部件并从中提取服务, 其中一些将比其他的更加独立. 可能促使做出此类决定的一些原因包括:减少运行测试的时间, 能够独立部署应用程序的不同部分, 或者加强子系统之间的边界. 服务提取需要软件工程师做出许多重要的决策, 其中之一就是这项新服务应该使用什么技术.

在这篇文章中,我们将分享一个关于从单片应用程序中提取新服务的故事 Toptal Platform. 我们解释了我们选择的技术堆栈及其原因, 并概述我们在服务实现过程中遇到的一些问题.

Toptal的Chronicles服务是一个应用程序,可以处理在Toptal平台上执行的所有用户操作. 操作本质上是日志条目. 当用户做某事时(e).g. 发布博客文章、批准作业等),创建一个新的日志条目.

虽然是从我们的平台上提取的, 它基本上不依赖于它,可以与任何其他应用程序一起使用. 这就是为什么我们要发布这个过程的详细描述,并讨论我们的工程团队在过渡到新堆栈时必须克服的一些挑战.

在我们决定提取服务并改进堆栈的背后有很多原因:

  • 我们希望其他服务能够记录可以在其他地方显示和使用的事件.
  • 存储历史记录的数据库表的大小呈非线性快速增长, 运营成本高.
  • 我们认为现有的实现背负着技术债务.

动作表-数据库表

乍一看,这似乎是一个直截了当的倡议. However, 处理替代技术栈往往会产生意想不到的缺点, 这就是今天这篇文章的目的所在.

Architecture Overview

Chronicles应用程序由三个部分组成,它们或多或少是独立的,运行在不同的Docker容器中.

  • Kafka consumer is a very thin Karafka-based Kafka 条目创建消息的消费者. 它将所有接收到的消息排队到Sidekiq.
  • Sidekiq worker 是处理Kafka消息并在数据库表中创建条目的工作器.
  • GraphQL endpoints:
    • Public endpoint 公开条目搜索API,该API用于各种平台功能(例如.g.,以在筛选按钮上呈现评论工具提示,或显示作业更改的历史记录).
    • Internal endpoint 提供从数据迁移中创建标记规则和模板的功能.

用于连接两个不同数据库的编年史:

  • 它自己的数据库(我们在其中存储标记规则和模板)
  • 平台数据库(存储用户执行的操作及其标记和标记)

在提取应用程序的过程中, 我们从Platform数据库中迁移了数据,并关闭了Platform连接.

Initial Plan

最初,我们决定用 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, 它们简直美极了. 我们甚至用契约验证应用程序配置.

Problems with ROM.rb and Sequel

ROM.rb and Sequel 不同于ActiveRecord,这并不奇怪. 我们最初的想法是,我们将能够复制和粘贴大部分来自Platform的代码. 问题是平台部分非常重ar, 所以几乎所有内容都必须在ROM/Sequel中重写. 我们只复制了一小部分与框架无关的代码. 在此过程中,我们遇到了一些令人沮丧的问题和一些bug.

Filtering by Subquery

例如,我花了几个小时才弄清楚如何在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

=> [#]

最后,它返回了一些东西,并且没有失败,尽管它最终产生了一个有漏洞的抽象. 表名不应该泄露给应用程序代码.

SQL参数插值

在编年史搜索中有一个功能,允许用户根据有效载荷进行搜索. 查询看起来像这样: {操作::情商,路径:“国旗”、“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 这里经过适当验证,因此它只包含字母数字字符, 但是这段代码看起来仍然很有趣.

rom工厂的无声魔法

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 自动向我们发送库更新的拉取请求, 在我们的测试中修复了一些输入错误的属性后,我们合并了哪些属性.

General Ergonomics

This says it all:

# ActiveRecord
PerformedAction.count _# => 30232445_

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

在更复杂的例子中,差异甚至更大.

The Good Parts

这并不全是痛苦、汗水和眼泪. There were many, 在我们的旅途中有许多美好的东西, 而且它们远远超过了新堆栈的负面影响. 如果不是这种情况,我们一开始就不会这么做.

Test Speed

在本地运行整个测试套件需要5-10秒,RuboCop也需要同样长的时间. CI时间更长(3-4分钟), 但这不是什么问题,因为我们可以在本地运行所有东西, thanks to which, 在CI上失败的可能性要小得多.

The guard gem 又变得可用了. 想象一下,您可以编写代码并对每次保存运行测试,从而获得非常快速的反馈. 这在使用平台时是很难想象的.

Deploy Times

部署提取的Chronicles应用程序只需两分钟. 虽然没有闪电般快,但也还不错. 我们经常部署,所以即使是很小的改进也可以产生大量的节省.

应用程序的性能

编年史中性能最密集的部分是条目搜索. For now, 平台后端大约有20个地方可以从历代志中获取历史条目. 这意味着《欧博体育app下载》的响应时间将贡献给平台60秒的响应时间预算, 所以编年史必须要快, which it is.

尽管操作日志的大小很大(3000万行), and growing), 平均响应时间小于100ms. 请看这张漂亮的图表:

应用程序性能图

平均来说,80-90%的应用时间花在数据库上. 这就是一份合适的绩效表应该有的样子.

我们仍然有一些很慢的查询,可能需要几十秒, 但我们已经有了消灭它们的计划, 允许提取的应用程序变得更快.

Structure

For our purposes, dry-validation 是一个非常强大和灵活的工具吗. 我们通过契约传递来自外部世界的所有输入, 它使我们确信输入参数总是格式良好且类型定义良好.

没有必要再打电话了 .to_s.to_sym.to_i 在应用程序代码中,因为所有的数据都在应用程序的边界进行了清理和类型转换. 从某种意义上说,它为动态Ruby世界带来了强大的完整性. 我怎么推荐都不为过.

Final Words

选择非标准堆栈并不像最初看起来那么简单. 在选择用于新服务的框架和库时,我们考虑了许多方面:单体应用程序的当前技术堆栈, 团队对新栈的熟悉程度, 所选择的堆栈是如何维护的, and so on.

尽管我们从一开始就试图做出非常仔细和计算的决定——我们选择使用标准的Hanami堆栈——但由于项目的非标准技术要求,我们不得不重新考虑我们的堆栈. 我们最终得到了Sinatra和基于dry的堆栈.

如果我们要提取一个新应用,我们还会选择花见吗? Probably yes. 我们现在对图书馆及其优缺点有了更多的了解, 因此,我们可以从任何新项目的一开始就做出更明智的决定. 然而,我们也会认真考虑使用普通的Sinatra/DRY.rb app.

All in all, 花在学习新框架上的时间, paradigms, 编程语言让我们对当前的技术栈有了全新的认识. 为了丰富你的工具箱,知道什么是可用的总是好的. 因此,每个工具都有自己独特的用例, 更好地了解它们意味着有更多的它们可供您使用,并将它们转化为更适合您的应用程序.

了解基本知识

  • What is a tech stack?

    技术栈是一组工具, programming languages, architecture patterns, 以及团队在开发应用程序时遵循的通信协议.

  • 如何为web应用程序开发选择技术栈?

    为了选择一个技术栈进行web应用程序开发, 有许多因素需要考虑, 包括开发团队对技术栈的熟悉程度, 堆栈对应用程序功能需求的适用性, 以及使用所选堆栈构建的解决方案的长期可维护性.

  • 你目前公司的技术栈是什么?

    我们的技术栈在后端依赖于Ruby,而在前端,我们使用React和Typescript. 前端通过GraphQL(有时是REST协议)与后端通信. 后端服务的异步通信通过Kafka实现, 或使用GraphQL/REST同步. 我们使用PostgreSQL和Redis作为我们的数据库.

聘请Toptal这方面的专家.
Hire Now
Viktar Basharymau的头像
Viktar Basharymau

Located in Warsaw, Poland

Member since November 26, 2015

About the author

Viktar是一位经验丰富的开发人员,具有很强的分析能力. 他拥有Ruby, JS, c#和Java的生产经验,并且擅长FP.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Previously At

epam systems

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.