Shred IT!!!!

IT全般について試したこと・勉強したことを綴ったり、趣味について語るブログ

Rails TDD/BDD開発向けテスト一式(ユニット/インテグレーションテスト、テスト自動化、静的解析) Rspec + Capybara + PhantomJS + Poltergeist + Turnip + Jasmine + FactoryGirl + Guard + Rubocop + Spring

概要


Rails4.2 で新規プロジェクトを立ち上げることになったらテストをどうしようか。
現時点で考えうる全部入りを試してみようと思う。

導入する gem やライブラリは下記。

  • Rspec
  • Capybara
    • WEBアプリケーションのテストを補助するライブラリ
  • PhantomJS
    • ヘッドレスブラウザ
  • Poltergeist
    • Capybara を PhantomJS で動作させるためのドライバ
  • Turnip
    • Rspec を Gherkin 書式に対応させる
  • Jasmine
  • FactoryGirl
    • テストデータを柔軟に生成
  • Guard
    • ファイル変更を監視して任意のコマンドを実行
  • Rubocop
    • 静的解析ツール(コーディング規約チェック)
  • Spring
    • アプリケーションプリローダー

何ができるかというと、

準備


前提
下記を gem install 済み。

gem のインストール

Gemfile を編集。

# vim Gemfile
group :development, :test do
  gem "rspec-rails"
  gem "factory_girl_rails"
  gem "guard-rspec"
  gem "guard-rubocop"
  gem "spring"
  gem "guard-spring"
  gem "spring-commands-rspec"
  gem "capybara"
  gem "poltergeist"
  gem "turnip"
  gem "jasmine-rails"
  gem "guard-jasmine"
  gem "database_cleaner"
end

gem install する。

# bundle install
Rspec を利用する準備
# bundle exec rails g rspec:install
      create  .rspec
      create  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb
テスト用 DB を準備
# bundle exec rake db:test:prepare
Guardfile の生成
# bundle exec guard init rspec
# bundle exec guard init rubocop
# bundle exec guard init jasmine

Guardfile が作成されたことを確認しつつ(guard :rspec guard :jasmine guard :rubocop の項目があること)、 spec, feature ファイルも監視対象にする。

# vim Guardfile
# ↓springに対応するように spring rspec へ書き換え
guard :rspec, cmd: "bundle exec spring rspec" do
  ...
  watch(%r{^spec/.+_spec\.rb})
  watch(%r{^spec/features/(.+\.feature)}){ |m| "spec/features/#{m[1]}" }
  ...
Rubocop の設定

Rubocop 設定用ファイルを生成。

# bundle exec rubocop --auto-gen-config
...
Created .rubocop_todo.yml.
Run `rubocop --config .rubocop_todo.yml`, or
add inherit_from: .rubocop_todo.yml in a .rubocop.yml file.

.rubocop_todo.yml が生成されるので、 リネームして Rails 用オプションを追加。

# mv .rubocop_todo.yml .rubocop.yml
# vim .rubocop.yml
AllCops:
  RunRailsCops: true
  Exclude:
    - 'vendor/**/*'
    - 'spec/steps/*'
...

後々必要になった Exclude の設定も書いておく。
step ファイルを対象外に設定したいのだが、デフォルト値が上書きされる問題がある。
詳細は下記。
Ruby - rubocop gemを使うためにたった1つの重要なこと - Qiita

Turnip 対応

.rspec に設定を追加して、.featureのファイルもテスト対象にする

# vim .rspec
-r turnip/rspec

turnip_helper.rb を追加して、stepファイルもテスト対象にする。

# vim spec/turnip_helper.rb
require 'rails_helper'
# steps ファイルの設置場所を指定する
Dir.glob("spec/steps/*step.rb") { |f| load f, true }
jasmine の設定

jasmine の設定ファイルを追加して、テストを動作してみる。

# bundle exec rails g jasmine_rails:install
   identical  spec/javascripts/support/jasmine.yml
       route  mount JasmineRails::Engine => '/specs' if defined?(JasmineRails)

# RAILS_ENV=test bundle exec rake spec:javascript
Starting...

Finished
-----------------
0 specs, 0 failures in 0.001s.
spring の設定

spring を rspec に対応させる。

# bundle exec spring binstub --all
* bin/rake: spring already present
* bin/rspec: generated with spring
* bin/rails: spring already present

下記コマンドが叩けることを確認する。

# ↓spring を起動して rspec を流す
# bundle exec spring rspec
# ↓spring 環境が立ち上がっていることを確認
# bundle exec spring status

エラーが出る場合は先に下の項目をやる。

spec_helper の設定

Capybara と Poltergeist と DatabaseCleaner の設定を追加する。
DatabaseCleaner はインテグレーションテストする際には必須で、 データベースにデータが残るのを初期化してくれる。
Rspec でのテストは設定にもよるが、基本的にはトランザクションをコミットしないで処理するため、 データが残らない仕組みになっている。

