Rails+JSフレームワークでリアルタイム掲示板を作成してみる(Ember.js編)


前回は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 .

emberjquery依存なので記述が必要だ。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がエラーなしに開けるはずだ。

pages#home

ポイントはブラウザのコンソールに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.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にチャレンジしようと思う。

参考リンク

関連する記事


コメントする

メールアドレスが公開されることはありません。

CAPTCHA


このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください