KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

PDF生成をバックエンドに移行する

こちらの記事は カケハシ Advent Calendar 2024 の 20日目の記事になります。

adventar.org

こんにちは、カケハシのAI在庫管理チームでフロントエンドエンジニアをしている Nokogiri です。
AI在庫では薬局で利用する伝票のPDFをフロントエンドで出力していたのですが、バックエンドに移行しました。今回はその背景や取り組み内容について紹介します。

はじめに:なぜフロントエンドからバックエンドへPDF生成を移行するのか

PDFをフロントエンドで出力するといくつかの問題があります。

  • サービスとしてユーザーに発行したPDFを保存しているべきだがフロントエンドでPDFを生成していると保存することができない
  • カケハシ側で出力されたPDFを確認することができないので、実際に出力されたPDFの解析ができない
  • フロントエンドでPDF生成に利用している pdf-lib というライブラリが絶対位置指定で要素を配置するのでレイアウトを変更する実装が難しい
  • PDF生成がユーザーの端末のスペックに依存するので処理に時間がかかることがある

以上の理由から、かねてよりPDF生成をバックエンド化したいというモチベーションがあり、今回それを実施しました。

バックエンド移行の全体像:ジョブキューとS3活用による非同期化

移行後のバックエンドでのアーキテクチャを画像にまとめると以下の通りです。

バックエンド移行の全体像

  • LambdaがブラウザからのPDF作成依頼を受け取ると必要な情報をDBから受け取りAmazon SQSに対してメッセージを送信します。
  • Amazon SQSにmessageが溜まると、別のLambdaを起動し puppeteer を利用してPDFを作成し、S3 にPDFを保存します。
  • ブラウザではPDF作成依頼とは別に、PDFをダウンロードするためのリンクをS3から取得します。このときまだPDFが作成されていなければURLがnullになるようにし、フロントエンドでPDFが生成される(URLがnullでなくなる)までポーリングを行います。

なぜ Amazon SQS の仕組みを導入したか?

事前にチームで以下のメリットとデメリットを話し合い、結果としてメリットが上回るためAmazon SQSの仕組みを導入しました。

メリット

  • 元々伝票の作成は入庫や出庫などの処理とセットで呼ばれることが多く、PDF作成処理と同時に行うとコードの結合度が高くなってしまう可能性がありました。Amazon SQSを挟むことでコードの結合度を下げる狙いがあります。
  • PDF作成にはそれなりに処理時間がかかりますが、Queueを導入することで安定して処理することができ、apiのタイムアウトを気にする必要がなくなります。
  • 出庫処理とPDF生成の間に Queue を挟むことで、仮に PDF 生成にトラブルが発生しても、よりコアな処理である出庫処理はその影響を受けずに済みます。
  • Amazon SQSによる順序の保証や、自動再実行もできます。

デメリット

  • フロントエンドでポーリングが発生することによる実装が複雑になります。
  • ポーリングが発生することでの同期実行時より処理が遅くなる恐れがあります。

PDF生成にpuppeteerを利用する

puppeteerにはpdf-generation という機能があり、pageからPDFを作成する機能があります。
フロントエンドで利用してたpdf-libは座標位置を指定してレイアウトを作成していましたが、この機能を使うことで page 側は実質Webページを作る技術であればどのような方法でも採用できます。

今回は一般的によく使われる技術であり、チームメンバーにもなじみがあるReact TypeScript Tailwind CSSを利用してpageを作成することにしました。

具体的には以下のような方法で実装できます。
コンポーネントから静的なマークアップ文字列を作成するには renderToStaticMarkup を利用します。

import { renderToStaticMarkup } from 'react-dom/server';
const html: string = renderToStaticMarkup(<SomeComponents />);

puppeteerのPDF作成処理を利用してPDFを生成します。

const browser = await puppeteer.launch({...});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
await page.addStyleTag({ path: path.resolve(__dirname, './styles.css') });
const pdf = await page.pdf({
    format: 'A4',
    scale: SCALE,
});

フロントエンドのポーリングの仕組み

以下の画像は実際にAI在庫でPDFを出力する場面です。

ポーリングの仕組み

  • 確定ボタンを押下するとダイアログを閉じ、「伝票ダウンロード中」というアラートが表示されます。
  • ダウンロードが完了するとアラートを閉じます。またブラウザ側でダウンロードが完了したことがわかるようになっています。

ダイアログの確定処理とPDFのダウンロードポーリング処理は疎結合に実装されています。
ダイアログの確定処理とPDFの作成処理は連続的に実行されますが、処理結果に依存関係がないためです。

また出庫の確定と伝票のダウンロードは関心が異なるため実装上も分離したいという意図がありました。

ボタン押下時の処理

const downloadPdf = useDownloadPdf();
const submit = async () => {
    await mutation({
      onCompleted: async (d) => {
        handleSuccess(d);
        // ポーリングしていることはボタン押下時からは隠蔽されている
        // シンプルにダウンロードを開始する関数を呼ぶだけ
        downloadPdf({ pdfId: xxx });
      },
    });
  };

ポーリング側の処理

export function usePdfDownloadPolling() {
  const shouldPolling = useShouldPolling; // downloadPdf が呼ばれたかを検知している
  useQuery(queryDoc, {
    notifyOnNetworkStatusChange: true,
    skip: !shouldPolling,
    pollInterval: shouldPolling ? 1000 : 0,
    onCompleted: async () => {
        // ポーリングを終了するかどうかの判定をしながらPDFをダウンロードする
    },
    onError: handleError,
  });
}

まとめ

今回の取り組みでは、PDF生成をフロントエンドからバックエンドへ移行することによって、以下のような改善を実現しました。

  • フロントエンドの負荷軽減とユーザー環境依存の解消:
    従来はユーザー端末側でPDFを生成していたため、処理時間がユーザー環境に左右されていました。バックエンドで一元的に生成することで、処理が安定し、端末依存から解放されます。
  • 開発・運用側での可観測性向上:
    バックエンド側でPDFを生成することで、生成結果を直接確認可能になり、問題発生時の解析・改善が容易になりました。
  • 柔軟なレイアウト設計と開発体験向上:
    pdf-libによる座標指定から脱却し、puppeteer + React + Tailwind CSSといった馴染みある技術スタックで、直感的なレイアウト設計が可能になりました。
  • ジョブキュー(SQS)による非同期化と結合度低減:
    SQSを用いた非同期処理により、コードの結合度を下げつつ長時間処理にも対応。バックエンドでPDF生成中、フロントエンドはポーリングで状態を監視し、準備完了後にS3からのダウンロードリンクを取得する仕組みが実現しました。
  • UXの改善と疎結合な実装:
    ポーリングによる進捗表示で、ユーザーは待機状態を把握でき、ストレスが軽減されます。出庫確定とPDFダウンロード処理を疎結合化したことで、保守性・拡張性も向上しました。

総じて、フロントエンド主導のPDF生成からバックエンド主導へと移行することで、可観測性、パフォーマンス、開発・運用のしやすさ、そしてユーザー体験が大きく改善されました。今後はさらなる最適化や新機能追加にも柔軟に対応可能な基盤が整ったといえます。

この記事が、バックエンドでのPDF生成処理について紹介しました。今後バックエンドでPDFを作成する人の参考になれば幸いです!

カケハシでは一緒に働いてくれるエンジニアを募集しています!
最後まで読んでいただきありがとうございました!