LaravelとFirebaseでプッシュ通知を実装する

# はじめに
APIなどのバックエンドをLaravelで実装したアプリがあったとして、そのアプリにどうやってプッシュ通知を飛ばすのでしょうか？

調べてみたのですが全体を通して解説されている情報が意外と少なかったので、ここに備忘録として記録したいと思います。

以下の点については省略しているのでご了承を:pray:
- クライアントサイド（アプリ側）の実装
- プッシュ通知で必要なAPNs Keyの発行など
- Laravelの環境構築

https://qiita.com/kokogento/items/18f5d1642f6f915f8961

全体ぞについては👆を参考に。

# Firebase側の準備
まずはFirebase側の準備をします。と言っても、Firebaseプロジェクトを作って秘密鍵のファイルを取得するだけです。

https://firebase.google.com/docs/admin/setup?hl=ja#initialize_the_sdk_in_non-google_environments

>サービス アカウント用の秘密鍵ファイルを生成するには:
>Firebase コンソールで、[設定] > [サービス アカウント] を開きます。
[新しい秘密鍵の生成] をクリックし、[キーを生成] をクリックして確定します。
キーを含む JSON ファイルを安全に保管します。

今回の場合は「Google 以外のサーバー環境」になるので👆の手順で秘密鍵を生成できます。

秘密鍵は`firebaseプロジェクト名-firebase-adminsdk-xxxxxxx.json`のようなファイル名でダウンロードされます。
取得したjsonファイルは、Laravelのプロジェクトのルートディレクトリに配置します。



# Laravel側の準備
## 必要なパッケージを追加する

```:composer.json
        "kreait/firebase-php": "^6.2.0",
        "kreait/laravel-firebase": "^4.1",
```

パッケージのドキュメントは以下を参考に。

kreait/firebase-php

https://firebase-php.readthedocs.io/en/stable/

https://firebase-php.readthedocs.io/en/stable/cloud-messaging.html

kreait/laravel-firebase

https://github.com/kreait/laravel-firebase

## Service Providerに追加
今回は[kreait/laravel-firebase](https://github.com/kreait/laravel-firebase)を使うのでService Providerに登録が必要です。

```php:config/app.php
   'providers' => ServiceProvider::defaultProviders()->merge([
        /*
         * Package Service Providers...
         */
        Kreait\Laravel\Firebase\ServiceProvider::class, // 追加
        /*
         * Application Service Providers...
         */
        App\Providers\AppServiceProvider::class,
        App\Providers\AuthServiceProvider::class,
        App\Providers\EventServiceProvider::class,
        App\Providers\RouteServiceProvider::class,
        App\Providers\RelationShipServiceProvider::class,
    ])->toArray(),

    /*
    |--------------------------------------------------------------------------
    | Class Aliases
    |--------------------------------------------------------------------------
    |
    | This array of class aliases will be registered when this application
    | is started. However, feel free to register as many as you wish as
    | the aliases are "lazy" loaded so they don't hinder performance.
    |
    */

    'aliases' => Facade::defaultAliases()->merge([
        // 'Example' => App\Facades\Example::class,
        'Firebase' => Kreait\Laravel\Firebase\Facades\Firebase::class, // 追加
    ])->toArray(),
```



## FCMトークンを保存するテーブルを作成

色々な設計がありそうですが、今回はシンプルにユーザーIDとデバイスのFCMトークンを紐付けて保存するようにします。

**FCMトークンはざっくり言うとプッシュ通知の送り先です。**

migrationファイルを作成してテーブルを追加します。

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::dropIfExists('devices');

        Schema::create('devices', function (Blueprint $table) {
            $table->id();
            $table->unsignedInteger('user_id')->nullable()->comment('ユーザーID');
            $table->string('fcm_token')->unique()->nullable()->comment('FCMトークン');
            $table->timestamps();

            $table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
        });
        DB::statement("ALTER TABLE `devices` comment 'プッシュ通知用のfcmトークンをユーザーと紐づけて保存するテーブル'");
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('devices');
    }
};
```


# Laravel側の実装
Laravel側の実装は大きく分けて以下２つが必要です。
・アプリから送られてきたFCMトークンを保存する処理
・FCMトークンを使ってプッシュ通知を飛ばす処理

**プッシュ通知を飛ばす部分ですが、Laravelにデフォルトで備わっている機能のJob(ジョブ)とQueue(キュー)で非同期処理として実行させます。**

なぜJobとQueueを使うのか？
👇のメール送信の記事を見ると理解しやすいかもしれません。

https://zenn.dev/yuzuyuzu0830/articles/693130b152b896

https://nogson2.hatenablog.com/entry/2019/12/22/144732

今回の場合で言うと、例えばアプリで何かを購入したとしましょう。
もし非同期でプッシュ通知送信処理を行っていない場合、「購入完了」のプッシュ通知が送信されるまでサーバー側は次の処理へ移ることができません。

なのでプッシュ通知を飛ばす部分は非同期処理にした方が良いのです。


また本筋とはあまり関係がないAPI Routes（ルーティング）の処理は省いているのでご了承を:pray:

## FCMトークンを保存・削除
アプリ起動時に毎回FCMトークンをDBに保存し、ログアウトした時にはdevicesテーブルのレコードごと削除するとします。

ここでは**Controller →Service →Repository →Model**で階層を分けた実装をします。

```php:app/Http/Controllers/DeviceController.php
<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use Illuminate\Http\Response;
use App\Http\Responses\JsonResponse;
use App\Services\DeviceService;
use Illuminate\Support\ItemNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;


