それマグで!

知識はカップより、マグでゆっくり頂きます。 takuya_1stのブログ

習慣に早くから配慮した者は、 おそらく人生の実りも大きい。

Laravel の通知でカスタムチャネルとカスタム通知を作ってみる

Laravel の通知でカスタムチャネルとカスタム通知を作ってみる

今回の目標laravelの通知を自前で作る。

Laravelのマニュアルを読んでも、スッキリわからなかったので、一度作ってみることにした。

作ったもの

作ったものは整理して、次の場所に設置した。 https://github.com/takuya/php-laravel-slack-post

最初に知っておくこと

laravel で通知の種類(Slack/Mail/SMS)をチャンネル と呼ぶ。

laravel の通知の概要

オブジェクトに指定したチャンネル経由で、イベントの発生を通知する。

少しわかりにくいが、通知内容や通知イベントをクラス化しコードを管理できるようになっている。

「ユーザーに、新規ログインをメールで通知する」とか「ユーザーに、支払い失敗をSMSとメールで通知する」など。

イベント・通知内容によって通知先を切り替える。という運用になる。

通知するもの、通知されるもの、通知チャンネル(ドライバ)

「ユーザーに、新規ログインをメールで通知する」場合、

  • 通知される対象は User オブジェクト
  • 通知する対象は「新規ログイン」
  • 通知は「Mailチャンネル」経由。

このように、通知を3つの役割に分けて考えている。

ユーザーに通知する箇所の概念的構造。

分けておくこととで、通知が完結に記述できる。

通知オブジェクトの関係を開発者視点でコードを書くと次のようになる。

通知を送る例

<?php
$user->notify( new NewLoginDeviceDetected());

ここには、通知チャンネルは一切登場しない。

ただ、ユーザーにイベント発生を通知するんだな。とだけわかる。シンプルで素晴らしい(?)。

シンプルとはいえ、実際に通知をするのはどこでどうやってるのか全然わからない。メール・SMS・Slackなのか、通知チャンネルはコードへ登場しない。

通知するクラスは自分で作る。

例に出したNewLoginDeviceDetected クラスは、開発者が作る「通知クラス」である。artisan make:notification で作る

artisan make:notification NewLoginDeviceDetected

通知内容のクラスは過去形が読みやすい。

通知クラスは、「〇〇が変更された」「〇〇が失敗した」などイベント名にするといい感じになると思う。イベント名なので、英語動詞の過去形が相応しい。

先程の例だと NewLoginDeviceDetected ですね。

通知されるオブジェクトと、通知するイベント

通知されるイベント、通知受けるオブジェクトはそれぞれ指定のメソッドを生やす必要がある。

  • 通知するオブジェクトは use Notifiable;
  • 通知内容オブジェクトは、extends Notification

で作る。

通知受信オブジェクトの例

通知受けるオブジェクトは use Notifiable;を使う。

<?php
class User{
    use Notifiable;
}

Notifiable をつかってどのクラスにもメソッド追加できる。

interface でもいいとおもうけど、traitです。traitなので型指定ができず不便だけど我慢しましょう。

通知されるイベント(通知内容オブジェクト)の規約

通知されるオブジェクトは、extends Notification を使ってメソッドをもらってくる。こちらは継承なのでクラス型指定ができて便利ですね。

コードサンプル(Notificationをつかった通知作成例)

<?php
class NewLoginDeviceDetected extends Notification{
}

Notification を継承しておけばオッケ~です。

どのチャンネルで通知されるのか

どこ経由で通知するのか。これは、viaで指定する。

コードサンプル(Notification設定値より通知経路を指定する)

<?php
class NewLoginDeviceDetected extends Notification{
  public function via( $notifiable ):array {
    $default_available_channels = ['mail','slack','sms'];
    $channel=['slack']
    return array_intersect($channel,$default_available_channels);
  }
}

通知経路の指定は via()の関数本文内で行う。引数の$notifiable はtrait なので型指定ができない。

via()引数の $notifiableの設定から、チャンネルを絞ることもできる。 Userクラスが notifiableで渡されていてUserに設定があるならそれを使って通知経路(チャンネル)を指定することができる

コードサンプル(Userの設定値より通知経路を指定する)

<?php
class NewLoginDeviceDetected extends Notification{
  public function via( $notifiable ):array {
    $default_available_channels = ['mail','slack','sms'];
    return array_intersect(
        $default_available_channels,
        $notifiable->settings->channel
    );
  }
}

