行動すれば次の現実

テック中心の個人ブログ

Railsで簡単にPDFが出力できるGroverを導入してみた(Herokuへのデプロイも)

この記事では、Groverを使ってPDFを出力する際の導入方法や注意点を記載しております。また、Herokuへのデプロイについても説明します。

Groverとは?

GroverとはGoogle PuppeteerとChromiumを使ってHTMLをPDFや画像ファイル変換するGemです。

RailsでGroverを使うと、HTML(ERB)を実装するのと同じ感覚で、簡単にPDF出力ロジックを実装することが出来ます。

github.com

Google Puppeteerとは?

スクリプトを介してヘッドレストブラウザ(GUIを持たないブラウザ)や通常のブラウザを操作することができるフレームワークです。主にブラウザの自動テストなどに使用されます。類似の代表的なフレームワークにSeleniumなどがあります。

developer.chrome.com

Chromiumとは?

Chromeのベースとなるブラウザエンジンです。Chromeとの違いは自動アップデートやクラッシュレポートなどの機能が搭載されていない点です。

Grover、Puppeteer、Chromiumの関係

Grover、Puppeteer、Chromiumの関係について整理します。

  • ① GroverはPuppeteerとChromiumを元にHTMLを描画します。
    • 実際にはPuppeteerがChromiumを使用してブラウザを操作してHTMLを描画します。
  • ② Groverは描画されたHTMLを元に画像ファイル(PDF、PNG、JPEG)を生成します。

Grover、Puppeteer、Chromiumの関係

実装イメージ

インストール

Gemfileにgroverを追加します。

Gemfile

gem 'grover'

設定ファイル

initializersにGroverの共通定義を設定します。 実際の出力処理で個別に上書きすることも可能です。

config/initializers/grover.rb

Grover.configure do |config|
  config.options = {
    format: 'A4',
    margin: {
      top: '30px',
      right: '20px',
      left: '20px'
    },
    cache: false,
    display_url: <%= ENV.fetch("GROVER_DISPLAY_URL") {'localhost:3000' } %>
  }
end

display_urlには自ホストのアドレスを入力してください。これは出力対象のHTMLでCSSファイルを読み込んでいる場合に必要になります。デプロイを想定して環境変数などする必要があります。

HTMLファイル内に直接スタイルを定義している場合、display_urlは不要です。

PDF出力コントローラーの実装例

  1. ActionController::Baseに定義されているrender_to_stringを使用して、指定したtemplateからHTML構文を生成します。
  2. HTML構文をGroverに渡して、to_pdfメソッドでPDFを生成します。
  3. send_dataでPDFファイルをクライアントに返します。
def show
  html = self.class.new.render_to_string(template: 'sample/index')
  pdf = Grover.new(html).to_pdf
  send_data(pdf, filename: 'sample.pdf', type: 'application/pdf', disposition: 'inline')
end

Herokuで使う場合の注意点

Herokuにデプロイする場合はいくつか注意点があります。

buildpackの追加

Herokuで使用するためにはNode.jsとpuppeteerのビルドパックを追加する必要があります。

Node.jsビルドパックの追加

heroku buildpacks:add heroku/nodejs --index=1

puppeteer-heroku-buildpackの追加

heroku buildpacks:add jontewks/puppeteer --index=2

puppeteerのビルドパックの実態はpuppeteer-heroku-buildpackです。このビルドパックにはHerokuサーバー上でpuppeteerを使用するために必要なライブラリ群が含まれています。

しかし、このビルドパックを導入するとSlug Size(Herokuの容量)をかなり消費することになります。HerokuのSlug Sizeは最大500MBまでなのですが、puppeteerを導入すると余裕で400MBは超えてしまうでしょう。

その原因はpuppeteer-heroku-buildpackに含まれているxdg-utilsというライブラリの影響が大きいと思います。このライブラリだけで344MB消費しています。

そのため、puppeteer-heroku-buildpackのGithubではxdg-utilsを除いたタグ(22.0.0-no-xdg-utils)も作成されているようです。xdg-utilsを除くことによる影響は100%ないとは保証できないとのことですので、導入は自己責任でお願いします。

ちなみに私の環境では特に問題なかったので22.0.0-no-xdg-utilsをビルドしています。

heroku buildpacks:add https://github.com/jontewks/puppeteer-heroku-buildpack#22.0.0-no-xdg-utils --index=2

GROVER_NO_SANDBOXの設定

