AndroidでFirebaseのgetHttpsCallableを呼び出すとタイムアウトが発生する問題とワークアラウンド
Firebase、便利ですよね。最近モバイルアプリを作るときはだいたい Firebaseも合わせて使っています。
とあるモバイルアプリ開発のプロジェクトでも Firebase を使っていたのですが、Cloud Functions for Firebaseを Android から呼び出して実行した際に、クライアント側でタイムアウトの例外が発生してしまうという現象に出くわしました。
Functions の SDK でタイムアウトの設定を行えば解決するかと思ったのですが、設定する方法が見当たりませんでした。
そこで今回は代替として OkHttp を使ってこの問題に対処をしてみたという話です。
環境
- firebase-core 16.0.6
- firebase-auth 16.1.0
- firebase-functions 16.1.3
例によってソースコードはすべて GitHub にあげてますので、全文確認したい方はこちらも参照ください。
タイムアウトする現象の確認
Android で Functions 呼び出し時にタイムアウトする現象ですが、公式ドキュメントの「アプリから関数を呼び出す」の方法で Functions を実装した場合に起きるようでした。
この機能は、認証状態を持ったままクライアントから直接 Functions を実行するような機能です。
下記、公式ドキュメントより引用
呼び出し可能関数は他の HTTP 関数と似ていますが、次の追加機能があります。
- 呼び出し可能関数では、Firebase Authentication と FCM トークンが使用可能な場合、自動的にリクエストに追加されます。
- functions.https.onCall トリガーは、自動的にリクエスト本文を逆シリアル化して認証トークンを検証します。
認証周りをクライアント側で処理せずに Functions を実行できるのは便利ですね。
SDKにはこの呼び出し専用の APIがあります。 Android の場合は getHttpsCallable
というやつです。
しかし、この API を Android 上で実行すると 10 秒程度でクライアント側でタイムアウトの例外が発生してしまうようです。
Stack Overflow にも同じ問題で困ってる人がいました。現状ではタイムアウト時間を設定する方法はないっぽいことが書かれていました。
試しに、Functions の方で下記のように20秒ウェイトしてからレスポンスを返すという関数を定義して
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
export const helloWorldOnCall = functions.https.onCall(async (data, context) => { const text = data.text || ""; const uid = context.auth.uid; console.log("start sleep"); await sleep(20000); console.log("end sleep"); return { message: "Hello World from onCall.", uid: uid, text: text }; }); |
getHttpsCallable で関数を呼び出してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
functions .getHttpsCallable(functionName) .call(mapOf("text" to "inputText")) .addOnCompleteListener {task-> if (!task.isSuccessful) { val e = task.exception if (e is FirebaseFunctionsException) { val code = e.code val details = e.details Log.e(TAG, "Functions error: $code, $details") } else { Log.e(TAG, "Error: ${Log.getStackTraceString(e)}") } return@addOnCompleteListener } val result = task.result?.data.toString() Log.i(TAG, result) } |
実行した際のログ
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
2019-01-16 14:37:31.547 25918-25918/com.ttechsoft.okhttp_callable I/OkHttpCallable: Start callable 2019-01-16 14:37:41.931 25918-25918/com.ttechsoft.okhttp_callable E/OkHttpCallable: Error: java.net.SocketTimeoutException: timeout at com.squareup.okhttp.internal.framed.FramedStream$StreamTimeout.newTimeoutException(FramedStream.java:605) at com.squareup.okhttp.internal.framed.FramedStream$StreamTimeout.exitAndThrowIfTimedOut(FramedStream.java:613) at com.squareup.okhttp.internal.framed.FramedStream.getResponseHeaders(FramedStream.java:143) at com.squareup.okhttp.internal.http.Http2xStream.readResponseHeaders(Http2xStream.java:150) at com.squareup.okhttp.internal.http.HttpEngine.readNetworkResponse(HttpEngine.java:737) at com.squareup.okhttp.internal.http.HttpEngine.access$200(HttpEngine.java:87) at com.squareup.okhttp.internal.http.HttpEngine$NetworkInterceptorChain.proceed(HttpEngine.java:722) at com.squareup.okhttp.internal.http.HttpEngine.readResponse(HttpEngine.java:576) at com.squareup.okhttp.Call.getResponse(Call.java:287) at com.squareup.okhttp.Call$ApplicationInterceptorChain.proceed(Call.java:243) at com.squareup.okhttp.Call.getResponseWithInterceptorChain(Call.java:205) at com.squareup.okhttp.Call.access$100(Call.java:35) at com.squareup.okhttp.Call$AsyncCall.execute(Call.java:171) at com.squareup.okhttp.internal.NamedRunnable.run(NamedRunnable.java:33) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1133) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:607) at java.lang.Thread.run(Thread.java:761) |
ほぼ 10秒で SocketTimeoutException が飛んできました。(内部で OkHttp 使ってる?)
これが発生するとクライアント側で関数の実行結果を取れなくなってしまいます。
ワークアラウンド: https.onCallの仕様に基づいて OkHttp で処理する
問題が発生した getHttpsCallable
ですが、実際の通信は特定のプロトコルに従った HTTPS の通信が行われているようです。
プロトコル仕様が公開されているので、今回はこの仕様に従って HTTP クライアント(OkHttp)の方で実装を施して対処しました。
上記ドキュメントに書いてあるとおりなんですが、
- POSTを使う
- HTTPヘッダー部
- Content-Type: application/json; charset=utf-8
- Authorization: Bearer ユーザーIDトークン
- Firebase-Instance-ID-Token: インスタンスIDトークン
- HTTPボディ部
- 関数のパラメータとなるJSONオブジェクト
でHTTPS通信してやれば良いみたいです。(一部オプションあり)
ユーザーIDトークンは FirebaseUser#getIdToken
という関数で取得できるのでそれを使いました。
1 2 3 4 5 6 7 |
user.getIdToken(false) .addOnCompleteListener { task -> if (task.isSuccessful) { idToken = task.result?.token } } |
インスタンス IDは FirebaseInstanceId#instanceId
で取得したものを使いました。
1 2 3 4 5 6 7 |
firebaseInstanceId.instanceId .addOnCompleteListener { task -> if (task.isSuccessful) { instanceId = task.result?.token } } |
一応、どちらも Functions 側で context.auth.token
と context.instanceIdToken
の内容を確認して、getHttpsCallable
で呼び出した時に送られてくる値と相違が無いことを確認しました。
ボディ部に関しては、Functions の data に渡す値を JSON オブジェクトで設定するのですが、”data” 要素でラップする必要があります。下記の様な感じで。
1 2 3 4 5 6 |
{ "data": { "text": "inputText" } } |
まとめると下記の実装になりました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
val jsonData = JSONObject() jsonData.put("text", "inputText") val json = JSONObject() json.put("data", jsonData) val requestBody = RequestBody.create(JSON, json.toString()) val request = Request.Builder() .url(url) .post(requestBody) .addHeader("Authorization", "Bearer $idToken") .addHeader("Firebase-Instance-ID-Token", instanceId) .build() val okHttpClient = OkHttpClient.Builder() .connectTimeout(1 , TimeUnit.MINUTES) .readTimeout(1, TimeUnit.MINUTES) .writeTimeout(1, TimeUnit.MINUTES) .build() Log.i(TAG, "Start Okhttp") okHttpClient.newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { if (!response.isSuccessful) { return } val responseBody = response.body() Log.i(TAG, responseBody?.string()) } override fun onFailure(call: Call, e: IOException) { } }) |
一応動作確認のログ。20秒後にタイムアウトせずに結果が無事取得できました。
1 2 3 |
2019-01-16 15:09:44.163 19984-19984/com.ttechsoft.okhttp_callable I/OkHttpCallable: Start Okhttp 2019-01-16 15:10:04.541 19984-21885/com.ttechsoft.okhttp_callable I/OkHttpCallable: {"result":{"message":"Hello World from onCall.","uid":"...(ユーザーID)...","text":"inputText"}} |
レスポンスに関しては、”result” で一段ラップされた JSON オブジェクトが返ってきます。あとはこの JSON をパースしてやれば関数の実行結果を取得できます。
おわりに
Functions の方でそんなに時間がかかる処理をしないとか、Firestore の書き込みをトリガにして実行するとかいった方法でも問題解決できそうです。
また、Stack Overflow で Feature Request を出すような話になっていたのでそのうち解決するかもしれません。
ただ、今回担当したプロジェクトでは iOS が先行してアプリの開発・リリースが行われており、そちらでは問題なく動作していたようでしたので、共通処理に手を入れないようひとまずクライアント側で対処する方法を取りました。
とりあえず現状問題なく動いてはいるんですが、何か動作が怪しいところとかあれば指摘いただければ大変助かります。
最後まで読んでいただきありがとうございます。 このブログを「いいな」と感じていただけましたら、Twiter にてフォローいただけるとうれしいです。ブログ更新情報などもお届けします。
Follow @ryuta461
この記事をシェアする