Help us understand the problem. What is going on with this article?

FirebaseとFlutterでアプリ開発してみる【其ノ一:CloudFunctionsでの処理篇】

■ 今回お話すること

  • Cloud FunctionsでMySQL(GCP CloudSQL)に接続し、検索、登録する処理について
  • Cloud Firestore.notificationsテーブルにデータが登録されたらアプリ(Android)に通知させる処理について

■ 前提

  • 今回作ったアプリは個人的に作った請求アプリです。
  • まだ開発途中段階なので、変更箇所については記事も更新しますが、gitリポジトリのソースと記事とで違う場合があります。(gitのリポジトリを信じてください(汗))
  • FirebaseとFlutterでアプリ開発するお話をしようと思ったけど思いの外長くなったので、今回はFirebase CloudFunctionsの処理メインの内容となってます。

  • FirebaseとFlutterの連携部分の詳細は後日、別の記事にてお話させていただきます。

■ アプリとしての流れ

  • Flutterアプリの請求画面で登録を押したら、CloudFunctions経由でMySQLの請求テーブル(claims)にデータを登録する
  • MySQLの請求テーブル(claims)に登録後、Firestoreの通知用テーブル(notifications)に登録し、そのタイミングでアプリ(Android)にPush通知させる

  • 画面的な流れと、操作的な流れの全体イメージ↓↓
    Group-Pay-App説明範囲.png
    通常運用ではこんな流れありえないかもしれないですが、CloudFunctionsでどこまでやれるのだろう?
    バックエンド用意するまでもないけど、RDBも使いたいよね?ってときにMySQLを気軽に使えたらいいかも?
    という興味本位からこの流れで作ってみることにしました(笑)
    今回は上記赤枠の部分のお話をしてみたいと思います(๑•̀ㅁ•́๑)✧

■ 今回の作成したプログラムの全貌

  • gitHub:pay_wish_api ※ ちょこちょこ修正入ります、あしからず…

■ 今回の環境

> 開発端末

MacOS Mojave 10.14.1
SCV36(android-arm64)

> Flutter | Dart

Flutter 1.0.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 5391447fae (6 days ago) • 2018-11-29 19:41:26 -0800
Engine • revision 7375a0f414
Tools • Dart 2.1.0 (build 2.1.0-dev.9.4 f9ebf21297)

google_sign_in: ^3.2.4
firebase_auth: ^0.6.6
firebase_messaging: ^2.1.0
cloud_firestore: ^0.8.2+3
cloud_functions: ^0.0.5

> Firebase

Firebase CLI: 6.1.2
Node.js 8
firebase-admin: ~6.0.0
firebase-functions: ^2.1.0
mysql: ^2.16.0

> GCP:Cloud SQL

MySQL5.7 第2世代

■ 開発する前準備

1. GCP CloudSQLの設定

  • GCP CloudSQLの設定は、別の記事にて説明いたします。
    こちらの記事『 GCP CloudSQLを使ってみた 』を参考に、データベース作成・テーブル作成まで実施してみてください。

  • 今回MySQLで使用するテーブルの構造は以下の通りです。

【インスタンス名】pay-wish
【データベース名】pay_wish
【テーブル名】請求テーブル:claims

MySQL [pay_wish]> show columns from claims;
+----------------+---------------+------+-----+---------+----------------+
| Field          | Type          | Null | Key | Default | Extra          |
+----------------+---------------+------+-----+---------+----------------+
| id             | mediumint(9)  | NO   | PRI | NULL    | auto_increment |
| buy_uid        | varchar(255)  | YES  |     | NULL    |                |
| buy_date       | datetime      | NO   |     | NULL    |                |
| buy_item       | varchar(255)  | YES  |     | NULL    |                |
| buy_amount     | decimal(10,0) | YES  |     | 0       |                |
| pay_method     | varchar(255)  | YES  |     | NULL    |                |
| pay_status     | varchar(255)  | YES  |     | NULL    |                |
| billing_amount | decimal(10,0) | YES  |     | 0       |                |
| billing_uid    | varchar(255)  | YES  |     | NULL    |                |
| created_at     | datetime      | NO   |     | NULL    |                |
| updated_at     | datetime      | NO   |     | NULL    |                |
| deleted_at     | datetime      | YES  |     | NULL    |                |
+----------------+---------------+------+-----+---------+----------------+
12 rows in set (0.15 sec)

2. Firebase プロジェクト作成

  • 作成手順は割愛します。当記事で使用しているプロジェクトは「pay-wish」としてお話を進めていきます。

3. Firebase Authenticationの設定

  • 以下の内容で設定しています。(メールアドレスとパスワード認証のみ設定) スクリーンショット 2018-12-22 14.50.08.png

4. Firebase Cloud Firestoreの初期設定

  • 以下の2つのテーブルを定義しています。(詳細は割愛)
users
 ├ {$uid}
  ├ deviceToken
notifications
 ├ [自動Id]
  ├ receiver
  ├ sender
  ├ deletedAt

5. Firebase Cloud Functionsの環境変数設定

  • 今回MySQLへ接続するので、接続情報をFirebaseの環境変数へ設定します。(直接プログラムに書くのは怖いので)
  • Macのターミナルを開いて、環境変数を設定していきましょう。(Firebase CLIのインストール済として説明します。詳しくはここ『【Firebase公式ドキュメント】 Firebase CLI リファレンス』参照)

① Firebase CLIで現在設定されている環境変数を確認する

$ firebase functions:config:get
  • 何も設定されていない場合は、以下のような空が表示されます。
$ {}

② MySQLの接続情報を登録する

  • GCPのCloudSQLのインスタンス内ダッシュボードに記載されているインスタンス接続名やrootのパスワードが必要になってきます。
  • 環境変数名に大文字は使えません((((;゚Д゚))))ガクガクブルブル
  • 今回は以下のような変数名で値を設定していきます。
$ firebase functions:config:set mysql.instance_connection_name=[インスタンス接続名]
$ firebase functions:config:set mysql.user=root
$ firebase functions:config:set mysql.password=[rootパスワード]
$ firebase functions:config:set mysql.database_name=pay_wish

※ ちなみに設定を間違った場合は、以下のコマンドでunsetすれば削除できます。

$ firebase functions:config:unset [key]

■ CloudFunctions環境構築

1. Firebase Functionsを初期化する

  • ターミナルでFunctionを作成したいディレクトリへ移動し、初期化コマンドを実行しましょう。
$ cd pay-wish
$ firebase init functions
  • プロジェクトを選択するか、作成するか聞かれるので、今回は先にプロジェクトを作成しているので対象のプロジェクトを選びます。
=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we'll just set up a default project.