下記は行頭に追加。

# vim spec/spec_helper.rb
require 'capybara'
require 'capybara/rspec'
require 'capybara/poltergeist'
require 'database_cleaner'
Capybara.register_driver :poltergeist do |app|
  Capybara::Poltergeist::Driver.new(app, :js_errors => true, :timeout => 60)
end

Capybara.configure do |config|
    config.default_driver = :poltergeist
    config.javascript_driver = :poltergeist
end
...
RSpec.configure do |config|
  ...
  config.before(:suite) do
    DatabaseCleaner.strategy = :truncation
    DatabaseCleaner.clean_with(:truncation)
  end
...
テスト用の一式を作成
# bundle exec rails g scaffold test_dayo name:string
      invoke  active_record
      create    db/migrate/20150611054639_create_test_dayos.rb
      create    app/models/test_dayo.rb
      invoke    rspec
      create      spec/models/test_dayo_spec.rb
      invoke      factory_girl
      create        spec/factories/test_dayos.rb
      invoke  resource_route
       route    resources :test_dayos
      invoke  scaffold_controller
      create    app/controllers/test_dayos_controller.rb
      invoke    erb
      create      app/views/test_dayos
      create      app/views/test_dayos/index.html.erb
      create      app/views/test_dayos/edit.html.erb
      create      app/views/test_dayos/show.html.erb
      create      app/views/test_dayos/new.html.erb
      create      app/views/test_dayos/_form.html.erb
      invoke    rspec
      create      spec/controllers/test_dayos_controller_spec.rb
      create      spec/views/test_dayos/edit.html.erb_spec.rb
      create      spec/views/test_dayos/index.html.erb_spec.rb
      create      spec/views/test_dayos/new.html.erb_spec.rb
      create      spec/views/test_dayos/show.html.erb_spec.rb
      create      spec/routing/test_dayos_routing_spec.rb
      invoke      rspec
      create        spec/requests/test_dayos_spec.rb
      invoke    helper
      create      app/helpers/test_dayos_helper.rb
      invoke      rspec
      create        spec/helpers/test_dayos_helper_spec.rb
      invoke    jbuilder
      create      app/views/test_dayos/index.json.jbuilder
      create      app/views/test_dayos/show.json.jbuilder
      invoke  assets
      invoke    coffee
      create      app/assets/javascripts/test_dayos.js.coffee
      invoke    scss
      create      app/assets/stylesheets/test_dayos.css.scss
      invoke  scss
   identical    app/assets/stylesheets/scaffolds.css.scss
テスト実行

テストを実行してみるが、エラーが発生。

# bundle exec rspec
DEPRECATION WARNING: The configuration option `config.serve_static_assets` has been renamed to `config.serve_static_files` to clarify its role (it merely enables serving everything in the `public` folder and is unrelated to the asset pipeline). The `serve_static_assets` alias will be removed in Rails 5.0. Please migrate your configuration files accordingly. (called from block in <top (required)> at /hoge_app/config/environments/test.rb:16)

Migrations are pending. To resolve this issue, run:

  bin/rake db:migrate RAILS_ENV=test
エラーの対応

エラーの通り設定を修正

# vim config/environments/test.rb
# config.serve_static_assets  = true
config.serve_static_files  = true

エラーの通り TestDayo モデルを追加したのでマイグレートする

# bundle exec rake db:migrate
# RAILS_ENV=test bundle exec rake db:migrate
再びテスト実行
# bundle exec rspec
...
Finished in 3.33 seconds (files took 2.27 seconds to load)
30 examples, 0 failures, 17 pending

Rspec によるテスト


Guard起動

Guard を実行しながらテストを書いてみる。

 # bundle exec guard

最初に rubocop が動作するが、一旦無視w

iTerm 使っているならcmd + shift + dとかで画面分割するといい。

# vim spec/models/test_dayo_spec.rb

適当に編集し、:w で保存の度に Guard で Rspec が実行されることを確認。

rubocop が若干うるさいので、Rspec が通ったら Rubocop が実行されるようにする。

# vim Guardfile
group :red_green_refactor, halt_on_fail: true do
  ...
  guard :rspec, cmd: "bundle exec spring rspec" do
  ...
  guard :rubocop do
  ...
end

guard :jasmine do
...

yujinakayama/guard-rubocop · GitHub
↑詳細はここに。

モデルのテストを書く

1つ目は FactoryGirl によって生成されたデータのテスト、通るテスト。
2つ目は namaedesu という存在しないメソッドのテスト。
3つ目は valid? メソッドを正しい/正しくないパラメータを与えてテスト。

