何気に、アプリケーションで取り扱うデータ量が増えた場合や、大容量データをデータベースとやり取りする時なんかは、Ajaxを使ってデータ通信をバックエンド側に押しやることで、WEBフロント側のUXからパフォーマンスの悪さを改善できたりする。また、ネットワーク経路的にproxyサーバとかを中継するような環境間でデータのやり取りをする場合などに、proxy側で接続時間にリミットがかけてあったりすると、通常アクセスではデータ通信時間がリミットに達して503エラーとかになってしまうような処理でもAjaxで通信をバックエンド化することで、回避できたりもするのだ。
通信帯域が小さいスマートデバイスが主力である今のご時勢、Ajaxによる非同期処理は、言語やフレームワークを問わずに必須な技術になっている。
私の主力スキルはPHPなので、PHP+JavaScript(jQuery)やWordPressでのAjax処理なら特に造作もなく作れていたんだが、今回Rubyでのアプリケーション開発をして、Ruby on Rails(バージョン 4.2.0)でのAjax処理の開発にかなり手を焼いた次第。確かに、広く云われているようにRailsでのAjax処理はお手軽で生産性が高いのだけれど、私の知見的にはかなり癖がある作り方が必要だった。応用を利かそうとすると毎度シビれる状況に陥ったりしてなかなかに大変だった──という経験から、一度ここらでRails+Ajaxについての情報を整理しておこうと思ったのだ。
環境情報
まずは本記事における私のRails環境情報をまとめておく。RubyやRailsではバージョンによって結構コーディング条件や使える関数が異なったりするので、これは重要。
- Ruby 1.9.3
- Ruby on Rails 4.2.0
- jquery-rails 4.0.3 (※ Railsに依存するgemなのでインストール時に自動で入るハズ?)
- jQuery 1.11.2(※ 2015/1/19時点。Rails の Turbolinkに よって自動で最新版が読み込まれる)
- coffee-script 2.3.0 (※ Railsに依存するgemの coffee-rails は 4.1.0)
一般的なAjax処理のおさらい
Railsに限らず、世間一般のAjax処理の全体な流れを図にすると下記のようになるのかな──と。
古典的なWEBアプリケーションだと、ボタンアクションや画面遷移などのユーザー側からの明確なイベント後に、おもにWEBページの読込処理を伴ってサーバーサイドとやり取りをするんだが、Ajaxだとユーザーからの明示的なアクションがなくても、およそJavaScript側で取得できるすべてのイベントから発火させることができ、さらにページ遷移などを伴わずにリアルタイムにフロントエンドに結果を反映できるのが大きな特徴だ。非同期で実行すれば、ブラウザ側の機能がロックされることもないので、データがすべて読み込まれるまでユーザーに待ちを強いるようなこともないし、都度最小限のデータアクセスでアプリケーションを動かすことができるようになるので、ユーザビリティやアクセシビリティを最大化させられる。
あと重要なのが、Ajaxの非同期処理はイベント発火順のシーケンシャルには処理されないということだ(下図参照)。
JavaScript側で、1つのイベントに対して非同期設定のAjaxで複数のリクエスト送信を行うような場合、そのレスポンスがリクエスト順には戻ってこないのだ。これは、Ajax処理を連結してAjaxチェーンを作る時などに注意しておかないと、意図しない結果になったりする。
RailsでのAjax処理のおさらい
Ruby on Railsでは View 側のヘルパータグ(form_tag
)等で :remote => true
を指定するだけで該当フォームでのsubmitアクションをAjax化してくれる。
Viewの例:
<%= form_tag({:controller => '<コントローラ名>', :action => 'index'}, :remote => true, :class => 'form-ajax') do -%>
<%= hidden_field_tag :ajax_handler, 'handle_name1' -%>
<%= submit_tag('Start Ajax', :class => 'btn btn-primary btn-sm') -%>
<% end %>
Controller側ではその アクションに対応する処理を行い、render
でレスポンスを指定する。
Controllerの例:
class XXXXsController < ApplicationController
def index
ajax_action unless params[:ajax_handler].blank?
# Ajaxリクエストではない時の処理
end
def ajax_action
if params[:ajax_handler] == 'handle_name1'
# Ajaxの処理
@data = Datum.all
if @data.size > 0
render
else
render json: 'no data'
end
end
end
end
デフォルトではレスポンスとしてフロントエンド側にJavaScriptの処理が戻ってくる。そのレスポンス用JavaScriptは View 側にJavaScriptテンプレートを用意する。
JavaScriptテンプレートの例:
if ($('#ajax-response-wrapper').size() === 0) {
$('body').append('<div id="ajax-response-wrapper"></div>');
} else {
$('#ajax-response-wrapper').html('');
}
$('#ajax-response-wrapper').html('<%=j render partial: "ajax_container", collections: @data %>');
この例では、レンダリングされたインスタンス変数@data
を整形したHTMLとして表示するため、さらにJavaScriptから partial を使ってhtmlテンプレートを呼んでいる。
partialテンプレートの例: (※ _<partial名>.html.erb
)
<ul>
<% @data.each do |datum| %>
<li><%= datum.slug %></li>
<% end %>
</ul>
これだけで(…といっても結構手間だが)Ajax処理(正常系のみ)は完結してしまうのだ(この辺の詳しい解説は色々なサイトに紹介されているので本記事では割愛する)。一番凄いと思ったのが、Ajax関連のJavaScriptなんぞ一切コーディングせずともAjax処理ができてしまうという点だ。このあたりがRailsでのAjax処理の生産性が高い理由でもある。
ただ、実用的なアプリケーション開発を行う場合、このような単純な処理だけでは機能不足なことが多くて、エラー処理やAjax中のview制御などもう少し突っ込んだハンドリングが最低限必要になってくる。
例えば、前述の例だと、controllerでDatumモデルにレコードがない場合エラーにしているが、フロントエンド側でエラー用のJSONをキャッチする処理がないため、ユーザーがエラーを認識できないのだ。ではAjaxのエラー処理のサンプルを書いてみる。
Ajaxエラーキャッチ用JavaScriptの例: (※ app/assets/javascript/ 内)
XXXXs = ->
$('.form-ajax').on 'ajax:error', (e, xhr, status, error) ->
alert xhr.responseText
$(document).ready(XXXXs)
$(document).on('page:load', XXXXs)
RailsのJavaScript側でのAjax制御の実装は、上記のようにAjax通信の発火フォームのイベントハンドラとして定義する。しかしまぁ、ここまで書いてきて改めて思ったが、単純な1つのAjax処理に対してあまりにも手を入れなければならないファイルが多い。これだと管理が大変だ。前出のJavaScriptテンプレートやpartialテンプレートを使わず、controllerからは処理結果を一律JSONでレンダリングして、コントローラ用のCoffeeScriptにAjaxの後続処理を一元化してしまった方が全体の処理として見通しが良くなる気がする。
──ということで、この記事では前出のようなRailsの独自実装型仕様に極力依存せずに、フロントエンドのAjax関連処理はJavaScript(jQuery)に集約するような建付けでやってみる。
jQueryでのAjaxイベント
そもそもJavaScriptでAjaxを取り扱う際は、素のJavaScriptよりjQueryでやった方が圧倒的に簡単だ。コードの見通しが良いのはもちろん、コード量も抑えられるためコーディングコストが低いというメリットもある。幸いにもRailsはデフォルトでjQueryが使える環境になっているし、さらにCoffeeScriptで書けるので、スクリプト部分のコーディングがとても楽だ(CoffeeScriptの作法に馴れるまではちょっと違和感があったが…)。
──そんなわけで、本記事でのJavaScriptコードはCoffeeScript+jQueryで書いてあるのでご注意を。
さて、まずは一般的なjQueryでのAjaxイベントをまとめてみた。
イベント名 | Railsでのイベント名 | 呼び出しタイミング | 第1引数 | 第2引数 | 第3引数 | 備考 |
---|---|---|---|---|---|---|
Before | ajax:before | 全Ajaxリクエストの開始前 | - | - | - | イベント停止時、Ajaxリクエストは中断 |
BeforeSend | ajax:beforeSend | Ajaxリクエストが送信される直前 | XHR | settings | - | イベント停止時、Ajaxリクエストは中断 |
Send | ajax:send | Ajaxリクエストの送信直後(通信成功時) | XHR | - | - | |
Error | ajax:error | 通信成功後、サーバーがエラーを返した時 | XHR | status | error | jQuery 1.7系まで |
Success | ajax:success | 通信成功後、HTTPレスポンスが成功した時 | data | status | XHR | jQuery 1.7系まで |
Complete | ajax:complete | (HTTPレスポンスの結果に関係なく)Ajaxリクエストが完了時 | XHR | status | - | jQuery 1.7系まで |
Fail | fail() | 旧errorの後継イベント。sendイベントでのXHRオブジェクトのメソッドとして呼び出す | XHR | status | error | jQuery 1.8以降推奨 |
Done | done() | 旧successの後継イベント。sendイベントでのXHRオブジェクトのメソッドとして呼び出す | data | status | XHR | jQuery 1.8以降推奨 |
Always | always() | 旧completeの後継イベント。sendイベントでのXHRオブジェクトのメソッドとして呼び出す | XHR/data | status | error/XHR | jQuery 1.8以降推奨。HTTPレスポンスがエラーか正常かで引数が変化する |
Aborted | ajax:aborted:required | Ajaxリクエスト中断時(空リクエスト型) | elements | - | - | 中断された際でも空リクエストとして送信される |
Aborted | ajax:aborted:file | Ajaxリクエスト中断時(ファイル破棄型) | elements | - | - | 中断された際、ファイルアップロード用フィールドのバイナリデータは送信されない |
Then | then() | イベントのレスポンス結果取得時 | doneCallbacks | failCallbacks | progressCallbacks | Ajax専用イベントではないが、イベントのレスポンス結果によってCallback処理を行えるため、Ajaxのチェーン処理には欠かせない |
よくWebでRailsのAjax系記事を調べると、旧(ふる)いjQueryのAjaxイベントである「success」や「error」を使って書いてあるのが多くて、今どきのjQuery+Ajaxの推奨実装と合ってないよなぁ…とか思っていたんだが、その原因はRailsのjQuery+Ajaxの処理実体であるjquery-ujsの実装によるところが大きかった。
本記事では、その jquery-ujs の仕様に依存することなく、jQuery自体の推奨実装で処理を書いていこうと思う。
RailsでjQuery推奨仕様に準じてAjax処理を書く
さて、具体的な処理例があった方がわかりやすいので、サンプルとしてそこそこ実用的(?…かもしれない)なAjax処理を定義してみる。
X軸Y軸が可変である範囲座標系テーブル内に、データベースから該当するデータのみをAjaxで取得してきて表示する。
いやぁ、文字で書くとまったくもって実際の仕様がピンとこないもんだな…実用的なのかも怪しいもんだが、まぁいいや(笑)
とりあえず、Ajax処理のサンプルアプリ用のrailsアプリを作成する。
$ rails new ajax-test
$ rails g controller coordinates index show
$ rake db:create
$ rails g model datum name:string axis_x:integer axis_y:integer axis_slug:string description:text
$ rake db:migrate
ここはあえて詳しく解説しないが、ajax-test
というrailsアプリを新規作成して、coordinates
というコントローラとDatum
というデータベースを作った次第。次にDBに入れ込むデータが欲しいので、db/seeds.rb
に初期データ作成用のスクリプトを書く。
require 'digest/md5'
for i in 1..10000 do
x = rand(100) + 1
y = rand(100) + 1
slug = "#{x}-#{y}"
md5_hash = Digest::MD5.new.update("#{i}").to_s
Datum.create(name: md5_hash, axis_x: x, axis_y: y, axis_slug:slug, description: nil)
end
初期データの説明としては、100×100の座標範囲内にランダムに10000個の点を打った感じ。それぞれの点にはmd5ハッシュ形式のnameが付けてある。axis_slug
はフロントエンドのHTML側にデータを表示する時に利用する、数値座標のエイリアス的なものだ。
で、seedができたので、流し込む(10000レコードあるのでちょいと時間がかかる)。流し込んだら、コマンドラインでDBにデータ入ってるか確認してみよう。
$ rake db:seed
$ rails db
> .table
data schema_migrations
> select count(*) from data;
10000
次に、Ajaxリクエスト発火時のルーティングを設定する(config/routes.rb
)。すでに定義済みのルートを利用してコントローラ側でAjax処理を振り分けてもいいのだが、コードの見通しが悪くなるので、Ajaxのルートは別途定義した方が後々保守しやすくなる。
Rails.application.routes.draw do
get 'coordinates/index'
get 'coordinates/show'
post 'coordinates/get_area'
end
実際に追加するのは、最後の一行の post 'coordinates/get_area'
だけ。
そして、Ajaxレスポンスの処理実体であるコントローラを作る(app/controllers/coordinates_controller.rb
)。
class CoordinatesController < ApplicationController
def index
@range = {
min_x: params[:min_x] ||= 0,
max_x: params[:max_x] ||= 20,
min_y: params[:min_y] ||= 0,
max_y: params[:max_y] ||= 20,
}
end
def show
end
def get_area
@data = Datum.where('axis_x >= ? AND axis_x <= ? AND axis_y >= ? AND axis_y <= ?', params[:min_x], params[:max_x], params[:min_y], params[:max_y])
if @data.size > 0
render json: @data
else
render json: "No data"
end
end
end
通常アクセス時は /coordinates/index
であり、Submitボタンを押すとAjax開始で get_area
のメソッドが呼ばれるというルーティングになっている。Ajax処理のサーバーサイド側の実装は get_area
メソッドで、データベースから指定範囲内に属するデータのみを取得して、JSONレスポンスとしてフロントエンドに返すというものだ。
今度はフロントエンド側を作って行く。まずはベースとなるHTMLテンプレートとして、HTML文書の大枠を定義する app/views/layouts/application.html.erb
だ。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Ajax test app</title>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
<link href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css" rel="stylesheet">
<%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
<%= csrf_meta_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
ここはそのままでも問題ないのだが、テーブルなどの見た目を整えたいので bootstrap をCDN経由で読み込むようにしている。そして、次にRailsアプリのフロントエンドのテンプレートを作る。パスは app/views/coordinates/index.html.erb
である。
<div class="container">
<h2>Coordinates#index</h2>
<table class="table table-striped table-bordered table-condensed table-hover">
<% @range[:max_y].downto(@range[:min_y]) do |row| %>
<tr class="row-<%= row %>">
<% @range[:min_x].upto(@range[:max_x]) do |col| %>
<% if col == 0 %>
<% if row == 0 %>
<th><%= col %></th>
<% else %>
<th><%= row %></th>
<% end %>
<% else %>
<% if row == 0 %>
<th><%= col %></th>
<% else %>
<td class="rc<%= col %>-<%= row %>"></td>
<% end %>
<% end %>
<% end %>
</tr>
<% end %>
</table>
<%= form_tag coordinates_get_area_path(), :method => :post, :class => 'form-get-coordinates form-inline' do %>
<div class="form-group">
<label class="sr-only" for="commit">Set Range</label>
<div class="input-group">
<div class="input-group-addon">X-Min</div>
<%= number_field_tag :min_x, @range[:min_x], in: 0...100, :class => 'form-control', :readonly => true %>
</div>
<div class="input-group">
<div class="input-group-addon">X-Max</div>
<%= number_field_tag :max_x, @range[:max_x], in: 1..100, :class => 'form-control', :readonly => true %>
</div>
<div class="input-group">
<div class="input-group-addon">Y-Min</div>
<%= number_field_tag :min_y, @range[:min_y], in: 0...100, :class => 'form-control', :readonly => true %>
</div>
<div class="input-group">
<div class="input-group-addon">Y-Max</div>
<%= number_field_tag :max_y, @range[:max_y], in: 1..100, :class => 'form-control', :readonly => true %>
</div>
</div>
<%= submit_tag 'Get Coordinates', :class => 'btn btn-primary submit-ajax' %>
<% end %>
</div>
テーブルを動的に生成するところがちょいと見づらいが仕方ない。あと、本当はテーブルの可視範囲を下部の入力欄から指定できるようにしようと思っていたのだが、上のテーブル部分と連携させるのが結構面倒そうだったので、とりあえず readonly
化して入力できないようにしてある(今回やりたいAjax処理のおさらいとはちょっと外れちゃうんで割愛ッス…)。
そして、ようやくAjaxリクエスト部のJavaScript(jQuery)をCoffeeScriptで書く。パスは app/assets/javascripts/coordinates.coffee
だ。
coordinates = ->
$('.submit-ajax').on 'click', (e) ->
e.preventDefault()
getCoordinatesInRange()
getCoordinatesInRange = ->
post_data = {
min_x: $('#min_x').val()
max_x: $('#max_x').val()
min_y: $('#min_y').val()
max_y: $('#max_y').val()
}
jqXHR = $.ajax({
async: true
url: $('.form-get-coordinates').attr('action')
type: $('.form-get-coordinates').attr('method')
data: post_data
dataType: 'json'
cache: false
beforeSend: (xhr, set) ->
$('td[class^="rc"]').html('')
})
jqXHR.done (data, stat, xhr) ->
console.log { done: stat, data: data, xhr: xhr }
data.forEach (obj) ->
$(".rc#{obj.axis_slug}").append("<div class=\"center-block\"><a href=\"/coordinates/show/#{obj.id}\">#{obj.id}</a></div>")
jqXHR.fail (xhr, stat, err) ->
console.log { fail: stat, error: err, xhr: xhr }
alert xhr.responseText
jqXHR.always (res1, stat, res2) ->
console.log { always: stat, res1: res1, res2: res2 }
alert 'Ajax Finished!' if stat is 'success'
$(document).ready(coordinates)
$(document).on('page:load', coordinates)
最後に、見た目をさらによくするためのスタイルを追加する。パスは app/assets/stylesheets/coordinates.scss
.table th, .table td {
text-align: center;
vertical-align: middle;
min-width: 3em;
}
.table th {
background-color: #e8e8e8!important;
color: #888888;
}
これで完成だ。早速、ブラウザで <Railsが動いている環境のホスト名>/coordinates/index
にアクセスしてみると、下記のようなページが表示される。
テーブル下の「Get Coodinates」ボタンを押すとAjax処理が開始し、データベースから20×20の範囲内座標を持っているデータを全て取得してきて、テーブル内に表示される(下図参照)。
もしこのページが、テーブルのセルを作成しながら、同時に合致するデータを各セルに入れ込みつつレンダリングするような仕様だった場合、ちょっと考えただけでもかなり面倒そうな処理を書かないといけなくなりそうで気が重くなる。なので、テーブルの枠だけサクっと作ってから、Ajaxでデータを流し込むというやり方は、結構スマートな方法なんではないだろうか…。
本当はこのサンプル、先にテーブルの表示範囲を定義するAjax処理を行ってから、今回のデータ取得処理をチェーンさせて、連続するAjax処理の進捗率をリアルタイムに表示させる例としてまとめておきたかったんだが…この記事自体が想像以上に長文になったので、ここいらでやめておく。途中Railsアプリの作り方みたいな手順が入って本筋から外れてしまったのが痛かったかな…(笑)
まぁ、Ajaxチェーンのおさらいはまた別の機会にやろうかと。