😺

LaravelでのPDF生成機能を、サービスコンテナを活用して疎結合に実装する

2021/03/12に公開

概要

小ネタです。

先日、領収書を PDF でユーザーがいつでも発行できる管理画面の機能をリリースしたのですが、
施策に入る前はさっぱりどうやって実装したらいいか分からなかったので、備忘を兼ねてメモ。

最終的には Interface を使うことで、実装方法にアプリケーション層が依存しないように組んでみたので、そこまで書き留めておきます。

TL;DR

  • PHP で提供されるパッケージを使う方法が最もシンプル
  • いち機能のために PDF 関連のライブラリを入れたいわけではないので、AWS Lambda で実装できる方法を探した
  • https://github.com/zeplin/zeplin-html-to-pdf を使って Lambda 関数を作成し、PHP から HTML を投げて動作させた
  • 実装方法が最高の手段だと自信が持てないときこそ、Interface を使って疎結合に実装するのが大事

前提条件

  • フロントは SPA
  • バックエンドで Laravel を使っている
  • 決済機能だけではなく、多くの機能がバックエンドには実装されている
  • 領収書発行機能の利用頻度は稀
  • インフラは AWS を使っており、AWS SDK は Laravel 上から既に使ったことがある(IAM ユーザーの割当がされている)

PHP で提供されるパッケージを使う

以下の記事に書いてあるように、TCPDF というパッケージを使えば PDF 出力ができる。

https://qiita.com/haruna-nagayoshi/items/85c172cf7c0a68e0c092

mPDF というパッケージもあるらしい。

https://gray-code.com/php/output-to-pdf-file-by-mpdf-libraries/

Pros

  • パッケージをインストールするだけで使える

Cons

  • 個人的な感覚の問題だが、ほぼこの機能でしか使わないライブラリをあまり入れたくない
  • 記事によると、HTML を PDF に変換できる機能はあるが、場合によっては上手く動かず座標で指定して書き込むことになるらしい。それは面倒だ

https://github.com/zeplin/zeplin-html-to-pdf を使う

こちらのライブラリは、このまま Clone してきて権限を持った IAM ユーザーを設定の上、deploy コマンドを叩けば Lambda にデプロイできるというもの。

Interface としては、HTML を投げると、Base64 エンコードされた PDF が返ってくるという仕様です。

Pros

  • Lambda なので、今後 Laravel 以外から利用したい用途が生まれても使い回すことができる
  • 単体でのテストが簡単。Lambda は HTML を投げたら Base64 が返ってきたら OK、Laravel は HTML を仕様に沿って生成できたら OK

Cons

  • (特に工夫しなければ)別リポジトリ、別デプロイになるので管理が煩雑になる
  • Lambda はコールドスタートの問題があり、時間を開けて実行すると起動が遅いことがある(課金すれば解消できる)

ということで、微妙なところでしたが今回は後者の Lambda を使ったアプローチを取ることにしました。

ちなみに、もしこのライブラリを使う場合は、UTF-8 に対応するように少し書き換える必要があったのでご注意ください。

実装方法

final class PublishPaidHistoryReceipt
{
    public function handle(PaidHistoryId $historyId) {
        $paidHistory = $this->repository->find($historyId);

        // データを元にHTMLを組み立てる
        $html = view('pdf.payment.paid_history_receipt')->with([
            'receiptNumber' => $this->publishReceiptNumber->execute($historyId),
            'paidDateString' => $paidHistory->getCreatedAt()->format('Y年m月d日'),
            'price' => number_format($paidHistory->getUserPaid()->value()),
            // 以下略
        ])->render();

        $base64 = $this->convertHtmlToPDF->execute($html);
        // 以下略
    }

html の先生は blade ファイルに対して view ヘルパを使う

HTML を手入力しつつ変数を埋め込むのは現実的ではないため、blade に書いて render で吐き出すようにしました。

Lambda の呼び出しは Interface を経由して疎結合に実装する

Lambda の呼び出し処理自体は、ConvertHtmlToBase64PDFInterfaceという Interface の実装クラスに対して書きました。
こうすることで、いずれ Lambda 以外で変換処理することになっても影響範囲を最低限度に抑えることができます。

前述したとおり、Lambda の選定が 100 点満点とは言い切れないので、今後変更することも加味したというところです。

AppServiceProviderで実装クラスに Bind しています。

        $this->app->bind(ConvertHtmlToBase64PDFInterface::class, AwsLambdaConvertHtmlToBase64PDF::class);
interface ConvertHtmlToBase64PDFInterface
{
    public function execute(string $html): string;
}

いずれ TCPDF 等の PHP のライブラリに戻したのであれば、TCPDFConvertHtmlToBase64PDF的な命名の実装クラスを生やし、Bind 先を切り替えれば対応は終わりになります。

テストコード

以下のように、正しい HTML が渡ったことを Mock を使ってテストします。HTML の生成部分ももしかしたら Interface として切り出しても良かった気がします。

$html = view('pdf.payment.paid_history_receipt')->with([
    // 省略
])->render();
$mock = \Mockery::mock(ConvertHtmlToBase64PDFInterface::class);
$mock->shouldReceive('execute')->withArgs(function (string $argHtml) use ($html) {
    return $argHtml === $html;
})->once();
$this->app->instance(ConvertHtmlToBase64PDFInterface::class, $mock);

まとめ

  • Laravel 上で動作する PDF 生成機能を実装した
  • HTML 生成までは Blade でやることが見えていたが、肝心の HTML to PDF な実装の方法が 1 択ではなかった
  • そのため、その部分を Interface を用いて疎結合に実装し、何かあった時に容易に切替可能な状態で実装した
  • テストコードも簡単に書くことができた

以上です。

Discussion