TechStackの代替案の選択-浮き沈み

公開: 2022-03-11

Webアプリケーションが大きくて十分に古い場合は、Webアプリケーションをより小さな分離された部分に分割し、そこからサービスを抽出する必要がある場合があります。その一部は、他のアプリケーションよりも独立しています。 このような決定を促す理由には、テストの実行時間を短縮する、アプリのさまざまな部分を個別にデプロイできる、サブシステム間の境界を強制するなどがあります。 サービスの抽出には、ソフトウェアエンジニアが多くの重要な決定を行う必要があり、そのうちの1つは、新しいサービスに使用する技術スタックです。

この投稿では、モノリシックアプリケーションであるToptalPlatformから新しいサービスを抽出する方法について説明します。 選択したテクニカルスタックとその理由を説明し、サービスの実装中に発生したいくつかの問題の概要を説明します。

ToptalのChroniclesサービスは、Toptalプラットフォームで実行されるすべてのユーザーアクションを処理するアプリです。 アクションは基本的にログエントリです。 ユーザーが何かを行うと(たとえば、ブログ投稿の公開、ジョブの承認など)、新しいログエントリが作成されます。

プラットフォームから抽出されていますが、基本的にはそれに依存せず、他のアプリで使用できます。 これが、プロセスの詳細な説明を公開し、新しいスタックに移行する際にエンジニアリングチームが克服しなければならなかった多くの課題について話し合う理由です。

サービスを抽出してスタックを改善するという私たちの決定の背後には、いくつかの理由があります。

  • 他のサービスで、他の場所で表示および使用できるイベントをログに記録できるようにしたかったのです。
  • 履歴レコードを格納するデータベーステーブルのサイズは急速かつ非線形に増大し、高い運用コストが発生しました。
  • 既存の実装は技術的負債によって負担されていると考えました。

アクションのテーブル-データベーステーブル

一見、それは単純なイニシアチブのように見えました。 ただし、代替の技術スタックを扱うと、予期しない欠点が生じる傾向があり、それが今日の記事で取り上げることを目的としています。

アーキテクチャの概要

Chroniclesアプリは、多かれ少なかれ独立した3つの部分で構成され、別々のDockerコンテナーで実行されます。

  • Kafkaコンシューマーは、エントリ作成メッセージの非常に薄いKarafkaベースのKafkaコンシューマーです。 受信したすべてのメッセージをSidekiqにキューイングします。
  • Sidekiqワーカーは、Kafkaメッセージを処理し、データベーステーブルにエントリを作成するワーカーです。
  • GraphQLエンドポイント:
    • パブリックエンドポイントは、さまざまなプラットフォーム機能に使用されるエントリ検索APIを公開します(たとえば、スクリーニングボタンにコメントのツールチップを表示したり、ジョブの変更の履歴を表示したりするため)。
    • 内部エンドポイントは、データ移行からタグルールとテンプレートを作成する機能を提供します。

2つの異なるデータベースに接続するために使用されるクロニクル:

  • 独自のデータベース(タグルールとテンプレートを保存する場所)
  • プラットフォームデータベース(ユーザーが実行したアクションとそのタグおよびタグ付けを保存する場所)

アプリを抽出する過程で、プラットフォームデータベースからデータを移行し、プラットフォーム接続をシャットダウンしました。

初期計画

当初、私たちは花見とそれがデフォルトで提供するすべてのエコシステム(ROM.rb、dry-rb、hanami-newrelicなどに裏打ちされたhanamiモデル)を使用することにしました。 物事を行う「標準的な」方法に従うことで、摩擦が少なく、実装速度が速く、直面する可能性のある問題の「グーグル性」が非常に優れていることが約束されました。 さらに、ハナミのエコシステムは成熟していて人気があり、ライブラリはRubyコミュニティの尊敬されるメンバーによって注意深く維持されています。

