Konifar's WIP

親方!空からどらえもんが!

Cloud Functions for Firebaseを使って、自分にPush Notificationを送るデバッグメニューを作りました

Cloud Functions for Firebase(以下Cloud Functions)は、Firebase Databaseへの書き込みやHTTPSリクエストなどのイベントをトリガーにして、任意の処理を追加できるサービスです。

例えば

  • Firebase Storageに画像が保存された時にサムネイル画像も生成して保存する
  • Firebase Databaseにレコードが追加された時にPush Notificationを送る
  • HTTPSリクエストのパラメータで渡したURLを短縮URLにして返す

といった具合に、様々な処理をJavascriptコードで記述できます。

他にどんなことができるかは、公式のfunctions-samplesにまとまっています。

今回、仕事でCloud Functionsを使って自分にPush Notificationを送るデバッグメニューを作りました。Productionコードはサンプルを見ればよいのですが、Testコード、CIの導入あたりはまだ知見が少なそうだったのでまとめておこうと思います。

背景

そもそもなぜわざわざCloud Functionsで作ったかというと、次のような経緯がありました。

  • Push Notification関連でインドネシアのユーザー向けに新しい機能を作ることになった。
  • バックエンドができていなかったので開発中はcurlコマンドを打ちながら実装していた。
  • インドネシアのメンバーにプロトタイプを試してもらうにあたり、彼らが好きなタイミングでPush Notificationを受け取れるようにしないと面倒そうだと感じた。
  • ローカルでダミーのNotificationを表示するだけでもよかったが、できれば実際のコードを通して検証したかった。
  • どうしようかなぁとボヤいていたら 「Cloud Functionsでできるんじゃないですか?面白そうだし」と隣の同僚が語りかけてきた。

Functionsプロジェクトを作る

Get Startedに書いてあるままに進めて、特に詰まることもありませんでした。

firebase-toolsをインストールして、firebaseにログインして、initコマンドでプロジェクトの雛形を作るだけです。

% npm install -g firebase-tools
% firebase login
% firebase init functions

Get Startedの通りに作っていけば、/addMessage?text=uppercaseHTTPSリクエストでパラメータに渡されたテキストを大文字に変換してFirebase Databaseに保存するサンプルが動くようになります。

Functionsを作る

サンプルのFunctionsが動くようになったので、次に自分が作りたいFunctionsを作っていきます。やりたいことは大体functions-samplesにあると思うので、それを見て作っていく感じです。

今回はPush Notificationを送りたかったのでsend-messagesを参考にして、最終的にこんな感じになりました。

'use strict';

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);

/**
 * Send FCM notification
 */
exports.sendNotification = functions.https.onRequest((req, res) => {
  const deviceToken = req.query.device_token;
  const title = req.query.title;
  const body = req.query.body;
  const action = req.query.action;

  if (title == null || body == null || deviceToken == null || action == null) {
    var message = 'Required params are lacked. device_token:' + deviceToken + 'title:' + title + 'body:' + body + 'action:' + action;
    res.status(500).send({success:false, message:message});
    return;
  }

  console.log('device_token:', deviceToken, 'title:', title, 'body:', body, 'action:', action);

  var payload = {
    notification: {
      title: title,
      body: body
    },
    data: {
      action: action
    }
  };

  // Send Push Notification
  admin.messaging().sendToDevice(deviceToken, payload)
  .then(response => {
    console.log("Successfully sent message:", response);
    res.status(200).send({success:true});
  })
  .catch(error => {
    console.log("Error sending message:", error);
    res.status(500).send({success:false, message:error});
  });
});

リクエストパラメータに、PushNotificationを送る対象のdeviceToken、Notificationを構成するtitle、body文字列、タップ時の遷移先を制御するactionなどを渡します。

このコードをdeployすると、次のURLでリクエストを受け取れるようになります。

