Chapter 16

Cloud Functions for Firebase編

snova301
snova301
2022.07.18に更新

概要

Cloud Functions for Firebaseはトリガーされたイベントに応じて、バックエンドコードを自動的に実行できるサービスです。

https://www.youtube.com/watch?v=vr0Gfvp5v1A

Cloud Functions for Firebaseはjavascripttypescriptでの記述に対応しており、サーバの管理やスケーリングせずにバックエンドを実装できます。

公式ドキュメントはこちら。

https://firebase.google.com/docs/functions

準備

本チャプターは準備編と前回までのチャプターが完了していることが前提です。

Cloud FunctionsをJavaScript環境で実行するためにはNode.js環境が必要なので、まだ導入していない場合はnvmを使ってインストールします。

https://nodejs.org/ja/

導入方法

firebase-toolsをインストールします。

npm install -g firebase-tools

プロジェクトを初期化します。

https://firebase.google.com/docs/functions/get-started#initialize-your-project

firebase loginが出来ている環境では、functionsとその他必要なツールを初期化します。
今回は言語にJavaScriptを選択しました。

firebase init functions

初期化出来たら、プロジェクト内に新しくfunctionsフォルダが作成されます。

関数の作成

Cloud Functionsを実行するための関数を、functions/index.jsに記述していきます。

必要なモジュールをインポートします。

functions/index.js
const functions = require("firebase-functions");

https://firebase.google.com/docs/functions/get-started#import-the-required-modules-and-initialize-an-app

関数の定義です。
関数を呼び出すには大きく3つあります。

  • アプリから直接呼び出す方法

https://firebase.google.com/docs/functions/callable

  • HTTPリクエスト経由で関数を呼び出す方法

https://firebase.google.com/docs/functions/http-events

  • スケジュール設定で呼び出す方法

https://firebase.google.com/docs/functions/schedule-functions

今回のカウントアップ機能は、アプリから直接呼び出して使用するため、バックエンド側ではonCallトリガーを使用します。
ついでに、UIDを呼び出せるかも実験してみます。

functions/index.js
exports.functionsTest = functions.https.onCall((data, context) => {
  /// 数値読み取り
  const firstNumber = data.firstNumber;
  const secondNumber = data.secondNumber;

  /// 計算実行
  const addNumber = firstNumber + secondNumber;

  /// ついでに、UIDも呼び出せるか実験
  const contextUid = context.auth.uid;

  return { addNumber:addNumber,  contextUid:contextUid }
});

エミュレータでテスト

無限ループになっていないかなどを確認するため、デプロイする前にローカルエミュレータでテストします。

https://firebase.google.com/docs/emulator-suite

なお、App Checkを使用している場合は、エミュレーターが実行できず、App Checkのデバッグプロバイダーを使用する必要がありますが、現時点では公式サイトにも記載されてある通り、Dart APIが提供されていないので、各実行環境でデバッグプロバイダーを使用しなければなりません。

https://firebase.google.com/docs/app-check/flutter/debug-provider

エミュレータの起動にはJavaが必要なので、Open JDKをインストールしておきます。

https://jdk.java.net/

ローカルエミュレータをインストール&初期化し、必要に応じてfirebase init ***で各プラグインをインストールします。

firebase init emulators

https://firebase.google.com/docs/emulator-suite/install_and_configure#install_the_local_emulator_suite

ローカルエミュレータを使うときは、Flutter側のmain関数にuseFunctionsEmulatorの設定をします。

main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // Ideal time to initialize
  FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);

...

https://firebase.flutter.dev/docs/functions/usage/#emulator-usage

インストールできたらemulators:startでエミュレータを起動し、ブラウザでhttp://localhost:4000/(デフォルトの場合)を開きます。

firebase emulators:start

デプロイ

問題なければ、本番環境にデプロイします。
Firebase ConsoleからFunctionsに移動し、「開始」を選択します。

firebase deploy --only functions:functionsTest

Flutterアプリからの呼び出し

Cloud FunctionsをFlutterプロジェクトに適用するため、pubspec.yamlcloud_functionsを導入します。

https://firebase.google.com/docs/functions/callable#dart_1

pubspec.yaml
dependencies:
  cloud_functions: ^3.3.2

https://pub.dev/packages/cloud_functions

functionsを実行するコードを書きます。

cloud_functions_page.dart
import 'package:cloud_functions/cloud_functions.dart';

/// Cloud Functionsの実行
void addNumber() async {
  try {
    /// カウントアップの関数の読み出し
    final result = await FirebaseFunctions.instance
        .httpsCallable('functionsTest')
        .call({'firstNumber': _number, 'secondNumber': 1});
    _number = result.data['addNumber'];
    print(result.data['contextUid']);
  } on FirebaseFunctionsException catch (error) {
    print(error.code);
    print(error.details);
    print(error.message);
  }
}

カウントアップするためだけにCloud Functionsを使う贅沢なアプリが完成しました。

コード全文

前回からの変更点です。

  • Cloud Functionsの関数を追加
  • cloud_functionsページを追加
  • その他、コードの変更
functions/index.js
functions/index.js
const functions = require("firebase-functions");

exports.functionsTest = functions.https.onCall(async(data, context) => {
  /// 数値読み取り
  const firstNumber = data.firstNumber;
  const secondNumber = data.secondNumber;

  /// 計算実行
  const addNumber = firstNumber + secondNumber;

  /// ついでに、UIDも呼び出せるか実験
  const contextUid = context.auth.uid;

  return { addNumber:addNumber,  contextUid:contextUid }
});
main.dart
main.dart
/// Flutter関係のインポート
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// Firebase関係のインポート
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:counter_firebase/remote_config_page.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:firebase_app_installations/firebase_app_installations.dart';
import 'package:firebase_in_app_messaging/firebase_in_app_messaging.dart';

/// 他ページのインポート
import 'package:counter_firebase/normal_counter_page.dart';
import 'package:counter_firebase/crash_page.dart';
import 'package:counter_firebase/auth_page.dart';
import 'package:counter_firebase/firestore_page.dart';
import 'package:counter_firebase/realtime_database_page.dart';
import 'package:counter_firebase/cloud_storage.dart';
import 'package:counter_firebase/cloud_functions_page.dart';
import 'package:counter_firebase/ml_page.dart';

/// プラットフォームの確認
final isAndroid =
    defaultTargetPlatform == TargetPlatform.android ? true : false;
final isIOS = defaultTargetPlatform == TargetPlatform.iOS ? true : false;

/// FCMバックグランドメッセージの設定
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  print('Handling a background message: ${message.messageId}');
}

void main() async {
  /// クラッシュハンドラ
  runZonedGuarded<Future<void>>(() async {
    /// Firebaseの初期化
    WidgetsFlutterBinding.ensureInitialized();
    await Firebase.initializeApp(
      options: DefaultFirebaseOptions.currentPlatform,
    );

    /// FCMのバックグランドメッセージを表示
    FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

    /// クラッシュハンドラ(Flutterフレームワーク内でスローされたすべてのエラー)
    FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;

    /// Cloud Functionsのローカルエミュレータ設定
    // FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);

    /// runApp w/ Riverpod
    runApp(const ProviderScope(child: MyApp()));
  },

      /// クラッシュハンドラ(Flutterフレームワーク内でキャッチされないエラー)
      (error, stack) =>
          FirebaseCrashlytics.instance.recordError(error, stack, fatal: true));
}

/// Providerの初期化
final counterProvider = StateNotifierProvider<Counter, int>((ref) {
  return Counter();
});

class Counter extends StateNotifier<int> {
  Counter() : super(0);

  /// カウントアップ
  void increment() => state++;
}

/// MaterialAppの設定
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Counter Firebase',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

/// ホームページ画面
class MyHomePage extends ConsumerStatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

  
  MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends ConsumerState<MyHomePage> {
  
  void initState() {
    super.initState();

    /// FCMのパーミッション設定
    FirebaseMessagingService().setting();

    /// FCMのトークン表示(テスト用)
    FirebaseMessagingService().fcmGetToken();

    /// Firebase ID取得(テスト用)
    FirebaseInAppMessagingService().getFID();
  }

  
  Widget build(BuildContext context) {
    /// ユーザー情報の取得
    FirebaseAuth.instance.authStateChanges().listen((User? user) {
      if (user == null) {
        ref.watch(userEmailProvider.state).state = 'ログインしていません';
      } else {
        ref.watch(userEmailProvider.state).state = user.email!;
      }
    });

    return Scaffold(
      appBar: AppBar(
        title: const Text('My Homepage'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(10),
        children: <Widget>[
          /// ユーザ情報の表示
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(Icons.person),
              Text(ref.watch(userEmailProvider)),
            ],
          ),

          /// ページ遷移
          const _PagePushButton(
            buttonTitle: 'ノーマルカウンター',
            pagename: NormalCounterPage(),
          ),
          const _PagePushButton(
            buttonTitle: 'クラッシュページ',
            pagename: CrashPage(),
          ),
          const _PagePushButton(
            buttonTitle: 'Remote Configカウンター',
            pagename: RemoteConfigPage(),
          ),
          const _PagePushButton(
            buttonTitle: '機械学習ページ',
            pagename: MLPage(),
          ),
          const _PagePushButton(
            buttonTitle: '認証ページ',
            pagename: AuthPage(),
            bgColor: Colors.red,
          ),

          /// 各ページへの遷移(認証後利用可能)
          /// 認証されていなかったらボタンを押せない状態にする
          FirebaseAuth.instance.currentUser?.uid != null
              ? const _PagePushButton(
                  buttonTitle: 'Firestoreカウンター',
                  pagename: FirestorePage(),
                  bgColor: Colors.green,
                )
              : const Text('Firestoreカウンターを開くためには認証してください。'),
          FirebaseAuth.instance.currentUser?.uid != null
              ? const _PagePushButton(
                  buttonTitle: 'Realtime Databaseカウンター',
                  pagename: RealtimeDatabasePage(),
                  bgColor: Colors.green,
                )
              : const Text('Realtime Databaseカウンターを開くためには認証してください。'),
          FirebaseAuth.instance.currentUser?.uid != null
              ? const _PagePushButton(
                  buttonTitle: 'Cloud Storageページ',
                  pagename: CloudStoragePage(),
                  bgColor: Colors.green,
                )
              : const Text('Cloud Storageページを開くためには認証してください。'),
          FirebaseAuth.instance.currentUser?.uid != null
              ? const _PagePushButton(
                  buttonTitle: 'Cloud Functionsページ',
                  pagename: CloudFunctionsPage(),
                  bgColor: Colors.green,
                )
              : const Text('Cloud Functionsページを開くためには認証してください。'),
        ],
      ),
    );
  }
}

/// ページ遷移のボタン
class _PagePushButton extends StatelessWidget {
  const _PagePushButton({
    Key? key,
    required this.buttonTitle,
    required this.pagename,
    this.bgColor = Colors.blue,
  }) : super(key: key);

  final String buttonTitle;
  final dynamic pagename;
  final Color bgColor;

  
  Widget build(BuildContext context) {
    return ElevatedButton(
      style: ButtonStyle(
        backgroundColor: MaterialStateProperty.all(bgColor),
      ),
      child: Container(
        padding: const EdgeInsets.all(10),
        child: Text(buttonTitle),
      ),
      onPressed: () {
        AnalyticsService().logPage(buttonTitle);
        Navigator.push(
          context,
          MaterialPageRoute(builder: (context) => pagename),
        );
      },
    );
  }
}

/// Analyticsの実装
class AnalyticsService {
  /// ページ遷移のログ
  Future<void> logPage(String screenName) async {
    await FirebaseAnalytics.instance.logEvent(
      name: 'screen_view',
      parameters: {
        'firebase_screen': screenName,
      },
    );
  }
}

/// Firebase Cloud Messageの設定
class FirebaseMessagingService {
  FirebaseMessaging messaging = FirebaseMessaging.instance;

  /// webとiOS向け設定
  void setting() async {
    NotificationSettings settings = await messaging.requestPermission(
      alert: true,
      announcement: false,
      badge: true,
      carPlay: false,
      criticalAlert: false,
      provisional: false,
      sound: true,
    );

    print('User granted permission: ${settings.authorizationStatus}');
  }

  void fcmGetToken() async {
    /// モバイル向け
    if (isAndroid || isIOS) {
      final fcmToken = await messaging.getToken();
      print(fcmToken);
    }
    // web向け
    else {
      final fcmToken = await messaging.getToken(
          vapidKey: FirebaseOptionMessaging().webPushKeyPair);
      print('web : $fcmToken');
    }
  }
}

class FirebaseInAppMessagingService {
  void getFID() async {
    String id = await FirebaseInstallations.instance.getId();
    print('id : $id');
  }
}
cloud_functions_page.dart
cloud_functions_page.dart
/// Flutter
import 'package:flutter/material.dart';

/// Firebase
import 'package:cloud_functions/cloud_functions.dart';

class CloudFunctionsPage extends StatefulWidget {
  const CloudFunctionsPage({Key? key}) : super(key: key);

  
  CloudFunctionsPageState createState() => CloudFunctionsPageState();
}

class CloudFunctionsPageState extends State<CloudFunctionsPage> {
  /// 初期化
  int _number = 0;

  /// Cloud Functionsの実行
  void addNumber() async {
    try {
      /// カウントアップの関数の読み出し
      final result = await FirebaseFunctions.instance
          .httpsCallable('functionsTest')
          .call({'firstNumber': _number, 'secondNumber': 1});
      _number = result.data['addNumber'];
    } on FirebaseFunctionsException catch (error) {
      print(error.code);
      print(error.details);
      print(error.message);
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Cloud Functionsページ'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text('You have pushed the button this many times:'),
            Text(
              '$_number',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            addNumber();
          });
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

上記のコードが含まれるGitHubのページです。

https://github.com/snova301/counter_firebase

参考

https://github.com/firebase/quickstart-js

https://firebase.google.com/docs/reference/admin/node

https://github.com/firebase/functions-samples