さらに、システムの大部分はすでにプラットフォーム側に実装されていたため(GraphQL Entry SearchエンドポイントやCreateEntry操作など)、変更を加えずに、多くのコードをプラットフォームからクロニクルにそのままコピーすることを計画しました。 これは、Elixirが許可しなかったため、Elixirを使用しなかった主な理由の1つでもありました。

Railsをやらないことにしたのは、そのような小さなプロジェクト、特にActiveSupportのような、私たちのニーズに多くの具体的なメリットをもたらさないようなプロジェクトではやり過ぎだと感じたからです。

計画が南に行くとき

私たちは計画に固執するために最善を尽くしましたが、いくつかの理由ですぐに脱線しました。 1つは、選択したスタックの経験不足であり、次にスタック自体の真の問題があり、次に非標準のセットアップ(2つのデータベース)がありました。 結局、私たちはhanami-modelを取り除き、次に花見自体を取り除き、シナトラに置き換えることにしました。

シナトラを選んだのは、12年前に作成された積極的にメンテナンスされているライブラリであり、最も人気のあるライブラリの1つであるため、チームの全員が十分な実地体験をしたからです。

互換性のない依存関係

クロニクルの抽出は2019年6月に開始され、当時、花見は最新バージョンのドライRBジェムと互換性がありませんでした。 つまり、当時の花見の最新バージョン(1.3.1)は、乾式検証0.12しかサポートしておらず、乾式検証1.0.0が必要でした。 1.0.0でのみ導入されたドライ検証からの契約を使用することを計画しました。

また、Kafka 1.2はドライジェムと互換性がないため、リポジトリバージョンを使用していました。 現在、最新のドライジェムに依存する1.3.0.rc1を使用しています。

不必要な依存関係

さらに、花見の宝石には、 hanami-clihanami-assetshanami-mailerhanami-view 、さらにはhanami-controllerなど、使用する予定がなかった依存関係が多すぎました。 また、hanami-model readmeを見ると、デフォルトで1つのデータベースしかサポートしていないことが明らかになりました。 一方、hanami hanami-modelのベースとなっているROM.rbは、すぐに使用できるマルチデータベース構成をサポートします。

全体として、花見全般、特にhanami-modelは、不必要なレベルの抽象化のように見えました。

それで、クロニクルに最初の意味のあるPRを行ってから10日後、私たちはハナミをシナトラに完全に置き換えました。 複雑なルーティング(4つの「静的」エンドポイント-2つのGraphQLエンドポイント、/ pingエンドポイント、およびsidekiq Webインターフェイス)が必要ないため、純粋なRackを使用することもできますが、ハードコアになりすぎないことにしました。 シナトラは私たちにぴったりでした。 詳細については、シナトラと続編のチュートリアルをご覧ください。

ドライスキーマとドライ検証の誤解

乾式検証を正しく「調理」する方法を理解するには、時間と試行錯誤が必要でした。

 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とは異なりますが、当然のことです。 プラットフォームからほとんどのコードをコピーして貼り付けることができるという最初のアイデアは失敗しました。 問題は、プラットフォーム部分が非常に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パラメータの補間

クロニクル検索には、ユーザーがペイロードで検索できる機能があります。 クエリは次のようになります: {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を使用しました。 ただし、コードが期待どおりに機能しなかったことが何度かありました。 このテストの何が問題になっているのか推測できますか?

 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)

いいえ、期待は失敗していません、期待は大丈夫です。

問題は、2行目が一意の制約検証エラーで失敗することです。 その理由は、 actionActionモデルが持つ属性ではないためです。 実際の名前はaction_nameであるため、アクションを作成する正しい方法は次のようになります。

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

誤って入力された属性が無視されたため、ファクトリで指定されたデフォルトの属性( action_name { 'created' } )にフォールバックし、2つの同一のアクションを作成しようとしているため、一意の制約違反が発生します。 私たちはこの問題に何度か対処しなければならず、それは負担が大きいことを証明しました。

幸い、0.9.0で修正されました。 Dependabotは、ライブラリの更新を含むプルリクエストを自動的に送信しました。これは、テストで誤って入力された属性をいくつか修正した後にマージされました。

