エクサウィザーズ Engineer Blog

株式会社エクサウィザーズのエンジニアチームブログ

Apolloを利用したGraphQLリクエストのパフォーマンスをFirebase Performance Monitoring で測定する

こんにちは。エクサウィザーズの介護記録AIアプリ「CareWiz ハナスト」(以下ハナスト)でiOSアプリ開発を担当している伊賀(@iganin_dev)です。

ハナストのテックリードの原のブログ記事にもありましたように、ハナストではAPI通信にGraphQLを利用しています。 本稿ではiOSアプリの通信ライブラリとしてApolloを用いた場合のGraphQLリクエストのパフォーマンスをFirebase Performance Monitoring(以下FPM)を使用して測定する方法に関して記載します。

環境

本稿記載の内容は以下環境を前提に記載しています。

  • Xcode 14.0
  • apollo-ios 0.51.0 (※ 1.0.0へのバージョンアップ検証中)
  • firebase-ios-sdk 9.6.0

ハナストについて

本題に入る前に「CareWiz ハナスト」に関して簡単にご紹介します。 ハナストは簡単に言うと「音声入力で介護の記録をするアプリ」です。

以下のLPによくまとまっています。 利用シーンを紹介するデモビデオもありますので、是非ご覧ください。

hanasuto.carewiz.ai

Firebase Performance Monitoringとは

FPMはGoogleが提供しているFirebaseの機能群の一つです。 ネットワークリクエストをはじめ、さまざまな処理にかかった時間や処理の結果(成功・エラー)などを記録、集計しGUIを通してグラフィカルに確認することができます。

ライブラリを追加するのみでアプリの起動時間や画面の滞在時間などを測定してくれる非常に便利なツールです。 ネットワークリクエストも自動的に計測し、カスタムURLパターンを作成すれば特定のリクエストの計測もできます。

FPMでGraphQLリクエストのパフォーマンスを測定する場合の問題点

非常に便利なFPMですが、GraphQLのリクエストを測定しようとした場合に問題が発生します。GraphQLのリクエストは一般的には同一エンドポイントへのPOSTリクエストとなります。例えば、 https://sample.com/graphql のようなPOSTリクエストのBodyにQueryやMutationのGraphQLドキュメントをのせリクエストを送ります。

FPMではリクエストのパフォーマンスをURLのパスを元に分類します。従って、GraphQLリクエストのパフォーマンスを測定しようとした場合、そのままではすべての計測結果がsample.com/graphqlのような単一のエンドポイントに集約されてしまい、各リクエストのパフォーマンスを別々に見ることができません。それではどのようにすれば、GraphQLリクエストのパフォーマンスをFPMで測定できるのでしょうか。

カスタムネットワークリクエストトレースについて

FPMでは自動収集するリクエストトレース以外に、開発者にて実装できるカスタムネットワークリクエストトレースを用意しています。HTTPMetricをurlとhttpMethodを引数で渡して初期化し、start()stop()を呼び出すことで、start()からstop()を呼び出すまでの時間を計測することができます。FPMのGUI上ではここで指定したurlがパスの分類に使用されます。また、HTTPMetricにはリクエストやレスポンスのペイロードサイズ、レスポンスのステータスコードを登録することもできます。

guard let metric = HTTPMetric(url: url, httpMethod: .post) else { return }
metric.start()
metric.requestPayloadSize = requestPayloadSize

Task {
    do {
        // ネットワークリクエスト実行
        let (data, response) = try await URLSession.shared.data(from: url)
        metric.responsePayloadSize = responsePayloadSize
        metric.responseCode = response.httpResponse.statusCode
        metric.stop()
    } catch let error {
        // エラーハンドリング
        metric.stop()
    }
}

ハナストでの解決方法

先ほどの全てのGraphQLリクエストの計測結果がまとめて集約されてしまうという問題に対して、ハナストではカスタムネットワークリクエストトレースの仕組みを活用して対処しています。基本的な解決方法としてはリクエストごとにURLのパスを分けるというものです。

Apolloを用いて自動生成されたQueryやMutationのclassにおいて、そのOperationの名称をoperationNameで取得することができます。例えば、以下のようなQueryをベースにSampleQuery.graphql.swiftのようなファイルが生成されます。

