Cloud FunctionsでCloud Firestoreのデータを定期更新する

今対応している案件で、Firestoreのデータを定期的にシミュレーションAPIに渡し、結果を反映するという処理が必要になってきました。
Functionsについては面倒くさがって触ってこなかったのですが、Firestoreの変更にトリガーしてプッシュ通知出したり、Storageの追加にトリガーしてバックエンドでサムネイル作ったりと使いみちはかなり豊富そうだったので、ここらで試してみようとなった次第です。

# 前提
- Nodeが入っている
- ブラウザ上でFirebaseプロジェクトが作れる
- FirebaseのBlaze(従量制)プランにアップグレードしている

ちなみにNodeのversion

```bash
$ node --version
v12.13.1
```

この辺は調べれば一瞬で構築できると思うので、省略します。

FirebaseのBlazeプランは、定期実行処理を構築するのに必要です。
まあSparkと同量の無料枠を使い切らないと課金は始まらないので(2020/06/08現在)、今回みたいな個人の試験的な実装程度ではまず課金は発生しません。
気軽にアップグレードしていきます。

# FunctionsでHello Worldする
プロジェクト生成時にHello Worldが用意されるので、まずはこれを動かしてみます。

## Firebaseプロジェクトの作成
CLIからも作成できますが、事前に作ってあった方が話が早いので作ります。
ここでは`functions-test`という名前にしました。

また、プロジェクト作成後、サイドメニューの`Database`よりFirestoreのデータベースを作成しておいてください。
データベースの保護ルールの選択を求められますが、お試しで使うだけなので`テストモードで開始`でスタートします。
リージョンはアジアのどこかにしておくと通信速度が早いかもしれません。

## 公式に沿ってCLIをインストール
https://firebase.google.com/docs/cli?hl=ja#npm

```bash
$ npm install -g firebase-tools
or
$ yarn global add firebase-tools

$ firebase --version
8.4.1
```

## ログイン

`firebase login`でFirebaseにログインします。

```
$ firebase login
i  Firebase optionally collects CLI usage and error reporting information to help improve our products. Data is collected in accordance with Google's privacy policy (https://policies.google.com/privacy) and is not used to identify you.

? Allow Firebase to collect CLI usage and error reporting information? No

Visit this URL on this device to log in:
https://accounts.google.com/o/oauth2/auth?...

Waiting for authentication...

✔  Success! Logged in as mail@example.com
```
途中「エラー情報の報告を許可するか」という選択肢がありましたが、これはどちらでもよいです。
今回は`No`にしました。

## プロジェクト作成
プロジェクト作成は`firebase init`。
ディレクトリは任意の場所を用意してください。

```
$ firebase init

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  /Users/evosystem/Work/vegetable-community/firebase

? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to co
nfirm your choices. (Press <space> to select, <a> to toggle all, <i> to invert selection)
❯◯ Database: Deploy Firebase Realtime Database Rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
 ◯ Functions: Configure and deploy Cloud Functions
 ◯ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules
 ◯ Emulators: Set up local emulators for Firebase features
```

使用するFirebaseの機能を選択しろとのことなので、`Firestore`と`Functions`を選択する。
- Firestore: Deploy rules and create indexes for Firestore
- Functions: Configure and deploy Cloud Functions

スペースで選択。複数選択可です。

### オプション選択

```
? Please select an option: (Use arrow keys)
❯ Use an existing project 
  Create a new project 
  Add Firebase to an existing Google Cloud Platform project 
  Don't set up a default project

```
Firesbaseプロジェクトを選択しろとのこと。
先ほど作ったプロジェクトを使うので、`Use an existing project`を選択します。

```
? Select a default Firebase project for this directory: (Use arrow keys)
❯ functions-test (functions-test) 
```

先ほどのプロジェクトを選択。

```
? What file should be used for Firestore Rules? (firestore.rules)
? What file should be used for Firestore indexes? (firestore.indexes.json)
```
データベース設定ファイルの名前を聞かれます。
エンターエンターでデフォルトの名称を使用します。

```
? What language would you like to use to write Cloud Functions? (Use arrow keys)

❯ JavaScript 
  TypeScript 
```

言語を聞かれますが、TypeScriptにしておきます。
Functionsの処理内容に合わせてお好みで。

```
? Do you want to use ESLint to catch probable bugs and enforce style? (y/N)
```

まあLintは入れておいて損はないと思います。

```
? Do you want to install dependencies with npm now? (Y/n) 
```

依存関係をインストールするか聞かれます。
別に後でもいいですが、今やります。

```
✔  Firebase initialization complete!
```

なんやかんやあって、init完了！

## Hello World

とりあえずhello worldしてみます。
作成されたディレクトリの`index.ts`を見ると、コメントアウトされたHello World文があるのでこれをアンコメントしてデプロイします。

```typescript
/* index.ts */
import * as functions from 'firebase-functions';

// // Start writing Firebase Functions
// // https://firebase.google.com/docs/functions/typescript
//
// ↓ これをアンコメント
export const helloWorld = functions.https.onRequest((request, response) => {
 response.send("Hello from Firebase!");
});
```

そして`firebase deploy`でデプロイ。

```
$ firebase deploy

=== Deploying to 'functions-test-75a5b'...

i  deploying firestore, functions
Running command: npm --prefix "$RESOURCE_DIR" run lint

> functions@ lint /Users/evosystem/Work/samples/firebase/functions-test/functions
> tslint --project tsconfig.json

Running command: npm --prefix "$RESOURCE_DIR" run build

> functions@ build /Users/evosystem/Work/samples/firebase/functions-test/functions
> tsc

✔  functions: Finished running predeploy script.
i  firestore: reading indexes from firestore.indexes.json...

...

✔  Deploy complete!
```

なんやかんやあってデプロイ完了！

![スクリーンショット 2020-06-08 8.57.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136254/44793336-f34e-c649-daa1-48ff9cd4682d.png)

ブラウザからFirebase Functionsのダッシュボードを見ると、ちゃんとデプロイできているようです。

# 本番
さて、いよいよ本番。
まず、FunctionsからFirestoreのフィールドを書き換えできるか試します。

## Functionsを使ってFirestoreのデータを書き換え
### データベース構築
とりあえず、Firestoreに適当なデータを作っておきます。
コレクションIDは`data`としておきます。
![スクリーンショット 2020-06-08 19.58.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136254/e1095f14-9304-395d-641b-a84f50cba1ea.png)


次に、ドキュメントとフィールドを作成し、初期データを挿入します。
ドキュメントIDは1、中に`count`という初期値0のnumberを用意しておきます。
![スクリーンショット 2020-06-08 20.01.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136254/fb0b7486-a4d2-8219-4a2a-ec6b021b705c.png)


データベース全体はこんな感じ。
![スクリーンショット 2020-06-08 20.04.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136254/77f7f0a8-eb5b-4374-917c-b583e1f82cfd.png)

### Firestoreのデータを書き換え
サーバサイドでFirebaseを扱うために、まずは作成したプロジェクトにfirebase adminをインストール

 ```console
$ yarn add firebase-admin
 ```

ソースはこんな感じ。
データベース上の`count`を0 → 1にするだけの簡単なプログラムです。

```typescript
/* index.ts */
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

admin.initializeApp();

// データベース情報
const collectionId: string = 'data';
const documentId: string = '1';

export const increment = functions.https.onRequest((request, response) => {
    // {count: 1}のJSONを定義
    const updateData = { 'count': 1 };

    // 更新
    admin.firestore().collection(collectionId).doc(documentId).update(updateData).then(() => {
        response.send('Success');
    }
    ).catch((err) => {
        response.send('Failed' + err);
    })
});
```

デプロイします。

```console
$ firebase deploy
```

無事Deploy completeしたら、コンソールにURLが表示されます。
これが実行URLなので、開きましょう。

```
Function URL (increment): https://us-central1-functions-test-1234a.cloudfunctions.net/increment
```

