方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Go×FCM】定期的にFCMへPush通知を送信する機能を実装:サーバー編

この記事は約22分で読めます。

はじめに

前回、こちらの記事で、Firebase Cloud Messaging(FCM)を利用して、iOS実機にバックグラウンド・フォアグラウドでのテスト送信を行うところまでを記載した。

今回は、Go言語でサーバーから定期時刻にFCMにPush通知を送信する機能を実装したいと思う。

FCMにPush通知を実装するのに必要な引数は下記4つ

  • FCMサーバーキー:HTTPリクエストヘッダーのAuthorization
  • デバイスのFCMトークン:メッセージの送り先の指定として
  • タイトル
  • 本文

FCMサービスアカウントの認証情報を取得

FCMへのAPIリクエストには、以前はFCMのサーバーキーをHTTPリクエストのヘッダーの Authorization として設定していたが、このCloud Messaging API(Legacy HTTP API)は2024年6月20日をもって廃止され、FCMサーバーキーは使用できなくなるので、HTTP v1 APIを利用する必要がある。

HTTP v1 APIはOauth2.0トークンを介して行われ、OAuth2.0トークン生成のためにはFCM認証情報(通常はJSON形式)をGoogle Cloudコンソールから取得する必要がある。

Google Cloudコンソール→IAMと管理→サービスアカウントを作成 をクリック

必要事項を入力して、完了をクリック

  • サービスアカウント名: わかりやすい名前を入力(例: FCM Service Account)
  • サービスアカウントID: サービスアカウント名を入力すると同一名が自動的に入力されるが、必要に応じてカスタマイズ可能
  • 必要なロール:Firebase Cloud Messagingに関連するロール(例えば、「Firebase Cloud Messaging Admin」など)

作成されたサービスアカウントを選択して、「キー」タブをクリックし「鍵の追加」→「新しい鍵」を作成 をクリック。JSON形式を選択すると、自動的にダウンロードが開始される。

ダウンロードしたjsonファイルを、.credential内など、適切なディレクトリ内に保管して呼び出せるようにする。

FCMトークンをサーバーに送信

サーバーからFCMバックエンドにプッシュ通知を送る際には、個々のデバイスを識別するFCMトークンを引数として渡す必要がある。

サーバーで格納しているUserテーブルにFCMトークンというカラムを新規に追加して、ユーザーが初回アプリ起動したタイミングで、DBに保存されるようにして、実際に通知を送る際はDBのFCMトークンカラムから呼び出すという流れで実装する。

FCM登録トークン管理のベストプラクティスによると、FCMトークンと一緒にタイムスタンプもサーバーに保存した方が良いとのことなので、タイムスタンプも保存する。

アプリは最初の起動時にこのトークンを取得し、タイムスタンプと一緒にトークンをアプリサーバーに保存する必要があります。このタイムスタンプは FCM SDK では提供されないため、自らのコードとサーバーによって実装する必要があります。

https://firebase.google.com/docs/cloud-messaging/manage-tokens?hl=ja

Go:DBにFCMトークンとタイムスタンプを格納するカラムを追加

ShellScript
$ cd scripts/migrations
$ migrate create -ext sql -format 2006010215 add_fcm_token_to_users

upとdownのsqlファイルが作成されるので、下記のように記載。

Go
// 2024050323_add_fcm_token_to_users.up.sql
ALTER TABLE users
    ADD COLUMN fcm_token VARCHAR(255),
    ADD COLUMN fcm_token_timestamp TIMESTAMP;
Go
// 2024050323_add_fcm_token_to_users.down.sql
ALTER TABLE users
    DROP COLUMN fcm_token,
    DROP COLUMN fcm_token_timestamp;

migration適用して、fcm_tokenカラムとfcm_token_timestampカラムをUserテーブルに追加した。

Go:User構造体にfcm_tokenフィールドを追加

GoのUser構造体にfcm_tokenフィールドを追加してfcm_tokenフィールドをデータベースから読み書きできるようする。

