こんにちは。エクサウィザーズの介護記録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によくまとまっています。 利用シーンを紹介するデモビデオもありますので、是非ご覧ください。
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からリクエストを送ると、interceptorProvider
のfunc 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
を実装し、interceptors
のNetworkFetchInterceptor
の前後に追加することでリクエストの都度カスタムネットワークリクエストトレースが行われるようにしています。
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の生成や保持、start
、stop
の呼び出しを責務に持つプロトコルです。
NetworkPerformanceMonitorable
と、それに準拠したFirebaseNetworkPerformanceMonitor
の実装は下記のようになっています。
計測の開始時点で呼び出すstart(url:,method:)
でurlとリクエストのペイロードサイズを渡し、そのタイミングでHTTPMetric
を生成して保持しています。その後、計測の終了時点で呼び出すstop(statusCode:, responsePayloadSize:)
にてレスポンスのペイロードサイズとstatusCode
をわたし、HTTPMetric
のstop
を呼び出しています。
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 } }
最後にInterceptorProvider
のinterceptors
メソッドの返却値に上述のクラス群を追加すれば完成です。
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リクエストの都度StartFPMMetricInterceptor
とStopFPMMetricInterceptor
で計測のstartとstopが呼ばれるようになり、リクエストにかかった時間を計測できるようになります。
GUI上では例えば下記のように表示されます。
補足
今回の実装はGraphQLリクエストのCachePolicyとして.fetchIgnoringCacheCompletely
を指定した場合を想定しています。
他のCachePolicyを使用した場合はさらに考慮が必要になるだろうと思います。
まとめ
今回はハナストでGraphQLリクエストのパフォーマンスをどのように測定しているのかをご紹介しました。
本稿ではご紹介できませんでしたが、エッジでの音声認識、ウェイクワード検知、フルSwiftUIでのアプリ開発、Swift Concurrencyの実践的導入などハナストiOSアプリの開発はチャレンジングで面白い課題に日々挑戦しています。
CareWiz事業部およびエクサウィザーズでは社会課題の解決に一緒に取り組む仲間を募集しています。 介護をより良くするプロダクトの開発、あるいはAIで社会課題を解決するエクサウィザーズに少しでも興味がありましたら、是非ご応募ください!