class DeviceController extends Controller
{
    protected $deviceService;

    public function __construct(DeviceService $deviceService)
    {
        $this->deviceService = $deviceService;
    }

	/**
     * FcmTokenを登録する
     *
     * @param Request $request
     * @return \Illuminate\Http\JsonResponse
     * @throws \Exception
     */
    public function store(Request $request)
    {
        $user_id = auth()->user()->id;
        $fcm_token = $request->fcm_token;
        if (is_null($fcm_token)) {
            throw new ItemNotFoundException('fcmトークンが存在しません。', Response::HTTP_BAD_REQUEST);
        }
        
        $this->deviceService->storeFcmToken($user_id, $fcm_token);

        $jsonResponse = new JsonResponse(
            status: HttpResponse::HTTP_OK,
            data: [],
        );

        return response()->json($jsonResponse->getJson(), HttpResponse::HTTP_OK);
    }

    /**
     * FcmTokenを連携解除する
     *
     * @param Request $request
     * @return \Illuminate\Http\JsonResponse
     * @throws \Exception
     */
    public function destroy(Request $request)
    {
        $user_id = auth()->user()->id;
        $this->deviceService->deleteFcmToken($user_id);
        
        $jsonResponse = new JsonResponse(
            status: HttpResponse::HTTP_OK,
            data: [],
        );

        return response()->json($jsonResponse->getJson(), HttpResponse::HTTP_OK);
    }
}
```

```php:app/Services/DeviceService.php
<?php

namespace App\Services;

use App\Repositories\DeviceRepository;

class DeviceService
{
	protected $deviceRepository;

	public function __construct(
		DeviceRepository $deviceRepository
	){
		$this->deviceRepository = $deviceRepository;
	}

	/**
	 * FCMトークンを登録する
	 *
	 * @param int $userId
	 * @param string $fcmToken
	 * @return array
	 */
	public function storeFcmToken(int $userId, string $fcmToken)
	{
		$device = $this->deviceRepository->findDeviceByUserId($userId);
		if ($device) {
			// ログイン中のユーザーの場合
			if ($device->fcm_token !== $fcmToken) {
				// FCMトークンが異なる場合のみ更新
				$this->deviceRepository->updateFcmToken($userId, $fcmToken);
			}
		} else {
			// 新規登録・再ログインのワーカーの場合
			$this->deviceRepository->createDevice($workerId, $fcmToken);
		}
		return [];
	}

