[Swift] FirebaseのログをCloud FunctionsでフックしてPuch通知を送る


# はじめに

２ヶ月以上前に [「集まれSwift好き！Swift愛好会 vol43 @レバレジーズ」](https://love-swift.connpass.com/event/141921/) にて
**「iOSのログテストをFirebaseで自動化する」**
という内容でLTをさせていただいたのですが、その時に話した内容の一部を抜粋したものになります。

以下のスライド参考
[![thumbnail](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/7e2bd037-72e3-1328-8c17-0b75784fa0e6.jpeg)](https://speakerdeck.com/hcrane/iosfalserogutesutowofirebasedezi-dong-hua-suru)

（いまさらで大変申し訳ないが、、adventに便乗して記事化しました笑）

# 実装

LTでは概要を話しただけで、具体的な実装方法には触れていなかったので、今回はコードを中心に説明していこうと思います。

## 0. 意図

詳しいことは資料を見てもらうのが早いですが、以下の２つを実施したいと考えました。

```
① ログのテストをリアルアイムに確認したい
② ログのテストを端末上で完結させたい
```

前提として、ログの確認にはサーバーに溜まったデータをクエリーで叩く必要があり、これをフロントだけですぐに確認できる機構が欲しくてこれを検討しました。

その際、**サーバーに送られた値がログとして正しいこと** を担保したいので、一度サーバーに送られた値を**フロント（アプリ）に返す**ということを考えました。

根本的にはFirebaseの管理画面（DebugView）でも確認できますが、

```
- いちいち管理画面開くのがめんどくさい
- 見づらい
- 複数人開発の場合は同じ管理画面に端末が複数出てしまい面倒
```

という理由からも、なんとかしたいという思いがありました。

## 1. ゴール

資料（LT）では別のゴールを提示していますが、今回はその試行錯誤した過程で生まれた部分（今回のタイトル部分）が知見になりそうだったので、そこだけを抜粋して説明していきます。

![0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/de5ef523-68b2-5920-91a3-fb43f97e7bb5.png)

行われる流れはざっくり以下の３つ

```
① Firebaseのログを送る
② 送られたログをCloud Functionで検知
③ Cloud Functionで端末にPushを送る
④ アプリ上でデータハンドリングする
```

詳細は各章でみていきましょう。

## 2. 実装

今回はFirebaseの導入部分に関しては載せません。

```
- Push通知が受け取れる
- Firebase CLIが使える
```

上記が既に終わっている前提に話を進めていきます。
導入から行いたい場合は参考文献を呼んでいただくか、調べてもらえると助かります。

- [iOSにおけるPush通知の基本1](https://yagamo-style.com/posts/remotenotification01/)
- [Firebase で Cloud Functions を簡単にはじめよう](https://qiita.com/tdkn/items/2ed2b01f2656fc50da8c)

### ① Firebaseのログを送る

#### 1. ログを送ること

ログを送ること自体はさほど難しくありません。Firebaseに準拠した送り方を実装するだけです。

```.swift
var parameters: [String: Any]? =[:]

FirebaseAnalytics.Analytics.logEvent("ログ名", parameters:parameters)
```


ただし、Push通知を端末に返すために、どの端末にPushを送るのか**端末を特定する**必要があります。
Cloud FunctionsからPush通知を送るので、端末を特定するための情報もログに付与する必要があります。

#### 2. トークンを取得する

Push通知は``Firebase Cloud Messaging``の機能を使って送ります。
この機能を使用する際には、端末（アプリ）ごとにユニークに生成される**トークンを指定する**必要があります。
このトークンは**fcm token**と呼ばれ、アプリで取得することができ、これをログのパラメータに付与します。
（Cloud Functionsでトークンを取り出せるようにするために）

アプリ側では

```.swift
Messaging.messaging().fcmToken
```

こんな感じでトークンをサクッと取得できます。
（中身がオプショナルなことに注意）

#### 3. トークンを分割する

``Firebase Analytics``の制約でパラメータの値は**100文字**を超えるとエラーになります。
（ちなみにパラメータは２５個までしか設定できないのも注意）

fcm tokenは**100文字を超えてしまう**ため、ログに付与する際に分割して送ります。
以下、実際のトークン（赤い部分で分割される予定です）
<img width="1194" alt="token_after.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/8e124aee-ba94-5158-c0c5-bc67ed9c6b7e.png">

分割の方法はなんでも良いですが、以下のようなextensionを用意すると簡単にできるでしょう。

```.swift
extension String {
    
    func split(_ length: Int) -> [String] {
        guard 0 < length else { return [] }

        let array = self.map { "\($0)" }
        let limit = array.count
        return stride(from: 0, to: limit, by: length).map {
            array[$0..<min($0.advanced(by: length), limit)].joined(separator: "")
        }
    }
}
```

トークンの分割コード

```.swift
guard let fcmToken = Messaging.messaging().fcmToken else { return }

var params: [String: Any]? =[:]

fcmToken.split(99).enumerated().forEach {
    params?["FCM_TOKEN_\($0.offset)"] = $0.element
}
```

後々、Cloud Functions側で受け取った際に連結します。
「分割したトークン」はこんな感じになります。

<img width="862" alt="token_before.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/0077f651-6f16-5488-4190-b3952b2e2b60.png">

探しやすいように **"FCM_TOKEN_"** というプレフィックスと**番号**を付けています。
（が、こちらはお好みでお願いします）





- [意識しておきたいFirebaseAnalyticsに設けられた制限](https://www.ginzaitlab.com/posts/6912241/)
- [アナリティクスのエラーコード](https://firebase.google.com/docs/analytics/errors?hl=ja)

#### 4. コードの全体像

1~3を総括すると以下のようになります。
（クラス名とかは適当です。お好みでシングルトンにするなり肉なり焼くなりしてください。）

```Logger.swift
import FirebaseMessaging
import FirebaseAnalytics

final class Logger {

    func logForTest(targetParams: [String: Any]) {
        /// firebaseのトークンを取得
        guard let fcmToken = Messaging.messaging().fcmToken else {
            debugPrint("token is nil")
            return
        }
    
         /// トークンを付与するために新しいパラメータを生成
        var params: [String: Any]? = targetParams

        /// firebaseのログでは、パラメータのValueに100文字制限があるため分割している
        fcmToken.split(99).enumerated().forEach {
            params?["FCM_TOKEN_\($0.offset)"] = $0.element
        }

        /// ログを送る
        FirebaseAnalytics.Analytics.logEvent("TestLog", parameters: params)
    }
}
```

もともと、出来上がっているログにトークンを差し込めるような仕組みにしています。
なので、ただ試したいだけの人は ``targetParams: [String: Any]`` はなくても問題ありません。

#### 補足

通常トークンはサーバーに保存するなりして、必要なユーザーIDに応じてそのトークンを取り出すような実装になります。なので**かなり無理矢理な実装**をしています。

実際firebaseのsampleでも、``Firebase Realtime Database``に保存したトークンを使用する例が紹介されています。
- [Send a coupon via FCM to users who have completed a purchase
](https://github.com/firebase/functions-samples/tree/master/coupon-on-purchase)


### ② 送られたログをCloud Functionsで検知

「ログの実装 + トークンの付与」は終わったので、Cloud Functions側でそれを検知していきます。
今回は、``Node.js(JavaScript)``で実装していきます。
（Javascriptをちゃんと書くのは３年ぶりなので、コード上で良い書き方があればご指摘お願いします、、笑）

#### 1. 環境構築

冒頭でも述べましたがこちらに関しては記載しません。
とはいえ、「ログのハンドリングまでの環境構築」を詳しく説明している記事があるので、以下を参考にするとよいでしょう。

- [Firebase Cloud FunctionsでFirebase Analyticsのカスタムイベントをリアルタイムで扱う方法](https://blog.nagisa-inc.jp/archives/885)

以下、基本的にコード説明のみなので、試したい方は**Cloud Functionsにデプロイする**のを忘れないでください。

#### 2. 送られたログを検知

アプリ側で「**TestLog**」という名前で送ったので、受け取り側でも指定します。

```index.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

exports.outputEventLogOfTestLog = functions.analytics.event('TestLog').onLog( event => {
    return console.log(event);
});
```

上の３行は``Firebase Cloud Functions``を使用する際のお作法みたいなものなものです。
下の３行がハンドリングと受け取った際の処理を記述しています。例として``console.log``を呼んでいます。

ログは実際にCloud Functionsの管理画面上でログを確認することできます。
うまく動作すれば、Cloud Functions上で定義した``outputEventLogOfTestLog``がFunctions上のログに現れるはずです。

<img width="1070" alt="consolelogevent.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/f0e90540-ad38-6bdb-36d0-0e32bb043dea.png">

（お見せできない黒塗りが多くてすいません。笑）

送られてきたログがCloud Functionsで**フックできている**ことが確認できました。

### ③ Cloud Functionsで端末にPushを送る

#### 1. ログからトークンを結合する

（Push通知に必要な）分割して送っているトークンをログから取り出し結合します。
そのためのスクリプトは以下になります。

```index.js
function takeOutToken(params) {
    return Object.keys(params)
        .filter(key => {
            return (key.search(/FCM_TOKEN_/) === 0);
        })
        .map(key => {
            return params[key];
        })
        .join("");
}
```

``FCM_TOKEN_``というプレフィックスがついたものだけを取り出して結合しています。

#### 2. トークンからPush通知を送る

取り出したトークンからPushを送りたい端末を特定できるので、Push通知を送れる機構を作ります。
そのためのスクリプトは以下になります。

```index.js
async function sendEventCallBack(tokens, params) {
    // 返したい値をすべてStringに変換（でないとPush通知に付与できない）
    var newParams = params
    Object.keys(params).forEach(key => {
        newParams[key] = String(params[key]);
    })

    // Pushの中身を生成
    let payload = {
        notification: {
            title: 'callback', // Pushに表示されるタイトル
            body: 'body', // Pushに表示される中身
        },
        data: newParams
    };

    // Pushを送信
    return admin.messaging().sendToDevice(tokens, payload);
}
```

ここで着目すべき点としては、Push通知というのは

```
- タイトル
- サブタイトル
- 画像
```

などの**ユーザーに目に見える要素、以外のデータも送れる！**ということです。
それを利用して、``data``というキーに送られてきたパラメータを付与します。

ただし、注意点として**Stringでないと送れない**という制約があるため、すべてStringにしています。
（この段階で返却する値の型が変わってしまうので、テストとしてはどうなのということがよぎっていた、、、笑）

titleとbodyは個人でわかるものをお好みでお願いします。

FirebaseのPush通史に関して詳しくみたい方は以下を参考にすると良いでしょう。
- [Cloud Function for Firebaseでチャットアプリのプッシュ通知を打つ](https://qiita.com/k-boy/items/419ad3a6f1ffd86c20f1)
- [特定のデバイスにメッセージを送信する](https://firebase.google.com/docs/cloud-messaging/send-message#send_messages_to_specific_devices)

#### 3. コードの全体像

今までのスクリプトコードを連結し、総括すると以下のようになります。
（最終的にCloud Functionsにデプロイするコードはこちらです）

```index.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();


/* triggers */

// 「TestLog」という名前のログを検知する
exports.outputEventLogOfTestLog = functions.analytics.event('TestLog').onLog( event => {
    const params = event.params;
    const tokens = takeOutToken(params);

    if (tokens.length === 0) {
    	return console.log('error: tokens is empty');
    } else {
    	return sendEventCallBack(tokens, params);
    }
});


/* functions */

// ログからfcmトークンを取り出す
function takeOutToken(params) {
	return Object.keys(params)
			.filter(key => {
			    return (key.search(/FCM_TOKEN_/) === 0);
			})
			.map(key => {
				return params[key];
			})
			.join("");
}

// Push通知を送る
async function sendEventCallBack(tokens, params) {
	var newParams = params

	Object.keys(params).forEach(key => {
		newParams[key] = String(params[key]);
	})

	let payload = {
        notification: {
          title: 'callback',
          body: 'body',
        },
        data: newParams
	};

	return admin.messaging().sendToDevice(tokens, payload);
}
```

念のために取り出したtokenのnullチェックを入れています。

Push通知の送信が成功した場合は、Functions上の管理画面に以下のようなログを出るはずです。

<img width="813" alt="functions_log.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/a243c55b-3195-2a56-9331-eca691cc89fa.png">

### ④ アプリ上でデータハンドリングする

ここまでくればもうお終いです。

Push通知が届けば完了ですが、裏側でもデータを取り出してみましょう。
といっても、デフォルトのプッシュ通知のDelegate(``UNUserNotificationCenterDelegate``)を使うだけです。

```AppDelegate.swift
extension AppDelegate: UNUserNotificationCenterDelegate {

    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {

        debugPrint(notification.request.content.userInfo) // データ格納場所

        completionHandler([.badge, .sound, .alert])
    }
}
```

ちなみに``didReceive``でも受け取れますが、

```.swift
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void)
```

**「通知がタップされたときの処理」**なので、Pushをタップしなくても取得できる``willPresent``で行うことをオススメします。

ということで、無事一連の動作を実装することがができました。

## 3. 問題点

結構これを実装するために試行錯誤したのですが、、、

Pushに**遅延**が発生することがわかり、この方法はログテストとしての**リアルタイム性は担保できない**ので、、、
結局採用しませんでした、、笑

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/0e32f995-c0e1-9ba2-6e2f-3c628e049fb6.png)

FirebaseのPushは結局のところAppleのAPNsを経由するために、**時々遅延が発生してしまう**のです。

実際、この記事を書くためにもう一度手元で実装してみましたが、遅延がひどく、、、
そもそもテストとして色々ダメなところがあったので、実装している最後の方は、半ば興味本位で実装できるか試してた感じですね、、笑

### 参考
- [Firebase Cloud Messaging on iOS: Improving message delay for foreground notifications](https://stackoverflow.com/questions/40364825/firebase-cloud-messaging-on-ios-improving-message-delay-for-foreground-notifica)

# 終わりに
所々、設定に関しては説明を省いているので、間に乗せている**参考文献を良く読んで**いただけると助かります。

例えば、
- 「firebaseの設定が24時間反映されない」
- 「発火するためのイベントは設定画面でオンにする必要がある」

とかがあるのですが、ここに書くと長くなってしまうので省略しており、本当に読んで欲しいです。笑

プロダクトとしては無駄になってしまいましたが、、、
Firebaseのいろんな機能を学べて**クセ**とかも勉強になったので、これはこれでよかったかなと笑

長くなってしまいましたが、間違い等あればご指摘あれば助かります、、、！！