GROVER_NO_SANDBOX=trueという環境変数を設定する必要があります。

no_sandboxはChromiumに対して適用されるようです。

そもそもChromiumのサンドボックスとはタブ毎に独立したプロセスでアプリケーションを稼働する仕組みをさします。デフォルトではこれが有効になっていますのが、Herokuで利用するためには無効にする必要があります。

heroku config:set GROVER_NO_SANDBOX=true

日本語フォントの追加

Herokuは日本語フォントに対応しておらず、そのまま出力しようとすると文字化けしてしまいます。

そのため、日本語フォントをHerokuに読み込ませる必要があります。

文字情報技術促進協議会のサイトからIPAフォントをダウンロードして、任意のttfファイルをプロジェクトのルートに配置します。

IPAexゴシックを使用する例

.fonts/
  - ipaexg.ttf

終わりに

PDF出力用のGemはいくつかありますが、 Groverを使用するとHTMLを実装する感覚でPDFレイアウトを組むことができるので保守性の観点でもおすすめです。

Groverのデメリットは処理が重くなりやすい点だと思います。シンプルなレイアウトであれば数秒で作成されますが、複数ページなどを出力する場合は処理時間の問題が発生します。

Herokuには30秒でタイムアウトしてしまうという仕様がありますので、要件によってはバックグラウンド(非同期処理)でPDFを作成して、完了したらクライアントにダウンロードさせるなどの考慮が必要になるかと思います。

Sidekiqでスタータスチェックや進捗管理ができるsidekiq-statusの使い方

Sidekiqのステータスチェックや進捗状況管理に便利なsidekiq-statusの使い方について説明します。

sidekiq-statusとは

sidekiq-statusを使うとジョブのスタータスや進捗状況を簡単に把握することができます。

sidekiq-statusはSidekiqのジョブに関する追跡情報をredis上に保存します。 クライアントからはjob_idを用いて簡単にそれらの情報を取得することができます。

例えば、重たい非同期処理(ジョブ)を画面から実行して、画面上にステータスや進捗情報を表示したい場合などに役立ちます。

sidekiq-statusの導入

sidekiq-statusのインストール

Gemfile

gem 'sidekiq-status'

sidekiq-statusの設定を追加

config/initializers/sidekiq.rb

Sidekiq.configure_server do |config|
  # 下記を追記
  Sidekiq::Status.configure_server_middleware config, expiration: 30.minutes
  Sidekiq::Status.configure_client_middleware config, expiration: 30.minutes
end

Sidekiq.configure_client do |config|
  # 下記を追記
  Sidekiq::Status.configure_client_middleware config, expiration: 30.minutes
end

expirationにはステータスをredisに保持する期間を指定します。デフォルトでは30分です。

expirationはジョブクラスごとに上書きすることも可能です。ジョブの実行時間よりも長い時間を指定する必要があります。

ジョブクラスにSidekiq::Status::Workerをinclude

ステータス管理したいジョブクラスにSidekiq::Status::Workerをincludeすることでステータスを確認することができます。

class SampleJob
  include Sidekiq::Job
  include Sidekiq::Status::Worker # 追記します

  def perform(*args)
  end
end

ジョブステータス用の管理画面を追加

sidekiq-statusではsidekiqの管理画面にスタータス情報を表示する拡張機能を提供しています。

routes.rbに下記の記載をするとstatusesタブが新たに追加されます。

config/routes.rb

require 'sidekiq/web'
require 'sidekiq-status/web' # 追記する

statusesタブ

sidekiq-statusで出来ること

スタータスの把握

Sidekiq::Status::statusを使用することでスタータスを把握することが出来ます。

job_id = SampleJob.perform_async
status = Sidekiq::Status::status(job_id)

# statusは下記のいずれか
# queued,working,retrying,complete,failed,interrupted

# スタータス毎のメソッドも用意されている
Sidekiq::Status::queued? job_id
Sidekiq::Status::working? job_id
Sidekiq::Status::retrying?  job_id
Sidekiq::Status::complete?  job_id
Sidekiq::Status::failed? job_id
Sidekiq::Status::interrupted? job_id

注意点

statusにnilやfalseが返却されることがあります。 これはexpirationを超過している場合や、何らかの理由でSidekiqやredisの情報が消失された場合などに発生します。

この場合、画面上では失敗やエラーとして表現するなどの工夫が必要になります。

進捗管理

