Practical Web Coding

デザイナーからエンジニアへ転身して学んだWeb系テクニックのまとめ

AngularJSからRailsに$httpモジュールを使ってデータをPOSTする

概要

AngularJSでAjax通信をしようとすると、複雑なオブジェクトをうまくPOSTできなかったり、Railsでパラメーターをうまく受け取れなかったりする。試行錯誤して落ち着いたパターンのメモ。

Angular側

$(document).on 'turbolinks:load', ->
  myApp = angular.module('myApp', [])

  myApp.controller 'MainController', ['$scope', '$http', ($scope, $http) ->

    # パラメーターエンコード関数
    transform = (data) ->
      $.param({data})

    # サーバーにデータを保存
    $http({
      method : 'POST',
      url : "/load_data",
      transformRequest: transform,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
        'Accept': 'application/vnd.api+json',
        'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
      },
      data: {
        myparam1: 123,
        myparam2: "string"
      }
    }).then (response) =>
      $scope.data = response.data
    , (response) =>
      alert '通信失敗'
  ]

  angular.bootstrap($("#app"), ["myApp"])

Rails

class HomeController < ApplicationController

  def index

  end

  def load_data

    logger.debug "params = #{params.inspect}"
    logger.debug "params[:data] = #{params[:data].inspect}"

    render json: [
      {
        name: '商品01',
        price: 1000,
      },
      {
        name: '商品02',
        price: 500,
      },
    ]
  end
end

load_dataアクションではlogger.debugを用いてパラメーターをログに出力している。該当する部分のログは以下のような感じ。

params = <ActionController::Parameters {"data"=>{"myparam1"=>"123", "myparam2"=>"string"}, "controller"=>"home", "action"=>"load_data"} permitted: false>
params[:data] = <ActionController::Parameters {"myparam1"=>"123", "myparam2"=>"string"} permitted: false>

params[:data]に実際に送信された内容が来ている。RailsのStrong Parameterを使って受理するパラメタを検証すれば普通のformタグと同様にコントローラーで処理できる。

日付を扱う

Angular(JavaScript)とRailsで日付を相互にやりとりするのは意外と難しい。

Angular → Rails

POSTするときに、Date型の変数に対してgetTime()を呼ぶ。

date = new Date();
// => Fri May 19 2017 09:14:54 GMT+0900 (JST)
postDate = date.getTime();
// => 1495152894311

Rails側では、これを次のようにTimeクラスに変換する。

# POSTされたデータを想定
time_param = "1495152894311"
# Timeクラスを初期化
time = Time.at(time_param.to_f / 1000.0)
# =>  2017-05-19 09:14:54 +0900

正しく受け渡すことができた。これはAngularと組み合わせるRailsプロジェクトでは必須になると思うので、ApplicationControllerにprivateメソッドを追加して全部のコントローラーで利用できるようにしておくと良いかもしれない。

Rails → Angular

RailsからAngularへ日付型の値を返すとき、また工夫が必要になる。

Rails

# 仮にrenderでjsonを返すケースとして考える
render json: { created_at: user.created_at.to_f * 1000 }

Angular(JavaScript)側

// こちらはシンプル
// returned_dateにRailsからの値が入っているとする
date = new Date(returned_date);

もっと便利なやり方があるかも。

RailsアプリとAngularをうまく組み合わせたWebアプリケーション

Ruby on Railsとは?

Ruby on Railsは、Rubyで書かれているWebアプリケーション開発用のフレームワーク。Webアプリケーション開発で必要となる一通りの機能をシンプルに実装することができる。Ruby自体も書いていて楽しい、「かゆい所に手が届く」プログラミング言語なので、難しい処理でもうまく頭を使うことで比較的簡単に開発することができる。Railsは「設定より規約」というフレーズを掲げており、独自で設計するというより、限りなくルールに乗っ取った開発をすることが思想となっている。そのため、同じような機能を作ろうと思った場合に同じようなソースコードになることが多く、Railsのコミュニティなどで情報発信がしやすい。このため、Railsに関する情報量はWebフレームワークの中ではトップクラスである。