# vim spec/models/test_dayo_spec.rb
RSpec.describe TestDayo, type: :model do
  subject(:subject) { FactoryGirl.create(:test_dayo) }
  describe "#name" do
    it { expect(subject.name).to eq "MyString" }
  end
  describe "#namaedesu" do
    it { expect(subject.namedesu).to eq "MyStringdesu"}
  end

  describe "#valid?" do
    context "given valid_param" do
      let(:valid_param) { {name: "name"} }
      subject(:subject) { FactoryGirl.build(:test_dayo, valid_param).valid? }
      it { expect(subject).to be true }
    end

    context "given invalid_param" do
      let(:invalid_param) { {name: ""} }
      subject(:subject) { FactoryGirl.build(:test_dayo, invalid_param).valid? }
      it { expect(subject).to be false }
    end
  end
end

guard の画面はこんな感じ。
モデルにメソッドを実装していないし、validateの定義がないため落ちる。

- INFO - Running: spec/models/test_dayo_spec.rb
.F.F

Failures:

  1) TestDayo#namaedesu
     Failure/Error: it { expect(subject.namaedesu).to eq "MyStringdesu"}
     NoMethodError:
       undefined method `namaedesu' for #<TestDayo:0x007f9575946530>
     # ./spec/models/test_dayo_spec.rb:10:in `block (3 levels) in <top (required)>'

  2) TestDayo#valid? given invalid_param should equal false
     Failure/Error: it { expect(subject).to be false }

       expected false
            got true
     # ./spec/models/test_dayo_spec.rb:23:in `block (4 levels) in <top (required)>'

Finished in 0.02159 seconds (files took 3.12 seconds to load)
4 examples, 2 failures
モデルを実装
# vim app/models/test_dayo.rb
class TestDayo < ActiveRecord::Base
  validates :name, presence: true
  def namaedesu
    "#{name}desu"
  end
end

Guard でテストが通っていることを確認。

4 examples, 0 failures
コントローラーのテスト

とりあえず、テストを流し見てると
16 examples, 0 failures, 15 pending
15 のテストが pending 状態になっている。

それらをテストされるように修正していく。
skipで検索すると設定すべき項目がわかるので、それらを修正していく。

# vim spec/controllers/test_dayos_controller_spec.rb
...
  let(:valid_attributes) {
    #skip("Add a hash of attributes valid for your model")
    { name: "namae" }
  }

  let(:invalid_attributes) {
    #skip("Add a hash of attributes invalid for your model")
    { name: "" }
  }
...
  describe "PUT #update" do
    context "with valid params" do
      let(:new_attributes) {
        #skip("Add a hash of attributes valid for your model")
        { name: "aiueo"}
      }

      it "updates the requested test_dayo" do
        ...
        #skip("Add assertions for updated state")
        expect(assigns(:test_dayo)).to eq(test_dayo)
      end
...

テストが全て通るようになることを確認。

16 examples, 0 failures
ヘルパーのテスト

ヘルパー単体でテストを流すと pending が1つと出るので、 適当なテストケースを作る。

# vim spec/helpers/test_dayos_helper_spec.rb
...
  #pending "add some examples to (or delete) #{__FILE__}"
  describe "#kuso" do
    it { expect(helper.kuso("a-")).to eq "a-kuso" }
  end
1 example, 1 failure

ヘルパーメソッドを実装していないのでエラーが出るので実装。

# vim app/helpers/test_dayos_helper.rb
module TestDayosHelper
  def kuso(text)
    "#{text}kuso"
  end
end

テストが通ることを確認。

1 example, 0 failures
Capybara, Turnip によるテスト

デフォルトで用意されている welcom/index のページで簡単なテストを書く。

フィーチャーファイルの作成、utf-8で作成すること。
# vim spec/features/welcome.feature

# language: ja
機能: ウェルカムページ
  シナリオ: ウェルカムページへアクセスする
    前提ウェルカムページへアクセスする
    ならば 画面にWelcomeと表示されていること

ステップファイルの作成。 # vim spec/steps/welcome_step.rb

# encoding: utf-8
step 'ウェルカムページへアクセスする' do
  visit '/welcome/index'
end

step '画面にWelcomeと表示されていること' do
  expect(page).to have_content('Welcome')
end

テストが通ることを確認。

1 example, 0 failures

ステップファイルは使い回し可能な書き方ができるようなので、 プロジェクトで使う際には下記参考。

hachi8833/turnip_generic_steps · GitHub

↑を利用して、テストを書く。

# vim spec/steps/generic_step.rb
# 上記の汎用ステップファイルを持って来て下記を追加。
# 操作用ステップ
step %(:pageページにアクセスする) do |page|
  visit "#{page}"
end

feature ファイルの追加。
行頭の# language: jaとファイルのエンコードutf-8にすること。