定義済みチャンネル(ドライバ)

via()がreturn しているのは String の配列です。

'main''slack' は、laravel に初期導入されてる予約名。

これをチャンネルのdriverというらしいですよ。

チャンネルにはドライバがあります。ややこしいですね。

通知経路を追加する。

Slackじゃなくて、discordがいいとか、流行りのMS Teamsがいいとか、追加した場合は。https://laravel-notification-channels.com/ にあるドライバをcomposer require すれば足せる。

自分で定義した通知経路(通知チャンネル・ドライバ)を使う場合

自作の通知経路を使うこともできる。そのときは、クラス名を名前空間付きで、returnすれば使える。Provierのような事前登録は不要。書けば動く。

<?php
class MyEventDetected extends Notification{
  public function via( $notifiable ):array {
      return [MyChannel::class];
  }
}

すでに定義済みのチャンネルで、足りる事が多いわけです。多分自分で作って使うことはないと思うのですが。

通知内容の文字列を整形(フォーマット)

通知されるオブジェクトと通知するオブジェクトの相互関係は少しわかった。

それでは、通知メッセージはどこでフォーマットされるのか。通知メッセージは、イベント名で作った通知オブジェクトが知っているという前提。

コードサンプル

toSlacke['slack'] に対応したテンプレートフォーマット。

<?php
    
class NewLoginDeviceDetected extends Notification{
  public function toSlack( $notifiable ){
    $message = new SlackMessage();
    $message
      ->from('通知ボット')
      ->to('#通知テスト用')
      ->content("新規ログインがありました");
    return $message;
  }
    
}

ドライバ名(mail/slack)と対応したメソッド(toMail/toSlack)と、それに対応したメッセージオブジェクト(MailMessage/SlackMessage)を使って通知の内容と本文を作成する。

通知内容のクラス読んでも、to${DriverName}() の名前は予測不可能だし、引数の $notifiable(通知を受けるオブジェクト)の型も予測不可能である。 何が来るか全く予想不能で少し使いづらそう。このあたり、今後のlaravel側の改善を期待したい。

通知チャンネルの具体的指定

メールやSMSのように宛先アドレスが1つしかない場合は、コードがシンプルで良き。

Slackにメッセージを投げる例

しかし、Slackのどのチャットルームにメッセージを投げるのか、など細かい指定はどこでやるのかパット見で分かりづらい。

通知を細かく設定する工夫

ここまで、見た来た結果、通知内容と通知経路は1対1で管理したほうがコードの見通しがよく、via で複数のチャンネル名を返却するのは本当に同じものを通知するとき以外は避けたほうが良さそう。

HTMLにするなど、整形が挟まるときは別々にしたほうが管理が楽になるでしょう。通知オブジェクトに細かい指定を作るのが良さそう

<?php
    
class SlackMyRoomNotification extends Notification {
    
}
class SendtoAdminMailNotification extends Notification {
}
interface toSlack {
    public function toSlack($notifiable);
}
interface toMail {
    public function toMail($notifiable);
}
class NewLoginDetectedToSlack extends SlackMyRoomNotification  implements toSlack {
    
}
class NewLoginDetectedToMail extends SendtoAdminMailNotification implements toMail {
    
}

「型」にこだわる必要はないと思うのですが。PHPだしある程度の型に関する寛容性は許しても良さそうなんだけど。

開発環境のツールでメソッド補完をしたいとか、引数の型チェックをしたいとか細かい要望を入れていると本当に難しい。

通知チャンネルの自作の懸念点

ここまで、いろいろ見てきたが 自作通知チャンネルに関するいくつかの懸念点が出てくる

  • toSlackでフォーマット時に$notifiableの設定を使いづらい。
  • 通知されるオブジェクトがないとき、どうするのか。
  • 自作の通知経路はinterfaceもtraitもないので、型指定が不便。
  • toSlack/toMailのようなフォーマットの型指定が不便。

なので、Laravel提供のドライバに併せてフォーマット範囲と定数を指定する必要がある。

自作で通知チャンネルを追加すると、汎用性は捨てて、作り捨てになる感じなりそうです。

自作で通知チャンネルを作ってみる。

自作通知チャンネルを作ってみる。

laravel デフォルトの slack 通知チャンネルは、Webhookを使うので、webhook の管理が煩雑になるので、個人のAPIキーをつかってBot作って投稿したい。

自作通知チャンネルで用意したクラス。