Go
// user.go
type User struct {
    UID                        string `db:"uid"`
    DeviceID                   string `db:"device_id"`
    Name                       string `db:"name"`
    // ... 他のフィールド
    FcmToken                   types.NullString `db:"fcm_token"`
    FcmTokenTimestamp          *time.Time      `db:"fcm_token_timestamp" json:"fcm_token_timestamp"`
}

Go:UpdateUser関数を更新して、fcm_tokenを扱えるようにする。

Go
// user_db.go
func UpdateUser(db *sqlx.DB, user *User) (*User, error) {
	tx, err := db.Beginx()
	if err != nil {
		return nil, err
	}

	query := `update users
			  set device_id				    = coalesce(:device_id, device_id),
			  ...
			  fcm_token = coalesce(:fcm_token, fcm_token),
			  fcm_token_timestamp		= coalesce(:fcm_token_timestamp, fcm_token_timestamp),
			  ...
			  WHERE uid = :uid;`
	return &updatedUser, nil
}

Flutter:アプリ起動時にFCMトークンをサーバーに送信

FCMトークンをユーザーデータの一部として取り扱い、ApiClient クラスを使用して API リクエストで管理する。

DartのUserモデルがFCMトークンのフィールドを含むようにし、このモデルを更新して、このフィールドのシリアライズとデシリアライズが行えるようにする。

Go
// lib/api/user.dart
class User {
  final String? uid;
  final String? fcmToken;
  final DateTime? fcmTokenTimestamp;

	User(
      {this.uid,
			 this.fcmToken,
       this.fcmTokenTimestamp});
}

build_runnerを使用して、JSONシリアライズのコード(user.g.dart)を生成する。

ShellScript
flutter pub run build_runner build --delete-conflicting-outputs

次に、アプリが起動した際に、FCMトークンを取得し、サーバーに送信する機能をmain.dartに実装する。UserNotifierupdateUserメソッドは既にUserオブジェクトを受け取って更新する機能を持っているため、このメソッドを使ってユーザーのFCMトークンを更新する。

Dart
import 'package:clocky_app/api/user.dart';
import 'package:clocky_app/api/user_notifier.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:overlay_support/overlay_support.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  runApp(ProviderScope(child: const ClockyApp()));
}

class ClockyApp extends ConsumerStatefulWidget {
  const ClockyApp({super.key});

  @override
  _ClockyAppState createState() => _ClockyAppState();
}

class _ClockyAppState extends ConsumerState<ClockyApp> {
  @override
  void initState() {
    super.initState();
    initializeApp();
  }

  Future<void> initializeApp() async {
    final messaging = FirebaseMessaging.instance;
    NotificationSettings settings = await messaging.requestPermission(
      alert: true,
      announcement: false,
      badge: true,
      carPlay: false,
      criticalAlert: false,
      provisional: false,
      sound: true,
    );
    debugPrint('User granted permission: ${settings.authorizationStatus}');

    final token = await messaging.getToken();
    debugPrint('FCM TOKEN: $token');

    if (token != null) {
    // FCMトークンをサーバーに送る
      final userNotifier = ref.read(userProvider.notifier);
      userNotifier.updateUser(User(fcmToken: token, fcmTokenTimestamp: DateTime.now()));
    }
  }
}

flutter runして、fcm_tokenとfcm_token_timestampが正しくDBに保存され、更新されることを確認する。

flutter起動時のログで、updateUserのリクエストがサーバーに対して送られている。

ShellScript
flutter: Request updateUser: {"fcm_token":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}

DBに無事、fcm_tokenとfcm_token_timestampが格納されたことを確認した。

Go:FCMへのメッセージ通知関数を実装(HTTP v1 API対応)

Go言語でサーバーからFCMに通知を送信するための実装(fcm.go)を進める。

今回、GCPではなくAWSで動かしているので、下記説明記事の「アプリケーションが Google 以外のサーバー環境で実行されている場合」に該当する。

https://firebase.google.com/docs/cloud-messaging/migrate-v1?hl=ja&_gl=11od2z5e_upMQ.._gaMTQyOTM0NjcyLjE3MTQ4ODYxMDI._ga_CW55HF8NVT*MTcxNDg4NjEwMi4xLjAuMTcxNDg4NjEwMi4wLjAuMA