# vim spec/features/test_dayo.feature
# language: ja
機能: テストだよ
  シナリオ: 一覧ページを開く
    前提"/test_dayos"ページにアクセスする
    ならば"Listing Test Dayos"と表示されている

  シナリオ: 新規作成ページで新規登録を行う
    前提"/test_dayos"ページにアクセスする
    ならば"New Test dayo"リンクをクリックする
    かつ"New Test Dayo"と表示されている
    かつ"test_dayo[name]"に"名前"を設定する
    かつ"Create Test dayo"ボタンをクリックする
    ならば"Test dayo was successfully created."と表示されている

  シナリオ: 新規作成で追加したデータが存在し、詳細ページを開ける
    前提"/test_dayos"ページにアクセスする
    ならば"名前"と表示されている
    かつ"Show"リンクをクリックする
    ならば"Name: 名前"と表示されている

  シナリオ: 新規作成したデータを編集して保存する
    前提"/test_dayos"ページにアクセスする
    ならば"Edit"リンクをクリックする
    ならば"Editing Test Dayo"と表示されている
    かつ"test_dayo[name]"に"名前です"を設定する
    かつ"Update Test dayo"ボタンをクリックする
    ならば"Test dayo was successfully updated."と表示されている
    かつ"Back"リンクをクリックする
    ならば"名前です"と表示されている

  シナリオ: 作成したデータを削除する
    前提"/test_dayos"ページにアクセスする
    ならば"Destroy"リンクをクリックする
    かつ"Test dayo was successfully destroyed."と表示されている

5つのシナリオテストが通っていることを確認。

5 examples, 0 failures

ちなみに Destroy ボタンを押したときに Javascript の Confirm ポップアップが出るが、 poltergeist を利用していると自動で OK が選択される。

featureスペックで確認ダイアログをクリックしたい - ツユダクの肉増しのRuby on Railsの初心者で

Jasmine でのテスト


js の spec ファイルを作成
# vim spec/javascripts/test_dayos_spec.js.coffee
//= require test_dayos
describe "TestDayo", ->
  beforeEach ->
    @test_dayo = new TestDayo("namae")
  it "name test", ->
    expect(@test_dayo.name).toBe "namae"

Guard ではエラーが発生する。

- INFO - TestDayo
- INFO -   ✘ name test
- INFO -     ➤ ReferenceError: Can't find variable: TestDayo
- INFO -       ➜ test_dayos_spec.js.coffee on line 8
- INFO -     ➤ TypeError: 'undefined' is not an object (evaluating 'this.test_dayo.name')
- INFO -       ➜ test_dayos_spec.js.coffee on line 11
- ERROR - 1 spec, 1 failure

coffeescriptTestDayo クラスを実装をする。

# vim app/assets/javascripts/test_dayos.js.coffee
class @TestDayo
  constructor: (name) ->
    @name = name
  name: ->
    @name

Jasmine のテストが通ることを確認。

- INFO - 1 spec, 0 failures

Guard を起動すると、Jasmine 用のサーバも立つので、
http://localhost:8888 とかでブラウザでテストを確認することもできる。

まとめ


一通りテストが動作するところまでの環境作りができた。
これだけのテスト環境を用意すると、快適な TDD/BDD を進めていけると思う。

Rspec + Capybara + Poltergeist で外部サイトのテストをする方法

概要


外部サイトをテストしたい!なんてことはほとんどないが、 スクレイピングだったりで外部サイトに依存する場合に検知できる方法ないかと思い、 Rspec + Capybara + Poltergeist で外部サイトをテストしてみた。

準備


  • ruby-2.1.3
  • bundler をインストールしておくこと
  • phantomjs をインストールしておくこと(Mac なら brew install phantomjs)
必要 gem のインストール
# cd [WORK_DIR]
# vim Gemfile
source "http://rubygems.org"
group :test do
  gem 'capybara'
  gem 'capybara-webkit'
  gem 'rspec'
  gem 'poltergeist'
end
$ bundle install --path vendor/bundler
Rspec の準備
$ rspec --init
  create   spec/spec_helper.rb
  create   .rspec
テストコードの実装
$ vim spec/test.rb
require 'spec_helper'
require 'capybara/rspec'
require 'capybara/poltergeist'
require 'capybara-webkit'
require 'capybara/dsl'

Capybara.javascript_driver = :poltergeist
Capybara.register_driver :poltergeist do |app|
  Capybara::Poltergeist::Driver.new(app, :js_errors => true, :timeout => 60)
end

Capybara.configure do |config|
    config.run_server = false
    config.default_driver = :poltergeist
    config.app_host = 'http://www.google.com'
end

describe "test", :type => :feature do
  subject{ page }
  before { visit('/') }

  it "test" do
    expect(page).to have_text('画像')
  end

  it "test2" do
    within("form") do
      fill_in "q", with: "aaa"
    end
    click_button "Google 検索"
    expect(page).to have_text('aaa')
    page.save_screenshot('screenshot.png')
    page.save_screenshot('screenshot2.png', full: true)
    expect(page).to have_text('次へ')
  end
end

テスト内容は下記。

  • test の内容
    • Google トップページを開いて「画像」の文字列があるか
  • test2 の内容
    • フォームに「aaa」と入力し、検索ボタンを押下
    • 検索結果ページに「aaa」という文字列があるか
    • スクリーンショットを「screen_shot.png」として保存
    • 全画面のスクリーンショットを「screen_shot2.png」として保存
    • 次への文字列があるか