通知を作るのに、クラスが4つ必要ですね。ちょっとした通知なのに大変だ。

  • class SlackApiPostMessage / Slack API を叩いてメッセージを送るクラス
  • class SlackRoom / use Notifiable; された「通知を受け取るオブジェクト」
  • class SampleNotification/ extends Notification された通知内容のオブジェクト
  • class SlackApiChannel / send を実装した、通知するチャンネルオブジェクト
  • class TestSlackMessage / extends Command して artisan から実行

slack API の準備

APIトークンを取得

Slackでは、APIトークンを用いて、とてもかんたんにメッセージを取得、メッセージの投稿ができる仕組みがある。

webhook に比べてTOKENのほうが管理が楽なので私はこちらが好きなのです。

また、Slackの投稿は、Curlでぱぱっとできるので、投稿用URLを作っておけば再利用もかんたんです。

laravel なのでcurkを直接使わずに、Guzzleを使います。

slack の api トークンを取得して、configへ

https://api.slack.com/apps/
  • 自分のSlackのAppのWebページを開く
  • Create an app → full scratch → 名前とワークスペースを決める、
  • app を作ったら、Botを選ぶ。
  • App Homeから Botユーザ名を決める。
  • Bot に Scope を追加
  • Scope は chat:writeまたはchat:write.customize
  • スコープ追加したら、Workspaceにインストール
  • 最後に、書込み可能な、Tokenを作る。

TOKEを設定に保存

laravel の設定は、.env から取り出すのが流儀らしい。

config/slack.php

return [
  'token'=>env('SLACK_TOKEN'),
];

TOKENを環境変数に追加

.env に環境変数として追加する。

.env

SLACK_TOKEN=xoxb-123456-9876543-0heWmS

設定を再キャッシュする

artisan cache:clear

Slack の投稿をテストする。

投稿を送信するのに、わざわざサーバーボタンを作るのは煩わしいので、コマンドから叩く。

artisan make:command SampleSlackMessage

SampleSlackMessage.php

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class SampleSlackMessage extends Command {
  
  protected $signature = 'test:slack_token';
  
  /**
   * Execute the console command.
   * @return int
   */
  public function handle() {
    $cli = new \GuzzleHttp\Client();
    $res = $cli->request(
      "POST",
      'https://slack.com/api/chat.postMessage',
      [
        'form_params'     => [
          'token'   => config('slack.token'),
          'channel' => '#通知テスト用',
          'text'    => '書き込みテスト',
        ],
        'allow_redirects' => false,
      ]);
    $ret = $res->getBody()->getContents();
    
    return true;
    
    return 0;
  }
}

チャットルームに送信してみる。

artisan test:slack_token

無事に投稿できたらSlackのトークンは準備完了です。

SlackAPIを叩く汎用をクラスを作る。

tokenが無事に作れたので、SlackAPIを呼び出すクラスを作っておきます。

mkdir -p app/Services/Slack
touch -p app/Services/Slack/SlackApiPostMessage.php

SlackApiPostMessage.php

<?php
namespace App\Services\Slack;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Notifications\Messages\SlackMessage;

class SlackApiPostMessage {
  
  /**
   * @var string
   */
  protected $endpoint;
  /**
   * @var \Illuminate\Config\Repository|\Illuminate\Contracts\Foundation\Application|mixed
   */
  protected $token;
  /**
   * @var string
   */
  protected $channel;
  /**
   * @var string
   */
  protected $content;
  
  /** @var SlackMessage */
  protected $message;
  
  public function __construct() {
    $this->endpoint = 'https://slack.com/api/chat.postMessage';
    $this->token = config('slack.token');
  }
  
  public function content( string $message ) {
    $this->content = $message;
    
    return $this;
  }
  
  public function to( string $channel ) {
    $this->channel = $channel;
    
    return $this;
  }
  
  public function send() {
    $this->send_to_api();
    
    return $this;
  }
  
  protected function send_to_api() {
    
    $params = $this->jsonBuilder();
    try {
      $cli = new Client();
      $res = $cli->request(
        "POST",
        $this->endpoint,
        [
          'form_params'     => $params,
          'allow_redirects' => false,
        ]);
      $ret = $res->getBody()->getContents();
      return true;
    } catch (ClientException $e) {
      return false;
    }
  }
  