https://リージョン-プロジェクト名.cloudfunctions.net/sendNotification
?device_token=長いdevice_token文字列
&title=タイトル
&body=ボディ
&action=アクション

このURLは、deploy時にコンソールに表示されます。

% firebase deploy --only functions

=== Deploying to 'PROJECT NAME'...

i  deploying functions
i  functions: ensuring necessary APIs are enabled...
i  runtimeconfig: ensuring necessary APIs are enabled...
✔  runtimeconfig: all necessary APIs are enabled
✔  functions: all necessary APIs are enabled
i  functions: preparing functions directory for uploading...
i  functions: packaged functions (3.39 KB) for uploading
✔  functions: functions folder uploaded successfully
i  starting release process (may take several minutes)...
i  functions: updating function sendNotification...
✔  functions[sendNotification]: Successful update operation.
✔  functions: all functions deployed successfully!

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/PROJECT_NAME/overview
Function URL (sendNotification): https://リージョン-PROJECT_NAME.cloudfunctions.net/sendNotification

テストを書く

テストの書き方も公式のunit-testingの項で説明されています。この項の説明は、functions-samples/quickstarts/uppercaseが元になっています。

どんなTestingフレームワークを使ってもいいのですが、公式で解説されている次の構成で進めることにしました。JSのフレームワークやライブラリに詳しくないので、説明が間違っていたら指摘をお願いします :bow:

mocha

mochaは、非同期のテストを楽に書けるようにするためのフレームワークです。 describeitexpect で記述する形式で、RSpecに似ています。

sinon

sinonは、mock、stub、spyするためのライブラリです。余談ですが、沢城みゆきを想起させるこのネーミングはセンスあるなと思いました。

chai

chaiは、読みやすいassertionを提供するライブラリです。 assertexpectshouldといった馴染みのある記法で書くことができます。 Cloud FunctionsのHTTPS以外のfunctionsは全てPromiseを返すので、その場合はchai-as-promisedも使います。今回はHTTPS functionsだけだったので必要ありませんでしたが、chai-as-promisedを使う場合のサンプルはこちらにあります。

これらを使うために、 package.json にdevDependenciesを追加します。

{
  ...
  "devDependencies": {
    "chai": "^3.5.0",
    "mocha": "^3.2.0",
    "sinon": "^1.17.7"
  },
  ...
}

今回は、次の3つのテストケースを追加しました。

  1. 全てのパラメータを渡してリクエストが成功する場合
  2. 全てのパラメータを渡してリクエストが失敗する場合
  3. パラメータが足りずにリクエストが失敗する場合

最終的に次のようなテストコードになりました。もっとよい書き方があれば、@konifarまで教えてもらえるとありがたいです。

// Initialize chai
const chai = require('chai');
const assert = chai.assert;

// Initialize sinon
const sinon = require('sinon');

describe('Cloud Functions', () => {
  var myFunctions, configStub, adminInitStub, functions, admin;

  before(() => {
    admin = require('firebase-admin');
    adminInitStub = sinon.stub(admin, 'initializeApp');
    functions = require('firebase-functions');
    configStub = sinon.stub(functions, 'config').returns({
      firebase: {
        databaseURL: 'https://not-a-project.firebaseio.com',
        storageBucket: 'not-a-project.appspot.com',
      }
    });
    myFunctions = require('../index');
  });

  after(() => {
    configStub.restore();
    adminInitStub.restore();
  });

  describe('sendNotification', () => {
    before(() => {
      const sendToDeviceStub = sinon.stub();
      // Product code: admin.messaging().sendToDevice(deviceToken, payload)
      messagingStub = sinon.stub(admin, 'messaging');
      messagingStub.returns({ sendToDevice: sendToDeviceStub });
      sendToDeviceStub.returns(Promise.resolve(sinon.stub()));
    });

    it('should return 200 response', (done) => {
      const req = {
        query: {
          title: 'dummy_title',
          body: 'dummy_body',
          action: 'dummy_action',
          device_token: 'dummy_device_token',
        }
      };
      // Production code: res.status(200).send({success:true});
      const res = {
        send: (hash) => {
          assert.equal(hash.success, true);
          // If `done()` is not called, the request never end.
          done();
        },
        status: (code) => {
          assert.equal(code, 200);
          return res;
        },
      }

      myFunctions.sendNotification(req, res);
    });

    it('should return 500 response when request error is occurred', (done) => {
      sinon.restore(admin);
      const sendToDeviceStub = sinon.stub();
      // Product code: admin.messaging().sendToDevice(deviceToken, payload)
      messagingStub = sinon.stub(admin, 'messaging');
      messagingStub.returns({ sendToDevice: sendToDeviceStub });
      sendToDeviceStub.returns(Promise.reject(sinon.stub()));

      const req = {
        query: {
          title: 'dummy_title',
          body: 'dummy_body',
          action: 'dummy_action',
          device_token: 'dummy_device_token',
        }
      };
      // Production code: res.status(500).send({success:false, message:error});
      const res = {
        send: (hash) => {
          assert.equal(hash.success, false);
          done();
        },
        status: (code) => {
          assert.equal(code, 500);
          return res;
        },
      }

      myFunctions.sendNotification(req, res);
    });

    it('should return 500 response when the parameters are lacked', (done) => {
      const req = {
        query: {
          device: 'dummy_title',
        }
      }
      // Production code: res.status(500).send({success:false, message:error});
      const res = {
        send: (hash) => {
          assert.equal(hash.success, false);
          done();
        },
        status: (code) => {
          assert.equal(code, 500);
          return res;
        },
      }

      myFunctions.sendNotification(req, res);
    });
  });
});

実行する時は mochaコマンドを打ちます。

% mocha ./functions/test
  Cloud Functions
    sendNotification
      ✓ should return 200 response
      ✓ should return 500 response when request error is occurred
      ✓ should return 500 response when the parameters are lacked

CIを導入する

最後に、CIでテストを走らせ、deployするように設定します。会社ではCircleCIを使っていたので次のようにcircle.ymlを書きました。

dependencies:
  override:
    - npm --prefix ./functions install ./functions

test:
  override:
    - mocha functions/test

deployment:
  production:
    branch: master
    commands:
      - npm install -g firebase-tools
      - firebase deploy --token=$FIREBASE_TOKEN --project PROJECT_NAME --only functions

FIREBASE_TOKENfirebase login:ciコマンドで取得できます。firebase-toolsは優秀ですね。

% firebase login:ci

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

Waiting for authentication...

✔  Success! Use this token to login on a CI server:

FIREBASE_TOKEN

Example: firebase deploy --token "$FIREBASE_TOKEN"

テストは毎回走り、masterブランチにマージされた時だけdeployするようにしています。今回はデバッグ機能用に作ったFunctionsだったので、masterにマージされたら即staging用のプロジェクトにdeployするようにしました。

もし本番で運用するなら、branchやtagによってdeploy先を分けた方がいいと思います。弊社でも今後Cloud Functionsをもっと使うことになれば修正する予定です。

デバッグ用の画面を作る

Android側の実装はシンプルで、パラメータを入力する画面を作ってSendボタンでリクエストを投げるだけです。まだリリースされていない機能の情報が含まれるのでモザイクだらけになってしまいましたが、こんな感じです。

f:id:konifar:20170627170919p:plain

メンバーからのフィードバック

このデバッグメニューによって開発中の検証も楽になり、インドネシアチームのメンバーとも特に問題なくプロトタイプを検証できました。新機能自体の評判も上々でよかったです。

Cloud Functionsは使ってみるとすごく簡単に動くところまでいけるので楽しかったです。できることも多いですし、社内用の何かをサッと作る時には非常に使い勝手がいいのではないかと思います。