HTTP v1用のサーバーエンドポイントは下記

JSON
POST https://fcm.googleapis.com/v1/projects/myproject-b5ae1/messages:send

HTTPリクエストのヘッダーに、先ほど取得したFCMサービス認証情報を通じて取得したOAuth 2.0トークンを使用する。

google.golang.org/api/fcm/v1 パッケージを利用し、 WithCredentialsJSON メソッドを使用して、FCMサービスアカウント認証情報のJSONファイルを読み込む。

https://pkg.go.dev/google.golang.org/api@v0.177.0/option#WithCredentialsJSON

SendFCMNotification 関数は、FCMに通知を送信するための関数。FCMトークン、タイトル、メッセージボディをパラメータとして受け取り、それを使ってプッシュ通知を送信する。

HTTP v1 API では、JSON メッセージ ペイロードの構造も変化している。特定のデバイスをターゲットにするには、message オブジェクト内にtoken キーでデバイスの現在の登録トークンを指定する。

Dart
  { "notification": {
      "body": "This is an FCM notification message!",
      "time": "FCM Message"
    },
    "to" : "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1..."
  }

コード全体はこちら。

Go
package clocky_be

import (
	"context"
	"fmt"
	"log"

	fcm "google.golang.org/api/fcm/v1"
	"google.golang.org/api/option"
)


func SendFCMNotification(ctx context.Context, config *Config,token, title, body string) error {
	opt := option.WithCredentialsJSON([]byte(config.GoogleApplicationCredentials))
	service, err := fcm.NewService(ctx, opt)
	if err != nil {
		return fmt.Errorf("could not create FCM service: %v", err)
	}

	// Construct the message to be sent.
	message := &fcm.Message{
		Token: token,
		Notification: &fcm.Notification{
			Title: title,
			Body:  body,
		},
	}

	// Create a new "messages" client from the FCM service.
	messagesClient := fcm.NewProjectsMessagesService(service)

	request := &fcm.SendMessageRequest{
		Message: message,
	}
	fmt.Printf("FCM log")

	// Send the message using the FCM v1 API endpoint
	response, err := messagesClient.Send(fmt.Sprintf("projects/%s", config.FirebaseProjectId), request).Do()
	if err != nil {
		log.Printf("Request failed: %+v", request)
		return fmt.Errorf("failed to send notification: %v", err)
	}

	fmt.Printf("Successfully sent notification. Response: %+v\n", response)
	return nil
}

天気情報をFCMに送信する関数実装

SendFCMNotification 関数を用いて、今回実装した天気情報をFCMに送信する関数を実装する。

.env ファイルにFCMサーバーキーを追加する。

Go
FCM_SERVER_KEY=your_fcm_server_key_here

今回は、ユーザーにPush通知したい天気情報のメッセージは、すでにDBのユーザーテーブルのweather_messageカラムに格納されているので、User構造体のWeatherMessageフィールドから呼び出す。メッセージタイトルは固定で「今日の天気情報」にする。

Go
// worker.go
// FCMを使用したリアルタイム天気通知の送信
func SendFCMWeatherNotification(ctx context.Context, db *sqlx.DB, message Message, config *Config) {
    user, err := GetUser(db, message.UserID)
    if err != nil {
        log.Printf("Failed to get user info for FCM notification: %v", err)
        return
    }

    // ユーザーのFCMトークンの存在を確認
    if !user.FcmToken.Valid {
        log.Printf("No FCM token available for user %s", user.UID)
        return
    }

    // 天気情報の更新が必要かどうかを確認
    if user.WeatherMessage.Valid && user.WeatherMessage.String != "" {
        // 天気情報をFCMで送信
        if err := SendFCMNotification(ctx, config, user.FcmToken.String, "[ミーア]今日の天気お知らせ", user.WeatherMessage.String); err != nil {
            log.Printf("Failed to send FCM notification for user %s: %v", user.UID, err)
            return
        }
        log.Printf("FCM notification sent for user: %s", user.UID)
    } else {
        log.Printf("No valid weather message to send for user %s", user.UID)
    }
}