  protected function jsonBuilder(){
    $params = $this->message_to_paramters();
    $params = array_merge($params,array_filter([
      'token'=>$this->token,
      'channel' => $this->channel,
      'text'    => $this->content,
    ]));
    return $params;
  }
  protected function message_to_paramters() {
    $message = $this->message;
    $optionalFields = array_filter(
      [
        'channel'      => data_get($message, 'channel'),
        'icon_emoji'   => data_get($message, 'icon'),
        'icon_url'     => data_get($message, 'image'),
        'link_names'   => data_get($message, 'linkNames'),
        'unfurl_links' => data_get($message, 'unfurlLinks'),
        'unfurl_media' => data_get($message, 'unfurlMedia'),
        'username'     => data_get($message, 'username'),
      ]);
  
    return array_merge([
        'text' => $message->content,
        //'attachments' => $this->attachments($message),
      ],
      $optionalFields);
  
  }
  
  public function setMessage( SlackMessage $message ) {
    $this->message = $message;
  }
}

通知チャンネルをつくる

ここからが大変な、通知チャンネルの作成ですね。TOKENとSlack投稿がわかっただけで、まだ何もクラスを作ってません・・・

  • class SlackApiPostMessage / Slack API を叩いてメッセージを送るクラス
  • class SlackRoom / use Notifiable; された「通知を受け取るオブジェクト」
  • class SampleNotification/ extends Notification された通知内容のオブジェクト
  • class SlackApiChannel / send を実装した、通知するチャンネルオブジェクト
  • class TestSlackMessage / extends Command して artisan から実行

通知を送る箇所を作っておく

通知を、実際に送る箇所はこの様になる。

$room = new SlackRoom('#通知テスト用');
$room->notify(new SampleNotification('通知内容メッセージとか'));

通知を送る箇所を、コマンドとして作る

artisan make:command TestSlackMessage

TestSlackMessage

class TestSlackMessage extends Command {
  protected $signature = 'test:slack_notification';
  public function handle () {
    $room = new SlackRoom('#通知テスト用');
    $room->notify(new SampleNotification('通知内容メッセージとか'));
    
    return 0;
  }
}

通知を受け取るクラスを作る

通知を受け取るクラスは、UserなどEloquent Model にすることが多いと思うが、今回は汎用性を考えて、SlackRoomという名前にして、Slackのチャットルームが通知を受け取るという流れになるようなクラスの相互関係の設計とする。

artisan make:model '\App\Models\SlackRoom'

SlackRoom.php

class SlackRoom {
  
  use Notifiable;
  
  /** @var string */
  protected $channel_name;
  
  /**
   * SlackRoom constructor.
   * @param $channelName_in_slack
   */
  public function __construct( $channelName_in_slack ) {
    $this->channel_name = $channelName_in_slack;
  }
  
  /**
   * @return string
   */
  public function getChannelName():string {
    return $this->channel_name;
  }
}

通知チャンネルを作る

mkdir -p app/Channels 
touch -p app/Channels/SlackApiChannel.php

SlackApiChannel.php

namespace App\Channels;

use Illuminate\Notifications\Notification;
use \App\Services\Slack\SlackApiPostMessage;

class SlackApiChannel {
  
  public function send( $notifiable, Notification $notification ) {
    
    
    $formatter = 'toSlack';
    $cli->content('ここはメッセージ')
      ->to('#通知テスト用')
      ->send();
    $cli->send();
    
  }
}

通知内容クラスを作る

通知されるオブジェクトに対して、通知される内容を示すオブジェクトを作る

artisan make:notification SampleNotification

SampleNotification.php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;

class SampleNotification extends Notification {
  
  use Queueable;
  public function via( $notifiable ):array {
    return [SlackApiChannel::class];
  }
}

通知する。

すべてができたら、 notification /notifiable を使って通知する。

artisan test:slack_notification

全体の流れ

  • SlackRoom クラスが notify する
  • notify されるのは、 SampleNotification クラス
  • SampleNotification が SlackChannel クラスを返す。
  • SlackChannel クラスの send が実行される
  • send 中で、SlackAPIを呼び出す。

この間に受け渡されるのはSampleNotificationインスタンスです。

SampleNotification内部で、SampleNotification自身がSlack用・Mail用に自分のToStrngを用意しておく、しかし、自作の場合は、toXXXは存在せず呼び出されない。

メリットは?

コードがスッキルする。Notificationをクラス別につくれば、再利用しやすい。

逆に、通知経路が一つしかなく、通知するべき内容もかぎられているのであれば、そこまで無理して使う必要はないかもしれない。