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


あー、俺もそろそろ最近のjsフレームワーク覚えなきゃ・・と思い始めてから早3プロジェクツ。自分で作りたいWebサービスがあるのだが、どのフレームワークを使用するのがベターなのか正直よく分かんない。有名どころののAngularJS, Backbone.js, Ember.jsのいずれかがよいのだろうと思うが、それぞれ一体どんな特徴があるのだろうか。というわけで今回は、各フレームワークを使用して実際に簡単なアプリを作成してみることにした。まずはAngularJS、お前からだ!

こんなアプリを作る

掲示板アプリを作ってみようと思う。単なる掲示板だとつまらないので、twitter風に自動で投稿が表示されるようにしてみた。

環境

  • Ruby on Rails 4.1
  • AngularJS 1.3

セットアップ

全てはGemfileから始まる。ポイントはRails Assetsを利用している点。Javascript用のパッケージマネージャBowerと、ruby用のパッケージマネージャBunlderの橋渡しをしてくれている。要するに、Bower用のJavascriptライブラリをラップしたgemを提供してくれるということだ。

📄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 'rails-assets-angular'
gem 'rails-assets-angular-resource'

group :development do
  gem 'spring'
end

Gemfileを記述したら、通常通りbundle installを実行する。localhost:3000を開くと、Welcome aboardが表示されるはずだ。

次にAngularJSを使用する準備にかかる。app/assets/javascripts/application.jsを編集する。

📄app/assets/javascripts/application.js
//= require angular
//= require angular-resource
//= require board_sample
//= require_tree .

jqueryやturbolinksは使用しないので削除した。3行目で指定しているboard_sapmpleというのはこれから作成するファイルだ。(このファイル内でAngularJSのセットアップをするのでrequire_treeよりも上に記述しておかなければならない。)

📄app/assets/javascripts/board_sample.js.coffee
window.App = angular.module('BoardSample', ['ngResource'])

# Ajax送信時にトークンを送信する(トークンがないとRails側で認証エラーになる)
App.config ($httpProvider) ->
  $httpProvider.defaults.headers.common['X-CSRF-Token'] =
    document.getElementsByName("csrf-token")[0].content

トークンの設定をしているのはコメントに記載している通り、認証を突破するためだ。railsのコントローラ側でスキップする手もあるが、今回はこのように対応した。(※IE9以下では動かない)

続いて、アプリケーションで使用するレイアウトファイルを作成しておく。

📄app/views/layouts/application.html.erb
<!DOCTYPE html>
<html lang="ja" ng-app="BoardSample">
<head>
  <title>Board Sample</title>
  <%= stylesheet_link_tag 'application', media: 'all' %>
  <%= javascript_include_tag 'application' %>
  <%= csrf_meta_tags %>
</head>
<body>

<%= yield %>

</body>
</html>

シンプルこの上ないレイアウトだが、重要な記述が2行目にある。ng-app="BoardSample"という部分で、AngularJSが自動起動するディレクティブを指定する。先程セットアップしたBoardSampleアプリケーションを指定している。

このあたりで、AngularJSがうまく設定できているか確認してみる。まずはコントローラを作成して・・・

📄config/routes.rb
$ rails g controller pages home

ルートURLをこのpagesコントローラに設定しておく。

Rails.application.routes.draw do
  root to: 'pages#home'
end

http://localhost:3000にアクセスするとページがレンダリングされるはずなので、JavascriptコンソールでAngularJSのバージョンを確認してみよう。angular.version.fullと入力してみよう。

バージョン
バージョンが表示されれば、正しくAngularJSがセットアップできているということだ。

step1. 投稿を表示する

それでは掲示板作りに入っていこう。投稿モデル(Post)を生成する。

📄config/routes.rb
$ rails g model Post author body

rake db:migrateも忘れずに。生成した投稿モデルにRESTインターフェースを実装してあげよう。

Rails.application.routes.draw do
  root to: 'pages#home'
  resources :posts, defaults: { format: 'json' }, only: %i(index show create)
end
📄app/controllers/posts_controller.rb
class PostsController < ApplicationController
  respond_to :json

  def index
    respond_with Post.all
  end
end

とりあえず今はindexメソッドだけ実装した。ちゃんとJSONが取得できるか試す前に、テストデータを登録しておこう。db/seeds.rbを編集する。

📄db/seeds.rb
Post.create!(author: '名無しさん', body: 'これは本文です')
Post.create!(author: '豊田サリー', body: 'テクマクマヤコンテクマクマヤコン')
Post.create!(author: 'ガンジー', body: "非暴力\n非服従\n言葉は要らない")

編集が終わったら、rake db:seedを実行してからhttp://localhost:3000/posts/にアクセスしてみよう。

