个性化阅读
专注于IT技术分析

选择技术栈替代方案-跌宕起伏

本文概述

如果一个Web应用程序足够大而且很旧, 那么有时你需要将其分解为更小的, 独立的部分并从中提取服务, 其中某些将比其他应用程序更独立。可能促使做出这样的决定的一些原因包括:减少运行测试的时间, 能够独立部署应用程序的不同部分, 或者在子系统之间实施边界。服务提取需要软件工程师做出许多重要的决定, 其中一项是用于新服务的技术栈。

在本文中, 我们分享了一个有关从整体应用程序中提取新服务的故事– srcmini平台。我们将说明选择哪种技术栈以及原因, 并概述在服务实施过程中遇到的一些问题。

srcmini的Chronicles服务是一款应用程序, 可处理在srcmini平台上执行的所有用户操作。操作本质上是日志条目。当用户执行某项操作(例如发布博客帖子, 批准工作等)时, 会创建一个新的日志条目。

尽管是从我们的平台中提取的, 但从根本上讲它并不依赖于它, 并且可以与其他任何应用一起使用。这就是为什么我们要发布详细的流程说明并讨论我们的工程团队在过渡到新栈时必须克服的许多挑战的原因。

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

  • 我们希望其他服务能够记录可以在其他地方显示和使用的事件。
  • 存储历史记录的数据库表的大小快速且非线性地增长, 从而导致高昂的运营成本。
  • 我们认为现有的实施工作受到技术债务的负担。
动作表-数据库表

乍一看, 这似乎是一个直接的倡议。但是, 与替代技术栈打交道往往会带来意想不到的弊端, 而这正是今天的文章旨在解决的问题。

架构概述

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

  • Kafka使用者是非常薄的基于Karafka的条目创建消息的Kafka使用者。它将所有收到的消息排队到Sidekiq。
  • Sidekiq worker是处理Kafka消息并在数据库表中创建条目的工作程序。
  • GraphQL端点:
    • 公共端点公开了条目搜索API, 该API用于各种平台功能(例如, 在筛选按钮上显示注释工具提示, 或显示工作变动的历史记录)。
    • 内部端点提供了从数据迁移创建标签规则和模板的能力。

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

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

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

初步计划

最初, 我们决定使用Hanami及其默认提供的所有生态系统(由ROM.rb, dry-rb, hanami-newrelic等支持的hanami模型)。遵循”标准”的处事方式可以使我们降低摩擦, 提高实施速度, 并很好地应对可能遇到的任何问题。此外, hanami生态系统已经成熟且流行, 并且该库由Ruby社区中受人尊敬的成员精心维护。

此外, 系统的很大一部分已经在平台端实现(例如GraphQL Entry Search端点和CreateEntry操作), 因此我们计划将很多代码从平台原样复制到Chronicles, 而无需进行任何更改。这也是我们不选择Elixir的主要原因之一, 因为Elixir不允许这样做。

我们决定不使用Rails, 因为对于一个如此小的项目, 尤其是像ActiveSupport之类的项目而言, 这感觉有些过头了, 因为它无法为我们的需求提供很多明显的好处。

当计划南下时

尽管我们竭尽全力坚持该计划, 但由于多种原因, 该计划很快就出轨了。一是我们对所选栈缺乏经验, 其次是栈本身存在真正的问题, 然后是我们的非标准设置(两个数据库)。最后, 我们决定放弃hanami模型, 然后放弃hanami本身, 将其替换为Sinatra。

我们选择Sinatra是因为它是12年前创建的一个主动维护的库, 并且因为它是最受欢迎的库之一, 所以团队中的每个人都有丰富的实践经验。

不兼容的依赖关系

编年史》的提取工作于2019年6月开始, 那时Hanami与最新版本的dry-rb宝石不兼容。即, 当时的最新版本Hanami(1.3.1)仅支持干燥验证0.12, 而我们想要干燥验证1.0.0。我们计划使用仅在1.0.0中引入的干式验证合同。

另外, Kafka 1.2与干宝石不兼容, 因此我们使用了它的存储库版本。目前, 我们使用的是1.3.0.rc1, 这取决于最新的干宝石。

不必要的依赖

此外, Hanami gem还包含许多我们不打算使用的依赖项, 例如hanami-cli, hanami-assets, hanami-mailer, hanami-view甚至是hanami-controller。另外, 查看hanami-model自述文件, 很明显它默认情况下仅支持一个数据库。另一方面, hanami模型所基于的ROM.rb支持开箱即用的多数据库配置。

总而言之, 总体而言, Hanami尤其是hanami模型看起来像是不必要的抽象级别。

因此, 在我们对《编年史》进行首次有意义的PR的10天后, 我们用Sinatra完全取代了hanami。我们也可以使用纯Rack, 因为我们不需要复杂的路由(我们有四个”静态”终结点-两个GraphQL终结点, / ping终结点和sidekiq Web界面), 但是我们决定不做得太硬。 Sinatra适合我们。如果你想了解更多信息, 请查看我们的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).filled(Types::Coercible::String)
end

在上面的代码段中, 以几种略有不同的方式定义了url参数。有些定义是等效的, 而另一些则没有任何意义。一开始, 我们无法真正分辨出所有这些定义之间的区别, 因为我们没有完全理解它们。结果, 我们合同的第一版非常混乱。随着时间的流逝, 我们学习了如何正确读写DRY合同, 现在它们看起来一致而优雅-实际上, 不仅优雅, 而且简直就是精美。我们甚至通过合同来验证应用程序配置。

ROM.rb和续集的问题

ROM.rb和Sequel与ActiveRecord不同, 不足为奇。我们最初的想法是, 我们将能够从Platform复制并粘贴大多数代码, 但失败了。问题在于, 平台部分的AR量很大, 因此几乎所有内容都必须用ROM /续集重写。我们仅复制了与框架无关的一小部分代码。一路上, 我们面临一些令人沮丧的问题和一些错误。

按子查询过滤

例如, 我花了几个小时才弄清楚如何在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片段和多行注释, 以解释造成这种不幸情况的原因。肿。

具有非平凡联接字段的关联

条目关系(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)不起作用。如果我们明确指定连接条件怎么办?如官方文档所建议的那样, 在entry.join(:access_taggings, perform_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_gid="gid://platform/Talent/124383" ... updated_at=2012-05-10 08:46:43 UTC>]

最终, 它返回了一些内容, 但没有失败, 尽管最终以泄漏的抽象告终。表名不应泄漏到应用程序代码中。

SQL参数插值

编年史搜索中有一项功能, 使用户可以按有效载荷进行搜索。查询如下所示:{operation::EQ, path:[” flag”, ” gid”], value:” gid:// plat / Flag / 1″}, 其中path始终是字符串数组, 并且值是任何有效的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))

幸运的是, 此处的路径已正确验证, 因此只包含字母数字字符, 但是此代码看起来仍然很有趣。

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_name, 因此创建操作的正确方法应如下所示:

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

由于mistyped属性被忽略, 它恢复为出厂时指定的默认属性(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应用程序的时间只有两分钟。不快如闪电, 但还算不错。我们经常部署, 因此即使是很小的改进也可以节省大量资金。

应用性能

编年史中最注重性能的部分是条目搜索。到目前为止, 平台后端中大约有20个位置可从编年史中获取历史记录条目。这意味着编年史的响应时间占平台响应时间的60秒预算, 因此编年史必须要快。

尽管动作日志的大小巨大(3000万行, 并且还在增长), 但平均响应时间仍不到100毫秒。看看这张漂亮的图表:

应用程序性能图

平均而言, 应用程序时间的80-90%花费在数据库中。这就是正确的性能图表的外观。

我们仍然有一些缓慢的查询, 可能要花费数十秒, 但是我们已经有一个计划, 如何消除它们, 以使提取的应用程序变得更快。

结构体

就我们的目的而言, 干式验证是一种非常强大且灵活的工具。我们通过合同将来自外部世界的所有输入传递出去, 这使我们确信输入参数总是格式正确且类型明确的。

不再需要在应用程序代码中调用.to_s.to_sym.to_i, 因为所有数据均已清理并在应用程序的边界处进行类型转换。从某种意义上说, 它为动态的Ruby世界带来了强大的理智性。我不能推荐它。

最后的话

选择非标准栈并不像最初看起来那样简单。在选择用于新服务的框架和库时, 我们考虑了很多方面:整体应用程序的当前技术栈, 团队对新栈的熟悉程度, 所选栈的维护方式等等。

即使我们从一开始就试图做出非常谨慎和周密的决定-我们选择使用标准的Hanami栈-由于项目的非标准技术要求, 我们不得不沿途重新考虑栈。我们最终得到了Sinatra和基于DRY的栈。

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

总而言之, 花在学习新框架, 范例或编程语言上的时间使我们对当前的技术栈有了新的认识。最好先了解可用的工具, 以丰富你的工具箱。每个工具都有其独特的用例-因此, 要更好地了解它们, 就意味着可以使用更多的工具, 并将其转变为更适合你的应用程序的情况。

赞(0)
未经允许不得转载:srcmini » 选择技术栈替代方案-跌宕起伏

评论 抢沙发

评论前必须登录!