Rails 4.2 に移行していて decimal 型の属性のバリデーションで嵌った

例えば、decimal 型の weight という属性を持つ Item クラスがあるとする。

class Item < ActiveRecord::Base
  validates :weight, inclusion: { in: [0.2, 0.4, 0.8, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0] }
end

Rails 4.1.2 までは下記のようなバリデーションが成功していたのに、 4.2.0 に上げたら失敗するようになった。

item = Item.new(weight: 1.2)
item.valid? #=> false
item.weight #=> #<BigDecimal:7f0319279bd8,'0.1199999999 9999999555 9107901499 3738383E1',45(54)>

環境は次の通り。

decimal 型の属性は BigDecimal で保持するみたいで、 初期値として渡した Float の値を BigDecimal に変換した結果、誤差が発生してしまっていた。

BigDecimal は文字列から作成できるので

item = Item.new(weight: "1.2")
item.valid? #=> true
item.weight #=> #<BigDecimal:7f031937dbd8,'0.12E1',18(18)>

という風に文字列でセットして回避。

rspec-mocks の and_raise で嵌った

rspec-mocks を使って

allow_any_instance_of(User).to receive(:save!).and_raise(ActiveRecord::RecordNotSaved)

という風にモデルの保存に失敗するテストを書いていたけど、 Rails 4.2 に更新したら実際に save! を呼び出すところで ArgumentError が発生するようになって嵌った。

ちなみに gem のバージョンは次の通り。

pry-bybug でステップ実行しつつ調べたら原因判明。 rspec-mocks では、and_raise で指定した例外クラスをスローするとき、 例外クラスの exception メソッドを使ってインスタンスを生成していた。

で、and_raise の引数でメッセージを渡さなかった場合は、引数なしの exception が呼ばれるんだけど、 ActiveRecord::RecordNotFound の exception は引数が必須。 だから ArgumentError が発生してしまっていた。

ひとまず

allow_any_instance_of(User).to receive(:save!).and_raise(ActiveRecord::RecordNotSaved, "error")

という風にメッセージを渡すことで回避に成功。

Vagrant の port forwarding を設定してもゲスト OS 側の Rails 4.2 の開発サーバーに接続できなくて嵌った

Rails 4.2 へのアップグレード作業中、下記のトラブルに遭遇。

  1. Vagrant でホストの 3000 番ポートをゲストの 3000 番ポートに port forwarding している
  2. ゲスト側で rails server でサーバー起動
  3. ホスト側の Web ブラウザで localhost:3000 にアクセス
  4. サーバーに接続できない\(^o^)/

rails server 実行時に出力されるメッセージを見ると、 Rails 4.1.2 まで開発サーバーは 0.0.0.0 で待ち受けていたけど、 Rails 4.2 では 127.0.0.1 で待ち受けるように変わっていた。

bin/rails server -b 0.0.0.0

という風に、バインドする IP アドレスを指定することで、以前と同じように Web ブラウザで表示できることを確認。

ActiveRecord enums で列挙型の値を取り出す方法

Rails のモデルで次のように列挙型を定義した場合

class Customer < ActiveRecord::Base
  enum payment_system: { credit_card: 1, bank_transfer: 2 }
end

列挙型の値を取り出すときは次のように書く。

Customer.payment_systems[:credit_card] #=> 1

ビューやヘルパーで使うからメモしておく。

環境や開発者に依存しない設定は Rails の config に追加すればよかった

ヘッダーに表示するサービス名や、フッターに表示するコピーライトを変更しやすいように、 設定ファイルとして YAML で抜き出そうと思っていたけど、 Rails の config に設定を追加できることを今さら知った。

例えば、config/application.rb に

module RailsSample
  class Application < Rails::Application
    # ...

    # タイトルとコピーライトを設定に追加
    config.app_name = "サンプルサービス"
    config.copyright = "tnakamura"
  end
end

と書いておけば、

Rails.application.config.app_name #=> サンプルサービス
Rails.application.config.copyright #=> tnakamura

という風に利用できた。 設定を追加したら、Rails アプリの再起動が必要だけど。

環境や開発者に依存する設定は dotenv 使うとして、 そういった環境に依存しない設定は config/application.rb に書けばいいや。

Rails のドキュメントはちゃんと読んでおくべきだったな。

Sidekiq をバックエンドに ActiveJob を導入

Heroku に 30 秒でレスポンスを返さないといけないルールがあったのを忘れていたので、 急遽 Rails アプリで時間がかかる処理を非同期にすることにした。

Rails で非同期というと Resque や Sidekiq が今のところ人気だけど、 今回は Rails 4.2 で追加予定の ActiveJob を使うことにした。 バックエンドには Sidekiq。

Redis をインストール

バックエンドに使う Sidekiq は Redis が必要なので、 Vagrant + Chef + Berkshelf で構築している開発環境にインストールする。

Berksfile に

cookbook "redisio"

を追加し

rm -rf cookbooks
berks vendor cookbooks

で Cookbook をインストール。

redisio のレシピを使うように Vagrantfile を

VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  # ...

  config.vm.provision "chef_solo" do |chef|
    chef.cookbooks_path = ["./chef/cookbooks", "./chef/site-cookbooks"]

    # ...

    # Redis のレシピを追加
    chef.add_recipe "redisio::install"
    chef.add_recipe "redisio::enable"
  end
end

という風に修正したら、vagrant provision で chef-solo を実行。

ActiveJob と Sidekiq をインストール

ここからは vagrant ssh で開発環境に入っての作業。

Gemfile に

