SmartHR Tech Blog

SmartHR 開発者ブログ

SmartHRのマルチアプリケーションに分散した従業員データを集約する

こんにちは、プログラマーのkinoppydです。最近はSmartHR内でのプロダクトを横断して開発を行うプロダクト基盤チームというところで仕事をしています。

tech.smarthr.jp

GraphQL集めるマンの概念図

分散したプロダクトの課題

SmartHRは、祖業である労務管理と従業員情報を集約している「基本機能」と呼ばれる巨大なアプリケーションと、その「基本機能」にある従業員情報を使い文書配布、年末調整、タレントマネジメントなどを行う小さなアプリケーション群によってサービスが提供されています。各アプリケーションは完全に独立したリポジトリとデータベースを持っており、「基本機能」とのデータのやり取りには公開・非公開のREST APIを利用しています。

SmartHRのプロダクト間の構成概略図

APIで繋がれた基本機能とサービスの世界観には、一つ問題点があります。それは、複数のサービスを跨る情報を、一括で取得する方法が無いことです。例えば人員配置をいい感じにやるためのアプリを作りたいと思った時、評価の情報は人事評価アプリを参照し、同時にサーベイを使ってキャリアに関する調査などの結果も考慮したいとします。その時、それぞれのサービスにAPIを提供してもらい情報を取得できますが、すでにこの時点で基本機能・人事評価・サーベイの3つのアプリを横断して情報の取得・検索が発生しています。取得に関しては、まあAPIを提供してくださいといえば良い話なのですが、検索を行いかつそれをページネーションするといったオペレーションを考えると、REST APIだけではやや大変な仕事の様に思えます。

複数アプリから情報を取得する方法がない様子

今後SmartHRを構成するアプリ群で、お互いにお互いのデータを参照したいと思うケースはますます増えていきます。そういった要求に応えるため、プロダクト基盤チームでは世の中の先行事例を調査し、この課題を解決する方法を調査検討しました。結果として、SmartHRのバックエンドでは今後 Apollo Federation (よくGraphQL Federationと呼ばれますが、Apollo的にはApollo Federationというらしいです)という技術を使い、各アプリケーション間での統合的な情報取得や検索を行っていくことに決めました。

先行事例として非常に有名なのは、Netflixの事例です。

netflixtechblog.com

Netflixでは、大量のマイクロサービスを運用していますが、フロントエンド開発のためにマイクロサービスを束ねてGraphQLを提供していました。しかし、GraphQLのスキーマが肥大化し続ける問題に直面し、GraphQLを更に束ねる Apollo Federation を採用したと書かれています。Netflixの規模はあまりにも巨大すぎてSmartHRとは直接比較できませんが、それでもスキーマが散らばり肥大化するという問題に関しては、将来的にSmartHRも間違いなく直面するという予想ができました。

国内の事例だと、Money Forwardでの採用事例がありました。Netflixに比べ規模も小さく、用途も比較的SmartHRに近いように感じます。

moneyforward-dev.jp moneyforward-dev.jp

2022年の春頃に、これらの先行事例を参考にしながら、実際にSmartHRのプロダクトに Apollo Federation を試験実装し、今後のアプリケーション間でのデータやり取りの需要を満たせるかどうかの検証を行いました。その結果、後述する横断検索の課題はあるものの、分散しているプロダクトから横断的かつアトミックにデータを取り出すことが出来るというメリットを確認したため、採用しました。

利用アプリがApollo Federationを使って情報を取得している図

GraphQL、Federation、実装、拡張

Apollo Federation は、GraphQLのクライアントでも有名なApollo社が開発している、GraphQLのスーパーセットです。複数のGraphQLのなかで分散しているエンティティに対して共通のキー、例えばユーザーIDなどを指定し関連付け、全てを連結したより大きなグラフを自動で作成します。連結したグラフをスーパーグラフ、個々のグラフをサブグラフと言います。スーパーグラフは通常のGraphQLと互換のスキーマを持っており、透過的に扱うことができます。Apollo Federationは、複数のサブグラフから一つのスーパーグラフを生成するときに使用する特別な定義であり、エンドユーザーからは通常のGraphQLと区別が付きません。

www.apollographql.com

検証を開始した当初はv1でしたが、現在はv2が使用されています。

Apollo Federationのスーパーグラフとサブグラフの概念図

サブグラフ

サブグラフは、実際にデータを持っている各サービスが実装するGraphQLのエンドポイントです。サブグラフの仕様は通常のGraphQLのスーパーセットなので、通常のGraphQLとして扱うこともできないことはないですが、一般的にはFederation専用の定義などが入り込みます。

Railsで Apollo Federationの仕様を満たすためには、通常のgraphql-rubyに加えてGustoが公開しているGemを使用します。

github.com

その他の言語のサポートは、以下の一覧で確認できます。

www.apollographql.com

基本的には、graphql-rubyで作成した各クラスに対して、Federation用のモジュールをincludeするだけでインストールは終わります。それに加えて、Apollo Federationで使用するkeyやexternalなどのディレクティブを、他のサービスが提供するサブグラフの構成に合わせて追加していきます。例えば、以下は基本機能側のサブグラフに定義されている従業員のスキーマの簡略化版です。

# frozen_string_literal: true

module Types
  class CrewType < Types::BaseObject
   key fields: :id

    field :id, ID, null: false
    field :email, String
  end
end

CrewType というエンティティに、 email というフィールドが用意されています。これは、従業員のメールアドレスが取得できる CrewType というスキーマだということがわかります。このスキーマには、keyディレクティブにIDが指定されています。これは、従業員を一意に特定するSmartHR内部でのIDです。

一方で、以下はサーベイサービスで定義されているサブグラフの従業員スキーマを簡略化したものです。

# frozen_string_literal: true

module Types
  class CrewType < Types::BaseObject
    key fields: :id
    extend_type

    field :id, ID, null: false, external: true
    field :latest_engagements, [Types::EngagementType]

    def latest_engagements
      # do something...
    end
  end
end

基本機能と同じ CrewType を定義し、 key ディレクティブにIDを指定した上で、 extend_type メソッドを呼び出しています。これは、基本機能側の CrewType をFederationで拡張するという宣言です。ここでは、 CrewTypelatest_engagements というフィールドを増やしています。

これら2つのスキーマは本来独立した異なるサービスのGraphQLエンドポイントですが、 key ディレクティブで共通の従業員を特定するIDを指定することによって、スーパーグラフは1度のクエリで CrewType から emaillatest_engatements を取得することができます。その仕組が、Apollo Routerというスーパーグラフを作成するための専用サーバーです。

スーパーグラフ

サブグラフから実際にデータを取得しスーパーグラフのスキーマで返却するためには、その計算をしてくれる専用のサーバーが必要です。Apollo Routerと呼ばれるサーバーで、クエリを分析しどのサブグラフにどんな順序でアクセスすることでスーパーグラフのスキーマとしてデータを返却できるかを決定してくれます。

先の例では、 Crew というエンティティに対して emaillatest_engagements という2つのフィールドを、別々のサービスから取得して組み合わせる必要があります。スーパーグラフを構成するサブグラフのうち、どこのグラフが必要な情報を持っているのかを計算し、それぞれにアクセスして最終的にデータをまとめあげ、ユーザーに返却するのがApollo Routerです。

Apollo RouterはRustで実装されており、RhaiというRust上で動くスクリプト言語を用いることで拡張が可能となっています。

www.apollographql.com

以前まではApollo GatewayというNode製のサーバーで、ExpressのMiddlewareを使って拡張ができたのですが、これによってだいぶ難易度が上がりました。Rhaiという新しいスクリプト言語を覚えることと、テストのためにRustを書く必要があるので、急に覚えることが増えます。

www.apollographql.com

SmartHRのサービス群は、基本機能のユーザー管理機能が認可するOAuth2.0のトークンを使用して、ユーザーを認識しています。そのため、Apollo Routerから各サービスのサブグラフへのアクセスも、このOAuthトークンを使用することで個人を特定した上で権限などを考慮したレスポンスを返すことを考えた設計にしています。つまり、Apollo Routerにアクセスすときにヘッダに付与したOAuthのトークンを、サブグラフにアクセスするときに伝搬する必要があります。

Apollo Routerの動作はいくつかのフェーズに分かれています。その中の一つに、サブグラフにアクセスする直前にフックするsubgraph_serviceというフックがあり、OAuthトークンの伝播はこのフェーズで行います。以下のコードスニペットは、Rhaiスクリプトです。

fn subgraph_service(service, subgraph) {
    let f = |request| {
        try {
            request.subgraph.headers["authorization"] = request.headers["authorization"];
        }
        catch(err) {
            print(err);
        }
    };
    service.map_request(f);
}

非常に簡単なスクリプトで、Apollo Routerへのリクエストヘッダーに含まれているAuthorizationヘッダーをサブグラフのヘッダーにコピーしています。try-catchで囲まれているのは、リクエストヘッダーにAuthorizationキーが無かった場合にエラーが発生して、リクエストごと落ちてしまうからです。

Rhai拡張は、Rustでテストを書きます。Apollo Routerそのものにテストハーネスが用意されているので、比較的手軽にテストが書けるのですが、もしRhaiスクリプト内で失敗しても何が失敗したのか全くわからないままテストが落ちるので(先程のtry-catchがまさにそれです)、あまり使い勝手は良くないなという印象です。

use anyhow::Result;

// `cargo run -- -s ../../graphql/supergraph.graphql -c ./router.yaml`
fn main() -> Result<()> {
    apollo_router::main()
}

#[cfg(test)]
mod tests {
    use apollo_router::graphql;
    use apollo_router::plugin::test;
    use apollo_router::services::subgraph;
    use apollo_router::services::supergraph;
    use http::StatusCode;
    use tower::util::ServiceExt;

    #[tokio::test]
    async fn test_auth_token_delegated_to_subgraph_headers() {
        // モックサービスのセットアップ
        let mut mock_service = test::MockSubgraphService::new();

        // モックに返して欲しいデータ。今回はここはそんなに検証しないのでなんでも良い
        let expected_mock_response_data = "response created within the mock";

        // 実際にモックでテストする内容
        mock_service.expect_clone().return_once(move || {
            let mut mock_service = test::MockSubgraphService::new();
            mock_service
                .expect_call()
                .once()
                .returning(move |req: subgraph::Request| {
                    // サブグラフのリクエストに、トークンが付与されていることを確認している
                    assert_eq!(
                        req.subgraph_request
                            .headers()
                            .get("authorization")
                            .expect("authorization token is present"),
                        "Bearer TOKEN_STRING"
                    );
                    // ここはモックの戻り値をテストしている。なくても良いが、正常終了したことを担保するために念のため
                    req.context
                        .insert("mock_data", expected_mock_response_data.to_owned())
                        .unwrap();
                    Ok(subgraph::Response::fake_builder().build())
                });
            mock_service
        });

        // Routerの設定をセットアップ、Rhaiスクリプトの場所を指定する
        let config = serde_json::json!({
            "rhai": {
                "scripts": "rhai",
                "main": "main.rhai",
            }
        });
      
        // テストハーネスのセットアップ
        let test_harness = apollo_router::TestHarness::builder()
            .configuration_json(config)
            .unwrap()
            .subgraph_hook(move |_, _| mock_service.clone().boxed())
            .supergraph_hook(|service| {
                service
                    .map_response(|response| {
                        let mock_data = response.context.get("mock_data").unwrap();
                        response.map_stream(move |mut stream_item| {
                            stream_item.data = mock_data.clone();
                            stream_item
                        })
                    })
                    .boxed()
            })
            .build_router()
            .await
            .unwrap();

        let request_with_auth_token = supergraph::Request::canned_builder()
            .header("authorization", "Bearer TOKEN_STRING") // 検証するトークン
            .build()
            .unwrap();

        // テスト実行
        let mut service_response = test_harness
            .oneshot(request_with_auth_token.try_into().unwrap())
            .await
            .unwrap();

        let response: graphql::Response = serde_json::from_slice(
            service_response
                .next_response()
                .await
                .unwrap()
                .unwrap()
                .to_vec()
                .as_slice(),
        )
        .unwrap();
        assert_eq!(response.errors, []);
        // ステータスコードを確認
        assert_eq!(StatusCode::OK, service_response.response.status());

        // ちゃんとモックに当てに行って値が返っているか
        assert_eq!(expected_mock_response_data, response.data.unwrap())
    }
}

