11分钟阅读

选择技术堆栈替代 - UPS和Downs

Viktar是一个经验丰富的开发人员,具有较强的分析技能。他在Ruby,JS,C#和Java中拥有生产经验,并且擅长FP。

如果Web应用程序很大,足够大,可能会有时间才能将其分解为较小的,隔离部分和从中提取服务,其中一些将比其他人更独立。可以提示这样的决定的一些原因包括:减少运行测试的时间,能够独立地部署应用程序的不同部分,或者在子系统之间强制执行边界。服务提取需要软件工程师进行许多重要决策,其中一个是技术堆栈用于新服务。

在这篇文章中,我们分享了一个关于从整体应用中提取新服务的故事 - 顶部平台。我们解释了我们选择的技术堆栈以及为什么,以及在服务实施期间遇到的一些问题。

Toptal的Chronicles Service是一个应用程序,它处理在顶部平台上执行的所有用户操作。操作基本上是日志条目。当用户做某事时(例如,发布博客文章,批准作业等),创建一个新的日志条目。

虽然从我们的平台中提取,但它根本不依赖于它,并且可以与任何其他应用一起使用。这就是为什么我们正在发布进程的详细帐户,并讨论许多挑战我们的工程团队必须在转换到新堆栈时克服。

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

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

动作表 - 数据库表

乍一看,它看起来像是一项直接的倡议。然而,处理替代技术堆栈往往会产生意外的缺点,这就是今天的文章旨在解决的问题。

建筑概述

Chronicles应用程序由三个部分组成,可以或多或少独立,并在单独的Docker容器中运行。

  • Kafka.消费者 是一个非常薄的 karafka为基础 Kafka. 进入创建消息的消费者。它将所有接收的消息都传染给sidekiq。
  • Sidekiq工作者 是一个工人处理Kafka消息并在数据库表中创建条目。
  • GraphQL端点:
    • 公共终点 公开进入搜索API,用于各种平台函数(例如,呈现筛选按钮上的注释工具提示,或显示作业更改的历史记录)。
    • 内部终点 提供从数据迁移创建标记规则和模板的功能。

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

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

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

初始计划

最初,我们决定去 汉语 默认情况下提供的所有生态系统(由此支持的Hanami-Models rom.rb.,干燥rb,hanami-newrelic等)。遵循“标准”做事的方式承诺我们可能面临的任何问题的低摩擦,巨大的实施速度,以及非常好的“Googleability”。此外,Hanami生态系统是成熟和流行的,并且由Ruby社区的尊重成员仔细维护图书馆。

此外,系统的大部分系统已经在平台侧(例如,GraphQL条目搜索端点和CreateEntry操作)来实现,因此我们计划将大量代码从平台复制到Chronicles,而不是进行任何更改。这也是我们没有与Elixir一起使用的主要原因之一,因为Elixir不会允许这一点。

我们决定不做Rails,因为这是一个如此小项目的矫枉过正,尤其是ActiveSupport这样的东西,这不会为我们的需求提供许多有形的好处。

当计划南方时

Although we did our best to stick to the plan, it soon got derailed for a number of reasons. One was our lack of experience with the chosen stack, followed by genuine issues with the stack itself, and then there was our non-standard setup (two databases). In the end, we decided to get rid of the hanami-model, and then of Hanami itself, replacing it with Sinatra.

我们选择了Sinatra,因为它是12年前创建的积极维护的图书馆,因为它是最受欢迎的图书馆之一,团队中的每个人都有充足的实践经验。

不兼容的依赖性

编年史提取于2019年6月开始,然后,Hanami与最新版本的干燥RB宝石兼容。即,当时的最新版本的Hanami(1.3.1)只支持干验证0.12,我们希望干预1.0​​.0。我们计划使用仅在1.0.0中引入的干预的合同。

此外,Kafka 1.2与干瑰宝不相容,所以我们使用它的存储库版本。目前,我们正在使用1.3.0.RC1,这取决于最新的干燥宝石。

不必要的依赖关系

Additionally, the Hanami gem included too many dependencies that we were not planning to use, such as hanami-cli, hanami-assets, hanami-mailer, hanami-view, and even hanami-controller. Also, looking at the 汉语-Model自述文件, it became clear that it supports only one database by default. On the other hand, ROM.rb, which the hanami-model is based on, supports multi-database configurations out of the box.

全部 in all, Hanami in general and the hanami-model in particular looked like an unnecessary level of abstraction.

所以,在我们将第一个有意义的公关到编年史之前,10天,我们完全用Sinatra取代了Hanami。我们可以使用纯机架,因为我们不需要复杂的路由(我们有四个“静态”端点 - 两个GraphQL端点,/ ping端点和sidekiq Web界面),但我们决定不要太硬核。西纳德拉适合我们。如果您想了解更多信息,请查看我们的 Sinatra和续集教程.

干式模式和干验证误解

我们花了一些时间和很多试验和错误,以弄清楚如何正确地“烹饪”干验证。

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

In the snippet above, the url parameter is defined in several slightly different ways. Some definitions are equivalent, and others don’t make any sense. In the beginning, we couldn’t really tell the difference between all those definitions as we didn’t fully understand them. As a result, the first version of our contracts was quite messy. With time, we learned how to properly read and write DRY contracts, and now they look consistent and elegant–in fact, not only elegant, they are nothing short of beautiful. We even validate application configuration with the contracts.

ROM.RB和续集的问题

rom.rb和 续集 与戏法不同,没有惊喜。我们最初的想法,我们将能够复制和粘贴来自平台的大多数代码失败。问题是平台部分非常雄厚,所以几乎一切都必须在ROM /续集中重写。我们设法仅复制框架独立于框架的小部分代码。一路上,我们面临了一些令人沮丧的问题和一些错误。

通过子查询过滤

For example, it took me several hours to figure out how to make a subquery in ROM.rb/Sequel. This is something that I would write without even waking up in Rails: scope.where(sequence_code: subquery). In Sequel, though, it turned out to be 不是那么容易.

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 //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: //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

So instead of a simple one-liner like base_query.where(sequence_code: bild_subquery(params)), we have to have a dozen of lines with non-trivial code, raw SQL fragments, and a multiline comment explaining what caused this unfortunate case of bloat.

与非琐碎连接领域的关联

entry relation (performed_actions table) has a primary id field. However, to join with *taggings tables, it uses the sequence_code column. In ActiveRecord, it is expressed rather simply:

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...

We’re lucky that the types of id and sequence_code are different, so PG throws a type error. If the types were the same, who knows how many hours I would spend debugging this.

So, entries.join(:access_taggings) doesn’t work. What if we specify join condition explicitly? As in entries.join(:access_taggings, performed_action_sequence_code: :sequence_code), as the official documentation suggests.

[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

Now it thinks that :access_taggings is a table name for some reason. Fine, let’s swap it with the actual table name.

[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_gid="gid://platform/Talent/124383" ... updated_at=2012-05-10 08:46:43 UTC>]

最后,它返回了一些东西而且没有失败,虽然它最终被泄漏了。表名不应泄漏到应用程序代码。

SQL参数插值

这re is a feature in Chronicles search which allows users to search by payload. The query looks like this: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"}, where path is always an array of strings, and value is any valid JSON value.

在Activerecord,它看起来像 :

@scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)

In Sequel, I didn’t manage to properly interpolate :path, so I had to resort to :

base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))

Luckily, path here is properly validated so that it only contains alphanumeric characters, but this code still looks funny.

罗马工厂的沉默魔法

我们使用了 rom-factory 宝石简化了在测试中创建模型。然而,几次代码没有按预期工作。你猜这个测试有什么问题吗?

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)

不,期望没有失败,期望很好。

这 problem is that the second line fails with a unique constraint validation error. The reason is that action is not the attribute that the Action model has. The real name is action_name, so the right way to create actions should look like this:

RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']

As the mistyped attribute was ignored, it falls back to the default one specified in the factory (action_name { 'created' }), and we have a unique constraint violation because we are trying to create two identical actions. We had to deal with this issue several times, which proved taxing.

幸运的是,它是 固定的 在0.9.0。 依赖 使用库更新自动向我们发送Plud请求,我们在修复我们测试中的一些错误的属性后合并。

一般人体工程学

这一切都说:

# ActiveRecord
PerformedAction.count _# => 30232445_

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

在更复杂的例子中,差异更大。

好的部分

这不是全部痛苦,汗水和泪水。我们的旅程中有很多好事,他们远远超过了新堆栈的负面方面。如果没有这种情况,我们就没有首先做到了。

测试速度

在本地运行整个测试套件需要5-10秒,并且对于曲折而长。 CI时间长得多(3-4分钟),但这缺少问题,因为我们可以在本地运行一切,感谢您,CI上的任何失败都不太可能。

守卫宝石 已再次使用。想象一下,您可以在每个保存上编写代码并运行测试,为您提供非常快的反馈。在使用平台时,这很难想象。

部署时代

部署提取的Chronicles应用程序的时间仅为两分钟。没有闪电 - 快速,但仍然不错。我们经常部署,因此即使轻微的改进也会产生大量的储蓄。

应用程序性能

Chronicles最具性能密集的部分是进入搜索。目前,平台上有大约20个地方,从Chronicles获取历史记录条目。这意味着编年史的响应时间有助于平台的响应时间的60秒预算,因此编年史必须快速,这是它的。

尽管行动较大的巨大尺寸日志(3000万行,而且增长),但平均响应时间小于100ms。看看这个美丽的图表:

应用程序性能图表

平均而言,80-90%的应用时间在数据库中花费。这就是适当的性能图表应该是什么样的。

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

结构

为了我们的目的, 干验证 是一个非常强大和灵活的工具。我们通过合同传递外界的所有输入,并使我们有信心输入参数始终是良好的形成以及定义明确的类型。

这re is no longer the need to call .to_s.to_sym.to_i in the application code, as all the data is cleaned up and typecasted at the borders of the app. In a sense, it brings strong types of sanity to the dynamic Ruby world. I can’t recommend it enough.

最后的话

选择非标准堆栈并不像最初似乎那样简单。我们考虑了许多方面选择用于新服务的框架和库:纪念碑的当前技术堆栈,团队熟悉新堆栈,如何维护所选堆栈,等等。

尽管我们试图从一开始就做出非常小心和计算的决定 - 我们选择使用标准的Hanami堆栈 - 我们必须沿着项目的非标准技术要求重新考虑我们的堆栈。我们最终用Sinatra和一个干式堆栈。

如果我们要提取新应用程序,我们会再次选择Hanami吗?可能是。我们现在更多地了解图书馆及其利弊,因此我们可以从任何新项目的一开始就制定更明智的决定。但是,我们也使用普通的Sinatra / Dry.rb应用程序认真考虑。

总而言之,投资新框架,范式或编程语言的时间为我们提供了新的技术堆栈的新视角。知道有什么可用的东西才能丰富您的工具箱,总是很好。每个工具都有自己独特的用例 - 因此,了解更好的意味着您可以使用更多,并将它们转化为更好的适合您的应用程序。

理解基础知识

什么是技术堆栈?

技术堆栈是一组工具,编程语言,体系结构模式和团队在开发应用程序时遵守的通信协议。

如何选择用于Web应用程序开发的技术堆栈?

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

您当前组织的技术堆栈是什么?

我们的技术堆栈依赖于后端的Ruby,而在前端,我们使用React和TypeScript。前端通过GraphQL和有时休息协议与后端通信。后端服务的异步通信通过Kafka或使用GraphQL / REST同步。我们使用PostgreSQL和Redis作为我们的数据库。