久々にブログを更新!!
(サボりがちなので、心機一転ここからまた継続して更新できるように頑張ります....)
はじめに
one visa というプロダクトの開発を行っており、
その中で、コア機能の一つとなる PDF 生成を Go + Headless Chrome で作成するという取り組みをしたので
その取り組みについて紹介いたします!
どうやって実現するか
- 生成したい書類を Webページとして作成する(HTML + CSS)
- 作成した Webページをレンダリングする Webサーバーを立てる
- → (今回は、Go縛りで Echo を使って Webサーバーを立てました!)
- 該当ページに headless chrome でアクセスする
- 表示したページを PDF として保存する
この一連の流れを作成します!
図で表すとこんな感じです。
また、今回は異なるページを PDF として生成し、最後に 一枚の PDF として合体させる必要があったので、
ゴルーチンを利用して、上記で説明したプロセスを並列で行います!
利用するツール
今回は、Chrome DevTools Protocol を利用して、Chrome を操作します。
この Chrome DevTools Protocol を go で簡単に操作できるライブラリである chromedp を利用します!
また PDF の合成には、 pdftk という PDF ファイルを編集するコマンドラインツールを利用しました!
実装
上記を実現するためのサンプルです。
echo を使った Webページのレンダリングは省略しています。
また、私は Docker コンテナに必要なツールをインストールして稼働させました!
もちろん、必要なツールが揃っていれば、ローカルで直接稼働させることもできるはずです。
(必要なツール chromedriver
chrome
pdftk
)
Docker上で、Headless Chrome を動かすということ自体はよく記事になっているので、省略します。
qiita.com
また、 pdftk
も apt-get install
で Docker に組み込めるのでこちらも省略します。
package main import ... func main() { // 生成したいPDFに該当するWebページのパスを持った構造体をマップでひとまとめにする // → 後述します builders := []builder.PDFBuilder{ &Sample{ Path: "sample" } } // 各WebページをPDFとして保存して fileNames, err := createPDF(); if err != nil { return } // 生成されたPDFを1つのPDFへ合体します mergePDF(fileNames) // 残ってしまった不要なファイルを削除する deleteTmpFile(fileNames) } func createPDF(builders []builder.PDFBuilder) (fileNames []string, err error){ var wg sync.WaitGroup wg.Add(len(builders)) // ゴルーチンを利用して、各PDF生成を並列で処理します for _, builder := range builders { go func(builder builder.PDFFactoryServicer) { defer wg.Done() builder.Exec() // PDF生成 }(v) } wg.Wait() // 全てのPDFが生成されたことを確認しています for _, builder := range builders { _, err := os.Stat(builder.Name()); if err != nil { return nil, err } fileNames = append(fileNames, builder.Name()) } return fileNames, nil } func mergePDF(fileNames []string) (mergePDFName string, err error) { mergePDFName = "merge.pdf" mergeCommand := append(fileNames, "cat", "output", mergePDFName) // exec.Command を利用して pdftk のコマンドを実行しています err = exec.Command("pdftk", mergeCommand...).Run(); if err != nil { return "", err } return mergeFileName, nil } // 各ページごとに生成したPDFを削除 func deleteTmpFile(fileNames []string) error { for _, v := range fileNames { if err = os.Remove(v); err != nil { return err } } return nil }
// WEBページへアクセスし、PDFとして保存するロジックをまとめる package print func Screenshot(path string, output string) error { // chromedb を実行する ctx, cancel := chromedp.NewContext(context.Background()) defer cancel() // chrome で実行したいタスクを走らせる var buf []byte err := chromedp.Run(ctx, createTask("URL"+ path, `div`, &buf)); if err != nil { return err } // 指定した名前でファイルを作成する file, err := os.Create(output); if err != nil { return err } defer file.Close() writer := bufio.NewWriter(file) writer.Write(buf) writer.Flush() } func createTask(url string, sel string, res *[]byte) chromedp.Tasks { return chromedp.Tasks{ chromedp.Navigate(url), // URL へアクセスして chromedp.WaitVisible(sel, chromedp.ByQuery), // 表示されるのを待って printToPDF(res), // pdf として保存する } } // chromeでPDFとして保存させる処理 func printToPDF(pdfbuf *[]byte) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { buf, err := page.PrintToPDF().WithPrintBackground(true).Do(ctx) if err != nil { return err } *pdfbuf = buf return nil }) }
// PDF 生成を担当する構造体 package builder // WebページからPDFを生成機能を持ったインターフェース type PDFBuilder interface { Exec() Name() string } // 以下のように、生成したい Path を持った構造体を作る type Sample struct { Path string } func (service *Sample) Exec() { print.Screenshot(service.Path, service.Name()) } func (service *Sample) Name() string { return service.Path + ".pdf" }
完成イメージ
完成形は、以下のような処理の流れになります!
まとめ
PDF 生成を Webページから行う方法についてまとめてみました
今回は、go で実装しましたが、 chrome 操作を簡単に行うことができれば言語は問わないと思います。
最後、PDF に変換されるため、レイアウトをうまく調整することが難しかったりもしたのですが、
PaperCSS といったツールを使えば、その辺りもうまくできそうです。
(※ 私はゴリッと生のCSSでコーディングしたので、テキトーなことを言っています)
HTML + CSS の構成なので、デザインの変更は簡単に行なえますし、
動的なデータもWebサーバーを利用して簡単に生成可能です!
(今回、例では出していませんが実際はクエリを利用して、動的なデータの表示や生成するページを切り替えるといったことも可能です!)
ゴルーチンを利用すれば、一定の速度を保って簡単にスケールできる環境も作れると思うので
大量のPDF生成にお困りの方にはおすすめです!(← そもそもここの需要が少なそうw)