行動すれば次の現実

テック中心の個人ブログ

Groverで非同期にPDFファイルを生成する方法 | Rails

PDFファイル生成GemとしてGroverはとても優秀なのですが、処理するデータ量によっては処理が重くなり、メモリ負荷やレスポンスタイムの懸念が付き纏います。

弊社プロダクトでも、もともと非同期処理でGroverを実装していなかったため、度々レスポンスが極端に遅くになりHeroku上でH12(タイムアウト)が発生することが有りました。

そこで今回は、Sidekiqを用いたGroverの非同期処理の実装例を紹介したいと思います。

実装例

下記は非同期処理の全体図になります。

請求書(Invoiceモデル)の内容をもとにPDFファイルを非同期で生成して、画面からダウンロードするというシナリオで実装イメージを説明していきます。

前提として、Invioceモデルにはpdf_fileというカラムを持っており、pdf_fileにはCarrierWaveをマウントしておきます。 aws-fogを使用してS3にPDFファイルを格納してクライアントからダウンロードさせるという構図になります。

Grover非同期処理の全体図

① クライアントからPDF生成アクション(POST)を実行します

クライアントは請求書を作成するために画面からPDF生成アクションを実行します。

後述するenumステータスによって、画面上に「PDF生成ボタン」「PDF作成中ローディング」「PDFダウンロードボタン」の出力を切り替えるなどすると良いかと思います。

② PDF生成アクションがPDF生成ワーカー(Sidekiq)にリクエストします

PDF生成アクションではWorkerに対してPDF生成処理のみを要求して、即座にレスポンスを返します。 Invoiceモデルには必要に応じてenumで処理状態のステータスを管理すると良いでしょう。

# endpoint
#    invoices/:invoice_id/pdf_files(post)
# action
#    invoices::pdf_files_controller#create

# スタータスの例
# - requested: 処理をリクエスト済み
# - creating: PDF作成中
# - created: PDF作成済み
# - failure: PDF作成失敗

def create
  invoice = Invoice.find(params[:invoice_id])
  invoice.requested!
  CreateInvoicePdfWorker.perform_async(invoice.id)
  redirect_to invoices_url
end

③ PDF生成ワーカーがGroverでPDFを生成してS3に配置します

ワーカーでは、PDFファイルをローカルに作成して、CarrierWave経由でS3にアップロードさせます。 コメントで処理の内容を記載していますので、詳しくはそちらを参照ください。

class CreateInvoicePdfWorker
  include Sidekiq::Worker
  def perform(invoice_id)
    # 対象のInvoiceを取得してステータスを作成中に変更します
    invoice = Invoice.find(invoice_id)
    invoice.creating!
    # html文字列を生成します
    # 生成処理は簡略化のためにダミーのHTML文字列としています
    html = "<div>請求書</div>"
    temp_file_name = "/tmp/invoice_#{invoice.id}_#{Time.zone.today.strftime('%Y%m%d%S')}.pdf"
    # pathを指定すると任意のローカルファイルにPDFファイルを書き出しをしてくれます
    Grover.new(html, path: temp_file_name).to_pdf
    File.open(temp_file_name) do |f|
      # 事前にInvoiceモデルのpdf_file項目にCarrierWaveをマウントしておきます
      # (aws-fogを使用してS3に配置します)
      # PDFファイルの内容をinvoice.pdf_fileに反映します
      invoice.pdf_file = f
    end
    # ステータスを作成済みに更新します
    invoice.created!
    # 一時ファイルは削除しておきます
    File.delete(temp_file_name)
  end
end

④ クライアントがPDF取得アクション(GET)を実行します

クライアントは請求書が「作成済み」になったら、PDF取得アクションでファイルをダウンロードします。 CarrierWaveでpdf_fileをS3にマウントしていますので、S3のパスは隠蔽したままダウンロードさせることができます。

# endpoint
#    invoices/:invoice_id/pdf_files(get)
# action
#    invoices::pdf_files_controller#index

def index
  invoice = Invoice.find(params[:invoice_id])
  send_data(invoice.pdf_file.read, filename: invoice.pdf_file_identifier)
end

【CarrierWave + aws-fog】S3上のファイルをローカルにダウンロードする方法

CarrierWaveとaws-fogを用いてS3上にアップロードしたファイルをローカルにダウンロードする方法を説明します。 サイズの大きいファイルを一旦ローカルにダンロードしてから処理したいケースなどに活用できるかと思います。

実装

モデルの定義

class Invoice < ApplicationRecord
  mount_uploader :csv_data, CsvUploader
end
  • Invoiceモデルのcsv_dataという項目にCarrierWaveをマウントします

ダウンロード処理

user = User.find(params[:id])

temp_file = Tempfile.new
# ダウロード対象がバイナリの場合は下記も実行
# temp_file.binmode

URI.parse(user.csv_data.file.url).open do |file|
  temp_file.write(file.read)
  temp_file.close
end
  • user.csv_data.file.urlを実行するとS3オブジェクトの署名付きURLが返却されます
  • Tempfileを作成して、URI.openでS3オブジェクトを取得してファイルに書き込みます。

ダウンロードしたファイルを使う

例えば、ダウンロードしたCSVファイル使って取込処理を実装する場合は以下のようになります。

File.open(temp_file) do |file|
  CSV.new(file).each do |row|
    # 取り込み処理を実装する
  end
end

Asset Pipelineの本番モードと開発モードの挙動の違い

アセットパイプラインは本番環境と開発環境で挙動が違うと言われていますが、具体的にどのように違うのかを深堀りしてみました。

そもそも本番モード、開発モードとは?

アセットパイプラインでは本番モード、開発モードで挙動が違うというのはRails開発者なら誰しもが知っていることかと思います。しかし厳密には本番モード、開発モードという個々のモードが存在するわけではありません。Railsガイドの説明上、明示的に別の表現が用いられているにすぎません。

本番モードとはconfig.assets.compile=falseの状態であることを指します。

config.assets.compileとは動的コンパイルをするかどうか制御するオプションです。 trueの場合は動的コンパイルをする、falseの場合は動的コンパイルをしません。 本番環境の場合はデフォルトでfalseが設定されます。

動的コンパイルとは、リクエストを受けたときに、最新のアセットがコンパイルされていなかった場合に、その場でコンパイルを実行してアセットを生成する仕組みのことです。アセットのコンパイルには多くのメモリを消費するため、本番環境ではconfig.assets.compile=falseにすることが原則とされています。

反対に開発環境では、動的コンパイルを有効にすることで多少メモリを消費したとしても開発作業の効率を優先して、動的コンパイルを有効にするケースが一般的です。 開発環境の場合はデフォルトでtrueが設定されます。

本番モードの仕組み

本番モードの仕組みを簡単に説明します。本番モードでは以下のようなアセットパイプラインの設定になっているのが一般的です。

config.assets.compile=false
config.assets.digest=true

config.assets.digest=trueに設定することで、コンパイルされたのファイル名に以下のようなフィンガープリントが付与されます。

/assets/application-47140618c97a4b155143c9a405b56b34e8ed3c3aeb621fb1110dc1f41c99c34c.js

javascriptの場合、アセットパイプラインはjavascript_include_tagでリンクさせます。

<%= javascript_include_tag "application" %>

このように記載することで、出力されたHTMLには以下のようなアセットがリンクされます。

ここで出力された/assets/というディレクトリの実態はデフォルトでは、pubic/aseets配下になります。

本番モードでは、アセットコンパイルは手動で行う必要がありので、以下のコマンドを実行します。

RAILS_ENV=production bin/rails assets:precompile

コマンドを実行すると、pubic/aseetsにapplication.jsのフィンガープリント付きのアセットが生成されることが確認できます。

このフィンガープリントはアセットパイプラインがコンパイルする対象のディレクトリにある全てのファイルを連結させたファイルに対してSHA-256でハッシュ化された文字列が付与されます。

アセットの内容が少しでも変更されるとフィンガープリントも変更されます。これによりWebサーバーやCDN等のキャッシュ破棄が正常に動作するようになります。