以下の様に進捗率を設定したり、自前の状態を保持することが可能です。

class SampleJob
  include Sidekiq::Job
  include Sidekiq::Status::Worker

  def perform(*args)
    total 100 # 進捗率の最大値

    # storeを使うと任意の状態(hoge_statusとする)を保持することができる
    store hoge_status: 'start'
    # retrieveでhoge_statusの状態を取得できる
    progress = retrieve :hoge_status
    p progress

    # 5秒待機(なんらかの処理を想定)
    sleep 5

    # 進捗率を50%に変更
    at 50, 'complete import'
    # hoge_statusの状態をimportedに変更
    store hoge_status: 'imported'

    # 5秒待機(なんらかの処理を想定)
    sleep 5

    # 進捗率を100%に変更
    at 100, 'finish'
    # hoge_statusの状態をfinishに変更
    store hoge_status: 'finish sample'
  end
end

クライアントからステータスを確認するには以下のメソッドを用います。

job_id = SampleJob.perform_async
Sidekiq::Status::get job_id, :hoge_status #=> 'start'
Sidekiq::Status::at job_id #=> 0

# 10秒後
Sidekiq::Status::total job_id #=> 100
Sidekiq::Status::message job_id #=> "finish"
Sidekiq::Status::get job_id, :hoge_status #=> 'finish sample'

管理画面からもステータスや進捗状況が確認できます。

管理画面から見た進捗率

Sidekiqの引数とエラーリトライ時の挙動について

Sidekiqを使用するために知っておいてほしい、引数とエラーリトライ時の挙動についてまとめました。

引数のベストプラクティス

Sidekiqのperform_asyncの引数をJSON形式でRedisに永続化します。

例えばモデルオブジェクト自体を引数に渡してしまった場合、デフォルトではto_sにより<Quote:0x0000000006e57288>のようなオブジェクトハッシュ値として扱われるので、正しく復元できない場合があります。

仮に、to_sをカスタマイズして正しくJSONに復元出来たとしても、perform_asyncに渡す前と後でモデルオブジェクトの内容が変わった場合に意図しない挙動を招くことがあるので避けるべきです。引数としてはstring, integer, float, boolean, nil, array, hashの型でなければなりません。

# 悪い例
quote = Quote.find(quote_id)
SomeJob.perform_async(quote)

# 良い例
SomeJob.perform_async(quote_id)

リトライと冪等性

SidekiqではJobの冪等性(何度実行しても同じ結果になる)を意識して設計する必要があります。

Sidekiqはエラー時にリトライする機能が備わっています。エラーが発生した場合に、一定間隔おきに再施行(リトライ)を繰り返す仕組みになっています。

デフォルトでは25回リトライします。これを考慮して設計しなければ思わぬ不具合を引き起こす恐れがあります。

例えばクレジットカードの決済を実行してユーザーに課金処理を行い、完了メールを送るJobがあるとします。

課金処理が成功後に、完了メールを送信する処理でエラーが発生したとします。この場合リトライが発生して再度、課金処理が走ってしまうと二重課金が発生してしまいます。(デフォルトのリトライ回数だと最大で26回課金されてしまう)

このような場合を考慮して、どこまで処理が完了しているのかステータス管理するなどして、冪等性、完全性を保証させる必要があります。

もし冪等性の考慮が難しいなどの理由で意図的にリトライをさせたくない場合、以下のオプションでリトライを無効にすることが出来ます。

class SomeJob
  include Sidekiq::Job

  # リトライを無効にする
  sidekiq_options retry: 0
  # sidekiq_options retry: falseでも無効にできるが後述の違いがある

  def perform(*args)
    # Do something
  end
end

sidekiq_options retry: 0とretry: falseの違い

retry: 0 の場合

エラー時はデッド状態になり画面から再試行可能

retry: false の場合

エラー時は画面から再試行不可能

画面からの再試行もさせたくないなどの理由がなければretry: 0にしておくのが良いかと思います。

リトライ時の挙動について

例えばsidekiq_options retry: 2とした場合、以下のようにステータス管理されてJobがリトライされます。

  1. Jobが実行されてエラーが発生する
  2. 再試行(1回目)状態になり一定時間後にキューに登録される
  3. 1回目のリトライJobが実行されてエラーが発生する
  4. 再試行(2回目)状態になり一定時間後にキューに登録される
  5. 2回目のリトライJobが実行されてエラーが発生する
  6. デッド状態になる(画面から任意で再試行可能)

