はじめに
前回、こちらの記事で、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トークンとタイムスタンプを格納するカラムを追加
$ cd scripts/migrations
$ migrate create -ext sql -format 2006010215 add_fcm_token_to_users
upとdownのsqlファイルが作成されるので、下記のように記載。
// 2024050323_add_fcm_token_to_users.up.sql
ALTER TABLE users
ADD COLUMN fcm_token VARCHAR(255),
ADD COLUMN fcm_token_timestamp TIMESTAMP;
// 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
フィールドをデータベースから読み書きできるようする。
// 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を扱えるようにする。
// 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トークンのフィールドを含むようにし、このモデルを更新して、このフィールドのシリアライズとデシリアライズが行えるようにする。
// 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)を生成する。
flutter pub run build_runner build --delete-conflicting-outputs
次に、アプリが起動した際に、FCMトークンを取得し、サーバーに送信する機能をmain.dart
に実装する。UserNotifier
のupdateUser
メソッドは既にUser
オブジェクトを受け取って更新する機能を持っているため、このメソッドを使ってユーザーのFCMトークンを更新する。
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のリクエストがサーバーに対して送られている。
flutter: Request updateUser: {"fcm_token":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}
DBに無事、fcm_tokenとfcm_token_timestampが格納されたことを確認した。
Go:FCMへのメッセージ通知関数を実装(HTTP v1 API対応)
Go言語でサーバーからFCMに通知を送信するための実装(fcm.go)を進める。
今回、GCPではなくAWSで動かしているので、下記説明記事の「アプリケーションが Google 以外のサーバー環境で実行されている場合」に該当する。
HTTP v1用のサーバーエンドポイントは下記
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
{ "notification": {
"body": "This is an FCM notification message!",
"time": "FCM Message"
},
"to" : "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1..."
}
コード全体はこちら。
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サーバーキーを追加する。
FCM_SERVER_KEY=your_fcm_server_key_here
今回は、ユーザーにPush通知したい天気情報のメッセージは、すでにDBのユーザーテーブルのweather_messageカラムに格納されているので、User構造体のWeatherMessageフィールドから呼び出す。メッセージタイトルは固定で「今日の天気情報」にする。
// 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通知タスクをキューに追加する。
// 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通知やその他の処理)を処理する
// 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
を実行するように設定する。この関数は、定期的なタスク(例えばユーザーの天気情報のアップデートなど)をスケジュールする。
// 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にお知らせ時刻を変更して、アプリをバックグラウンド状態にする。
無事届いた。
最後に、ロック画面状態にする。無事届いた。
コメント