? Select a default Firebase project for this directory: (Use arrow keys)
❯ [don't setup a default project] 
  pay-wish (pay-wish)
  [create a new project]
  • 色々質問されるのですが・・・自分の好みで選択していってください。今回私が指定したのは以下の通りです。
? What language would you like to use to write Cloud Functions? JavaScript
Do you want to use ESLint to catch probable bugs and enforce style? Yes
Do you want to install dependencies with npm now? Yes
  • 以下のメッセージが表示されたら、初期セットアップは完了です。
✔  Firebase initialization complete!

2. ライブラリの追加と、 Node.jsのバージョンの指定

  • package.json に以下のライブラリ追加と、nodeのバージョンを明示的に指定してください。
pay_wish_api/functions/package.json
  "dependencies": {
    "moment": "^2.23.0",
    "moment-timezone": "^0.5.23",
    "mysql": "^2.16.0"
  },
  "engines": {
    "node": "8"
  }

3. Eslintの設定

  • Eslintの設定を以下のように変更します。
    (初期状態のままだと、みんなが大好き?async awaitが利用できない場合があるため)
pay_wish_api/functions/.eslintrc.json
  "parserOptions": {
    "ecmaVersion": 2017
  },

4. パッケージのインストール

  • 2.でライブラリ追加したので、npm installを実行してください。
$ npm install

5. カレントプロジェクトの確認

  • 念の為、Firebaseのプロジェクトが指定したものとなっているか確認します。
 $ firebase list
┌───────────────────────┬───────────────────────┬─────────────┐
│ Name                  │ Project ID / Instance │ Permissions │
├───────────────────────┼───────────────────────┼─────────────┤
│ pay-wish (current)    │ pay-wish              │ Owner       │
└───────────────────────┴───────────────────────┴─────────────┘

6. ディレクトリ構造の確認

  • 今回説明するソースの構造は以下の通りとなっています。
  • functions/src配下のjsファイルは今回私が作成したファイルです。(ポイントの部分だけ後ほど説明します)
pay_wish_api/
  ├ functions/
    ├ src/
      ├ claim.js
      ├ notification.js
      ├ table/
        ├ claims.js
        ├ notifications.js
        ├ users.js
    ├ index.js
    ├ package.json
  ├ firebase.json

■ 実装ファイル説明:MySQLに接続し、検索、登録する処理

  • MySQLの操作をしている処理は、src/table/claims.jsに記載しています。
api/functions/src/table/claims.js
const functions = require('firebase-functions')
const mysql = require("mysql")  // ← ( (01) mysqlライブラリをrequire )
const util = require('util')

// 日付関連
const moment = require('moment-timezone')
// Timezoneを日本に設定
moment.tz.setDefault("Asia/Tokyo")

// =======================================
// 作成
// =======================================
async function create(data) {

  // MySQLへ接続(環境変数よりセット) // ← ( (02) 接続 )
  const connect = await mysql.createConnection({
    socketPath: "/cloudsql/" + functions.config().mysql.instance_connection_name,
    user: functions.config().mysql.user,
    password: functions.config().mysql.password,
    database: functions.config().mysql.database_name
  })

  try {

    console.log(' === claims.create Start')

    await connect.connect()

    // Transaction Start ← ( (03) トランザクションも使えるよ )
    await connect.beginTransaction()

    // 日付
    data.created_at = moment().format('YYYY-MM-DD HH:mm:ss')
    data.updated_at = moment().format('YYYY-MM-DD HH:mm:ss')

    // 登録 ← ( (04) 登録処理 )
    await connect.query('INSERT INTO claims SET ?', data)

    // Commit
    await connect.commit()

    console.log(' === claims.create End')

    return true

  } catch (e) {
    console.log(e)
    await connect.rollback()
    return false
  } finally {
    await connect.end()
  }

}
exports.create = create

// =======================================
// 取得
// =======================================
async function select(data) {

  // MySQLへ接続(環境変数よりセット)
  const connect = await mysql.createPool({
    socketPath: "/cloudsql/" + functions.config().mysql.instance_connection_name,
    user: functions.config().mysql.user,
    password: functions.config().mysql.password,
    database: functions.config().mysql.database_name
  })

  // 関数をPromise処理に変換 ← ( (05) Promiseに変換しないと値取得できなかった・・・ )
  connect.query = util.promisify(connect.query)

  try {

    let results = await connect.query('SELECT * FROM claims') // ← ( (06) 検索 )
    connect.end()

    return results

  } catch (err) {
    throw new Error(err)
  } finally {
    console.log('claims selected.')
  }

}
exports.select = select

(01) node.jsが用意してくれてる、mysqlライブラリをrequireします。

(02) 以下ソース内コメントにも記載してますが、createConnectionを使ってGCPのMySQLへ接続しに行きます。ポイントとなるのが、functions.config()〜で、「5. Firebase Cloud Functionsの環境変数設定」で設定した環境変数を使って接続しに行きます。

(03) もちろん、mysqlのライブラリで使える処理は、使えるので…トランザクション処理もOKです。ただ、CloudFunctionsはタイムアウト問題もあるのであんまり処理が重いものに関しては適してないかも(。・ω・。)

(04) テーブルと同じ構造の連想配列な場合、Value値をそのままセットして登録処理ができます。connect.query('INSERT INTO claims SET ?', data)でいう 「data」部分です。(中身は以下参照)

data
data: {
    buy_uid: 'k3RO***************************',
    buy_date: 2018-12-09T00:00:00.000Z,
    buy_item: '食費',
    buy_amount: 3500,
    pay_method: 'クレジット',
    pay_status: '',
    billing_amount: 2400,
    billing_uid: 'cS6M************************',
    created_at: 2018-12-10T00:20:23.000Z,
    updated_at: 2018-12-10T00:20:23.000Z 
} 

(05) 検索の時のポイントとしては、connect.query = util.promisify(connect.query)で、Node.jsのpromisityクラスを利用してPromice処理しないと結果が取れなかった。( 参考:util_util_promisify_original )

(06) 後は、登録のときと一緒で、 connect.query = にSelect処理をすれば、データが取得できます。

◉ ちなみに・・・Flutter側から関数を呼び出す時の処理

  • CloudFunctionsのトリガーとしては、functions.https.onCallです。
  • Flutter側から渡したデータは、引数のdataに格納されています。context引数には認証情報が格納されています。
api/functions/index.js
// ========================================================================
// 【アプリからのトリガー】請求情報を(GCP)CloudSQL:MySQLのテーブルへ登録
// ========================================================================
exports.onCallClaimsCreate = functions.https.onCall((data, context) => {
  let returnContext = claim.claimsCreateByApp(data, context)
  return returnContext
});
  • Flutter側からCloudFunctionsを呼び出す時は、cloud_functions.dartをimportして、CloudFunctions.instance.callで実行します。
****.dart
import 'package:cloud_functions/cloud_functions.dart';

        final dynamic resp = await CloudFunctions.instance.call(
                                functionName: 'onCallClaimsCreate',
                                parameters: <String, String> {
                                  'buy_uid': 'k3RO***************************',
                                  'buy_date': '2018-12-09T00:00:00.000Z',
                                  'buy_item': '食費',
                                  'buy_amount': '3500',
                                  'pay_method': 'クレジット',
                                  'pay_status': '未',
                                  'billing_amount': '2400',
                                  'billing_uid': 'cS6M************************',
                                  'created_at': '2018-12-10T00:20:23.000Z',
                                  'updated_at': '2018-12-10T00:20:23.000Z'
                                },
                              );
        print(resp);

■ 実装ファイル説明:アプリに通知する処理

  • 通知を検知する部分は、index.jsに記述しています。
pay_wish_api/functions/index.js
const functions = require('firebase-functions')
const notification = require('./src/notification')

// ========================================================================
// notificationsテーブルにデータが登録・更新されたらメール通知飛ばす処理
// ========================================================================
// ↓ (01) トリガー
exports.onStocksCreated = functions.firestore
  .document('notifications/{notificationsKey}')
  .onWrite(async (change, context) => { 

    //現在のドキュメント値を持つオブジェクトを取得します。
    //ドキュメントが存在しない場合、ドキュメントは削除されています。 ← (02) 変更か削除か確認
    const document = change.after.exists ? change.after.data() : null

    if (document) {
      notification.paymentNotifier(change, context, document) // ← (03) 処理している関数呼び出し
    }

  })

(01) トリガーとしては、functions.firestore.document('notifications/{notificationsKey}').onWrite(async (change, context)の部分の、onWriteで通知テーブルnotificationsに変更があった時に起動する処理となっています。今回、notifications/[自動ID]/〜といった形でデータが登録されるので{notificationsKey}このように、カッコでくくってワイルドカード指定してあげます。実際、カッコつけた名前にしていますが、特にdocument名と連動してなくても良いです。(まぁ、わかりやすい名前にしておくのがベスト!)

(02) 変更ドキュメントがあるときだけ、通知処理を行いたいので、データがあるかどうか確認してます。 引数のchange.before.data()に更新前データが、change.after.data()に更新後のデータが格納されています。なので、今回change.after.existsを使用しているのはデータがあるかどうかで判断したいからです。

(03) 実際に通知処理を行っている処理を呼び出します。

  • FCMを実施している部分は、src/notification.jsに記述しています。
pay_wish_api/functions/src/notification.js
const admin = require('firebase-admin')

// 参照
const users = require('./table/users')
const notifications = require('./table/notifications')

async function paymentNotifier(change, context, document) {

  console.log('>>> PaymentNotifier start')

  try {

    // =======================================
    // 変数
    // =======================================    
    const notificationsKey = context.params.notificationsKey // Notifications Key

    // =======================================
    // FCMへ登録
    // =======================================
    // 受信者
    const receiverUid = document.receiver

    // CloudFirestoreのusersよりトークン取得 ← (01)
    const user = await users.getUsersInfo(receiverUid) 

    // 通知内容設定 ← (02)
    let options = {
      priority: 'high'
    }
    const payload = {
      notification: {
        title: 'タイトル:請求がきたよ(。□。;)!!',
        body: '本文:(・ω<) てへぺろ'
      },
      data: {
        "id" : "1",
        "status": "default",
        "click_action": "FLUTTER_NOTIFICATION_CLICK"
      }          
    }

    // 対象デバイスへ送信 ↓ (03)対象の端末へPush通知
    const response = await admin.messaging().sendToDevice(user.deviceToken, payload, options)
    response.results.forEach((result, index) => {
      const error = result.error
      if (error) {
        // =======================================
        // 【異常】エラーが出るDeviceToken削除
        // =======================================
        console.error('通知の送信に失敗: ', user.deviceToken, error)
        // 送信できないトークンを初期化します
        if (error.code === 'messaging/invalid-registration-token' ||
            error.code === 'messaging/registration-token-not-registered') {
          console.log(error.code)
          // Users.deviceToken をNullにする処理
          users.deleteUsersDeviceToken(receiverUid)
        }
      } else {

        // =======================================
        // 【正常】FCMへ登録したら、notificationsから削除
        // =======================================
        notifications.deleteNotification(notificationsKey)
      }
    })

    // =======================================
    // 処理終了
    // =======================================
    console.log('Successfully device payment notification.')

  } catch (error) {
    console.error(error.toString())
  }

  console.log('>>> PaymentNotifier end')
}

exports.paymentNotifier = paymentNotifier

(01) デバイス通知トークンを取得します。(ここで言う、デバイス通知トークンと言っているのはFlutterアプリでサインイン、または新規登録時にCloudFirestoreのusers.{$uid}.deviceTokenにデバイストークン端末情報を登録しているもののことです)

(02) 通知内容を設定します。priorityは、メッセージの優先度の設定でhighにしてメッセージをすぐに送信させます。ここでいう"data"はメッセージのペイロードカスタムのkey-valueを指定します。今回はFlutter側のクリックアクションで起動させたいので、"click_action": "FLUTTER_NOTIFICATION_CLICK"を指定しています。(当記事執筆中は起動せず・・・) "notification"にnotificationで表示させたいタイトルや内容を記述します。

(03) admin.messaging().sendToDeviceを利用して、Push通知します。(01)で取得した、デバイストークンとメッセージ内容を引数に渡して呼び出します。そしたらよきにはからって通知してくれます。また今回は、一つのデバイストークンを指定しているので、個別の端末への通知を想定していますが、配列でデバイストークンを指定すると複数人に通知が送れます!
例えば、以下の書き方のように配列で指定。(【Firebase公式ドキュメント】以前の API を使用して端末に送信する

node.js
let deviceToken = [
  'bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...',
  // ...
  'ecupwIfBy1w:APA91bFtuMY7MktgxA3Au_Qx7cKqnf...'
];

◉ ちなみに・・・Flutter側のfirebaseMessaging設定

  • main.dartのinitState部分に記載しました。onMessageが表示された時は、以下で設定しているメッセージが表示されるはずです。
main.dart
  initState() {
    final FirebaseMessaging _firebaseMessaging = new FirebaseMessaging();
    super.initState();

    // Firebase 認証
    widget.auth.currentUser().then((userId) {
      setState(() {
        authStatus = userId != null ? AuthStatus.signedIn : AuthStatus.notSignedIn;
      });
    });

    // Firebase FCM
    _firebaseMessaging.configure(
      onMessage: (Map<String, dynamic> message) async {
        print("onMessage: $message");
        _buildDialog(context, "onMessage:請求が届いたようだ(๑•̀ㅁ•́๑)✧");
      },
      onLaunch: (Map<String, dynamic> message) async {
        print("onLaunch: $message");
        _buildDialog(context, "onLaunch:請求が届いたようだ(๑•̀ㅁ•́๑)✧");
      },
      onResume: (Map<String, dynamic> message) async {
        print("onResume: $message");
        _buildDialog(context, "onResume:請求が届いたようだ(๑•̀ㅁ•́๑)✧");
      },
    );
    // Push通知の許可
    _firebaseMessaging.requestNotificationPermissions(
        const IosNotificationSettings(sound: true, badge: true, alert: true));
    // Push通知の許可・設定(iOS)
    _firebaseMessaging.onIosSettingsRegistered
        .listen((IosNotificationSettings settings) {
      print("Settings registered: $settings");
    });
  }
  • 実行した結果
Push通知 Notification表示

■ あとがき

  • 環境設定さえしてしまえれば、FirebaseでもRDBを簡単に使えるってことがわかりました!
  • 今回、セキュリティルールなどは設定せずに実行したので、セキュリティを高めるとなるとGCP側のIAM系の設定も必要になってくるかもな・・・
  • FirebaseとGCPってなんだかんだでつながってくれるので、うまく使いこなせば簡単に色々作れるんじゃない?って思ってしまった。
  • 通知もFlutter側でデバイス情報を取得して、CloudFirestoreに登録する処理が必要だけど…基本的にトークンさえあればpush通知も簡単にできるぜ!という印象ですね。(表示させる分には・・・)
  • 今回iOSでは試してないけど、Androidお作法やiOSお作法がありそうなので、そこれへんのチュートリアルも沢山見ました…。アプリ開発のノウハウがあったらきっとこれくらいの処理なら秒なんだろうな・・・と痛感しました。
  • そして・・・思いの外環境設定とかで記事が長くなったので、とりとめのない記事になってしまい申し訳ないです(´・ω・`)
  • 間違いがあったらこっそり教えてください[壁]・ω・)チラッ

■ 参考リンク

■ 追記

2018/12/23 08:40

  • この方法だと、通知が何度も来ることが分かりました((((;゚Д゚))))ガクガクブルブル
    現在方法を変更中です(๑•̀ㅁ•́๑)✧

【原因】

2018/12/23 23:12

  • 上記の対応で通知テーブル(notifications)へ登録するトリガーを利用して通知処理を実施していたが、請求テーブル(claims)の登録が成功したら、通知処理を行うように変更しました。ソースは 『gitHub:pay_wish_api』 を参照ください。本当は、onWriteトリガー使いたかったのに・・・もう少し調べてみます(*Ծ﹏Ծ)ぐぬぬ……ぐぬぬ!
Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away