json
文句なしにJSONが取得できた。(ちなみに私はJSONViewというChrome拡張を使用してます)

ひとまずこれでrails側はうまく動いているので、クライアント側の実装に入る。この投稿RESTモデルにマッピングしたangular-resourceというのを実装することから始めよう。app/assets/javascripts/angular/services/post.js.coffeeというファイルを作成する(場所は任意)。

📄app/assets/javascripts/angular/services/post.js.coffee
App.factory 'Post', ($resource) ->
  $resource '/posts'

/postがマッピングするRESTインターフェースのURLである。これだけで投稿モデルに対するCRUDインターフェースが用意できた。それでは投稿を表示する画面・コントローラを作成することにしよう。まずはhome.html.erbを修正する。

📄app/views/pages/home.html.erb
<div ng-controller="PostsCtrl">
  <div class="post" ng-repeat="post in posts">
    <span>{{$index}}.</span>
    <span class="author">{{post.author}}</span>
    <span class="time">{{post.created_at | date: 'yyyy-MM-dd HH:mm:ss'}}</span>
    <div class="body">{{post.body}}</div>
  </div>
</div>

ng-controller="PostsCtrl"でコントローラを指定している。これから作成するAngularJSのコントローラだ。2行目ではng-repeat="post in posts"と記述し、投稿を繰り返し表示するようにしている。その他詳細は公式サイトのドキュメントを漁れば分かると思う。

ではいよいよコントローラを作成する。いよいよという程のコード量ではないのだが。

📄app/assets/javascripts/angular/controllers/posts_ctrl.js.coffee
App.controller 'PostsCtrl', ($scope, Post) ->
  # postを全件取得
  $scope.posts = Post.query()

1行目でPostリソースをDI注入しており、3行目のPost.query()にてGET /post.jsonが走るようになっている。$scope.postsが先程のビューにデータバインドされるというわけだ。http://localhost:3000にアクセスしてみると・・・

投稿全表示
うまく表示できた! ほんのすこしだけカッコをつけよう。

📄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;
  }
}

white-space: pre; を指定しておけば、投稿本文に改行(\n)が含まれている場合、実際に改行して表示てくれるので楽チンだ。「そんなの<%= simple_format ... %>とすればいいじゃないか」と思ったあなたは、まだrailsのクセが抜けてない。今我々が記述しているのは『あっち側』じゃなくて、『こっち側』だ。

step2. 投稿できるようにする

掲示板らしくするために、投稿する機能を実装しよう。まずは投稿フォームがなければ何も始まらない。

📄app/views/pages/home.html.erb
<div ng-controller="PostsCtrl">
  <div class="post" ng-repeat="post in posts">
    <span>{{$index}}.</span>
    <span class="author">{{post.author}}</span>
    <span class="time">{{post.created_at | date: 'yyyy-MM-dd HH:mm:ss'}}</span>
    <div class="body">{{post.body}}</div>
  </div>

  <form ng-submit="create()">
    <div>
      お名前:<input type="text" ng-model="newPost.author" required>
    </div>
    <div>
      本文:
      <textarea ng-model="newPost.body" required></textarea>
    </div>
    <input type="submit" value="投稿する">
  </form>
</div>

ng-submitでフォームのsubmit時に呼び出すコントローラ内のメソッドを指定する。ng-modelはコントローラからデータバインドされるオブジェクト名だ。修正後のposts_ctrl.js.coffeeを見れば一目瞭然だろう。

📄app/assets/javascripts/angular/controllers/posts_ctrl.js.coffee
App.controller 'PostsCtrl', ($scope, Post) ->
  # 初期表示時の投稿フォーム初期値
  $scope.newPost = { author: '名無しさん', body: '' }
  # postを全件取得
  $scope.posts = Post.query()
  # submit時に呼び出される
  $scope.create = ->
    # 保存
    post = Post.save($scope.newPost)
    # 投稿した内容を追記
    $scope.posts.push(post)
    # 入力フォームのクリア(投稿したお名前は引き継ぐ)
    $scope.newPost = { author: post.author, body: '' }

普段はほとんど書かないコメントを書きまくったので、先程のビューとの関連が分かると思う。そういえばrails側のPostsController#createメソッドを実装していないことを思い出したぞ。

📄app/controllers/post_controller.rb
class PostsController < ApplicationController
  respond_to :json

  def index
    respond_with Post.all
  end

  def create
    respond_with Post.create!(post_params)
  end

  private

  def post_params
    params.require(:post).permit(:author, :body)
  end
end

投稿機能の実装はこれにて完了。ブラウザで動作を確認してみる。

投稿
投稿が即座に反映され、リロードしても元に戻っていなければ成功だ!

step3. 未読投稿を自動取得する

だんだんとそれっぽくなってきたとはいえ、この掲示板は仕様上の大問題がある。最初に画面を表示してからリロードするまで、他人の投稿が一切反映されないくせに、自分の投稿は反映させるということ。実際の投稿の並びとは異なる可能性があるのに。。。チュートリアルとはいえ、さすがにこんなダサい仕様はアレルギーものなので、定期的に未読の投稿を取得するという今風な仕様に変更してみることに。

posts_ctrl.js.coffeeを思いっきり修正する。

📄app/assets/javascripts/angular/controllers/parts_ctrl.js.coffee
App.controller 'PostsCtrl', ($scope, $interval, Post) ->
  # 初期表示時の投稿フォーム初期値
  $scope.newPost = { author: '名無しさん', body: '' }

  # postを全件取得
  Post.query (posts) ->
    $scope.posts = posts
    $interval fetchUnread, 5 * 1000  #未読分を5秒毎に取得する

  # 未読分の取得
  fetchUnread = ->
    lastId = $scope.posts[$scope.posts.length - 1].id
    Post.query 'q[id_gt]': lastId, (posts) ->
      $scope.posts.push.apply($scope.posts, posts)

  # submit時に呼び出される
  $scope.create = ->
    # 保存
    post = Post.save($scope.newPost)
    # 入力フォームのクリア(投稿したお名前は引き継ぐ)
    $scope.newPost = { author: post.author, body: '' }
    # 未読postの取得
    fetchUnread()

1行目に$intervalという引数を追加しているが、こいつで定期的に未読投稿をサーバから取得するようにしている。ちなみにJavascript標準のsetTimeout()は使用しない方がよい。AngularJSによってバインドされたデータの変更が検知できないので(このあたりについては、angularjs timeout apply といったワードでググってみるとよい)。未読分の取得の方法だが、現在表示している投稿の最大idを利用して検索をかけるようにしている。

ではその検索に対応するため、rails側を編集することにしよう。思いっきり手を抜くためransackを利用することにした。

📄Gemfile
gem 'ransack'
📄app/controllers/post_controller.rb
class PostsController < ApplicationController
  respond_to :json

  def index
    respond_with Post.search(params[:q]).result(distinct: true)
  end
...

bundle installしてwebrickを再起動するのを忘れずに。準備が整ったらブラウザを2つ並べて片方で何か投稿してみよう。他方の掲示板にも自動的に未読分が表示されるはず!

step4. アニメーションさせる

機能としてはこれで完成だが、未読分の投稿が追記されたことに気づきにくいという難点がある。AngularJSはアニメーションにもつおいらしいので、調子に乗って未読分をハイライトさせてみよう。

AngularJSではアニメーションは別パッケージになっているので、Gemfileを修正する必要がある。

📄Gemfile
gem 'rails-assets-angular-animate'

また、application.jsとboard_sample.js.coffeeの修正も必要だ。

📄app/assets/javascripts/application.js
//= require angular
//= require angular-resource
//= require angular-animate
//= require board_sample
//= require_tree .
📄app/assets/javascripts/board_sample.js.coffee
window.App = angular.module('BoardSample', ['ngResource', 'ngAnimate'])
...

ではアニメーション機能を実装する・・・といっても実はたいしてやることはない。というのもAngularJSは非常に気の利くいいヤツでして、リストにエントリーを追加する際に専用のCSSを付加してくれているらしい。すなわち、次のようなcssを用意するだけで・・・

📄app/assets/stylesheets/animate.css.scss
.ng-enter,
.ng-leave,
.ng-move {
  -webkit-transition: opacity 0.20s linear;
  transition: opacity 0.20s linear;
}
.ng-enter {
  opacity: 0;
}
.ng-enter.ng-enter-active {
  opacity: 1;
  background: lightyellow;
}
.ng-leave {
  opacity: 1;
}
.ng-leave.ng-leave-active {
  opacity: 0;
}

完成
ピカっと輝くようになった。完成!!

完成品はこちらからダウンロードできます

所感

他のフレームワークをまだ試してないので比較はできないものの、AngularJSの雰囲気がある程度つかめた。

  • RESTインターフェースと親和性が高い
  • アニメーションやらバリデーションまで、オールインワン
  • ng-appやng-controllerのおかげでディレクティブ単位のコンポーネント作成なんかが簡単っぽい
  • railsと同様、Fatコントローラになりやすそうな予感

個人的な開発にも手軽に使えそうだなーと分かったところで、寝ることにしよう。。あぁ長かった・・・

次回はEmber.jsにチャレンジする予定。

参考リンク

関連する記事


コメントする

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

CAPTCHA


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