前回はAngularJSで実装したが、今回はEmber.jsにチャレンジしたいと思う。作成するリアルタイム掲示板がどんなものかは以下の動画の通り。(AngularJSで作成したものだが、仕様は同じ)
目次
環境
- Ruby on Rails 4.1.6
- Ember.js 1.8.0
セットアップ
今回もGemfileから見ていこう。前回同様、Rails Assetsを使用している。
📄Gemfile
source 'https://rubygems.org' source "https://rails-assets.org" gem 'rails', '4.1.6' gem 'sqlite3' gem 'sass-rails', '~> 4.0.3' gem 'uglifier', '>= 1.3.0' gem 'coffee-rails', '~> 4.0.0' gem 'jbuilder', '~> 2.0' gem 'active_model_serializers' gem 'rails-assets-ember', '~> 1.8.0' gem 'rails-assets-ember-data', '1.0.0.beta.11' gem 'rails-assets-moment' group :development do gem 'spring' end
active_model_serializers
はJSONレスポンスを整形してくれるgemだ。これを入れておけばEmber.js向けにJSONレスポンスを整形しなくて済む。またrails-assets-moment
(moment.js)は、日付周りの処理をいい感じにゴソゴソしてくれるJavascriptなので、ぜひインストールしておこう。手抜きは善だ。
bundleインストールしてrails newまで終わったら、お約束のapplication.jsを編集する。
📄app/assets/javascripts/application.js
//= require jquery //= require handlebars //= require ember //= require ember-data //= require moment //= require board_sample //= require_tree .
ember
はjquery
依存なので記述が必要だ。Gemfileにはjquery-railsなんて記述なかったじゃないかと思うかもしれないが、jqueryはember内に同梱されている。handlebars
も必須。依存関係があるので、必ずこの順で記述すること。board_sample
という記述があるが、このファイルをこれから作成し、Emberオブジェクトを初期化する。
📄app/assets/javascripts/board_sample.js.coffee
window.App = Ember.Application.create() # Ajax送信時にトークンを送信する(トークンがないとRails側で認証エラーになる) $ -> token = $('meta[name="csrf-token"]').attr('content') $.ajaxPrefilter (options, originalOptions, xhr) -> xhr.setRequestHeader('X-CSRF-Token', token)
1行目でEmberアプリケーションを初期化している。その下はコメントに記述している通りで、Railsはトークン無しの更新系HTTP METHODを認めてくれないので、ここで設定しておく。
続いて、アプリケーション共通レイアウトを作成する。
📄app/views/layouts/application.html.erb
<!DOCTYPE html> <html> <head> <title>Board sample</title> <%= stylesheet_link_tag 'application', media: 'all' %> <%= javascript_include_tag 'application' %> <%= csrf_meta_tags %> </head> <body> <%= yield %> </body> </html>
なんの変哲もないレイアウトファイルだ。Emberにはレイアウトを構成する機能があるけれど、ここでは使用しない。(というか使用するほど画面が切り替わらない)
あとはコントローラとビューがあれば、とりあえず画面が描画できる。コントローラを生成しよう。
📄app/config/routes.rb
$ rails g controller pages home
さらにルートURLに設定する。
Rails.application.routes.draw do root to: 'pages#home' end
うまく設定できていれば、localhost:3000がエラーなしに開けるはずだ。
ポイントはブラウザのコンソールにEmberのログが出力されていること。これがちゃんと出力されていれば、Emberのセットアップは完了しているということだ。
step1. 投稿を表示する
前回と同様、掲示板の全投稿を表示するところから始めようと思う。まずは投稿モデル(Post)の作成から。
📄db/seeds.rb
$ rails g model Post author body
rake db:migrateも実行するのを忘れずに。投稿機能がまだないので、テストデータはseedで作成することにする。
Post.create!(author: '名無しさん', body: 'これは本文です') Post.create!(author: '豊田サリー', body: 'テクマクマヤコンテクマクマヤコン') Post.create!(author: 'ガンジー', body: "非暴力\n非服従\n言葉は要らない")
いつも通り、知性のないデータだこと。rake db:seedを実行したら、今度はデータを覗いてみたくなるのが人の常。GETに対応するRESTインターフェースを作成してあげよう。
📄config/routes.rb
Rails.application.routes.draw do root to: 'pages#home' resources :posts, defaults: { format: 'json' }, only: %i(index show create) end
3行目を追記した。今回使用する最低限のHTTPメソッドにしか対応しない。コントローラも実装してあげよう。posts_controller.rb
を作成して、以下のコードを記述する。
📄app/controllers/posts_controller.rb
class PostsController < ApplicationController respond_to :json def index respond_with Post.all end end
ここいらで少し確認しておこう。http://localhost:3000/posts/を開くとJSONレスポンスが表示されるだろうか?
表示できればよろしい。ちなみにレスポンスがposts: [ ... ]
とpostsから始まるハッシュになっていることにお気付きだろうか? 通常のrailsのJSONレスポンスは[...]
のように配列が返されるのだが、これはEmberが求めているものとは異なる。冒頭でactive_model_serializers
をインスコしたのはこのようなハッシュを返却するためなのである。
さて、Rails側は正しく動いているので、いよいよクライアント側を実装しよう。まずはモデルというやつから。app/assets/javascripts/emberjs/modelsというディレクトリを作成して、その中にpost.js.coffeeというファイルを生成しよう。
📄app/assets/javascripts/emberjs/models/post.js.coffee
App.Post = DS.Model.extend author: DS.attr('string') body: DS.attr('string') created_at: DS.attr('date') updated_at: DS.attr('date')
EmberではMVCアーキテクチャを採用している。Mがモデルにあたり、これはDS.Modelを継承することで実現できる(厳密言うと、これはember-data
モジュールの一部)。各属性をEmber側でどう扱うのか、型を指定してあげる必要がある。モデルといっても、railsのActiveRecordとは全然違うので都合よく脳内replaceしない方がよい。J2EEでいうところの(今更誰も言わない?)、エンティティに近いかな。
モデルができたら、ビューと繋げるコントローラを作成しなければならない。app/assets/javascripts/emberjs/controllersディレクトリを作成し、その中にposts_controller.js.coffeeを作成しよう。
📄app/assets/javascripts/emberjs/controllers/posts_controller.js.coffee
App.PostsController = Ember.ArrayController.extend()
といっても、今のところは1行だけ。Ember.ArrayControllerを継承したオブジェクトをAppに登録するのだ。ArrayControllerを継承するのは、このコントローラで扱うモデルが配列だということ。つまりはpost(投稿)モデルの配列。う〜ん、なんだかすごい暗黙的。。
コントローラまで作成したらルーティングを定義しよう。EmberではURLに対応したリソースを定義することができる。この掲示板の場合、トップページ(/)に対して/postsというREST APIを割り当てたいので、app/assets/javascripts/emberjs/router.js.coffeeは次の通りとなる。
📄app/assets/javascripts/emberjs/router.js.coffee
App.Router.map -> @resource 'posts', { path: '/' } App.PostsRoute = Ember.Route.extend model: -> @store.find 'post'
2行目でルーティングを定義している。4行目のPostsRouteというのは、Routerとは違うので注意が必要(何このネーミング?)。PostsControllerに対するモデルを割り当てる処理をここに記述する。@storeがそのリソースと紐付いているので、@store.find 'post'
とするとPostモデルの内容が全件取得できることになる。(その度にAPIが投げられ、Rails側でSQLが発行されるというわけではないんだけど)
ではとどめに、投稿を表示するhtmlを記述しよう。pages/home.htmlを編集する。
📄app/views/pages/home.html
<script type="text/x-handlebars" data-template-name="posts"> {{#each}} <div class="post"> <span>{{_view.contentIndex}}</span> <span class="author">{{author}}</span> <span class="time">{{created_at}}</span> <div class="body">{{body}}</div> </div> {{/each}} </script>
なんとな〜く意味が分かるかと思うが、data-template-name="posts"
にてPostsController・PostsRoute・PostsView(今回は使用せず)に対する関連付けが行われる。#each
って何繰り返してんの?と思うかもしれないが、これはPostsControllerのモデル、つまりPostモデルの配列のイテレートを意味している。{{author}}
{{created_at}}
{{body}}
はそれぞれPostモデルの属性を表示する。_view.contentIndex
というのは、このeachループのインデックス数を示している。
ここまで実装すると、実際に画面に投稿全件が表示できるはずだ。http://localhost:3000/にアクセスしてみよう。
ちゃんと表示できた! 色気を出してcssを記述しよう。
📄app/assets/stylesheets/posts.css.scss
.post { margin: 10px 0; border-bottom: 1px solid; .author { background: lightyellow; padding: 3px; } .time { color: gray; margin-left: 10px; } .body { white-space: pre; margin: 5px 0; } }
少し見栄えが良くなるはずだ。
step2. ヘルパーを作成する
画面内に気になる点がある。投稿日時(created_at)の書式が全然フレンドリーじゃない。
こいつをローカル時刻に変換して表示してみよう。AngularJSにはフィルタ機能で日付書式が標準装備されていたが、Emberにはない。自力でヘルパー関数を実装してあげる必要がある。app/assets/javascripts/emberjs/helpers/time.js.coffeeというファイルを新規作成しよう。
📄app/assets/javascripts/emberjs/helpers/time.js.coffee
Ember.Handlebars.registerBoundHelper 'formatTime', (time) -> moment(time).format('YYYY-MM-DD HH:mm:ss')
やっとこさ、moment.jsの出番が来たのだ。formatTimeというダサい名前でヘルパーを定義したら、今度は呼び出し側を修正する。
📄app/views/pages/home.html
<script type="text/x-handlebars" data-template-name="posts"> {{#each}} <div class="post"> <span>{{_view.contentIndex}}</span> <span class="author">{{author}}</span> <span class="time">{{formatTime created_at}}</span> <div class="body">{{body}}</div> </div> {{/each}} </script>
{{formatTime 引数}}
という形で先程登録したヘルパーを呼び出すことができる。率直に言うと、時刻の書式ぐらい標準添付していてほしい。オールインワンのAngularJSの懐かしみつつ、ブラウザのリロードを試みる。
うん、ちゃんとローカル時刻で表示されている。自分で作ったものが動くのは今でも楽しい。
step3. 投稿できるようにする
掲示板なので投稿できなければ意味がない。さっそくhome.htmlを修正する。
📄app/views/pages/home.html
<script type="text/x-handlebars" data-template-name="posts"> ... <form {{action "create" on="submit"}}> <div> お名前:{{input type="text" value=author required=true}} </div> <div> 本文: {{textarea value=body required=true}} </div> <input type="submit" value="投稿する"> </form> </script>
<form {{action "create" on="submit"}}>
というのは、フォームがサブミットされたらPostsControllerのcreateアクションを呼び出せ、という意味。input/textareaで指定しているvalue値で、これらの値をコントローラから取得できる。コントローラのコードを見た方が早いかな。
📄app/assets/javascripts/emberjs/controllers/posts_controller.js.coffee
App.PostsController = Ember.ArrayController.extend init: -> @_super() @set('author', '名無しさん') actions: create: -> # 新規投稿を生成 post = @store.createRecord 'post', author: @get('author') body: @get('body') # DBに保存 post.save() # 投稿本文をクリアする @set('body', null)
initメソッドはコンストラクタ(のようなもの)で、ここで投稿者名の初期値を設定している。createメソッドがフォームをサブミットしたときに呼び出されるメソッドだ。@store.createRecordがモデルを作成する場合の定石で、postモデルを新たにnewして各値をセットしている。@getで取得しているのはhome.htmlのinput/textareaにあたる(EmberにはJavaやStrutsを思い出させるフシがあるな・・・)。この段階では永続化はされておらず、次のpost.save()でDBに保存されることになる。ただし要注意なのは、save()はPromiseオブジェクトを返却するということ。つまりsave()自体は同期メソッドだが、実際にDBに保存するのは非同期処理だといういうこと。AngularJSも同様のアプローチを取っている。これはキチンと理解しておかないとハマりどころになり得る。
そういえば、Rails側のコントローラの実装がまだだった。こちらもそそくさと修正しておこう。
📄app/controllers/posts_controller.rb
class PostsController < ApplicationController ... def create respond_with Post.create!(post_params) end private def post_params params.require(:post).permit(:author, :body) end end
さらにもうひとつ忘れていることがあった。app/serializers
というディレクトリを作成し、その中にpost_serializer.rb
というファイルを作成しよう。
📄app/serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer attributes :id, :author, :body, :created_at, :updated_at end
これがないと、GET posts/:id でPost1件のJSONを取得した時に、Ember側で警告が出てしまうのだ。
全て実装を終えたらrailsを再起動して、投稿できるか確認しよう。
画面に投稿内容が追加されリロードしても投稿内容が保存されているなら、問題なく実装できている。
step4. 未読投稿を自動取得する
前回のAngularJS版と同様、投稿を定期的に自動取得することにしよう。まずはEmber側のPostsコントローラに追記する。
📄app/assets/javascripts/emberjs/controllers/posts_controller.js.coffee
App.PostsController = Ember.ArrayController.extend init: -> @_super() @set('author', '名無しさん') @setNextFetch() fetchUnread: -> lastId = @model.content[@model.content.length - 1].id @store.find('post', { q: { id_gt: lastId } }).then (unread) => @store.pushMany 'post', unread @setNextFetch() setNextFetch: -> Ember.run.later @, @fetchUnread, 5 * 1000 #未読分を5秒毎に取得する ...
ハイライトされている部分が追記した処理だ。@model.content
でPostの配列が取得できるので、その中から最大のidを探す。そのidよりも大きい投稿だけを取得して、@store.pushMany
する(idがauto incrementだから成り立つロジック)。@store.find(...).then
としているのがポイントで、確実にデータが取得できてから次回の取得処理(setNextFetch)をセットするようにしている。Ember.run.later
はsetTimeoutのようなものだが、第1引数にコンテキスト@(=this)を渡す必要がある。そうでないと、トップレベルコンテキスト(window)にてsetNextFetchが実行されてしまうので。
5秒ごとに/posts.jsonというAPIに{"q"=>{"id_gt"=>"最大id"}}
というパラメータ付きでリクエストが送られるわけだが、こいつにRails側が対応してあげないといけない。容易に検索できるように、お約束のransackというgemを導入する。
📄Gemfile
gem 'ransack'
bundleコマンドを実行したら、Rails側のPostControllerを編集する。
📄app/controllers/posts_controller.rb
class PostsController < ApplicationController respond_to :json def index respond_with Post.search(params[:q]).result(distict: true) end ...
これで未読分のみの取得が可能になったはずだ。localhost:3000のタブを2つ開き、一方で何か投稿してみよう。自動的にもう一方の掲示板でも投稿が表示されればOK。
まだ1つ課題が残っている。自分が投稿した場合に他人が投稿した未読分を取得していないことだ。もう一度posts_controller.js.coffeeを編集する。
📄app/assets/javascripts/emberjs/controllers/posts_controller.js.coffee
App.PostsController = Ember.ArrayController.extend ... actions: create: -> # 未読分を取得する @fetchUnread().then => # 新規投稿を生成 post = @store.createRecord 'post', author: @get('author') body: @get('body') # DBに保存 post.save() # 投稿本文をクリアする @set('body', null)
先程作成したfetchUnreadメソッドを呼び出してから新規投稿を生成する。しつこいようだが、thenメソッドを使用しているのがポイントだ。こうしないと、まず自分の投稿が一覧追加されてから、その後に未読分が追加されることになる。
step5. アニメーションさせる
ヒッソリと投稿が追加されても気が付かないので、ちょっとしたアニメーションをさせてみよう。AngularJSと違い、Ember.jsには標準でアニメーションは含まれていない。手っ取り早くできそうなgemがあったので、そちらを利用してみる。
📄Gemfile
gem 'rails-assets-ember-animate' gem 'rails-assets-jquery.ui'
📄app/assets/javascripts/application.js
//= require jquery //= require jquery.ui/ui/core //= require jquery.ui/ui/effect //= require jquery.ui/ui/effect-highlight //= require handlebars //= require ember //= require ember-data //= require ember-animate //= require moment //= require board_sample //= require_tree .
ember-animateというgemを導入した。これはEmberViewの中にDOMの変化をフックしたイベントを簡単に定義できるようにするもの。さっそくREADMEをパクって、app/assets/javascripts/emberjs/views/post_view.js.coffeeを作成した。
📄app/assets/javascripts/emberjs/views/post_view.js.coffee
App.PostView = Ember.View.extend willAnimateIn: -> @$().css("opacity", 0) animateIn: (done) -> @$().fadeTo(200, 1).effect('highlight', {}, 500, done) animateOut: (done) -> @$().fadeTo(200, 0).effect('highlight', {}, 500, done)
アニメーションが開始する前に完全透過にして(willAnimateIn)、そこからフェードイン&ハイライトをかける(animateIn)。その他にも各種イベントがあるようなので、詳細はREADMEを参照すればよい。
最後にもう1箇所修正しなければならない。home.htmlだ。先程のPostViewをここで使用することを明示してやる必要がある。
📄app/views/pages/home.html
<script type="text/x-handlebars" data-template-name="posts"> {{#each}} {{#view "post"}} <div class="post"> <span>{{_view.contentIndex}}</span> <span class="author">{{author}}</span> <span class="time">{{formatTime created_at}}</span> <div class="body">{{body}}</div> </div> {{/view}} {{/each}} ...
{{view “post”}}~{{/view}}で囲まれた間に対して、PostViewがバインドされるというわけだ。つまり、この範囲内で追加されたDOM要素に対してのみアニメーションが適用される。
最終確認をしよう。localhost:3000を開いたとき、未読が自動取得されたとき、さらには投稿を書き込んだときに、フェードイン&ハイライトが適用されていれば完成だ!
所感
はっきりいって、こんなチンケなサンプルアプリケーションではEmber.jsの真価は判断できない。サンプルアプリケーションの仕様がJSフレームワーク向けじゃなかったな・・完全に失敗した。ということで、使い込まないとEmberの良さは分からないなりにも、今回感じたことを以下にまとめてみた。
- とにかく日本語情報が少ない
- 敷居が高い(これは日本語情報の少なさと関係あるかもしれない)
- handlebarsがキモい(何かStruts思い出しちゃう)
- 暗黙の規約が多い(命名による規約が多いので、最初は何で動いてるのか分からずキョトンとすることが多いかも)
- Controller、Model、Routes、Route、View、Helperとレイヤー分化が進んでいるので、そこそこ規模のある開発には良いかも
- マスコットがかわいい
なんだかネガティブな感想が多くなってしまったが、使いこなせれば強力な武器になる予感はしている。個人ではなく業務使用で、ある程度規模がある開発向けだろうか。もしくは学習コストをペイできるような状況下がよい。
AngularJS・Ember.jsでかれこれ10日近く費やしてしまった・・・。一旦海に出た船は沈むか進み続けるしかないらしいので、次回は嫌々Backbone.jsにチャレンジしようと思う。
参考リンク
関連する記事
- Rails+JSフレームワークでリアルタイム掲示板を作成してみる(Backbone.js編)
- Rails+JSフレームワークでリアルタイム掲示板を作成してみる(AngularJS編)
- AngularJS + Railsで国際化(i18n)
- Railsアプリを『浅く』パフォーマンス・チューニングしてみる(その1)
- chosen-railsによる検索機能付きセレクトボックスで、検索画面作成の手間を省く