[Laravel]DIを利用したメールプレビュー機能の実装


はじめに

今回はLaravelにおいて、メールのプレビュー機能を実装する方法について紹介します。
Laravelでは、メールクラスのインスタンスが生成できれば、工夫せずともWebページとして表示でき、プレビューすることができます。
この記事では、そもそも「どうやってメールの一覧を取得するのか」、「どうやってメールをインスタンス化するのか」について掘り下げて紹介します。

「メールのプレビュー機能を作ると、新しいメールを追加するたびに面倒なコード追加が必要なのでは?」という懸念が浮かぶかと思いますが、
今回の手法は、メールを追加するごとに生じるメールプレビューのためのコード追加を最小限にし、メールの開発体験をなるべく妨げないように考慮しています。

メールのプレビュー機能があると何が嬉しいか?

Webサービスでは会員登録時や各種通知、メールマガジンなど様々なメールを扱うケースがあります。
たとえば、弊社のプロダクトでは100種類以上のメールを扱っています。
メールのプレビュー機能がなければ、開発環境などで実際にメールを送信する必要があります。メールの発生条件が複雑であったり、メールに必要な前提データを作るのが難しい場合があるので、メールを送信するのは手間のかかる作業です。
メールのプレビュー機能があれば、開発時にPdMへ内容の確認を行ったり、営業などの他部署から内容の問い合わせを受けたときに、すぐにメールの内容を共有することができます。

どんな機能か?

メールプレビューの一覧画面

メールのプレビュー画面

実装方法

ファイル探索とリフレクションを利用したメールクラスリストの取得

この章では、メールのクラスをファイル探索とリフレクションを利用して取得する方法について紹介します。
ファイル探索とリフレクションを利用することで、新しいメールを追加した時に、いちいちメールのプレビュー一覧にメールクラスを追加しなくてもよくなります。

すべてのコードを貼ると長くなってしまうので、肝となるメソッドのみ記載します。

    // 特定のディレクトリからphpファイルを再起的に探し出しパスのリストを取得する
    private function collectMailFiles(string $path): array
    {
        $list = array();
        $files = glob($path . '/*');

        foreach ($files as $file) {
            if (is_file($file)) {
                $path = str_replace(base_path(), '', dirname($file));
                $namespace = ucfirst(substr(str_replace('/', '\\',$path), 1));

                if (pathinfo($file)['extension'] !== 'php') {
                    continue;
                }

                $list[] = $namespace . '\\' . pathinfo($file)['filename'];
            }
            if (is_dir($file)) {
                $list = array_merge($list, $this->collectMailFiles($file));
            }
        }

        return $list;
    }
    // リフレクションを利用してMailableを継承したクラス(=メール)であるか判定する
    private function isMailable(string $file, bool $isParent = false): bool
    {
        $fileData = new \ReflectionClass($file);

        if (!$fileData->getParentClass()) {
            return false;
        }

        if (!$fileData->isInstantiable() && !$isParent) {
            return false;
        }

        if ($fileData->getParentClass()->getName() === 'Illuminate\Mail\Mailable') {
            return true;
        } else {
            return $this->isMailable($fileData->getParentClass()->getName(), true);
        }
    }

ファイルを探索するので処理に少し時間がかかってしまうかもしれません。その場合はコマンド化したり、キャッシュに載せるなど工夫が必要です。

DIを利用したメールのインスタンス化

プレビューできるメールとできないメールについて

今回紹介する方法では、プレビューできるメールとプレビューできないメールというのが生じます。
メールのプレビューするには、メールクラスをインスタンス化する必要があります。
しかし、メールのインスタンスは簡単に作ることはできません。 なぜなら、メールは送信先が可変であったり、本文が可変であるものがほとんどであり、可変な部分はインスタンス生成の引数として渡してあげる必要があるためです。

use Illuminate\Mail\Mailable;

// メールのインスタンス化に引数が必要なケースの例
class HogeMail extends Mailable
{
    private User $user;

    // 例として引数にUserを渡している。インスタンス化するにはUserのインスタンスが必要。
    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function build()
    {
        return $this
            ->subject( $user->getName() . "さんこんにちは!") // 対象のユーザによってタイトルを変える
            ->to($user->getEmail())  // 対象のユーザによって送信先を変える
            ->markdown('emails.hoge_mail');  // 対象のユーザによって本文を変える (Markdownの外部ファイルにて記述)
    }
}

