こんにちは、ファインディでFindy Team+(以下Team+)を開発しているEND(@aiandrox)です。この記事はFindy Advent Calendar 2024 10日目の記事です。
Team+ではコード管理ツール・イシュー管理ツール・カレンダーなど、様々な性質の外部サービスと連携して、エンジニア組織における開発生産性の可視化・分析を行うためのデータを取得しています。
この分析を行うためには、外部サービスごとに異なるデータ構造やAPI仕様の差を吸収した統一的なデータ管理を行う必要があります。この課題を解決するため、異なるサービスのデータを統合し、単一のUIで一貫性を持って表示する仕組みを整えています。
この記事では、コード管理ツールのデータインポートをどのようなアーキテクチャで実現しているかを紹介します。
Team+と外部サービス差分の例
前提として、Team+では現在GitHub, GitLab, Bitbucket, Backlogから取得したコード管理系のデータを以下のように表示しています。
この画面の分析のために取得しているデータは、プルリクのステータス、コミット日、オープン日、最初のレビュー日、レビューステータス、マージ日です。
外部サービスごとの小さな差分の例として、プルリクのステータスがあります。Team+では「対応中」「クローズ」「マージ」の3種類がありますが、各サービスのAPIレスポンスの値は以下を返すようになっています。
GitHub | GitLab | Bitbucket | Backlog |
---|---|---|---|
open, closed | opened, closed, locked, merged | OPEN, MERGED, DECLINED, SUPERSEDED | Open, Closed, Merged |
GitHubの場合、ステータスの値だけを参照してもクローズとマージの区別がつかないため、merged_at
に値が入っているかどうかで判定しています。また、GitLabのlocked
やBitbucketのSUPERSEDED
のようなイレギュラーなステータスは、他の値に丸めるようにしています。
全体のアプリケーションアーキテクチャ
全体の流れは、以下のようになっています。これらのインポート処理は、各サービスごとに分割して全8インスタンスで行い、それぞれのインスタンス内で組織ごとに20プロセスで並列して処理しています(2024年12月時点)。
層 | 主な役割 |
---|---|
Client層 | APIの仕様差を吸収し、レスポンスをRepresentationインスタンスとして返す |
Importer層 | エラーハンドリングをし、サービス独自テーブルに保存する |
Transformer層 | 各サービスごとのデータ形式差分を吸収し、共通テーブルに保存する |
API層 | 表示データのフロントエンド提供 |
このように4つの層と2種類のテーブルを使うことで、以下のようなメリットとデメリットがあります。
メリット
責務が明確になりコードの見通しがよい
この設計では、新しい外部サービスを追加する際はClient層とTransformer層、それぞれ単独で実装することができます。また、コードレビュー時も変更範囲が限定されるため、確認すべき箇所を絞りやすく、レビューの効率がよいです。
不具合が起きたときの原因がわかりやすくなる
例えば、外部サービスのAPIのエラーによってデータが取得できなかった場合、Importer層でエラーがキャッチされます。また、値がTeam+で扱うものとして想定外だった場合はTransformer層でエラーになります。
外部サービスのAPIを使っているため、こちらで対応できないエラーが起きることや想定されないデータが返ってくることは避けられませんが、それによる影響が最小限になるようにしています。
2種類のデータを扱うことで、柔軟性と速度を担保する
Team+には、サービス独自テーブルと共通テーブルの2種類があります。前者はAPIレスポンスの形に近い形で保存することを目的とし、後者はTeam+で扱いやすい形式で保存することを目的としています。これにより、フロントエンドからのリクエストに対しては、外部APIと独立して安定したデータソースとして迅速に提供することができます。
他にも、サービス独自テーブルに保存されたデータはTransformer層でエラーが発生しても再利用可能なため、外部APIを再度叩く必要がありません。
層ごとにスケールすることができる
現時点では行っていませんが、Import処理とTransform処理が独立しているため、必要に応じて片方のみスケールするという選択肢を持つことができます。
デメリット
層ごとの独立性が高いがゆえの複雑さがある
層ごとに責務が分離しているため、全体の流れを把握するのが大変です。特に新しいメンバーが参加した場合、どの処理がどの層で行われているかを理解するまでに学習コストがかかります。また、デバッグ時には層の間をまたぐデータフローを追う必要があり、状況によっては負担になることもあります。
リアルタイム性に欠ける
このアーキテクチャは、データの取得から変換、提供までを複数の層に分割しているため、一連の処理がリアルタイムで完結する用途には適していません。例えば、ユーザーがリアルタイムにデータを参照したい場合、現行のバッチ処理的なインポートでは対応が難しいです。
Team+でも、初回連携の際の一時的なデータ取得ではClient層しか利用していません。
一部のリソースのみインポートするといった処理が難しい
このアーキテクチャでは、すべてのデータのImport処理が完了した後にTransform処理を行っています。これが完了するまで画面上にデータを表示できないため、Import処理に時間がかからないリソースから逐次的に表示できるようにするなどの柔軟性は持たせづらいです。
各層について
Client層
この層では、外部サービスのAPIレスポンスや仕様がサービスごとに違うため、その差分を吸収することが大きな目的としています。
libディレクトリ配下に置いてGemのように独立して作用できるようにしつつ、アプリケーションで統一して扱えるようにしています。主に、以下のような処理を行っています。
- 外部サービスのAPIにリクエストを行い、そのレスポンスをアプリケーションで扱いやすい形に成形加工するRepresentationクラスに格納する
- リトライ処理を行う
- 例外を発生させる
エラーに対しては、Rate limitのみリトライ処理を行っています。その他のエラーと、最大回数を上回ったエラーは例外を投げるようにしています。ここでは、主にレスポンスステータスを参照しています。
これらの処理には、外部サービス独自のGemは使わずスクラッチで実装しています。また、1ページごとに遅延実行をするようにしてメモリを圧迫しないように対策しています。
Importer層
Client層から取得したRepresentationインスタンスを使い、レコードごとのアソシエーションに応じて関連レコードと一緒に独自テーブルに保存します。
また、Clientインスタンスのような依存性を注入することで、Importerの単体テストを容易にしています。
class PullsImporter def self.call(client, repo_id:, repo_full_name:) new(client, repo_id:, repo_full_name:).call end def initialize(client, repo_id:, repo_full_name:) @client = client @repo_id = repo_id @repo_full_name = repo_full_name @user_finder = UserFinder.new end def call client.pulls(repo_full_name).each do |response| attributes = response.map do |representation| PullApiMapper.call(representation, repo_id:, user_finder:) end Source::Pull.import!(attributes) end end end
Importer内では、FinderやApiMapperなどを定義し、それを用いてインポート処理を行っています。
関連レコードの取得はFinderクラスを作成し、これを介するようにしています。これにより、関連レコードを読み込むためのN+1を最小限に抑えることができるようにしています。
class UserFinder def find_by(uuid:) users_index_by_uuid[uuid] end private def users_index_by_uuid @users_index_by_uuid ||= Source::User.reload.index_by(&:uuid) end end
ApiMapperは、Representationからサービス独自テーブルへの変換のためのattributes作成を行っています。関連レコード(この例ではuser)の外部キーを取得するためにFinderを渡しておき、レコード取得の処理はApiMapper内で行っています。
class PullApiMapper def call { repo_id:, user_id: user.id, title: representation.title, state: representation.state } end private attr_reader :representation, :repo_id, :user_finder def user @user ||= user_finder.find_by(uuid: representation.user.uuid) end end
Transformer層
サービス独自のテーブルからレコードを取得し、各サービスのデータ構造の差分を吸収し、表示データ用のテーブルに保存します。
class PullConverter def self.call(duration) sources = Source::Pull.where(updated_at: duration) attributes = sources.map { |source| PullMapper.new.call(source) } View::Pull.import!(attributes) end end
ここでもPullMapperが使われていますが、これはImporterで使われているApiMapperとは似ているようで少し違います。前者は、Representationクラスのインスタンスをテーブルに保存するためのもので、後者はモデルのレコードを共通テーブルに保存するためのものです。外部サービスのデータ形式の差分はここで吸収されることがほとんどです。
class PullMapper def call(source) { source_type: source.class.name, source_id: source.id, repo_id: source.repo.view_repo.id, user_id: source.user.view_user.id, title: source.title, status: to_view_status(source) } end private def to_view_status(source) return :merged if source.merged? return :closed if source.closed? :created end end
API層
バックエンドから表示データ用のテーブルの値をフロントエンドに返します。ここでは、サービス独自テーブルにはアクセスせず、共通テーブルの値のみを使用します。
リアルタイムのデータ集計には独自のアーキテクチャを利用していますが、こちらに関してはまた別の機会にご紹介します。
おわりに
今回、Team+のデータインポート周りのアプリケーションアーキテクチャについて紹介しました。
このアーキテクチャは、最初からこの形だったわけではありません。複数の外部サービスと連携する中で、API仕様やデータ形式の違いに直面し、それらを克服するために少しずつ設計を見直してきました。また、例えば、初期にはエラー処理がClient層やImporter層に散在していたり、それぞれの層が密結合になっていたため、層ごとの役割分担を明確化しました。こうした試行錯誤を繰り返しながら、現在の仕組みに至っています。
今も、旧アーキテクチャの名残が残っている箇所があったり、データインポートの時間が長い組織があるといった伸びしろがあります。これからも、改善を重ねて、みなさんにとって価値のあるサービスを提供していきます!
ファインディでは一緒に会社を盛り上げてくれるメンバーを募集中です。興味のある方は、ぜひカジュアル面談で話を聞きに来てください!