tobb422のブログ

スタートアップエンジニアの奔走

Go + Headless Chrome で HTML から PDF を生成する基盤を作成した

久々にブログを更新!!
(サボりがちなので、心機一転ここからまた継続して更新できるように頑張ります....)

はじめに

one visa というプロダクトの開発を行っており、
その中で、コア機能の一つとなる PDF 生成を Go + Headless Chrome で作成するという取り組みをしたので
その取り組みについて紹介いたします!

どうやって実現するか

  • 生成したい書類を Webページとして作成する(HTML + CSS
  • 作成した Webページをレンダリングする Webサーバーを立てる
    • → (今回は、Go縛りで Echo を使って Webサーバーを立てました!)
  • 該当ページに headless chrome でアクセスする
  • 表示したページを PDF として保存する

この一連の流れを作成します! 図で表すとこんな感じです。

f:id:tobb422:20190716010611p:plain
流れのイメージ

また、今回は異なるページを 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

また、 pdftkapt-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"
}

完成イメージ

完成形は、以下のような処理の流れになります!

f:id:tobb422:20190716021116p:plain
完成イメージ

まとめ

PDF 生成を Webページから行う方法についてまとめてみました
今回は、go で実装しましたが、 chrome 操作を簡単に行うことができれば言語は問わないと思います。
最後、PDF に変換されるため、レイアウトをうまく調整することが難しかったりもしたのですが、
PaperCSS といったツールを使えば、その辺りもうまくできそうです。
(※ 私はゴリッと生のCSSでコーディングしたので、テキトーなことを言っています)
HTML + CSS の構成なので、デザインの変更は簡単に行なえますし、
動的なデータもWebサーバーを利用して簡単に生成可能です!
(今回、例では出していませんが実際はクエリを利用して、動的なデータの表示や生成するページを切り替えるといったことも可能です!)
ゴルーチンを利用すれば、一定の速度を保って簡単にスケールできる環境も作れると思うので
大量のPDF生成にお困りの方にはおすすめです!(← そもそもここの需要が少なそうw)