テストの結果
$ bundle exec rspec test.rb
..

Finished in 3.21 seconds (files took 0.45994 seconds to load)
2 examples, 0 failures

スクリーンショットは下記。

f:id:jetglass:20150512131041p:plain

まとめ


外部サイトのテストができることが確認できた。

実際に運用するとなると、 外部サイトが高負荷でタイムアウトするとか・エラーで落ちてるとか、 外部サイト起因でテストにこけることがストレスになりそう。

さじ加減によるが、テスト対象のサーバへ負荷をかけてしまうので、 その辺りの配慮も必要になるのかなと思う。

Rails | Ajax で動的な検索・ソート機能付きページネーションを実装する(jQuery DataTablesプラグイン連動)

概要

Rails でページング処理といえば、kaminari だと思う。
github.com

kaminari で Ajax のページングもできるようだが、
AdventCalendar - kaminari徹底入門 - Qiita

jQuery の DataTables プラグインと連携して、ページング(一覧画面)を実装する。
(ページ送りは kaminari 使います・・・) github.com
この gem を利用することで jQuery の DataTables プラグインを簡単に導入できる。

DataTables プラグイン自体のサンプルは下記。
DataTables example - Ajax data source (arrays)

検索・ソート・件数指定など、javascript 側で実装してくれている。
件数が少ないようであれば、全件データを JSON形式で DataTables に渡してしまえば、 上記のサンプルのように軽快な動作で使い勝手が良い。

件数が多い場合はサーバへ Ajax 通信しながら表を作る。
DataTables example - Server-side processing

ソート条件や検索ワードなどがサーバ側へ送られて来るので、 それらの対応をして JSON 形式でデータを返してやればいい。

今回は Ajax 通信しながら動的に表を作る方の実装を行う。

準備

Gemfile に下記を追加。

gem 'kaminari'
gem 'jquery-datatables-rails'

インストールする。

$ bundle install
...
Installing kaminari 0.16.3
...
Installing jquery-datatables-rails 3.3.0
...

rweng/jquery-datatables-rails · GitHub
手順にある通り、generator からのインストール。

$ bundle exec rails generate jquery:datatables:install
      insert  app/assets/javascripts/application.js
      insert  app/assets/stylesheets/application.css

下記ファイルに下記内容が追加されていることを確認する。

$ less app/assets/javascripts/application.js
//= require dataTables/jquery.dataTables

$ less app/assets/stylesheets/application.css
*= require dataTables/jquery.dataTables

bootstrap 3 をインストール。

bundle exec rails generate jquery:datatables:install bootstrap3
      insert  app/assets/javascripts/application.js
      insert  app/assets/stylesheets/application.css

下記ファイルに下記内容が追加されていることを確認する。

$ less app/assets/javascripts/application.js
//= require dataTables/jquery.dataTables
//= require dataTables/bootstrap/3/jquery.dataTables.bootstrap

$ less app/assets/stylesheets/application.css
*= require dataTables/bootstrap/3/jquery.dataTables.bootstrap

本家の bootstrap 3 を配置する。

Bootstrap · The world's most popular mobile-first and responsive front-end framework.
上記から bootstrap 3 をダウンロード。

# 下記のようにファイルを配置する
app/assets/javascripts/bootstrap.min.js
app/assets/stylesheets/bootstrap.min.css

実装

実装に入るが一覧ページを新たに作るのもダルいので、 前にこのブログで作ったページを利用する。

Railsで階層化された複数モデルに対応するフォームの作り方 - Shred IT!!!!
Railsで階層化された複数モデルに対応するフォームの作り方【JavaScript/CoffeeScriptによる動的処理追加】 - Shred IT!!!!

ルーティング

index とは別に list というルーティングを新たに追加。

# config/routes.rb 一部抜粋
  resources :events do
    collection do #追加
      get :list #追加
    end #追加
  end

コントローラー

list メソッドを実装。
EventsDatatableはパラメーターを受け取って、それに従った SQL を実行し、JSON 形式に変換するクラス。

# app/controllers/events_controller.rb 一部抜粋
  def list
    respond_to do |format|
      format.html
      format.json {render json: EventsDatatable.new(params) }
    end
  end

ビュー

必要最低限の項目を作成。
あとは jQuery DataTables プラグインが色々な機能を足してくれる。

# app/views/events/list.html.erb を追加
<table id="events" class='table table-striped table-bordered'>
  <thead>
    <tr>
      <th>ID</th>
      <th>Name</th>
    </tr>
  </thead>
  <tbody>
  </tbody>
</table>

JSON変換クラス

一番大きい実装(モデルに実装できる内容だが、何も考えず切り分けた )。
受け取ったパラメーターから条件に合うデータを取得、結果を JSON 形式にする。

まずはAjax 通信で飛んでくるパラメーター。