gem "activejob", "4.2.0.beta1", require: "active_job/railtie"
gem "sidekiq"
gem "sinatra"

を追加して bundle install

ActiveJob は gem を直接使う。 Rails 4.1 のアプリで使いたかったので、activesuport 4.2.0.beta2 が不要な、4.2.0.beta1 を使用。 active_job/railtie を require しないと、ActiveJob が Rails に読み込まれなかった。

あとは config/environment/development.rb で ActiveJob の設定を記述。 バックエンドで Sidekiq を使うように指定する。

Rails.application.configure do
  # ...

  # ActiveJob のバックエンドに Sidekiq を指定
  config.active_job.queue_adapter = :sidekiq
end

ローカルにインストールした Redis を使うので、Sidekiq の Redis 設定は省略可能。

Sidekiq の Web コンソールをマウント

ちゃんとジョブが実行されたか確認したいので、Sidekiq の Web コンソールをマウントする。 sinatra もインストールしたのは、これが理由。 Sidekiq の Web コンソールは Sinatra を別にインストールしないといけない。

config/routes.rb を次のように修正。

Rails.application.routes.draw do
  # ...

  # Sidekiq の Web コンソールをマウント
  mount Sidekiq::Web => "/sidekiq"
end

ジョブを作成

次のコマンドでジョブを生成。

bundle exec railg generate job calc_score

すると

class CalcScoreJob < ActiveJob::Base
  def perform(*args)
  end
end

みたいな雛形が app/jobs/calc_score_job.rb に生成されるので、 perform メソッドに非同期で実行したい処理を書く。 こんな感じ。

class CalcScoreJob < ActiveJob::Base
  def perform(*args)
    project_id = args[0]
    project = Project.find(project_id)
    project.calc_score # 30 秒以上かかる集計処理
  end
end

コントローラーでジョブを使う

コントローラーで作成したジョブを使ってみる。

ドキュメントには perform_later を使うと書いてあったけど、 RubyGems.org からインストールした 4.2.0.beta1 にはまだ実装されていないので、NoMethodError。 代わりに古い APIenqueue を呼び出す。

class ProjectsController < ApplicationController

  # ...

  def calc_score
    # 本当は
    # CalcScoreJob.perform_later(data)

    # ジョブ実行
    CalcScoreJob.enqueue(@project.id)
    respond_to do |format|
      format.html { redirect_to @project }
    end
  end
end

ジョブを実行

Rails プロジェクト直下に、次の内容を書いた Procfile を作成する。

web: bundle exec unicorn -p $PORT -c config/unicorn.rb
worker: bundle exec sidekiq

Foreman を使って Rails サーバーと Sidekiq ワーカーを起動。

web: bundle exec unicorn -p $PORT -c ./config/unicorn.rb
worker: bundle exec sidekiq

コントローラーのアクションを呼び出してジョブを実行したら、 Sidekiq の Web コンソールで確認してみる。

ブラウザで http://localhost:8080/sidekiq にアクセスして

f:id:griefworker:20140928122506p:plain

Processed の数が増えていれば、ActiveJob のバックエンドとして Sidekiq がちゃんと動いたことになる。

Capybara + Poltergeist を使ってテストするための環境を Vagrant + Chef で構築

プライベートで開発に関わっている Rails アプリが完成に近づいてきたので、 Capybara と RSpec を使ってインテグレーションテストを書くことにした。

JavaScript で動きをつけたページもきちんとテストしたいので、 JavaScript ドライバに Poltergeist を選択。

以下、作業メモ。

Chef で Phantomjs をインストール

まずは、Poltergeist が依存している、ヘッドレスブラウザの Phantomjs のインストールが必要。

Phantomjs はコミュニティ Cookbook を使ってインストールする。

Berkfile に

cookbook "phantomjs"

の1行を追加し、

berks vendor cookbooks

でインストール。既に cookbooks フォルダが存在する場合は、先に削除しておくこと。

開発環境は Vagrant で構築しているので、Vagrantfile に

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  # ...

  config.vm.provision "chef_solo" do |chef|
    chef.cookbooks_path = ["./chef/cookbooks", "./chef/site-cookbooks"]

    # ...

    # Phantomjs のレシピを追加
    chef.add_recipe "phantomjs::default"
  end
end

を記述して

vagrant provision

を実行。

Capybara と Poltergeist をインストール

ここからは vagrant ssh仮想マシンに入って作業する。

Rails プロジェクトの Gemfile に

group :test do
  # 下記を追加
  gem "capybara"
  gem "database_cleaner"
  gem "poltergeist"
end

を記述。投入したテストデータがちゃんと削除されるように、database_cleaner もあわせて使う。

bundle install

を実行してインストール。

Capybara と Poltergeist と DatabaseCleaner を有効にする

spec_helper.rb に、Capybara と Poltergeist と DatabaseCleaner の設定を記述。

# ...

# Capybara の設定
require 'capybara/rspec'
require 'capybara/rails'
require 'capybara/poltergeist'
Capybara.javascript_driver = :poltergeist

RSpec.configure do |config|
  # ...

  # database_cleaner の設定
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end
  config.around(:each) do |example|
    DatabaseCleaner.cleaning do
      example.run
    end
  end
end

これで準備完了

あとは spec/features フォルダ下にフィーチャーを書いて、

bin/rake spec:features

でテストを走らせればいい。

下記のように、js: true オプションを指定した箇所は Poltergeist を使って実行されるようになる。

describe "JavaScript を使ったページのテスト", js: true do
  it "JavaScript が実行されたかのテスト" do
    # テスト内容を書く
  end
end