Apollo Routerのリポジトリには、様々なケースでのテスト例があるので、参考にしてみて下さい。

github.com

この他にも、いくつかSmartHR独自の事情による拡張や検証を追加して、本番環境で稼働しています。

横断的検索の課題

Apollo Federationは、分散したサービスをつなぎとめてくれるとても良い手段ではありますが、いくつかの問題も存在します。その一つが検索の弱さです。

Apollo Routerは、クエリを受け取るとスーパーグラフの構築に必要なエンティティをどこのサブグラフが持っているか計算し、優先順位をつけて取得にいきます。基本的には、検索は最も優先順位が高い行動です。なぜなら、検索によって対象の数を事前に十分に絞らないと、その後のサブグラフへのアクセス数がむやみに増えてしまうためです。

基本的に、Apollo Federationの検索は、どこか一つのサブグラフに対する検索用クエリを実行し、得られた結果の他のサブグラフのフィールドを集約していくという方法を取っており、横断的検索はできません。これは Apollo Federationの仕様の問題で、非常に使いづらいと言わざるを得ません。同様の問題提起は仕様をまとめているGitHubのIssueにも立っており、事前に検索を行うためにエンティティの解決を遅延するディレクティブなども提案されていますが、もう4年近く開発チームは無視し続けているので望みは薄いでしょう。

github.com

同様に、ページネーションに関しても Apollo Federationの開発チームは問題を無視し続けており、Apollo Federation自体は素晴らしい仕様だと思う一方で現実の複雑な問題からは逃げ続けているなというイメージです。

現在はrequiresディレクティブと各サブグラフの検索専用クエリを定義することによって、複数サービスの間で特定の条件を満たすkeyのみを取り出し、そのkeyの一覧をもう一度スーパーグラフに問い合わせるという二段階の検索アイディアを検証しています。

またNetflixが大量に存在するGraphQLのエンドポイントに適切にアクセスするために横断的な検索システムを構築している事例を見つけました。

netflixtechblog.com

これは、各サービスのGraphQLを使ってElasticSearchにインデックスを作成し、GraphQLで検索をかけるときにElasticSearchで先に横断的検索を実行するというアイディアです。これ自体は Apollo Federationに関連するアイディアではありませんが、同じようなことは複数のGraphQLでも出来るはずなので、Federationにも応用できると考えています。

どんな方法を採るかはまだ未定ですが、近く訪れるSmartHRのサービス間横断検索の要望にも応えていけるように準備するのが、プロダクト基盤チームの仕事です。

We are 以下略

プロダクト基盤チームでは、全プロダクトを横断する、技術的チャレンジが好きな開発者を求めています。無限に難しい問題にチャレンジしたり、まだ社内で一度も考えられたことがないような技術の導入などもどんどんやっていく必要があるので、やったるぜって方を募集中です。

open.talentio.com

open.talentio.com

また、開発者だけではなく、今後大きくなっていくチームのプロダクトマネージャーも求めています。こちらは、通常のプロダクトマネージャーとは少し違う経験を求めているので、現在は開発者だけどPdMとか興味ある、といったキャリアをお考えの人にもおすすめです。あんまり直近は転職とか考えていなくても、いまのプロダクト基盤チームのPdMは元開発者からPdMになって無双してる人なので、カジュアル面談とかで指名してもらえれば面白い話聞けると思います。

open.talentio.com

アディオス!