query sample {
    user {
        id
        name
    }
}
public final class SampleQuery: GraphQLQuery {
    ...
    public let operationName: String = "sample"
    ...
}

このoperationNameは作成したGraphQLファイルのqueryやmutationの名称と1対1で対応するため、operationNameをもとにリクエストを一意に特定することができます。そのため、さきほどのHTTPMetric初期化時に渡すurlにこのoperationNameを付与することでリクエストのPathがGraphQLのリクエストごとに異なるようになり、FPMのGUI上で各リクエストのパフォーマンスを測定することができるようになります。

実装

先ほどoperationNameをURLに付与してリクエストを一意に特定することで問題に対処するとお伝えしました。次にApolloを用いた場合の実装例をご紹介します。具体的な実装に入る前に、Apolloを用いた実装を確認しておきましょう。 まず、Apolloの初期化は下記のように行われます。

let cache = InMemoryNormalizedCache()
let store = ApolloStore(cache: cache)
let client = URLSessionClient()
let transport = RequestChainNetworkTransport(
        interceptorProvider: SampleInterceptorProvider(
            store: store,
            client: client,
            apiConfig: apiConfig
        ),
        endpointURL: endpointURL
    )
ApolloClient(networkTransport: transport, store: store)

Apolloからリクエストを送ると、interceptorProviderfunc interceptors(for _: some GraphQLOperation) -> [ApolloInterceptor]メソッドが提供するApolloInterceptorが通信リクエストの内容とレスポンスにさまざまな処理を加えたり、それらをもとにさまざまな処理を行い、最終的な返却値を返します。なお挙動を確認したところ、func interceptors(for _: some GraphQLOperation) -> [ApolloInterceptor]はApolloからリクエストを送る都度呼び出されていました。

final class SampleInterceptorProvider {
    func interceptors(for _: some GraphQLOperation) -> [ApolloInterceptor] {
        var interceptors: [ApolloInterceptor] = []
        // pre fetch interceptors - fetch前に行いたい処理のinterceptorをここに実装します
        interceptors.append(NetworkFetchInterceptor(client: client))
        // post fetch interceptors - fetch後に行いたい処理のinterceptorをここに実装します
    }
}

今回の実装では、FPMの計測開始を担当するStartFPMMetricInterceptorとFPMの計測終了を担当するStopFPMMetricInterceptorを実装し、interceptorsNetworkFetchInterceptorの前後に追加することでリクエストの都度カスタムネットワークリクエストトレースが行われるようにしています。 StartFPMMetricInterceptorの実装は下記のようになっています。

final class StartFPMMetricInterceptor: ApolloInterceptor {
    init(performanceMonitor: any NetworkPerformanceMonitorable) {
        self.performanceMonitor = performanceMonitor
    }

    private let performanceMonitor: any NetworkPerformanceMonitorable

    func interceptAsync<Operation>(
        chain: RequestChain,
        request: HTTPRequest<Operation>,
        response: HTTPResponse<Operation>?,
        completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
    ) where Operation: GraphQLOperation {
        let operationName = request.operation.operationName
        let url = request.graphQLEndpoint.appendingPathComponent("/\(operationName)")
        let requestPayloadSize = try? request.toURLRequest().urlRequest?.httpBody?.count
        performanceMonitor.start(url: url, method: .post, requestPayloadSize: requestPayloadSize)
        chain.proceedAsync(request: request, response: response, completion: completion)
    }
}

ApolloInterceptorに準拠した場合、interceptAsync<Operation>メソッドを実装する必要があります。 このメソッド内で、requestからoperationNameを取得し、urlのパスにoperationNameを追加しています。 StopFPMMetricInterceptorの実装は下記のようになっています。

final class StopFPMMetricInterceptor: ApolloInterceptor {
    init(performanceMonitor: any NetworkPerformanceMonitorable) {
        self.performanceMonitor = performanceMonitor
    }

    private let performanceMonitor: NetworkPerformanceMonitorable