一般的な人間工学

これはすべてを言います:

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

そして、より複雑な例では、その違いはさらに大きくなります。

良い部品

それはすべての痛み、汗、そして涙ではありませんでした。 私たちの旅には多くの良いことがあり、それらは新しいスタックのマイナス面をはるかに上回っています。 そうでなければ、そもそもそうしなかっただろう。

テストスピード

テストスイート全体をローカルで実行するには5〜10秒かかり、RuboCopの場合は5〜10秒かかります。 CI時間ははるかに長くなりますが(3〜4分)、とにかくすべてをローカルで実行できるため、これはそれほど問題にはなりません。そのおかげで、CIで失敗する可能性ははるかに低くなります。

ガードジェムが再び使えるようになりました。 コードを記述し、保存ごとにテストを実行して、非常に高速なフィードバックを提供できると想像してみてください。 プラットフォームを使用する場合、これを想像するのは非常に困難です。

デプロイ時間

抽出されたChroniclesアプリをデプロイする時間はわずか2分です。 電光石火ではありませんが、それでも悪くはありません。 展開は非常に頻繁に行われるため、わずかな改善でも大幅な節約を実現できます。

アプリケーションのパフォーマンス

Chroniclesの最もパフォーマンスを重視する部分は、エントリ検索です。 今のところ、プラットフォームのバックエンドには、クロニクルから履歴エントリを取得する場所が約20か所あります。 これは、Chroniclesの応答時間がプラットフォームの応答時間の60秒の予算に寄与することを意味します。したがって、Chroniclesは高速である必要があります。

アクションログのサイズが非常に大きい(3000万行、増加している)にもかかわらず、平均応答時間は100ミリ秒未満です。 この美しいチャートを見てください:

アプリケーションパフォーマンスチャート

平均して、アプリ時間の80〜90%がデータベースに費やされています。 これが、適切なパフォーマンスチャートの外観です。

まだ数十秒かかる可能性のある遅いクエリがいくつかありますが、それらを排除する方法をすでに計画しており、抽出されたアプリをさらに高速にすることができます。

構造

私たちの目的にとって、乾式検証は非常に強力で柔軟なツールです。 外部からのすべての入力をコントラクトを介して渡すため、入力パラメーターは常に適切に形成され、明確に定義されたタイプであると確信できます。

すべてのデータがクリーンアップされ、アプリの境界でタイプキャストされるため、アプリケーションコードで.to_s.to_sym.to_iを呼び出す必要がなくなりました。 ある意味で、それはダイナミックなRubyの世界に強いタイプの正気をもたらします。 私はそれを十分に推薦することはできません。

最後の言葉

非標準のスタックを選択することは、当初考えられていたほど簡単ではありませんでした。 新しいサービスに使用するフレームワークとライブラリを選択する際には、モノリスアプリケーションの現在の技術スタック、チームが新しいスタックに精通していること、選択したスタックがどのように維持されているかなど、多くの側面を考慮しました。

最初から非常に慎重で計算された決定を下そうとしましたが(標準のハナミスタックを使用することを選択しました)、プロジェクトの非標準の技術要件のために、途中でスタックを再検討する必要がありました。 最終的にSinatraとDRYベースのスタックになりました。

新しいアプリを抽出する場合、もう一度花見を選択しますか? おそらくそうだ。 これで、ライブラリとその長所と短所について詳しく知ることができたので、新しいプロジェクトの最初から、より多くの情報に基づいた決定を下すことができました。 ただし、プレーンなSinatra/DRY.rbアプリの使用も真剣に検討します。

全体として、新しいフレームワーク、パラダイム、またはプログラミング言語の学習に費やされた時間は、現在の技術スタックに新鮮な視点を与えてくれます。 ツールボックスを充実させるために、何が利用できるかを知ることは常に良いことです。 各ツールには独自のユースケースがあります。したがって、ツールをよりよく理解することは、より多くのツールを自由に使用できるようにし、アプリケーションにより適したものに変えることを意味します。