Functionsのダッシュボードリストにincrement関数が追加されているので、そこのURLをコピペしてもOKです。
![スクリーンショット 2020-06-08 17.42.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136254/4cc00d7a-6b73-a28d-9915-913157e80748.png)


実行すると、しばらくしてから画面左上に`Success`の文字列が表示されます。
うーん、簡素。
しかし、これでFirestoreのデータ書き換え完了です！

データベース見ると、ちゃんと`count`が1に書き換わってます。
![スクリーンショット 2020-06-08 20.04.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136254/8b7c4de9-11ae-b7bf-7905-bd842c85e78b.png)


## Functionの定期実行
いよいよ定期実行に移ります。
今回は、データベースのconstフィールドを無限インクリメントする謎処理を実装します。

### 無限インクリメント処理の実装

まず、関数を少し書き換えます。

```typescript
/* index.ts */
export const increment = functions.https.onRequest((request, response) => {
    // データベースからデータ取得
    admin.firestore().collection(collectionId).doc(documentId).get().then((val) => {
        const data: any = val.data();
        
        // constフィールドをインクリメント
        const updateData = { 'count': (data.count + 1) };

        // 更新
        admin.firestore().collection(collectionId).doc(documentId).update(updateData).then(() => {
            response.send('Success');
            response.send('count === ' + updateData);
        }
        ).catch((err) => {
            response.send('Failed ' + err);
        })
    }
    ).catch((err) => {
        response.send('Failed ' + err);
    })
});
```

データベースから現在のデータを取得し、countをインクリメントし、新たなcount値としてupdateします。
`firebase deploy`して実行してみると、実行するたびにcountが増えているのが確認できると思います。

あとはこれを定期実行できれば、フィールドが無限にカウントアップし続ける謎データベースの完成です。

### GCPの機能を使った定期実行処理の実装
今回は、Google Cloud Schedulerを使ってスケジュール設定していきます。
この機能を使うために、Blaze(従量制)プランへのアップグレードが必要になります。

`index.ts`を以下のように書き換えます。

```typescript
/* index.ts */
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

admin.initializeApp();

// データベース情報
const collectionId: string = 'data';
const documentId: string = '1';

// インクリメント関数
export function increment() {
    // データベースからデータ取得
    admin.firestore().collection(collectionId).doc(documentId).get().then((val) => {
        const data: any = val.data();

        // constフィールドをインクリメント
        const updateData = { 'count': (data.count + 1) };

        // 更新
        admin.firestore().collection(collectionId).doc(documentId).update(updateData).then(() => {
            console.log('Success');
        }
        ).catch((err) => {
            console.log('Failed: ', err);
        })
    }
    ).catch((err) => {
        console.log('Failed: ', err);

    })
}

// 1分ごとにインクリメント関数をを実行
export const scheduledIncrement = functions.pubsub
    .schedule('* * * * *') // 1分ごと
    .timeZone('Asia/Tokyo')
    .onRun((context) => {
        increment();
        return null;
    });
```

`functions.pubsub.schedule().onRun()`を使用するすることで、Google Cloud Schedulerによるイベントトリガーが定義できます。

トリガータイミングは`UNIX cron`形式で定義します。
上記設定では1分ごとにトリガーされますが、`'* * * * *'`を`'*/5 * * * *'`にすることで、5分ごとになります。
詳しくはググってください。

これを`firebase deploy`してみましょう。
1分ごとに無限インクリメントする謎処理のできあがりです！:kissing_heart:

ちなみに今回はイベントトリガーをコードで書きましたが、GUIから設定することも可能です。
Firebaseプロジェクトを作ると自動でGCP(Google Cloud Platform)にもプロジェクトが登録されるので、GCPからCloud Schedulerを設定してください。
<img width="671" alt="スクリーンショット 2020-06-09 11.19.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136254/2f407377-9150-1e8d-b196-1d386dc4f3b0.png">


# 参考
[Firebase functions内でFirestoreを操作する](https://qiita.com/ryo2132/items/43cd7f3ff7fedfb2d2e8)
[Firebase 関数のスケジュール設定](https://firebase.google.com/docs/functions/schedule-functions?hl=ja)