{
  "draw" => "1", 
  # 今回の実装では columns 内 data  の項目しか使っていない
  # 項目毎に検索対象やソート対象にするか、とかのオプション指定ができると思われる
  "columns" => {
    "0" => {"data"=>"id", "name"=>"", "searchable"=>"true",
            "orderable"=>"true", "search"=>{"value"=>"", "regex"=>"false"}},
    "1" => {"data"=>"name", "name"=>"", "searchable"=>"true",
            "orderable"=>"true", "search"=>{"value"=>"", "regex"=>"false"}}
  },
  # どのカラムを昇順・降順にするか
  "order"=>{"0"=>{"column"=>"0", "dir"=>"asc"}},
  # ページ数と1ページに取得する件数
  "start"=>"0", "length"=>"10", 
  # 検索キーワード
  "search"=>{"value"=>"hoge", "regex"=>"false"}, "_"=>"14328624114557"}

パラメータを解釈して、データを取得するクラス。

# app/datatables/events_datatable.rb 追加
# -*- coding: utf-8 -*-
#
class EventsDatatable
  attr_accessor :params

  def initialize(params)
    @params = params
  end

  # jQuery DataTables へ渡すためのハッシュを作る
  # 補足:コントローラーの render json: で指定したオブジェクトに対して as_json が呼び出される
  def as_json(options = {})
    {
      recordsTotal: Event.count, # 取得件数
      recordsFiltered: events.total_count, # フィルター前の全件数
      data: events.as_json, # 表データ
    }
  end

  def events
    @events ||= fetch_events
  end

  # 検索条件や件数を指定してデータを取得
  def fetch_events
    Event.where(search_sql).order(order_sql).page(page).per(per)
  end

  # カラム情報を配列にする
  def columns
    return [] if params["columns"].blank?
    params["columns"].map{|_,v| v["data"]}
  end

  # 検索ワードが指定されたとき
  def search_sql
    return "" if params["search"]["value"].blank?
    search = params["search"]["value"]
    # name カラム固定の検索にしている
    # "name like '%hoge%'"のようにSQLの一部を作る
    "name like '%#{search}%'"
  end

  # ソート順
  def order_sql
    return "" if params["order"]["0"].blank?
    order_data = params["order"]["0"]
    order_column = columns[order_data["column"].to_i]
    # "id desc" のようにSQLの一部を作る
    "#{order_column} #{order_data["dir"]}"
  end

  # kaminari 向け、ページ数
  def page
    params["start"].to_i / per + 1
  end

  # kaminari 向け、1ページで取得する件数
  def per
    params["length"].to_i > 0 ? params["length"].to_i : 10
  end

end

coffeescript

下記を追加。

# app/assets/javascripts/events.js.coffee
jQuery ->
  $('#events').dataTable
    "processing": true, # 処理中の表示
    "serverSide": true, # サーバサイドへ Ajax するか
    "ajax": "list", # Ajax の通信先
    "columns": [ # 扱うカラムの指定
      { "data": "id" },
      { "data": "name" },
    ]

オートロード追加

追加した JSON 変換クラスをオートロードさせる。