定期実行処理を追加

天気情報を特定の時刻(データベースに格納されたWeatherAnnouncementTimeに基づいて)にFCMを通じてユーザーに送信するようにする。

ScheduleTask 関数:タスクのスケジューリング

  • clocky.ScheduleTask(db, messagesChan) は、現在の時間に正確に一致するweather_announcement_timeを持つユーザーのみをデータベースから取得し、FCM通知タスクをキューに追加する。
Go
// worker.go
func ScheduleTask(db *sqlx.DB, messagesChan chan Message) {
		var users []User
		query := `SELECT * FROM users WHERE DATE_FORMAT(weather_announcement_time, '%H:%i') = DATE_FORMAT(?, '%H:%i');`
		err = db.Select(&users, query, currentTimeJST.String())
    for _, user := range users {
			if user.WeatherAnnouncementTime.Valid {
				log.Printf("Sending FCM weather notification for user: %s", user.UID)
				message := Message{
					UserID: user.UID,
					Type:   "weather_fcm",
				}
				messagesChan <- message
			}
    }
    // ...
}

ProcessMessages 関数:メッセージの処理

  • clocky.ProcessMessages(db, messagesChan, config) はメッセージチャネルから受け取ったメッセージに基づいて適切なアクションを行う。この関数は異なるタイプのメッセージ(天気情報のFCM通知やその他の処理)を処理する
Go
// worker.go
// メッセージ処理の抽象化
func ProcessMessages(db *sqlx.DB, messagesChan chan Message, config *Config) {
    for message := range messagesChan {
        log.Printf("Processing message for user: %s, type: %s", message.UserID, message.Type)

        // タイムアウトを設定
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
        defer cancel()

        switch message.Type {
        case "weather_fcm":
            SendFCMWeatherNotification(ctx, db, message, config)
        default:
            log.Printf("Unknown message type: %s", message.Type)
        }
    }
}

最後に、main.goで、Cronジョブをセットアップする。

Go:Cronジョブの設定と開始

Cronジョブの初期化とスケジュール設定: cron.New で秒単位のスケジュールをサポートする Cron インスタンスを作成し、毎分0秒に clocky.ScheduleTask を実行するように設定する。この関数は、定期的なタスク(例えばユーザーの天気情報のアップデートなど)をスケジュールする。

Go
// main.go
package main

import (
	"context"
	"log"
	"github.com/jmoiron/sqlx"
	"github.com/robfig/cron/v3"
	"github.com/EarEEG-dev/clocky_be"
)

func main() {
	ctx := context.Background()
	config := clocky.NewConfig()

	db, err := sqlx.Open("mysql", config.DSN)
	if err != nil {
		log.Fatalf("unable to connect to database: %v", err)
	}

	if err := db.Ping(); err != nil {
		log.Fatalf("unable to ping database: %v", err)
	}
	log.Printf("connected to database")

	// メッセージチャネルとCronジョブの設定
	messagesChan := make(chan clocky.Message)

	// 秒単位をサポートしてcronを初期化
	c := cron.New(cron.WithSeconds())

	// 毎分0秒時点で起動
	c.AddFunc("0 * * * * *", func() {
		log.Println("Cron job triggered")
		clocky.ScheduleTask(db, messagesChan)
	})
	go c.Start()

	// 処理関数の起動数を増やして並列に処理させることも可能
	go clocky.ProcessMessages(db, messagesChan, config)

	// その他のアプリケーション初期化コード(HTTPサーバー、SQSリスナー、gRPCサーバー、Rest API)
	// ...
}

動作確認

アプリで天気お知らせ時刻を13:53に変更して、指定時刻にアプリにPush通知が来るか検証。
フォアグラウンド状態で通知が届いた。
サーバーからFCMに送信したPush通知はリアルタイムでユーザーのデバイスに送信される。

次に、13:54にお知らせ時刻を変更して、アプリをバックグラウンド状態にする。
無事届いた。

最後に、ロック画面状態にする。無事届いた。

コメント

タイトルとURLをコピーしました