そこで、DIを行うことでメールに必要な引数についてダミーデータを挿入し、メールのインスタンス化を行います。
全てのメールの全ての引数についてDIを行うことができれば、全てのメールについてプレビューができます。
しかし、全てのDIを行うのは大変な作業です。今回の方法では、段階的にDIを追加することができるので、よく使われる引数や、特にプレビューしたいメールの引数のみDIの処理を追加することがきます。
そのため、全ての引数がDIにより注入されるメールのみプレビューでき、まだ全ての引数がDIにより注入されてないメールはプレビューできないということになります。

プレビューできるかの判定

プレビューできるかできないかは、実際にインスタンス化とbuildを試し、インスタンス化できたら「プレビュー可能」、例外が発生したら「プレビュー不可」と判定します。


    public static function canPreview(string $classPath): bool
    {
        try {
            // メールクラスのインスタンス化を試みる
            $mailable = app()->make($classPath);
            // buildメソッドを呼べるかも確かめる
            $mailPreview = app()->call([$mailable, 'build'])
            return true;
        } catch (BindingResolutionException $exception) {
            return false;
        } 
    }

メールのインスタンス化とビルド

メールのインスタンス化に必要なDIを行う方法を紹介します。
DIは引数のクラス単位でどんなインスタンスを渡すか指定できるので、同じクラスの引数を使っているメールであれば、DIを追加しなくてもインスタンス化することができます。

Mailableクラスはインスタンス化し、build()メソッドが実行されることでメールとして完成します。
buildメソッドにも引数を設定できるので、そのためContractorの引数だけでなく、buildメソッドで必要な引数も合わせてDIしてあげる必要があります。

以下のようなDI用のクラスを作り、コントローラから呼び出します。


class MailPreviewBinder
{
    public static function mailPreviewBinder()
    {
        // メールのConstructorとbuildメソッドの引数についてDIするためのバインド設定を書く
        app()->bind(Seller::class, function () {
            // ダミーのインスタンスを作るメソッドを用意しておくと便利。
            return SellerFactory::makeDummy();
        });

        app()->bind(SellerId::class, function () {
            return new SellerId(1);
        });
        // 以下略
   }
} 

メールのプレビューを実装する

LaravelではMailableインスタンスをコントローラーから返却することでHTMLとしていい感じにプレビューを表示してくれます。
コントローラではMailPreviewBinderを呼び出したのち、メールのクラスについてインスタンス化し返却します。


class PreviewController extends Controller
{
    public function __construct()
    {
        // MailPreviewBinderを呼ぶ
        // 記憶が定かではないが、Middlewareとして呼び出さないとDIがうまく動かなかったためこのようにと思う
        $this->middleware(function (Request $request, $next) {
            MailPreviewBinder::mailPreviewBinder();
            return $next($request);
        });
    }

    public function detail($mailClass)
    {
        return app()->make($mailClass);
    }
}

補足

  • 弊社のプロダクトでは、UnitTestようにダミーインスタンスを生成するメソッドをすでに作っていたので、簡単にダミーインスタンスを差し込むことができました。
  • 同じクラスでもメールによって違うインスタンスを渡したい場合は、MailPreviewBinderにメールのクラス名を渡し、MailPreviewBinder内でバインド処理を分岐すると良いでしょう。

プレビューできるメールの一覧を表示する

メールクラスのリストをもとにそれぞれのメールについてプレビュー可能か判定を行なって表示を行います。
プレビューできるメールについてはプレビューボタンを用意し、プレビュー画面に遷移できるようにしましょう。
一般的な処理なのでコードは割愛します。

まとめ

今回はLaravelでのメールプレビュー機能の実装方法を紹介しました。
ファイル探索とリフレクション、DIを活用することで、メールを追加するごとに生じるメールプレビューのためのコード追加を最小限にし、メールの開発体験を妨げないメールプレビュー機能を実現できました。

実際のプロダクトでは、メールの説明文を追加する機能や、テストメール送信機能もありますが、今回は割愛しました。

以上、ご参考になりましたら幸いです。