# app/config/application.rb
...
  class Application < Rails::Application
    config.autoload_paths += %W(#{config.root}/app/datatables) # 追加
...

実装完了

これで実装は終わり。
見づらいが、動作している画面をキャプチャにする。
ページ送り・ソート・検索などで都度 Ajax 通信で JSON を取得している。

f:id:jetglass:20150526170200g:plain

まとめ

jQuery DataTables プラグインを使うことで検索・ソート・ページ送りなどの機能をビュー側で実装する必要がなくなるので、手っ取り早く一覧画面を作りたいときにおすすめ。

今回の実装はほぼ初期設定だが、それでも十分だと感じた。

編集ボタンや削除ボタンの HTML を表データとして JSON にして返却すれば、 ボタンの設置も可能。

jQuery DataTables プラグインは高機能だと思われるので、 必要に応じてリファレンス読み込んで機能追加するといい。
Reference

新規追加・編集・削除も Ajax で動的にできるようだ。
https://editor.datatables.net/examples/simple/simple

ギターのネックの反りや捻れを簡単に直す方法

概要

ギターを久しぶりに弾こうと思ったら、 ネックが反ったり捻れたりしてる・・・

トラスロッドで調節するのも気が重いし、 楽器屋に持って行くのもなぁーって人に向けた記事です。


ギターのネックが捻れて反ってる?

昔働いていた職場の同僚で、某F社のギタークラフトマンとして仕事されていた方に伝授してもらった方法。

一緒に酒飲んでる時にこんな相談・会話をした。

俺「14フレット2弦だけが異常に音の伸びが悪いんですよ」  
同「それはたぶん、ネック捻れてますよ」  
俺「えー、トラスロッドでなんとかなりますか?」  
同「いや、トラスロッドは下手にいじらない方がいいです」  
同「ネック外せるなら、ネック外して一週間くらい床に置いておくだけで解消しますよ」  
俺「おぉ、ありがとうございます、試してみます!」  
同「それで解消しないなら楽器屋持って行ってください。」

早速、試してみたところ、見事に解消した!

手汗をかくからギターの弦はエリクサーのサビづらいのを使っているが、 弦を交換する度に必ずネックも外して床に置くようにしていた。

Elixir エリクサー エレキギター弦 NANOWEB Super Light .009-.042 #12002 【国内正規品】


久々にネック矯正してみる

色々なことがあって、3年くらいギターを放置していたが、 趣味の時間も作れそうだから、またギターに没頭したいと思っている。

それでは3年ほど放置してしまったギターで試してみる。
埃と錆だらけ、まじでごめんなさい・・・

1.ギターの弦を外す
f:id:jetglass:20150524203440j:plain

2.ギターが埃かぶってるのでポリッシュで拭く
f:id:jetglass:20150524204440j:plain

3.指板をオイルで拭く
f:id:jetglass:20150524203439j:plain

4.ネジが錆びまくっているが、ネックのネジを外す
f:id:jetglass:20150524203437j:plain

5.ネックを床に置いて 1, 2週間放置
f:id:jetglass:20150524203441j:plain

6.元に戻して、弦を張る
f:id:jetglass:20150524204441j:plain

これでギターのメンテ終わり、音のノビは上々!

ネックを外せるギター限定の方法だが、反りや捻れに悩んでいる方はぜひお試しあれ。
※ギターが壊れても責任取れないので自己責任でお願いします。
直らないなら素直に楽器屋やらリペアショップやらに持っていくのがいいかと。

次に機会があれば、ギターの錆びたネジを交換する回でもやる。

MySQL で IN 句 + サブクエリの処理時間が遅い場合の改善方法

概要

とあるプロジェクトで Rails 3.2 + MySQL 5.5 を利用してる。

管理画面の機能追加と改修をしているのだが、 ある機能の一覧ページが重くて開かないと報告を受けた!

1台しかない MySQL サーバが落ちて、 サービスに支障が出たことが何度かあるらしい。

問題のページについて

問題のページはよくある、管理画面の一覧画面。

Ajax により、JSON で一覧情報を受け取って表にするページなのだが、 集計値も表に含まれており、この集計値の算出が遅い原因になっていた。

調査・再現

本番同等のDBを準備してもらい、問題の画面を開く。

数分待っても、JSON が返却される様子がなく、そのままタイムアウト

MySQL のクライアントからshow processlist;で実行中の SQL を軽く覗いてみると、 集計値を出すための SQL が 15 〜 30 秒くらいかかっている。

Rails のソースでは hoge.join(...).where(...).count をしていて、 初期表示では 25 件分の表示があるため、 ざっくり 「15 秒 * 25 件」 の時間がかかってしまっていたのだ。

Rails で何も考えず作られた SQL だろうし、 できることならインデックス追加するだけで対応したいってのが本音。

複合インデックスを追加する簡易対応

何となく勘で、どうせインデックスが使われてないんやろと思って、 実行計画を見てみた。
(この時点で SQL はよく見てない)

+----+--------------------+------------------+------+--------------------------------------+--------------------------------------+---------+-------+------+----------------------------------------------+
| id | select_type        | table            | type | possible_keys                        | key                                  | key_len | ref   | rows | Extra                                        |
+----+--------------------+------------------+------+--------------------------------------+--------------------------------------+---------+-------+------+----------------------------------------------+
|  1 | PRIMARY            | hoge | ref  | FK_hoge_fugaid | FK_hoge_fugaid | 8       | const |    2 | Using where                                  |
|  2 | DEPENDENT SUBQUERY | hoge | ref  | FK_hoge_fugaid | FK_hoge_fugaid | 8       | const |    2 | Using where; Using temporary; Using filesort |
+----+--------------------+------------------+------+--------------------------------------+--------------------------------------+---------+-------+------+----------------------------------------------+

Using temporary; Using filesort はあかん、 複合インデックスを新たに追加。

+----+--------------------+------------------+------+-----------------------------------------------------------------+--------------------------------------+--
-------+-------+------+--------------------------+
| id | select_type        | table            | type | possible_keys                                                   | key                                  | k
ey_len | ref   | rows | Extra                    |
+----+--------------------+------------------+------+-----------------------------------------------------------------+--------------------------------------+---------+-------+------+--------------------------+
|  1 | PRIMARY            | hoge | ref  | FK_hoge_fugaid,idx_add_test_key | FK_hoge_fugaid    | 8       | const |    2 | Using where              |
|  2 | DEPENDENT SUBQUERY | hoge | ref  | FK_hoge_fugaid,idx_add_test_key | idx_add_test_key  | 8       | const |    2 | Using where; Using index |
+----+--------------------+------------------+------+-----------------------------------------------------------------+--------------------------------------+---------+-------+------+--------------------------+

へ改善。
(あとで説明するが、 これらの実行計画で問題なのは 2 番目の select_type = DEPENDENT SUBQUERY)

問題のページを開いてみると、ページが開くようになり、 JSON 返却まで 3〜4 秒くらいになった。

だが、ページングでいろんなページを開いてみると、 10 秒くらいかかる遅いページもあるし、納得できない。

複合インデックス追加だけで逃げられないか、と SQL を調査。

SQL を調査

適当に省略 + 簡略化した問題の SQL を下記へ。

SELECT COUNT(*) FROM hoge WHERE
  hoge.id IN (SELECT max(id) as max_id FROM hoge  WHERE hoge.fuga_id = 1 GROUP BY user_id)
  AND (flag IS NULL OR flag = 0)

どうやら IN 句内のサブクエリで取得する件数が多いほど、 SQL の実行速度が遅くなる傾向を掴んだ。

このケースでは 1000 件から 2 秒以上かかる感じだった。

IN を INNER JOIN へ

問題のSQL は id の突き合わせに IN 句を利用しているが、 これは上記の通り件数が増えるごとに遅くなっていく。

仮に 10 万件の集計値を求めるときには IN 句の指定が 10 万件必要になる。
MySQL がどう解釈して動いているか知らないけど、 人間の頭で考えても非効率そうだ。

INNER JOIN で id の突き合わせをした後の行数をカウントした方が速いだろうと、 SQL を下記のように修正。

SELECT COUNT(id) FROM hoge h1
  INNER JOIN  (SELECT max(id) AS max_id FROM hoge  WHERE hoge.fuga_id = 1 GROUP BY user_id) h2
  ON h1.id = h2.max_id
  WHERE h1.flag IS NULL OR h1.flag = 0

とある遅いクエリ、
8.73 sec -> 0.13 sec へ。

実行計画は下記。

+----+-------------+------------------+--------+-------------------------------------------------------------------------+----------------------------+---------+------------+------+--------------------------+
| id | select_type | table            | type   | possible_keys                                                           | key                        | key_len | ref        | rows | Extra                    |
+----+-------------+------------------+--------+-------------------------------------------------------------------------+----------------------------+---------+------------+------+--------------------------+
|  1 | PRIMARY     | <derived2>       | ALL    | NULL                                                                    | NULL                       | NULL    | NULL       | 2198 |                          |
|  1 | PRIMARY     | h1               | eq_ref | PRIMARY                                                                 | PRIMARY                    | 8       | h2.max_id  |    1 | Using where              |
|  2 | DERIVED     | hoge             | ref    | idx_add_test_key                                                        | idx_add_test_key           | 8       |            | 2218 | Using where; Using index |
+----+-------------+------------------+--------+-------------------------------------------------------------------------+----------------------------+---------+------------+------+--------------------------+

(あとで説明するが、select_type = DEPENDENT SUBQUERY が消えている)

Rails で上記 SQL を発行するように ArelTable を利用して書き直して、修正終わり。

# 修正前
Hoge.where(id: Fuga.where... ).where...

# 修正後
to = hoge_arel_table.project(...).where...
hoge_arel_table.project(...).join(to, Arel::Nodes::InnerJoin).on(...).where...

参考

無責任な記事は書けないので、さらなる調査をしたら下記サイトを見つけた。

漢(オトコ)のコンピュータ道 さんは大昔から何度もお世話になっている。

要点だけを書くと、

  • IN 句 + サブクエリは EXISTS 句に書き換えられて実行される
  • 実行計画の select_type が DEPENDENT SUBQUERY は遅い
  • 遅い IN 句 は JOIN へ置き換えろ

nippondanji.blogspot.jp

MySQLにおいてDEPENDENT SUBQUERYが何故遅いか?それはクエリの評価方法にある。 現時点でのMySQL(バージョン5.1)では、サブクエリはまず外部クエリの条件から評価される。そして、外部クエリの条件に合致する行が見つかると、その行がサブクエリの条件に合致するかどうかが評価されるわけである。即ち、サブクエリにおいてフェッチしなければいけない行数が平均N行、外部クエリでフェッチされる行数がM行のとき、サブクエリにおいてM×N行の評価が行われることになる。これは膨大な計算量である。

tech.aainc.co.jp

サブクエリを使うと実行順が外側からと固定になってしまいますが、JOINの場合はMySQLが最適な実行順を勝手に計算して実行してくれます。

まとめ

  • 実行計画の select_type が DEPENDENT SUBQUERY は遅いから要注意
  • IN 句 + サブクエリでインデックスを適切に貼っても遅い場合は JOIN で書き直す

JOIN で書き直した実行計画は DEPENDENT SUBQUERY が消えて、 適切にクエリを処理できるようになったようだ。

実行計画は本当に奥が深くて難しいし、まだまだ解っていないことも多い。
Rails で開発しているから SQL と触れる機会は激減したけど、SQL の改善作業ってのは楽しい。

しかしこれ、MySQL Cluster だと JOIN 遅くて使い物にならないから、解決にならんだろうな・・・