is Neet

Ruby,javascriptなど、技術系のネタを中心に。

Rails4.0でangularjsを使ってRESTfulなajaxを実装する

長文注意。 angularjsについて今更ながらに触り始めて色々と感動したので纏めておく。 angularjsがどういったフレームワークかは公式のチュートリアルを眺めてたらぼんやりと把握できると思うので今回その辺の話はあまり触れない。 http://angularjs.org/

angularjsのAPIについては公式のドキュメント含めて様々なメディアやブログに取り上げられているが、導入から体系的に語られてるものはあまり無い印象だったので、僕のブログでは導入から具体的な目的に沿った実装方法を紹介していこうと思う。
ちなみに自分のangularjsへの理解も触り始めて一週間程度なのでだいぶ甘い。

angularjsを一週間やってみた感想

最初の2日くらいがだいぶつらい。
飲み込みが早い人ならすぐに使いこなすのかもしれないが、angularjsはdirective, controller, filter, resource, serviceなどのキーワードが沢山出てきて、そして本当にどれも重要だったりする。
とにかく、公式のドキュメントを読みまくって手を動かすしか無い。
あとこの辺とか読んだ。英語よくわからないけどまぁなんとなく読んだ。
AngularJS in 60 Minutes

3日目くらいから感動の連続だった。
ほんとこれすごいとおもった(小並感)

Rails4にangularjsを導入する

sampleのrailsアプリのRails.root/app以下はこんな感じだと想定する。

.
├── assets
│   ├── javascripts
│   │   ├── application.js
│   │   ├── widgets.js
│   └── stylesheets
│       ├── application.css
├── controllers
│   ├── application_controller.rb
│   ├── widgets_controller.rb
├── models
│   ├── widget.rb
└── views
    ├── layouts
    │   └── application.html.haml
    └── widgets
        └── index.html.haml

まずはGemfileに

gem 'angular-rails-engine'

を追記して、bundle installをして、application.jsに

//= require angular/angular
//= require angular/angular-resource

を追記する。
あと僕の環境ではangularjsをproduction環境で動かす際にconfig/environments/production.rb

config.assets.js_compressor = :uglifier

を設定しないとエラーがでて動かなかった。

取り敢えずHello World

angularjsのスコープを設定する

angularjsは自身がviewのどの部分を管理するかを知る必要がる。
逆に言うと、サイトの大部分はjQueryやbackboneなどで構築してるけど部分的にangularjsを導入してみたい、などの要件にもスコープの設定ができるがゆえに簡単に対応することが出来きる。
まずはapp/views/layouts/application.html.hamlに、

!!!
%html{'ng-app' => 'sampleApp'}

sampleAppというネームスペースをhtmlタグに宣言する。
次にapp/views/widgets/index.html.hamlに以下の内容を記述する。

%div{"ng-controller" => "WidgetsController"}
  {{ hello }}

divに"ng-controller"という属性でネームスペースを宣言することで、このdiv以下の要素はangularjsの管理下にある事をangularjs自身は知ることが出来る。
"ng-controller"以下のviewではjavascript expressionが自在に呼び出せるようになる。
上記のviewでは{{ … }}で囲まれた部分がexpressionとして評価されhelloという変数の内容をhtml上に表示することが出来る。

viewで宣言したスコープとjsを紐付ける

"ng-controller"以下のhelloに値を結びつける処理は以下になる。

//./app/assets/javascripts/widgets.js
var sampleApp = angular.module('sampleApp', []);

sampleApp.controller('WidgetsController', function($scope){
    $scope.hello = 'Hello World';
})

まず一行目の

var sampleApp = angular.module('sampleApp', []);

によって%html{'ng-app' => 'sampleApp'}で宣言したモジュールを作成する。

3行目以下の処理によりviewで宣言したhelloに値を代入する。

sampleApp.controller('WidgetsController', function($scope){
    $scope.hello = 'Hello World';
})