プロセスの再起動とジョブの再実行

retry: 0やretry: falseを指定しても、処理中にプロセスが再起動された場合、そのジョブは再起動後に再実行される可能性があります。これは、retryオプションがジョブの失敗時の再試行に関する設定であり、プロセスの再起動による中断とは異なるためです。

再起動後も含めてジョブを完全に再実行させたくない場合は、Sidekiq Proのユニークジョブ機能を使用するか、アプリケーション側で適切に制御する必要があります。

参考

https://github.com/mperham/sidekiq/wiki/Best-Practices

Sidekiqの導入手順【令和版】

SidekiqをRailsに導入する手順をまとめました。

Gemのインストール

Gemfile

# sidekiq本体(必須)
gem 'sidekiq'
# sidekiqのモニタリング画面で使用
gem 'sinatra', require: false
# redisのkeyに任意のprefixを付与させることができる
gem 'redis-namespace'

Sidekiqジョブの作成

rails console

rails g sidekiq:job sample

実行するとsample_job.rbが作成されます

app/sidekiq/sample_job.rb

class SampleJob
  include Sidekiq::Job

  def perform(*args)
    # Do something
  end
end

ちなみにインクルードされているSidekiq::Jobは元々はSidekiq::Workerというモジュール名でした。

Workerという言葉の定義が誤解を招くため、SidekiqのOSSプロジェクトではWorkerをJobにリネームするよう動いています。

現状はどちらの名前でも動きますが、ver7では完全移行となるようですので、Sidekiq::Workerを使用している場合は早めにSidekiq::Jobに変更しておくことをおすすめします。

Rename Sidekiq::Worker to Sidekiq::Job · sidekiq sidekiq · Discussion #4971 · GitHub

redisの設定

sidekiqはredisを使用してメッセージングしていますので、開発環境にインストールする必要があります。

redisのインストール方法は各プラットフォームによりますので割愛させていただきます。redisがインストールできたら以下の設定ファイルを作成します。

config/initializers/sidekiq.rb

Sidekiq.configure_server do |config|
  # redisの設定
  config.redis = {url: 'redis://localhost:6379', namespace: "my_app_sidekiq_#{Rails.env}"}
end

Sidekiq.configure_client do |config|
  # redisの設定
  config.redis = {url: 'redis://localhost:6379', namespace: "my_app_sidekiq_#{Rails.env}"}
end

「redis-namespace」のgemによってnamespace: "my_app_sidekiq_#{Rails.env}"のようにnamespaceを個別に割り振ることができます。 同じredisサーバーを複数の環境で共有する場合などに管理がラクになります。

url: 'redis://localhost:6379'の部分は環境によって変わる部分ですので環境変数などで切り替えられるようにしておくと良いでしょう。

動作確認

rails console上で実際にJobを起動させてみましょう

rails c
> SampleJob.perform_async(1)
=> "51190418577b3dcafd192368"

sidekiqプロセスのログには以下のようにstartとdoneのログが出力されれOKです。

2022-10-19T05:27:06.508Z pid=1 tid=dnp class=SampleJob jid=51190418577b3dcafd192368 INFO: start
2022-10-19T05:27:06.685Z pid=1 tid=dnp class=SampleJob jid=51190418577b3dcafd192368 elapsed=0.177 INFO: done

モニタリングUIについて

sidekiqではJobの状態をモニタリングできる画面を提供してくれています。

routesファイルに記載の上、指定のURLにアクセスすることで画面を起動することが出来ます。

config/routes.rb

require 'sidekiq/web'

Rails.application.routes.draw do
  root "pages#index"
  # 下記を追記
  mount Sidekiq::Web => "/sidekiq"
end

/sidekiqにアクセスするとモニタリング画面が確認できます。

モニタリングUI

また、sidekiqにはキューという概念があり、キューごとに優先度を振ることが出来ます。 例えば優先度が2のキューは1のキューよりも2倍の頻度でチェックされるようになります。

Jobごとに優先度を変えたい場合は複数のキューをSidekiqに登録しておいて、Jobごとに割り振ると良いでしょう。

Advanced Options · sidekiq/sidekiq Wiki · GitHub

Rails7(esbuild+stimulus)にdatepickerライブラリのflatpickrをインストールする

Rails7+esbuild+stimulusの構成でflatpickrを使えるところまでを説明します。

flatpickrのインストール

yarn add flatpickr

app/assets/stylesheets/application.scss

// 下記を記載
@import 'flatpickr/dist/flatpickr';

app/javascript/application.js

// 日本語化するため下記を記載
import "flatpickr/dist/l10n/ja.js"

実装イメージ

app/javascript/controllers/hello_controller.js

import { Controller } from "@hotwired/stimulus"
import flatpickr from "flatpickr";

export default class extends Controller {
  static targets = [ "inputDate" ]

  connect() {
    flatpickr(this.inputDateTarget, {
      locale: 'ja',
      dateFormat: 'Y/m/d(D)',
    });
  }
}

view

<div data-controller="hello">
  <input data-hello-target="inputDate" type="text">
</div>

画面確認

flatpickr

覚えておくと便利な「rails db:migrate:xxx」「rails db:xxx」コマンド集

よく忘れてしまうのでメモとして残しておきます。

rails db:migrate系

rails db:migrate

  • 未適用のmigrationを適用します

rails db:migrate:down VERSION=20221003050833

  • VERSIONに指定されたmigrationを戻します
  • self.downメソッドが実行されます

rails db:migrate:up VERSION=20221003050833

  • VERSIONに指定されたmigrationを適用します
  • self.upメソッドが実行されます

rails db:migrate:redo (オプション引数:STEP=2)

  • 一つ前のmigrationを戻して再度適用させます
  • STEPを指定するとその件数分、適用されます

rails db:migrate:status

  • migrationの適用状態を表示します

rails db:migrate:reset

  • rails db:drop、rails db:create、rails db:migrateをまとめて実行します

rails db系

rails db:rollback (オプション引数:STEP=2)

  • migrationを1つ前の状態に戻します
  • STEPを指定するとその件数分、戻します

db:seed

  • seedデータを投入します

db:create

  • DBを作成します

db:drop

  • DBを削除します

db:reset

  • db:drop、db:create、db:schema:load、db:seedをまとめて実行します

Rails7のimportmapでsemantic uiを使用する方法

Rails7のimportmapでsemantic uiを使用しようとしたところ苦戦したので備忘録として載せておきます。誰かのお役に立てば幸いです。

さっそく結論から

以下のように設定するとsemantic uiが使用できます。

Gemfile

# これによりsprocketsでアセットを管理することができます。sass版のsemantic-uiです。
gem 'semantic-ui-sass'

後述のjavascriptとはバージョンを合わせておくと良いでしょう。

app/assets/stylesheets/application.scss

/* semantic-uiをアセットパイプラインに載せます */
@import 'semantic-ui';

app/javascript/application.js

import "jquery"
import "semantic-ui"

// 動作確認用のコード
$(function () {
  $('.ui.modal').modal('show')
})

また、viewファイルに動作確認用コードとして以下を記述してください。

<div class="ui modal">
  <div class="header">
    Hello
  </div>
  <div class="content">
    <div class="description">
      <p>hello world!</p>
    </div>
  </div>
</div>

config/importmap.rb

pin "application", preload: true
pin "jquery", to: "https://code.jquery.com/jquery-3.1.1.min.js"
pin "semantic-ui", to: "https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.js"

何に苦戦したのか?

importmapとapplication.jsとの繋ぎ込みに苦戦しました。

私は最初に./bin/importmap pin jqueryコマンドによりjspmのjqueryを使おうとしていましたが、application.jsのimport "semantic-ui"jQuery is not definedとなり上手くいきませんでした。

application.jsに以下のようなコードを記述しても同様でした。

import jQuery from 'jquery';
window.jQuery = jQuery;
import "semantic-ui"

stackoverflowでも同様のトピックがあり、https://code.jquery.com/を使用すると上手くいくという説明がされていましたが、理由がなかったので腑に落ちませんでした。

改めて公式ドキュメントを確認するとhttps://code.jquery.com/のjqueryを使用していることが確認できました。jspmとの違いを深堀りする気にはなりませんでしたが、公式でもこちらのCDNが記述されていますので、何らかの理由があるように思います。

ちなみにhttps://code.jquery.com/経由だと

import jQuery from 'jquery';
window.jQuery = jQuery;

のような記述がなくても$JQueryはグローバル変数に定義されるようです。

おそらく当事象はimportmap(Rails7)だからということではないと思います。 明確な理由は不明ですが、公式がhttps://code.jquery.com/となっているので納得することにしました。