	/**
	 * FCMトークンを連携解除する
	 *
	 * @param int $userId
	 * @return array
	 */
	public function deleteFcmToken(int $userId)
	{
		$this->deviceRepository->deleteFcmToken($userId);
		return [];
	}
}
```

```php:app/Repositories/DeviceRepository.php
<?php

namespace App\Repositories;

use App\Models\Device;

class DeviceRepository
{
	protected $model;

	public function __construct(Device $model)
	{
		$this->model = $model;
	}

	/**
     * Deviceをuser_idで検索
     *
     * @param int $userId
     * @return Builder|\Illuminate\Database\Eloquent\Model|object|null
     */
	public function findDeviceByWorkerId(int $userId)
	{
		return $this->model->where('user_id', $userId)->first();
	}

	/**
     * FCMトークンを登録する
     *
     * @param int $userId
     * @return void
     */
	public function createDevice(int $userId, string $fcmToken)
	{
		return $this->model->create([
			'user_id' => $userId,
			'fcm_token' => $fcmToken,
		]);
	}

	/**
     * FCMトークンを更新する
     *
     * @param int $userId
     * @return void
     */
	public function updateFcmToken(int $workerId, string $fcmToken)
	{
		$this->model->where('user_id', $userId)->update([
			'fcm_token' => $fcmToken,
		]);
	}

	/**
     * FCMトークンを削除する
     *
     * @param int $userId
     * @return void
     */
	public function deleteFcmToken(int $workerId)
	{
		$this->model->where('user_id', $userId)->delete();
	}

}
```

```php:app/Models/Device.php
class Device extends Model
{
	use HasFactory;
	use SerializeDate;
	protected $table = 'devices';

	protected $casts = [
		'user_id' => 'int'
	];

	protected $hidden = [
		'id',
		'user_id',
		'fcm_token'
	];

	protected $fillable = [
		'user_id',
		'fcm_token'
	];

    // userとリレーションで関連付け
	public function user()
	{
		return $this->belongsTo(User::class);
	}
}
```

これでアプリから送られてきたFCMトークンを扱う準備が整いました。
次は実際にプッシュ通知を飛ばします。

## プッシュ通知を飛ばす
### Queue用のテーブルを作成
まずはartisanコマンドでQueue用のテーブルを作成します。

```console
php artisan queue:table
php artisan migrate
```

`jobs`、`failed_jobs`の2つのテーブルが追加されます。

### Jobを作成

```console
php artisan make:job SendPushNotificationJob
```
`/app/Jobs`配下にファイルが作成されます。
プッシュ通知のタイトルはJobを呼び出す（`dispatch`する）部分で引数として渡します。

```php
<?php

namespace App\Jobs;

use App\Services\Mobile\MessagingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class SendPushNotificationJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $user_id;
    public $message_title;

    /**
     * Create a new job instance.
     * @param int $user_id
     * @param string $message_title
     */
    public function __construct(int $user_id, string $message_title)
    {
        $this->user_id = $user_id;
        $this->message_title = $message_title;
    }

    /**
     * Execute the job.
     * @param MessagingService $messagingService
     */
    public function handle(MessagingService $messagingService): void
    {
        $messagingService->sendMessage(
            $this->user_id,
            $this->message_title,
        );
    }
}
```

### Jobの中身となるプッシュ通知を飛ばす処理

ここではLaravelに追加したパッケージを使ってプッシュ通知のオブジェクトを生成したり、それを実際に送信したりします。

👇のドキュメントが参考になるかと！

https://firebase-php.readthedocs.io/en/stable/cloud-messaging.html#send-messages-to-specific-devices


```php:app/Services/MessagingService.php
<?php

namespace App\Services\Mobile;

use App\Entities\ValueObjects\Mobile\PushMessage;
use App\Repositories\DeviceRepository;
use Illuminate\Support\Facades\Log;
use Kreait\Firebase\Exception\FirebaseException;
use Kreait\Firebase\Exception\MessagingException;
use Kreait\Firebase\Contract\Messaging;
use Kreait\Firebase\Messaging\CloudMessage;
use Kreait\Firebase\Messaging\Notification;

