Ruby on RailsのAjax処理のおさらい

  • 514
    Like
  • 4
    Comment
More than 1 year has passed since last update.

何気に、アプリケーションで取り扱うデータ量が増えた場合や、大容量データをデータベースとやり取りする時なんかは、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処理の全体な流れを図にすると下記のようになるのかな──と。

Ruby on RailsでのAjax処理の概要

古典的なWEBアプリケーションだと、ボタンアクションや画面遷移などのユーザー側からの明確なイベント後に、おもにWEBページの読込処理を伴ってサーバーサイドとやり取りをするんだが、Ajaxだとユーザーからの明示的なアクションがなくても、およそJavaScript側で取得できるすべてのイベントから発火させることができ、さらにページ遷移などを伴わずにリアルタイムにフロントエンドに結果を反映できるのが大きな特徴だ。非同期で実行すれば、ブラウザ側の機能がロックされることもないので、データがすべて読み込まれるまでユーザーに待ちを強いるようなこともないし、都度最小限のデータアクセスでアプリケーションを動かすことができるようになるので、ユーザビリティやアクセシビリティを最大化させられる。

あと重要なのが、Ajaxの非同期処理はイベント発火順のシーケンシャルには処理されないということだ(下図参照)。

非同期なAjaxチェーンのレスポンス

JavaScript側で、1つのイベントに対して非同期設定のAjaxで複数のリクエスト送信を行うような場合、そのレスポンスがリクエスト順には戻ってこないのだ。これは、Ajax処理を連結してAjaxチェーンを作る時などに注意しておかないと、意図しない結果になったりする。

RailsでのAjax処理のおさらい

Ruby on Railsでは View 側のヘルパータグ(form_tag)等で :remote => true を指定するだけで該当フォームでのsubmitアクションをAjax化してくれる。

Viewの例:
index.html.erb
<%= 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の例:
XXXXs_controller.rb
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テンプレートの例:
index.js.erb
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
_ajax_container.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.coffee
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に初期データ作成用のスクリプトを書く。

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のルートは別途定義した方が後々保守しやすくなる。

config/routes.rb
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)。

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 だ。

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 である。

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 だ。

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

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 にアクセスしてみると、下記のようなページが表示される。

Ajaxテスト用サンプルアプリ(Ajax前)

テーブル下の「Get Coodinates」ボタンを押すとAjax処理が開始し、データベースから20×20の範囲内座標を持っているデータを全て取得してきて、テーブル内に表示される(下図参照)。

Ajaxテスト用サンプルアプリ(Ajax後)

もしこのページが、テーブルのセルを作成しながら、同時に合致するデータを各セルに入れ込みつつレンダリングするような仕様だった場合、ちょっと考えただけでもかなり面倒そうな処理を書かないといけなくなりそうで気が重くなる。なので、テーブルの枠だけサクっと作ってから、Ajaxでデータを流し込むというやり方は、結構スマートな方法なんではないだろうか…。

本当はこのサンプル、先にテーブルの表示範囲を定義するAjax処理を行ってから、今回のデータ取得処理をチェーンさせて、連続するAjax処理の進捗率をリアルタイムに表示させる例としてまとめておきたかったんだが…この記事自体が想像以上に長文になったので、ここいらでやめておく。途中Railsアプリの作り方みたいな手順が入って本筋から外れてしまったのが痛かったかな…(笑)

まぁ、Ajaxチェーンのおさらいはまた別の機会にやろうかと。