こんにちは!株式会社ゼストでエンジニアをしている山下です。
弊社が提供している在宅医療・介護の収益改善プラットフォーム「ZEST」では、2025年1月に 利用者配布用カレンダーの自動作成&印刷機能 をリリースしました🎉
この機能は、ご利用者様の1ヶ月間の予定をカレンダー形式にして、ExcelもしくはPDFファイルにして出力できる機能になります。 かねてよりご要望いただいていた機能だったこともあり、リリース後はお客様から大変ご好評いただいております。
そんな印刷機能、ExcelやPDFと聞くと身近なファイル形式ですが、開発の裏ではいくつかの落とし穴がありました。 今回はこの印刷機能の開発を通して得られた知見を紹介します。
ExcelファイルやPDFファイルの生成・出力機能を作成される際に参考になれば幸いです。
印刷機能の仕組み
まず初めに印刷機能の仕組みについて簡単に説明します。
印刷機能はバッチジョブを行うCloud Run Jobs上のコンテナで稼働しています。 リクエストに応じてDBから予定データを取得し、ExcelファイルもしくはPDFファイルにカレンダー形式で予定情報を埋め込み、ファイルに出力して保存しています。 カレンダーはご利用者1人あたり1枚で、一度に複数名の出力も可能です。
ExcelファイルもPDFファイルも同じようなデザインですが、データを埋め込む処理は別々のロジックが動いています。
なお、弊社はFull-Stack TypeScriptを採用しています。そのため以下のExcel・PDFの生成はどちらも全てTypeScriptで処理しています。
Excelファイルの生成
事前にテンプレートとなるExcelファイルを作成して、そこに日付やご利用者情報や予定データを埋め込んでいきます。 1日の予定が多い場合はエクセルの行を増やしてカレンダーのセルが大きくなるようにしています。
PDFファイルの生成
一方PDFでは、HTMLのWebページを作成してからPDFファイルとして出力しています。 このWebページに、Excelと同じデザイン・スタイルのカレンダーを作成しています。
なぜ一度HTMLにしているのかというと、PDFファイルを生成するには一番の近道だったからです。詳しくは後述します。
ファイル生成の落とし穴
その1. TypeScriptでExcelファイルを編集できるライブラリが少ない
Excelを編集できるnpmライブラリを探したところ、いくつか候補が上がりました。*1*2
しかしいざ比較してみるとライブラリごとに機能の過不足があることや、長い間メンテナンスされていないライブラリもあることがわかります。
Name | 最終更新日 | TypeScript | 読み込み | 書き込み | スタイル設定 | スタイル保持 | 画像挿入 | セルコメント |
---|---|---|---|---|---|---|---|---|
xlsx | 2022-03-25 | ◯ | ◯ | ◯ | ◯ | |||
xlsx-js-style | 2022-04-05 | ◯ | ◯ | ◯ | ◯ | ◯ | ||
node-xlsx | 2024-04-15 | ◯ | ◯ | ◯ | ||||
ExcelJS | 2023-10-20 | ◯ | ◯ | ◯ | ◯ | ◯ | ◯ | ◯ |
excel4node | 2023-05-03 | ◯ | ◯ | ◯ | ◯ | |||
officegen | 2021-03-06 | ◯ | ||||||
xlsx-populate | 2020-03-02 | ◯ | ◯ | ◯ | ◯ |
プロダクトの機能として提供するので、できるだけメンテナンスされておりかつ必要十分な機能を有しているライブラリを選択したいところです。
今回の印刷機能では、
- TypeScriptで型安全に開発できること
- 文字のボールドや予定の色など、スタイルを設定できること
- テンプレートを使いたいので、ファイルの読み込みとスタイルの維持ができること
以上が要件となるため、それに対応できるライブラリとなると ExcelJS しか選択肢に残らなくなってしまいました。
ちなみに各ライブラリのDL数も確認してみると、意外とWeeklyベースでも100万〜200万超えでダウンロードされるほど人気のものもあります。 つまるところ、Excelを編集できるライブラリの需要はあるけれど、どれもあまりメンテナンスできていないという状況のようです。。
その2. 余計なセルは削除しよう
冒頭で解説したように、今回の機能ではテンプレートとなるExcelファイルを使っています。 このテンプレートになるExcelファイルを編集する際に気をつけなければならないのが、余計なセルが残ったまま保存してはいけないということです。 余計なセルというのは、以下のGifのように不必要な範囲までセルが作成された状態を指します。
なぜ余計なセルが残っていると良くないかというと、パフォーマンスに影響するからです。
Excelの出力では1つのシートに1人のご利用者が出力される仕様になっています。 そのため出力する利用者ごとにテンプレートのシートをコピーしていくのですが、この時余計なセルが残っていると無駄にコピーの処理が行われます。 もちろん範囲を指定してコピーすればその限りではないですが、シート丸ごとコピーする場合はここに時間がかかってしまいます。
実際に今回の印刷機能で負荷試験を行った際には、以下のような違いが発生しました。
Cloud Run Jobsの構成 | 実行時間 | 結果 | エラー内容 | |
---|---|---|---|---|
余計なセル有 | 2CPU 8GiB | 04:09 | ❌ | FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory |
余計なセル有 | 4CPU 16GiB | 20:08 | ❌ | Killed |
余計なセル無 | 1CPU 4GiB | 01:49 | ✅ | |
余計なセル無 | 2CPU 8GiB | 01:48 | ✅ |
どちらも実利用以上の高負荷なデータで実施したのですが、余計なセルがある場合はそもそも出力が完了できず、余計なセルを無くすと構成に左右されず2分以下で終わるという結果になりました。 テンプレートの2つのExcelファイルの差は数MB違う程度なのにここまで差が出ることに驚きですし、当初テンプレートファイルが原因なんて思いもしませんでした。これは確実に落とし穴なので、テンプレートを使う際は気をつけましょう。
その3. TypeScriptでPDFを編集できるライブラリが少ない
今度はPDF生成について。 PDFはNotionなど最近のSaaSのexportにも良くあるので、Excelよりもモダンなイメージがありライブラリもいいのが見つかると思っていたのですがここでも少し詰まりました。
以下に調査中に見つけたライブラリを挙げます。
- pdf-lib
- pdf-lib.js.org
- コードベースでPDFを編集できるpure jsライブラリ
- 線を引いたり画像を埋め込んだりフォームを作成することも可能
- PDFファイル編集のためのutility関数も提供している
- ただしx,y座標で指定する必要があったり、今回の用途には合わなかった
- pdfme
自分が見つけた中だと上記が筆頭だったのですが、今回の要件に対してミスマッチ感がありました。 調べている中でもjsでPDF編集となるとサインやフォームの埋め込み用途が多かったので、今回のようなニーズはそもそも少ないのかもしれません。
結局、自由度の高さとチームでメンテナンスしていくことを考慮し、HTMLでマークアップ → PDFファイルにして出力という方法を選択しました。
PDFへの変換は、puppeteerを使っています。 実はこちらも選択肢は多くなく、ほぼ一択の状態でした(いくつかライブラリは存在しますが、どれもpuppeteerがdependenciesに含まれていました)。
node-wkhtmltopdfというpuppeteerに依存しないライブラリも見つけたのですが、最終更新日が4年前でかつ内部で使われているエンジンが2024年3月を以てアーカイブになってしまったようで、選択肢から外れました。
その4. puppeteerで大量のページを出力するなら分割せよ
PDFファイル生成は、前述の通りpuppeteerを用いて行なっています。 puppeteerにはpdf生成のための関数があり、それを呼び出すだけでPDFファイルとして保存することができます(正確にはpuppeteer経由でChromiumの印刷機能が呼び出されます)。
// Saves the PDF to hn.pdf. await page.pdf({ path: 'hn.pdf', });
なのでこちらがやることはHTMLを作成してpuppeteerに渡すだけです。とても簡単。……と思っていたのですが、ここにも思わぬ落とし穴がありました。
先ほどと同様、負荷試験を行なったところ、数千人規模の出力をした際にPDF出力が失敗してしまう問題が発生しました。 以下はPDF出力に使用したCloud Run Jobsの構成と実行結果になります。構成の違いで実行時間が違いますが、どちらもエラーが発生しPDF出力が失敗しました。
Cloud Run Jobsの構成 | 実行時間 | 結果 | エラー内容 | |
---|---|---|---|---|
全ページ一括出力 | 1CPU 4GiB | 01:17 | ❌ | ProtocolError: Protocol error (Page.printToPDF): Printing failed |
全ページ一括出力 | 2CPU 8GiB | 0:22 | ❌ | ProtocolError: Protocol error (Page.printToPDF): Printing failed |
調べたところpuppeteerにissueが上がっており、巨大なページをPDF出力しようとすると解析に失敗してしまうとのことでした。
issueのコメントに回避策が書かれており、分割してPDF出力をしてから最後に結合することで、うまく機能するという提案がありました。
実際にこの対応を入れたところ、先ほどの負荷試験が以下のように改善しました。
Cloud Run Jobsの構成 | 実行時間 | 結果 | エラー内容 | |
---|---|---|---|---|
全ページ一括出力 | 1CPU 4GiB | 01:17 | ❌ | ProtocolError: Protocol error (Page.printToPDF): Printing failed |
全ページ一括出力 | 2CPU 8GiB | 0:22 | ❌ | ProtocolError: Protocol error (Page.printToPDF): Printing failed |
分割出力してから結合 | 1CPU 4GiB | 03:57 | ✅ | |
分割出力してから結合 | 2CPU 8GiB | 01:48 | ✅ |
Excel出力と異なりマシン構成によって実行時間が変わりますが、ご利用者数千人でも問題なく一気に出力できるようになりました。
終わりに
ExcelやPDFファイルを生成する機能は、ペーパーレスとなりつつある現代でもさまざまなケースで求められる可能性があります。 何かのタイミングでこの知見が生かされることがあれば、とても嬉しいです。