AngularJSとは?

AngularJSは、Googleが開発に携わっているフロントエンドフレームワークJavaScriptで動きのあるWebサイトを作ろうとすると、よく耳にするライブラリとしてjQueryがの名前が挙がる。しかし、jQueryは簡単な処理は非常に楽に書ける反面、規模が少し大きくなると設計者の技量によって見やすく、バグなく、メンテナンスしやすいコードになるかが大きく変わってくる。HTML上の表示欄とJavaScript上の変数の値をうまく表示・取得してやりとりしたり、オブジェクトを動的に増減させたりが意外と難しい。そんな状況を解決し、複雑なWebアプリケーションを効率的に切り分けて開発できるようにしたものがフロントエンドフレームワークであり、その一つにAngularJSがある。AngularJSでは、Railsと同じくMVCというデザインパターンが取り入れられていて、規模の大きいアプリケーションでも規約に沿ってスムーズにコーディングを始めることができる。

しかし、AngularJSで登場するDI(Dependency Injection)をはじめとする独特の機能が初心者にとってわかりづらく、学習コストが高くなってしまっている。

最近ではAngular 2というAngularJSの最新バージョンも公開された。こちらはTypeScriptが推奨されているため、AngularJSとTypeScriptの両方を学ばなければならない。

学習コストがそれなりにある反面、AngularJSをそこそこ使えれば、動きのあるWebアプリケーションの開発が一気に楽になる。

最近ではどんなに小さくてもJavaScriptでHTMLを操作するときはAngularを使いたくなるほど。

RailsとAngularJSを組み合わせて使う

RailsはWebフレームワークですが、JavaScriptの部分があまりうまくフレームワーク化できている気はしない。JavaScriptだけ少し浮いているというか、実装が開発者に委ねられていて、「設定より規約」が十分に機能していないと思う。

動的に変化するフォームや、表形式で大量のデータを扱ったり、FullCalendarなどのライブラリを使う時にはやはりしっかりとしたJavaScriptを書く必要がある。

RailsではjQueryが基本的に使われるが、jQueryだけでは先述の問題が発生してしまう。

一方、Railsを完全にAPIサーバーとして開発して、フロントエンドはAngularを使ってSPA(Single Page Application)という形で開発するという方法も考えられる。

この場合、APIに特化したプログラミングを行うため、Railsで用意されている強力なフォーム開発ヘルパー類が軒並み使えなくなる。ログインも含めたWebアプリケーションを作るとなると、Railsの手助けを無くしてしまうのは少し勿体無い。

そこで、RailsとAngularをうまく組み合わせて、必要なページの必要な部分だけAngularを使って動きを持たせて、Railsアプリと連携する方法を模索する。

ただし、今回はRailsに組み込むことを考えているので、jQueryを使える部分は積極的に利用している。

Railsアプリケーションを開発する

Railsプロジェクトを新規に作成する部分は省略。VagrantでCentOS7を起動して、その上でRails5.1.1を動かす。

テスト用なので、画面は1つのみとする。

config/routes.rbにルーティングを作成

Rails.application.routes.draw do

  # メインの画面
  root to: 'home#index'
end

ルートディレクトリにアクセスすると、HomeControllerのindexアクションにルーティングされるようにした。

その設定に基づいて、Railsのコントローラーを作る。 app/controllers/home_controller.rb

class HomeController < ApplicationController

  def index

  end
end

次に、コントローラーのアクションに対応するビューを作成する。 app/views/home/index.html.erb

home#indexです

最後に、JavaScriptを記述するためのCoffeeScriptファイルを作成する。CoffeeScriptは、JavaScriptを効率的に記述するプログラミング言語で、コンパイルするとJavaScriptになる。

app/assets/javascripts/home/index.coffee

# DOMの準備ができた時に発火する
$(document).on 'turbolinks:load', ->
  alert '準備完了'

ここまで実装した状態でサーバーを起動していみる。

$ bundle exec rails s -b 0.0.0.0 -p 3000

この状態でブラウザからhttp://localhost:3000/にアクセスすると次のような画面になる。 f:id:developer-northeast-1:20170516235643p:plain

「準備完了」とメッセージが出ているはず。

ここまで来たら準備完了。次に、Angularを導入していく。

RailsアプリケーションでAngularを使う準備をする

Angularを使うために必要なファイルを読み込む。今回はCDNを利用してJavaScriptファイルを利用することにした。

レイアウトファイルにAngular本体を読み込むソースを追加する。 app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
  <head>
    <title>RailsAngular</title>
    <%= csrf_meta_tags %>

    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>

    <!-- 以下の行を追加 -->
    <%= javascript_include_tag 'https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.6.1/angular.js', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

この状態で、先ほど作成したindex.html.erbとindex.coffeeにAngularの動作を確認するソースを追加していく。

app/assets/javascripts/home/index.coffee

$(document).on 'turbolinks:load', ->
  myApp = angular.module('myApp', [])

  myApp.controller 'MainController', ['$scope', ($scope) ->
    $scope.message = "Angularです"
  ]

  angular.bootstrap($("#app"), ["myApp"])

app/views/home/index.html.erb

<div id="app" ng-controller="MainController">
  home#indexです {{message}}
</div>

この状態でサーバーを起動し、ブラウザを確認してみるとこのようになった。 Angularは無事に動作している模様。 f:id:developer-northeast-1:20170517001730p:plain

通常のAngularでは、「ng-app」を指定してAngularを初期化するが、Railsと組み合わせる時はあえてng-appをつけない。理由はRailsのgem「Turbolinks」と干渉してしまうためだ。

HTML上では必要なオブジェクトを用意した状態で待っておき、Turbolinksが準備完了して「turbolinks:load」イベントをコールしたタイミングでangular.bootstrap($("#app"), ["myApp"])を実行してAngularを初期化する。こうすることで、Turbolinksを利用した画面遷移を行うWebアプリケーションでもAngularアプリを開発することができる。

AngularからRailsAjaxリクエストを行い、データを取得する

さて、ここからがAngularとRailsの連携のメインとなる部分となる。AngularからAjax通信を利用するためには、$httpサービスを利用する。$httpはAjax通信をラップしたもので、Angularで通信する時はこれを用いる。

通信を行う前にまずは、AngularからのAjax通信を捌くRailsのルーティング・コントローラーアクションを作成する。

config/routes.rb

Rails.application.routes.draw do

  # メインの画面
  root to: 'home#index'

  # データの読み込み
  post 'load_data' => 'home#load_data'
end

load_dataの行を追加した。 コントローラーには、このルーティングに対応するアクションを定義する。

app/controllers/home_controller.rb

class HomeController < ApplicationController

  def index

  end

  def load_data

    render json: [
      {
        name: '商品01',
        price: 1000,
      },
      {
        name: '商品02',
        price: 500,
      },
    ]
  end
end

次に、AngularからAjax通信を行うための処理を記述する。 app/assets/javascripts/home/index.coffee

$(document).on 'turbolinks:load', ->
  myApp = angular.module('myApp', [])

  myApp.controller 'MainController', ['$scope', '$http', ($scope, $http) ->

    # パラメーターエンコード関数
    transform = (data) ->
      $.param({data})

    # サーバーにデータを保存
    $http({
      method : 'POST',
      url : "/load_data",
      transformRequest: transform,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
        'Accept': 'application/vnd.api+json'
      },
      data: {}
    }).then (response) =>
      alert '通信成功'
    , (response) =>
      alert '通信失敗'
  ]

  angular.bootstrap($("#app"), ["myApp"])