開発モードの仕組み

次に開発モードの仕組みを説明します。開発モードでは以下の設定になっているのが一般的です。

config.assets.compile=true
config.assets.digest=true

本番モードとの違いはconfig.assets.compile(動的コンパイル)がtrueであるかどうかです。

開発モードの場合、assets:precompileのような明示的なコンパイルを実行しません。動的コンパイルが働きますので、あまり意識せずともアセットパイプラインがよしなに動いてくれます。どのようにコンパイルが実行されているかは良い意味でブラックボックス化されています。

詳細は省きますが、簡単に動的コンパイルの流れを説明すると以下のようになります。(Railsのバージョンによって多少の違いがありますのでご容赦ください)

  1. /assets/**へのリクエストをSprocketsが受け取る
  2. config.assets.compile=trueなので、初回リクエストの場合はアセットをコンパイルしてキャッシュに保存して返却する
  3. 以降のリクエストではアセットに変更がないことを確認する
  4. 変更がなければキャッシュされた内容を返却する
  5. 変更がある場合は再度コンパイルした結果をキャッシュに保存して返却する

ここでいうキャッシュとはSprocketsキャッシュストアを指します。デフォルトでは、tmp/cache/assetsのパスが設定されています。

開発モードの場合、アセットが最新であるかどうかの確認やコンパイルが実行されるため、本番モードと比べてメモリ消費が多く、パフォーマンスも低いという特徴があります。

また、Rails6.0の場合下記に該当するassetsはプロジェクトディレクトリのどこにも存在しません。

このことから、動的コンパイルモードの場合はアセットそのモノを作成せずに、キャッシュを使って動的にアセットを生成していることが伺えます。コンパイル対象となるアセットに変更がある場合は、キャッシュの中身を更新した後に、動的にアセットを生成しているようです。

参考

アセットパイプライン - Railsガイド

config.assets.compile=true in Rails production, why not? - Stack Overflow

inputファイル経由ではなく、アプリ内で作成したファイルをCarrierWaveで管理する方法

CarrierWaveの使用例として、inputファイルをアップロードしてModelとリンクさせるというケースが多いかと思います。

本記事では、inputファイルではなくRailsアプリケーションで作成したファイルをModelとリンクさせる方法を紹介します。

実装例

「Reportモデルのoutput_fileという項目にCarrierWaveをマウントしている」という体での実装イメージです。

# reportオブジェクトを取得します
report = Report.find(params[:id)

# tmp/hello.txtにファイルを作成します
file_path = 'tmp/hello.txt'
File.open(file_path, 'wb') do |file|
  file.puts('hello wolrd!')
end

# reportオブジェクトのoutput_fileにファイルをリンクさせます
File.open(file_path) do |file|
  report.output_file = file
end

# ファイルを削除します
FileUtils.rm_rf file_path

CarrierWaveを使用することで、Modelとファイルのライフサイクルを一体化することが出来ますので、とても保守が楽になります。

使用できる箇所では積極的に使用していくと良いでしょう。

参考

https://github.com/carrierwaveuploader/carrierwave#activerecord

FTPに配置されたファイルを取り込む処理をRSpecでテストする方法

外部サービスとデータ連携する際に、FTPに配置されたファイルをローカルにダウンロードして、データを取り込むような処理を実装するケースは少なくないと思います。

当該処理のユニットテストを実装する場合、毎回リアルなFTPサーバーを使ってテストするのは現実的ではありません。 本記事では、FTP通信の実装例とモックを使用したRSpecのテストコードを紹介したいと思います。

net/ftpをラップしたFtpClientクラスを用意

net/ftpはRuby標準のFTPライブラリです。そのまま使うには自由度が高すぎるので、専用のラッパークラスを介してnet/ftpを扱うようにします。

下記クラスはあくまでもサンプルなので作りが甘い部分があります。実際にはエラーハンドリングなどを考慮する必要があります。

lib/utils/ftp_client.rb

require 'net/ftp'

class FtpClient

  def initialize(host, user, password)
    @host = host
    @user = user
    @password = password
  end

  def connect
    @ftp = Net::FTP.new
    @ftp.connect(@host)
    @ftp.login(@user, @password)
  end

  def close
    @ftp.close
  end

  def copy_to_local(remote_filepath, local_filepath)
    @ftp.get(remote_filepath, local_filepath)
  end
end

FtpClientの使用例

FtpClientの使用例は以下の通りです。 ファイル取込用のサービスクラス(ImportDataSerice)で、FtpClientを使用する例を記します。

app/services/import_data_service.rb

class ImportDataSerice

  def call
    # ログイン
    # 実際にはログイン情報は環境変数などで渡したほうが良いです
    ftp_client = FtpClient.new('111.111.111.111', 'ftp-user', 'password')
    # 接続開始
    ftp_client.connect
    # ファイルダウンロード
    ftp_client.copy_to_local('ftp_dir/data.csv', 'tmp/data.csv')
    # 接続終了
    ftp_client.close

    # 以下に取込処理を実装する
  end
end

RSpecの実装例

ImportDataSericeをRSpecでテストしていきます。

FtpClientを実際に接続することを避けるためにFtpClientの各メソッドをallow_any_instance_ofでモック化します。 *1

connectとcloseメソッドは単純にtrueを返す実装に変更させます。

copy_to_localメソッドは、spec/data/services/import_data_service/data.csvにあるテストデータをtmp/data.csvにコピーするという処理に置き換えます。

こうすることで、ImportDataSericeの取込処理では、tmp/data.csvに配置された状態で動作することになります。

テストデータはFTPにアップロードすることなく、ローカルのspec/data/services/import_data_service/data.csvを直接編集すれば良いので楽にテストすることが出来ます。

require 'rails_helper'

RSpec.describe ImportDataService, type: :service do
  describe 'call' do
    example 'ファイルが取り込まれること' do
        allow_any_instance_of(FtpClient).to receive(:connect) do
          true
        end
        allow_any_instance_of(FtpClient).to receive(:close) do
          true
        end
        allow_any_instance_of(FtpClient).to receive(:copy_to_local) do
          FileUtils.cp('spec/data/services/import_data_service/data.csv', 'tmp/data.csv')
        end

        ImportDataService.new.call
        # 以下で取込結果を検証していく
    end
  end
end

*1:※ allow_any_instance_ofを使用していますが、別のメソッドでもモック化出来るかもしれません

新規システムをスムーズに開発する方法

今まで様々なシステムを新規開発してきましたが、毎回考えることは「スムーズに新規開発するにはどうしたら良いのか」ということです。

「前回よりも今回」という目標を持って、この問いに取り組んできましたが、ある程度答えが明確になってきたので、今回アウトプットすることにしました。

新規開発に苦戦している方の一助になれば幸いです。

まずは顧客の「要望」を「要件」に落とし込む

まずは顧客の頭の中にある要望を吸い上げて、要件に落とし込む作業を行います。

「顧客がどのような目的でシステムを作りたいのか」を一人称視点で理解する必要があります。

前提として、顧客と最低限会話できるレベルの業務知識を持っておく必要があります。業務知識といっても概要レベルで構いません。細かな部分は顧客とのヒアリングの中でイメージできれば良いので、ここでは大まかな業務知識を持ち合わせておけば良いです。

業務知識を持ち合わせていないと、顧客が抱えている本質的な課題を捉えることが出来ません。さもなくば顧客から挙げられた具体的な要望を、そのまま要件として捉えてシステムに反映させてしまいます。顧客はシステム開発のプロではないので、要件に矛盾が生じたり、システムとしての一貫性が欠けてしまい、カオスなシステム出来上がってしまいます。

顧客の要望を一人称視点で理解することで、適切な要件に落とし込むことが出来きます。 顧客から挙げられた要望を、グルーピングなどの作業を通じて抽象化を行います。顧客がどのような課題に悩んでいて、どのように改善したいのか。そのためにはどのようなシステムを作ればよいのかを考えていきます。

これらを整理すると、顧客の要望から要件に落とし込む手順を以下の通りになります。

  1. 顧客と同レベルの業務知識を持ち合わせる
  2. 顧客の要望をヒアリングする(具体レベルの内容であることが多い)
  3. 顧客の要望からグルーピングなどの抽象化作業を行い、要件に落とし込む

ワイヤーフレームで顧客と認識を合わせる

要件の成果物としてワイヤーフレームを作成します。 要件に落とし込んだ抽象的な内容をワイヤーフレーム通じて画面という形に具現化します。

実際にワイヤーフレームを作成してみると、抽象的だった要件が段々と具体化されていくことに気付くかと思います。具体化する作業の中で様々な疑問が生じてきます。 それらの疑問を「課題管理表」というシートに全て記載して顧客とのミーティングの中で解決させていきます。

一通り作成すると要件がブラッシュアップされ、より実装イメージが湧いてきます。 ワイヤーフレームを顧客にレビューしてもらい、フィードバックを得るという作業を繰り返すことで、さらにブラッシュアップされていきます。

完成物イメージを通じて、顧客と要件の齟齬を無くすことで、相互のイメージ不一致、いわゆる「顧客が本当に必要だったもの」のリスクを潰すことができます。

ワイヤーフレームを作成にはfigmaなどのツールを用いることをおすすめします。 figmaを使うと部品の共通化などが行えるので、顧客のフィードバックを反映するのも比較的容易です。 また、画面遷移の機能も備わっていますので、実物をイメージしてもらいやすいのもメリットの一つです。

洗い出した機能をベースにテーブル設計を行う

ワイヤーフレームが確定すると必要な機能が一通り洗い出されたことになります。この時点でテーブル設計を行います。

テーブル設計の後に画面設計(ワイヤーフレーム作成)を行ったほうが良いという考えもありますが、ワイヤーフレーム=顧客との仕様の確定作業と考えると、テーブル設計を後に回したほうが手戻りが少なくなります。そのため私は画面設計、テーブル設計の順番で進めることを推奨します。

テーブル設計にはdiagrams.net (旧draw.io)の「ソフトウェア > Database3」のプリセットを使ってER図を作成することをおすすめします。diagrams.net使うと各テーブル間の関係線やキーの属性などを直感的に設定できるので、とてもスピーディーにER図を作成することが出来ます。

操作性や非機能要件を元に技術を選定する

ワイヤーフレーム作成を終えると、画面の操作感や複雑性を把握することが出来ます。また、テーブル設計を終えるとシステムの規模感やデータ量が見えてきます。

これらの情報を元に、開発で使用する技術を選定します。

言語、フレームワーク、フロントエンド周り、バックエンド周り、ミドルウェア、データベース、その他ソフトウェアなどを決める必要があります。

チームのスキルセットやシステムの保守性、非機能要件と照らし合わせて、最適な技術を選定します。

例えば、インタラクティブな画面が多い場合は、SPAベースのフロントエンドを検討します。また、大量データを扱う必要がある場合は、スシャーディングできるデータベースを検討したり、NoSQLの導入なども検討します。

チームのスキルセットも重要な指標です。習熟していない言語や技術を採用するのはリスクを伴いますので、開発難易度とのバランスをみて新たな技術を導入するかどうか判断すると良いでしょう。

粗い粒度でタスク化する

技術選定が終えると、いよいよタスク化できる材料が揃います。

タスク化の粒度は「ログイン機能の作成」「トップ画面の作成」のように機能単位や画面単位のように粗くで構いません。

実際にそのタスクに取り掛かるタイミングで具体的なタスクの細分化を行います。 タスク(親)に対して複数のタスク(子)を持たせる形で細分化を行います。

ZenHub(Githubのプロジェクト管理用アドオン)でいうところの、親タスクをepic、子タスクをissueのような関係です。

システム全体の進捗管理は親タスク(epic)で行います。個々の機能単位での進捗管理は子タスク(issue)で行います。こうすることでユーザー目線とエンジニア目線の両方でスケジュール管理ができます。

開発環境、ステージング環境は真っ先に構築する

最も優先度が高いタスクは、開発環境(ローカル)とステージング環境(クラウド)の構築です。

ステージング環境構築は後回しにしがちですが、ローカルとクラウドでは、環境制約によって実装に違いが生じるリスクがあります。 開発環境に依存した実装になってしまい、そのままのコードではステージング環境では動かないという自体になりかねませんので、なるべく早めにステージング環境を構築することをおすすめします。

ひたすらタスクを消化する

ここまで来れば、あとは黙々とタスクを消化していきます。

タスクを消化するフェーズは、その上段にある要件定義、設計、タスク化、環境構築という土壌があって成り立ちます。 土壌がしっかりしていないとタスクはうまく消化できません。

土壌の組み立ては時間がかかる作業ですが、しっかりと思考することで高い保守性が実現できますので、腰を据えて望みましょう。

Heroku Pipelineでデプロイ作業がかなりラクになった

今まで同じコードベースを複数のアプリで別管理していたのですが、さすがに片手で数えられないアプリ数にもなると管理が辛くなってきたので、Heroku Pipelineを導入してみました。

実際に使用してみると、痒いところに手が届く素晴らしい機能だと感じたので紹介したいと思います。

ステージごとに「複数」のアプリを載せられる

Heroku PipelineではReview App、Staging、Productionの3つのステージが存在します。

Review AppにはPR単位の使い切りの一時的なアプリを載せて、StagingとProductionにはそれぞれ該当のアプリを登録します。

StagingとProductionそれぞれアプリは1つのみ載せられると勝手に思い込んでいましたが、複数のアプリを載せられるという仕様なのは「さすがHeroku」だと感じました。

例えばパッケージソフトを扱うシステムの場合、Staging環境に「開発用アプリ」「デモ用アプリ」、Productiion環境に「A社用アプリ」「B社用アプリ」のように複数のアプリを登録することが可能です。

「開発用アプリ」のスラグを一括で「A社用アプリ」「B社用アプリ」にプロモートすることができます。これによりリリース作業の煩わしさがなくなり、とても管理が楽になりました。

また、Herokuではプロモートやデプロイ時に実行される「リリースフェーズ」に特定のコマンドを発行することができます。

Procfileに以下のように「release」の定義をすることで、今までコマンドで直接叩いていたマイグレーションも自動化できるようになりました。 マイグレーションをし忘れるというリスクもなくなり至れり尽くせりです。

Procfile

release: bin/rails db:migrate
web: bundle exec puma -C config/puma.rb

自動テストがHeroku CIで簡単に実行できる

有料にはなりますが、Heroku CIを利用することでRSpecやRubocopなどのテスト環境を簡単に構築することができます。

CIツールというと有名所だとCircle CIなどがあります。Circle CIの場合、Docker上にテスト環境を構築してテストを実行する必要があります。

シンプルにRSpecとRubocopを実行したいだけでも、それなりに複雑なDockerの定義が必要になります。 (例えばimageを定義したり、dbを構築したり、各種ライブラリをインストールしたりなど)

Heroku CIの場合、Herokuのdyno上で実行されるため、Dockerの定義が必要ありません。そのため、設定ファイルはかなりシンプルな形式になります。

app.json

{
  "environments": {
    "test": {
      "addons": [
        "heroku-postgresql"
      ],
      "scripts": {
        "test": "bundle exec rubocop app && bundle exec rspec spec"
      }
    }
  }
}

ただし、複雑な自動テストをしたい場合にはHeroku CIは向いていないかもしれません。

例えば、数回に1回何らかの理由で失敗してしまうテストを抽出して、再テストするような自動化を構築する場合、これをHeroku CI再現させるのは難しいように感じました。 (そもそもそのようなテストがある事自体が問題なのですが。。)

Circle CIではworkflowという機能を使って、テストjobと再テストjobをコントロールして失敗したテストを再テストするということが可能ですが、Heroku CIでは一筋縄では実現できなそうでした。(もしかすると方法があるのかもしれませんが)

とはいえほとんどのケースはHeroku CIで実現できますので、一考の価値はあるかと思います。