次にsampleApp.controllerでviewで宣言したcontrollerである'WidgetsController'を生成し、第二引数のfunctionにわたってくる$scopeという変数を経由してview(html)側のscopeにアクセスでき、$scopeを経由して変更された値は「リアルタイム」でviewに反映される。
$scopeで管理できるものは変数だけでなくfunctionでもいけるので例えば、

// view
%div{"ng-controller" => "WidgetsController"}
  {{ hello('soplana') }}
//./app/assets/javascripts/widgets.js
sampleApp.controller('WidgetsController', function($scope){
    $scope.hello = function(name){
        'Hello '+name+'!!';
    }
})

のような事も可能だ。

directiveを使ってみる

まだ僕自身directiveって何だよ感があるんだけど、現段階の理解としては「DOMに対する操作、あるいはテンプレートそのもの」を指すと考えている。
directiveは「DOMに対する操作、あるいはテンプレートそのもの」をDOM属性や要素名によって表現する。
いくつか例をあげてみる。

ng-clickを使う

$('#hoge').on('click', function(){})みたいな事がしたい場合

// view
%div{'ng-click' => 'click()'}
  hoge

//./app/assets/javascripts/widgets.js
sampleApp.controller('WidgetsController', function($scope){
    $scope.click = function(){}
})

ng-hideを使う

$('#hoge').hide()みたいな事がしたい場合

// view
%div{'ng-hide' => 'true'}
  hoge

ng-repeatを使う

要素を繰り返し出力したい

// view
%ul
  %li{'ng-repeat'=>'widget in widgets'}
    {{ widget.name }}

//./app/assets/javascripts/widgets.js
sampleApp.controller('WidgetsController', function($scope){
    $scope.widgets = [
        {name: 'widget1'},
        {name: 'widget2'},
        {name: 'widget3'}
    ]
})

// 出力結果
<ul>
  <li>widget1</li>
  <li>widget2</li>
  <li>widget3</li>
</ul>

この他にも非常に強力で便利なdirectiveが沢山容易されている。
また、自分でcustom directiveを作成して使うことも可能だ。

ここまでで一旦まとめ

基本的にはdirectiveを使って、controllerに定義したclickイベントを呼び出したり、DOMを操作したりする使い方が多そうだ。

そうなるとcontrollerがだんだんfatになっていく問題が浮上するが、それについてはいくつか対処法があり、

  • そもそも細かい単位でcontrollerを作っていく
  • DIを利用する
  • custom directiveを使う

などが考えられる(他にもあったら or 違うだろ!みたいなのがあったら教えてください)。
DIの話も面白いので次回まとめます。

angularjsでajax

やっとか…疲れた…。

railsのrouting

以下のような何の変哲もないroutingを想定する。

GET    /widgets(.:format)     widgets#index
POST   /widgets/:id(.:format) widgets#update
DELETE /widgets/:id(.:format) widgets#destroy

※本来はPUTでupdateされるべきだが、PUTが出てくると内容が濃くなりすぎるのでPOSTに変更させてもらう

処理フロー

まず画面はこんな感じだ

sampleApp

画面にはwidgetが7つ並んでおり、それぞれに「この機能を使う」「この機能を削除する」というアクションボタンが用意されている。
この画面でやりたいことは以下になる。

  • htmlのロードが完了したら、ajaxによりwidget一覧を取得する
  • widget一覧を画面に出力する
  • 「この機能を使う」をユーザがクリックした場合
    • ajaxでPOSTリクエストを出しwidgetモデルを使用可能状態にupdateする
    • ボタンを「この機能を削除する」に変更する

さて、これをjQueryで実装することを想像すると簡単な処理とは言えど、ロジックとDOM操作が入り組んだ保守性の悪いコードが容易に想像できる。(綺麗に書ける人も当然いるだろうけども)

view書いちゃう

まずはview

%div{"ng-controller" => "WidgetsController"}

  .row
    .col-lg-12
      %h4.page-header
        %i.fa.fa-gavel.fa-fw
        機能の追加・削除

  .row
    .col-lg-4{'ng-repeat'=>"widget in widgets"}
      .panel.panel-default.widget
        .panel-heading
          %span.glyphicon.glyphicon-user
            {{ widget.label }}
          %button.btn.btn-info
            この機能を使う
          %button.btn.btn-warning
            この機能を削除する
        .panel-body
          {{ widget.description }}

viewはdirectiveの章で扱ったng-repeatの例とさほど変わらない。
すこしhamlの要素が増えただけだ。
これによりcontrollerでwidgetsという変数に値を入れれば、繰り返し処理が行われhtmlに出力してくれることになる。

次にボタンをクリックした時の挙動のことを想像してみよう。
一連のフローで必要になるdirectiveは、

  • ajaxリクエストを出すイベント発火用のng-click
  • showとhideをtoggleさせる為のng-showng-hide

が必要になるっぽいので追加しちゃう。

%div{"ng-controller" => "WidgetsController"}

  .row
    .col-lg-12
      %h4.page-header
        %i.fa.fa-gavel.fa-fw>
        機能の追加・削除

  .row
    .col-lg-4{'ng-repeat'=>"widget in widgets"}
      .panel.panel-default.widget
        .panel-heading
          %span.glyphicon.glyphicon-user>
            {{ widget.label }}
          %button.btn.btn-info{'ng-click'=>'widget.save()', 'ng-hide'=>'widget.active'}
            この機能を使う
          %button.btn.btn-warning{'ng-click'=>'widget.remove()', 'ng-show'=>'widget.active'}
            この機能を削除する
        .panel-body
          {{ widget.description }}

ng-showng-hidewidgetが持っているactiveというbool値を渡す事でtoggleを実現できそうだ。

js書いちゃう

// ./app/assets/javascripts/widgets.js
var sampleApp = angular.module('sampleApp', ['ngResource']);

sampleApp.factory('Widget', function($resource){
    var Widget = $resource('/widgets/:id.json', {id: '@id'});
    return Widget;
});

sampleApp.controller('WidgetsController', function($scope, Widget){
    $scope.widgets = Widget.query();
})

はい、三行目あたりからfactoryやら$resourceやら良くわからないのが出てきましたね。
この辺がcontrollerをfatにしない為の仕組みの一つであるDIなんだけど、今回はそこは掘り下げて話さない。
次回頑張る。

まずvar Widget = $resource('/widgets/:id.json', {id: '@id'});この行によってWidgetクラスが生成される。
誤解を恐れずにいうなら、angularjsにおけるajax通信部分はrailsで考えるとmodelに相当するものだと思った。
$resourceを使って生成されたオブジェクトには以下のメソッドが追加される。

Widget.get();    // 特定のデータを取得する   : GET
Widget.save();   // 特定のデータを更新する   : POST
Widget.query();  // 複数件のデータを取得する : GET
Widget.remove(); // 特定のデータを削除する   : DELETE
Widget.delete(); // 特定のデータを削除する   : DELETE

これらは$resourceの第一引数に渡したURLにRESTfulにアクセスできるメソッドだ。
また上記のメソッドは、Widgetクラスのインスタンスにもコピーされる。
インスタンスからこれらのメソッドにアクセスする場合は、prefixとして$がつく。(widget.$save(), widget.$remove()みたいに)

一度話はそれるがRailsWidgetモデルをupdate and deleteすることを考えてみよう。
以下のようになるはずだ。

widget = Widget.find(2)
widget.active = true
widget.save   #=> updateされる

widget = Widget.find(2)
widget.delete #=> deleteされる

考えてみればfindによってMysqlなりmongoなりにコネクションを貼りデータを取得してオブジェクトを操作して結果をまたDBに通知している。
angularjsの$resourceが行うこともこれと何ら変わりない。
ただ問い合わせる先がDBでなくWebアプリケーションであるだけだ。

$resourceについて考えてみる

sampleApp.factory('Widget', function($resource){
    var Widget = $resource('/widgets/:id.json', {id: '@id'});
    return Widget;
});

ここはWidgetクラスの宣言に過ぎない。
$resourceの第二引数に渡す{id: '@id'}は第一引数の:idに対応しており、@をつけて宣言することでWidgetクラスのインスタンス.idから取得せよ、という宣言になる。
idを渡さない場合は:id部分が無視されて、'/widgets.json'に対するアクセスになってくれる。
インスタンスwidget.idのように値を持っているpropertyを持つ場合は/widgets/5.jsonのようなURLを自動生成してくれる。

sampleApp.controller('WidgetsController', function($scope, Widget){
    $scope.widgets = Widget.query();
})

ではこれはどうか。
上記で話した通りWidget.query()$resourceが付与するメソッドであり、複数件のデータを取得する場合に用いられる。
この例だと、二行目は/widgets.jsonにGETでリクエストを発行するので対応するRailsアクションで

 render json: @widgets

など複数件のwidgetをrenderするようにしておく。

callbackは書かない

$scope.widgets = Widget.query();

まぁ書いてもいいんだけど、上記のように書いてもqueryメソッドが裏側でajax通信を非同期で始めても完了したかどうかはプログラマは知らなくても良い。
この時点でまず空の参照をquery()が返し、$scope.widgetsに入れる。
後のことはcontrollerの仕事ではなく$scopeに処理を委譲するのだ。
無事レスポンスが返ってくる事で$scopeがdirectiveに通知しテンプレートに反映する。

改めて処理フローを考える

さて、ここまでの事を踏まえて改めてviewのコードをみると、なんとなく分かるはずだ。

    .col-lg-4{'ng-repeat'=>"widget in widgets"}
      .panel.panel-default.widget
        .panel-heading
          %span.glyphicon.glyphicon-user>
            {{ widget.label }}
          %button.btn.btn-info{'ng-click'=>'widget.$save()', 'ng-hide'=>'widget.active'}
            この機能を使う
          %button.btn.btn-warning{'ng-click'=>'widget.$remove()', 'ng-show'=>'widget.active'}
            この機能を削除する
        .panel-body
          {{ widget.description }}

注目すべき点は以下の二行になる。

%button.btn.btn-info{'ng-click'=>'widget.$save()', 'ng-hide'=>'widget.active'}
  この機能を使う
%button.btn.btn-warning{'ng-click'=>'widget.$remove()', 'ng-show'=>'widget.active'}
  この機能を削除する

ng-clickにより呼び出されるwidget.$save()は、ng-repeatによりeachやfor文のように繰り返されている、Widgetクラスのインスタンスから呼び出しているメソッドだ。
もっと具体的にいうとRailsでrenderしたjsonデータの一件一件がココに入ってくる。
つまりこれらのインスタンスwidget.idというプロパティを持つことが保証されているので、

var Widget = $resource('/widgets/:id.json', {id: '@id'});

この宣言により'/widget/5.json'というURLが、インスタンスが持つpropertyから組み立てられPOSTリクエストが発生することになる。
さらにレスポンスが返ってくるとこのインスタンスは自動でアップデートされる。
さらにさらに$scopeがそのアップデートを検知して、ng-showおよびng-hideのbool値を更新し、要素を隠したり出したりしてくれる。

ちなみにRailsでは、対応するアクションでそれぞれ

 render json: @widget

と更新されたwidgetオブジェクトのjsonを返すようにするのを忘れてはならない。

とにかく処理の記述量が少なく済む

これだけの内容ならほんの数行のjavascriptを書くだけで実装できてしまった。
DIなどの仕組みによりコードの再利用性も高く、可読性も高い。
ちょっと思いの外長文になりすぎて疲れたのでこの辺で今回は勘弁しといてやるか…。

最後に、railsのprecompileによりjsがMinifyされるとangluarjsがうまく動作しない場合があるので、その対策としてcontrollerの宣言には以下の様なシンタックスも容易されている。
本番運用を考えて最初からこっちで書いておくといいかもしれない。

sampleApp.controller('WidgetsController',  ['$scope','Widget', function($scope, Widget){
    $scope.widgets = Widget.query();
}])

次回はDIについてもう少し話したい。