さて、この状態でサーバーを起動してページにアクセスしてもエラーが発生する。 f:id:developer-northeast-1:20170517090052p:plain

サーバーのエラーはこんな感じ。 f:id:developer-northeast-1:20170517090139p:plain

RailsにはCSRF攻撃の対策として、POSTする際に認証トークンを送信している。普通のformタグでは、それらはform_forヘルパーなどが自動で生成しているが、今回実装したAngularの$httpを使ったAjax通信ではその認証トークンを送信できていない。

なので、Railsが処理を中断してエラーを出す動作をしている。

これを解決するには、通信部分を次のようにする。 app/assets/javascripts/home/index.coffee

$(document).on 'turbolinks:load', ->
  myApp = angular.module('myApp', [])

  myApp.controller 'MainController', ['$scope', '$http', ($scope, $http) ->

    # パラメーターエンコード関数
    transform = (data) ->
      $.param({data})

    # サーバーにデータを保存
    $http({
      method : 'POST',
      url : "/load_data",
      transformRequest: transform,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
        'Accept': 'application/vnd.api+json',
        'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
      },
      data: {}
    }).then (response) =>
      alert '通信成功'
    , (response) =>
      alert '通信失敗'
  ]

  angular.bootstrap($("#app"), ["myApp"])

$httpの中にheadersというオプションがあり、それに「X-CSRF-Token」という項目を追加した。値はRailsが自動でheadタグ内に埋め込むメタタグに設定されているトークンをjQueryにて取得している。

こうすることによってRailsに攻撃と見なされずにAngularからPOSTリクエストを発行することができる。

実行すると「通信成功」というメッセージが表示されるはずだ。 デベロッパーツールで確認すると、コントローラーでrenderした商品のリストが返ってきていることがわかる。 f:id:developer-northeast-1:20170517090854p:plain

あとはこれをAngularでうまく$scope変数を利用してビューに反映させるだけだ。

Ajax通信で読み込んだデータをAngularを使って表示する

ここまでくればあと少しでビューにデータを表示できる。

app/assets/javascripts/home/index.coffee

$(document).on 'turbolinks:load', ->
  myApp = angular.module('myApp', [])

  myApp.controller 'MainController', ['$scope', '$http', ($scope, $http) ->

    # パラメーターエンコード関数
    transform = (data) ->
      $.param({data})

    # サーバーにデータを保存
    $http({
      method : 'POST',
      url : "/load_data",
      transformRequest: transform,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
        'Accept': 'application/vnd.api+json',
        'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
      },
      data: {}
    }).then (response) =>
      $scope.data = response.data
    , (response) =>
      alert '通信失敗'
  ]

  angular.bootstrap($("#app"), ["myApp"])

通信が成功した時の処理に$scope.data = response.dataを追加した。これで$scopeを通じでビューで値を利用できる。

次に、htmlでビューを作成する。

app/views/home/index.html.erb

<div id="app" ng-controller="MainController">
  home#indexです {{message}}


  <table>
    <thead>
      <tr>
        <th>商品名</th>
        <th>価格</th>
      </tr>
    </thead>
    <tbody>
      <tr ng-repeat="product in data">
        <td>{{product.name}}</td>
        <td>{{product.price}}</td>
      </tr>
    </tbody>
  </table>
</div>

スタイルを当てていないので見た目は簡素だが、次のように動作することが確認できる。 f:id:developer-northeast-1:20170517091505p:plain

まとめ

基本的にRailsを使い、画面の動きが複雑な部分をAngularでカバーするこの使い方、結構便利なのではないか。RailsのいいところであるWebアプリケーション開発のスピード感を生かしつつ、サーバーといちいち通信せずにビューを変更し、必要なデータはAjaxを使ってAngularからRailsに要求する。

RailsとAngularを組み合わせようとすると、RailsAPIサーバーに完全に振り切るパターンが多く紹介されているようだが、うまく組み合わせるのもおすすめだ。