    func interceptAsync<Operation>(
        chain: RequestChain,
        request: HTTPRequest<Operation>,
        response: HTTPResponse<Operation>?,
        completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
    ) where Operation: GraphQLOperation {
        let statusCode = response?.httpResponse.statusCode ?? 0
        let responsePayloadSize = response?.rawData.count
        performanceMonitor.stop(statusCode: statusCode, responsePayloadSize: responsePayloadSize)
        chain.proceedAsync(request: request, response: response, completion: completion)
    }
}

ネットワークのレスポンスからstatusCodeやレスポンスペイロードサイズを取得し、HTTPMetricに渡しています。 NetworkPerformanceMonitorableはFPMのHTTPMetricの生成や保持、startstopの呼び出しを責務に持つプロトコルです。 NetworkPerformanceMonitorableと、それに準拠したFirebaseNetworkPerformanceMonitorの実装は下記のようになっています。

計測の開始時点で呼び出すstart(url:,method:)でurlとリクエストのペイロードサイズを渡し、そのタイミングでHTTPMetricを生成して保持しています。その後、計測の終了時点で呼び出すstop(statusCode:, responsePayloadSize:)にてレスポンスのペイロードサイズとstatusCodeをわたし、HTTPMetricstopを呼び出しています。

public protocol NetworkPerformanceMonitorable {
    func start(url: URL, method: PerformanceMonitorHttpMethod, requestPayloadSize: Int?)
    func stop(statusCode: Int, responsePayloadSize: Int?)
    func cancel()
}

public final class FirebaseNetworkPerformanceMonitor: NetworkPerformanceMonitorable {
    public init() {}
    private var metric: HTTPMetric?
    
    public func start(url: URL, method: PerformanceMonitorHttpMethod, requestPayloadSize: Int?) {
        let metric = HTTPMetric(url: url, httpMethod: method.convertToFirebaseHttpMethod())
        metric?.requestPayloadSize = requestPayloadSize ?? 0
        self.metric = metric
        metric?.start()
    }
    
    public func stop(statusCode: Int, responsePayloadSize: Int?) {
        guard let metric else { return }
        metric.responsePayloadSize = responsePayloadSize ?? 0
        metric.responseCode = statusCode >= 0 ?  statusCode : nil
        metric.stop()
    }
    
    public func cancel() {
        metric = nil
    }
}

最後にInterceptorProviderinterceptorsメソッドの返却値に上述のクラス群を追加すれば完成です。

final class SampleInterceptorProvider {
    func interceptors(for _: some GraphQLOperation) -> [ApolloInterceptor] {
        let performanceMonitor = FirebaseNetworkPerformanceMonitor()
        var interceptors: [ApolloInterceptor] = []
        // pre fetch interceptors - fetch前に行いたい処理のinterceptorをここに実装します
        interceptors.append(StartFPMMetricInterceptor(performanceMonitor: networkPerformanceMonitor))

        interceptors.append(NetworkFetchInterceptor(client: client))

        // post fetch interceptors - fetch後に行いたい処理のinterceptorをここに実装します
        nterceptors.append(StopFPMMetricInterceptor(performanceMonitor: networkPerformanceMonitor))
    }
}

これでApolloを用いたGraphQLリクエストの都度StartFPMMetricInterceptorStopFPMMetricInterceptorで計測のstartとstopが呼ばれるようになり、リクエストにかかった時間を計測できるようになります。 GUI上では例えば下記のように表示されます。

補足

今回の実装はGraphQLリクエストのCachePolicyとして.fetchIgnoringCacheCompletelyを指定した場合を想定しています。 他のCachePolicyを使用した場合はさらに考慮が必要になるだろうと思います。

まとめ

今回はハナストでGraphQLリクエストのパフォーマンスをどのように測定しているのかをご紹介しました。

本稿ではご紹介できませんでしたが、エッジでの音声認識、ウェイクワード検知、フルSwiftUIでのアプリ開発、Swift Concurrencyの実践的導入などハナストiOSアプリの開発はチャレンジングで面白い課題に日々挑戦しています。

CareWiz事業部およびエクサウィザーズでは社会課題の解決に一緒に取り組む仲間を募集しています。 介護をより良くするプロダクトの開発、あるいはAIで社会課題を解決するエクサウィザーズに少しでも興味がありましたら、是非ご応募ください!

hrmos.co

hrmos.co

open.talentio.com