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
- Javascript向けテストフレームワーク
- FactoryGirl
- テストデータを柔軟に生成
- Guard
- ファイル変更を監視して任意のコマンドを実行
- Rubocop
- 静的解析ツール(コーディング規約チェック)
- Spring
- アプリケーションプリローダー
何ができるかというと、
- 単体テスト(Ruby, Javascript)
- 結合テスト
- 静的解析
- 高速自動テスト(ファイル変更がトリガー)
準備
前提
下記を 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
coffeescript で TestDayo
クラスを実装をする。
# 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 で外部サイトをテストしてみた。
準備
必要 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 の内容
テストの結果
$ bundle exec rspec test.rb .. Finished in 3.21 seconds (files took 0.45994 seconds to load) 2 examples, 0 failures
スクリーンショットは下記。
まとめ
外部サイトのテストができることが確認できた。
実際に運用するとなると、 外部サイトが高負荷でタイムアウトするとか・エラーで落ちてるとか、 外部サイト起因でテストにこけることがストレスになりそう。
さじ加減によるが、テスト対象のサーバへ負荷をかけてしまうので、 その辺りの配慮も必要になるのかなと思う。
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 を取得している。
まとめ
jQuery DataTables プラグインを使うことで検索・ソート・ページ送りなどの機能をビュー側で実装する必要がなくなるので、手っ取り早く一覧画面を作りたいときにおすすめ。
今回の実装はほぼ初期設定だが、それでも十分だと感じた。
編集ボタンや削除ボタンの HTML を表データとして JSON にして返却すれば、 ボタンの設置も可能。
jQuery DataTables プラグインは高機能だと思われるので、
必要に応じてリファレンス読み込んで機能追加するといい。
Reference
新規追加・編集・削除も Ajax で動的にできるようだ。
https://editor.datatables.net/examples/simple/simple
ギターのネックの反りや捻れを簡単に直す方法
概要
ギターを久しぶりに弾こうと思ったら、 ネックが反ったり捻れたりしてる・・・
トラスロッドで調節するのも気が重いし、 楽器屋に持って行くのもなぁーって人に向けた記事です。
ギターのネックが捻れて反ってる?
昔働いていた職場の同僚で、某F社のギタークラフトマンとして仕事されていた方に伝授してもらった方法。
一緒に酒飲んでる時にこんな相談・会話をした。
俺「14フレット2弦だけが異常に音の伸びが悪いんですよ」 同「それはたぶん、ネック捻れてますよ」 俺「えー、トラスロッドでなんとかなりますか?」 同「いや、トラスロッドは下手にいじらない方がいいです」 同「ネック外せるなら、ネック外して一週間くらい床に置いておくだけで解消しますよ」 俺「おぉ、ありがとうございます、試してみます!」 同「それで解消しないなら楽器屋持って行ってください。」
早速、試してみたところ、見事に解消した!
手汗をかくからギターの弦はエリクサーのサビづらいのを使っているが、 弦を交換する度に必ずネックも外して床に置くようにしていた。
久々にネック矯正してみる
色々なことがあって、3年くらいギターを放置していたが、 趣味の時間も作れそうだから、またギターに没頭したいと思っている。
それでは3年ほど放置してしまったギターで試してみる。
埃と錆だらけ、まじでごめんなさい・・・
1.ギターの弦を外す
2.ギターが埃かぶってるのでポリッシュで拭く
3.指板をオイルで拭く
4.ネジが錆びまくっているが、ネックのネジを外す
5.ネックを床に置いて 1, 2週間放置
6.元に戻して、弦を張る
これでギターのメンテ終わり、音のノビは上々!
ネックを外せるギター限定の方法だが、反りや捻れに悩んでいる方はぜひお試しあれ。
※ギターが壊れても責任取れないので自己責任でお願いします。
直らないなら素直に楽器屋やらリペアショップやらに持っていくのがいいかと。
次に機会があれば、ギターの錆びたネジを交換する回でもやる。
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 へ置き換えろ
MySQLにおいてDEPENDENT SUBQUERYが何故遅いか?それはクエリの評価方法にある。 現時点でのMySQL(バージョン5.1)では、サブクエリはまず外部クエリの条件から評価される。そして、外部クエリの条件に合致する行が見つかると、その行がサブクエリの条件に合致するかどうかが評価されるわけである。即ち、サブクエリにおいてフェッチしなければいけない行数が平均N行、外部クエリでフェッチされる行数がM行のとき、サブクエリにおいてM×N行の評価が行われることになる。これは膨大な計算量である。
サブクエリを使うと実行順が外側からと固定になってしまいますが、JOINの場合はMySQLが最適な実行順を勝手に計算して実行してくれます。
まとめ
- 実行計画の select_type が DEPENDENT SUBQUERY は遅いから要注意
- IN 句 + サブクエリでインデックスを適切に貼っても遅い場合は JOIN で書き直す
JOIN で書き直した実行計画は DEPENDENT SUBQUERY が消えて、 適切にクエリを処理できるようになったようだ。
実行計画は本当に奥が深くて難しいし、まだまだ解っていないことも多い。
Rails で開発しているから SQL と触れる機会は激減したけど、SQL の改善作業ってのは楽しい。
しかしこれ、MySQL Cluster だと JOIN 遅くて使い物にならないから、解決にならんだろうな・・・