class MessagingService
{
	protected $messaging;
	protected $deviceRepository;

	public function __construct(
		Messaging $messaging,
		DeviceRepository $deviceRepository,
		)
	{
		$this->messaging = $messaging;
		$this->deviceRepository = $deviceRepository;
	}

	/**
	 * メッセージオブジェクトを生成
	 *
	 * @param string $message_title
	 * @return PushMessage
	 */
	private function buildMessage(string $message_title): PushMessage
	{
		$message = new PushMessage(
            $message_title,
            '購入が完了しました',
        );
        return $message;
	}

	/**
	 * プッシュ通知を送信する
	 *
	 * @param int $user_id
	 * @param string $message_title
	 * @return void
	 */
	public function sendMessage(int $user_id, string $message_title)
	{
        // user_idからFCMトークンを取得
        $device = $this->deviceRepository->findDeviceByWorkerId($user_id);
		// デバイスが存在しない場合はエラーログを出力して終了
		if ($device === null) {
			Log::error("user_id {$user_id} に対応するデバイスが見つかりませんでした。");
			return;
		}

		// メッセージオブジェクトを生成
		$message = $this->buildMessage($message_title); 
		// CloudMessageオブジェクトを生成
		$cloud_message = $this->convertToCloudMessage($message, $fcm_token);

		try {
			// プッシュ通知を送信
			$this->messaging->send($cloud_message);
			Log::info("プッシュ通知を送信 sendMessage " . $user_id);
		} catch (FirebaseException | MessagingException $e) {
			// エラーでも通す
			Log::error("プッシュ通知の送信に失敗 " . $e->getMessage());
		}
	}

	/**
	 * CloudMessageオブジェクトを生成
     * @param PushMessage $pushMessage
	 * @param string $fcm_token
     * @return CloudMessage
     */
    private function convertToCloudMessage(PushMessage $pushMessage, string $fcm_token): CloudMessage
    {
        $cloud_message = CloudMessage::withTarget('token', $fcm_token)
            ->withNotification(Notification::create($pushMessage->getTitle(), $pushMessage->getBody()));
        return $cloud_message;
    }
}
```

`buildMessage`メソッドで使うPushMessageモデル

```php:app/Entities/ValueObjects/PushMessage.php
<?php

namespace App\Entities\ValueObjects;

use Illuminate\Database\Eloquent\Model;

class PushMessage extends Model
{

    /**
     * @var string
     */
    protected $body;
    /**
     * @var string
     */
    protected $title;

    public function __construct(string $title, string $body)
    {
        parent::__construct();
        $this->title = $title;
        $this->body = $body;
    }

    /**
     * @return string
     */
    public function getTitle(): string
    {
        return $this->title;
    }

    /**
     * @return string
     */
    public function getBody(): string
    {
        return $this->body;
    }
}
```

### プッシュ通知を飛ばしたい所でJOBを呼び出す

`dispatch`で呼び出します。

```php
use App\Jobs\SendPushNotificationJob;

SendPushNotificationJob::dispatch(
                    $user->id,
                    'プッシュ通知のタイトル',
                )
```

Queueの中にあるJOBは自動で実行されるわけではなく、別のプロセスであるWorker(ワーカー)が必要なので👇のコマンドで起動させましょう。

```
php artisan queue:work
```

ただ、いろいろな原因でworkerが停止になる可能性があります。そうなると新しくQueueに入るJOBが処理されなくなります。(JOB実行のためにWorkerがないので。。)
なのでSupervisorというプロセス管理システムを使うことも多いかと思うので、試してみてください！

https://qiita.com/kenta_kitagawa/items/c9144fe0e33e4e98d844

長々となりましたが、以上がざっくりとした全体像となります！
実際の仕様や開発環境などで色々と違う点もありますが、あくまで一つの参考例としてみていただけると幸いです:slight_smile: