「クラウドxスマフォ時代のRuby on Rails入門」セミナーで使ったコードをGitHubに置きました

昨日、行われた クラウドxスマフォ時代のRuby on Rails入門 セミナーで使ったコードをGitHubに置きました。
簡単な Ruby on Rails で作ったサーバーと連携できる iOSアプリです。













コードの特徴

Rails側は

  • CarrierWaveを使った画像アップロート・サムネール付き
  • Deviseを使った認証
  • デザインはTwitter Bootstrap
  • iOSアプリの認証(basic認証)
  • JSONでの画像受け取り
  • Herokuへデプロイ出来ます
  • モデルにRSpecが書いてあります

iOS側は

  • UICollectionView
  • UIImagePickerController
  • NSRailsを使ったRailsとの通信

などです。

Ruby on Railsのログ(production.log)にUserAgentを追加する

下のように、Ruby on Railsのログ (例えば production.log)にUserAgentを追加してみました (実際にはログは1行で出力されています)。

I, [2014-07-06T08:40:53.574498 #1317]  INFO -- : Started GET "/products/767" for 192.168.0.1
at 2014-07-06 08:40:53 +0900 by Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko

Ruby on Railsのログのうち、上のようなブラウザーからのアクセス情報は Rackレベルで行っています、またログフォーマットを変更するようなAPIは用意されていません。正式な手順はRackミドルウェアのレベルで Logger を差し替える事になりますが、面倒なので以下のようなパッチを作り config/initializers に置いて フォーマットを変更しました。

Rails 4.1.4 で確認確認しましたが他のバージョンで試す場合はRailsのソースを見てから対応して下さい。

  • config/initializers/rack_logger.rb
module Rails
  module Rack
    class Logger < ActiveSupport::LogSubscriber
      # Add UserAgent
      def started_request_message(request)
         'Started %s "%s" for %s at %s by %s' % [
          request.request_method,
          request.filtered_path,
          request.ip,
          Time.now.to_default_s,
          request.env['HTTP_USER_AGENT'] ]
      end
    end
  end
end                                                                                                                    

NSRailsを使ってみた

7月1122日に行う 「クラウドxスマフォ時代のRuby on Rails入門」 セミナーで使うデモアプリを作るために、NSRails を使ってみました。


概要

NSRails の使い方は https://github.com/dingbat/nsrails に書かれているように、iOS側に Rails と同じモデルを用意し、 モデルクラスの 取得(remoteAll: , remoteObjectWithID: ...)やCRUD(remoteCreate: , remoteUpdate: , remoteDestroy: ...) メソッドを呼び出すだけで、Railsサーバーとのデータやり取りが出来ます。また通信は、同期、非同期をサポートしています。

使ってみて分かったこと

1. Pod は GitHub から

Railsサーバーが起動してないときにiOS側で通信するとへんなエラーで落ちますが GitHubのmasterでは修正されているのでPodfileは以下のようにした方が良いでしょう。

  pod 'NSRails', :git => 'https://github.com/dingbat/nsrails.git'
2. autogen/generate がモデルの雛形を作ってくれて超便利

NSRailsのGitHubにある autogen/generate コマンドで Railsプロジェクトのパスを指定すると、そのプロジェクト内の全モデルに対応する iOS側のモデルの雛形を作ってくれて、超便利です。

./autogen/generate RailsProject
Making directory RailsProject.gen/
Writing files to /Users/yy/tmp/nsrails/autogen/RailsProject.gen
  + Category.h
  + Category.m
  + Customer.h
  + Customer.m
   ...
3. iOS側モデルは必ずしもRailsのモデルと同じで無くても良い

Rails側とiOS側での処理分担は思案のしどころですが、必ずしも一致される必要はありません。iOS側モデルは Rails側のview (〜.json.jbuilder) と対応していれば良いので、iOS側を簡単にしたい場合はviewでたくさんの情報を作り、iOS側はそれを利用する事もできます。今回作っているデモアプリは下のように、簡単な処理も Rails 側でやって渡しています。

json.array!(@timelines) do |timeline|
  json.extract! timeline, :id, :user_id, :caption, :created_at
  json.name timeline.user.name
  json.title photo_title(timeline)
  json.photo_url full_url(timeline.photo.url)  if timeline.photo.present?
  json.photo_thumb_url full_url(timeline.photo.thumb.url)  if timeline.photo.present?
end
  • TimeLine.h
#import <NSRails/NSRails.h>

@interface Timeline : NSRRemoteObject
@property (nonatomic, strong) NSString *caption;
@property (nonatomic, strong) NSNumber *userId;
@property (nonatomic, strong) NSString *name;
@property (nonatomic, strong) NSString *title;
@property (nonatomic, strong) NSString *photoUrl;
@property (nonatomic, strong) NSString *photoThumbUrl;
@property (nonatomic, strong) NSString *imageData;
@property (nonatomic, strong) NSDate   *createdAt;
@end
4. 戻さなくて良いプロパティー(インスタンス変数)が指定出来る

NSRailsのCookbookiOS側モデルに付いて色々と書かれています。 shouldSendProperty: メッソドをオーバーライトして、iOSからRailsには戻さないプロパティーを指定できます。これを使い、3. のように余計な情報をRailsから送ってもらってる場合は、それらをshouldSendProperty: に指定しておけば、通信コストを小さくできます。

  • TimeLine.m
#import "Timeline.h"

@implementation Timeline

- (BOOL) shouldSendProperty:(NSString *)property whenNested:(BOOL)nested
{
    if ([property isEqualToString:@"title"] || [property isEqualToString:@"name"] ||
        [property isEqualToString:@"photoUrl"] || [property isEqualToString:@"photoThumbUrl"])
        return NO;
    return [super shouldSendProperty:property whenNested:nested];
}

@end

その他、プロパティー名をRails側と変えたいとか、型を変えるとか・・・ 色々な事ができますので NSRailsのCookbook は一読しておくと良いと思います。

5. 画像データをJSONで送りたい

今回のアプリでは、iOS側から画像データをRails側に送る必要があるので、StackOverflow 等を調べ以下のようなコードを書きました。

iOS側は画像データをBase64エンコードして送ります。

    ....
    Timeline *newTimeline = [[Timeline alloc] init];
    newTimeline.caption = post.caption;
    newTimeline.imageData = [self base64FromImage:post.image];
    [newTimeline remoteCreateAsync:^(NSError *error) {
        if (error) {
            NSLog(@"+++ err %@", error);
        } else {
            NSLog(@"+++ ok : id=%@", newTimeline.remoteID);
        }
    }];
    ....

- (NSString *)base64FromImage:(UIImage *)image
{
    return [UIImagePNGRepresentation(image) base64EncodedStringWithOptions:NSDataBase64Encoding64CharacterLineLength];
}


Rails側は、image_dataの送られて来たBase64エンコードされた画像データをデコードし StringIOで受け取り、その IO Stream を file_field に対応する photo フィールド に代入しています。この後の処理は Carrierwave で行っています。Carrierwaveの処理では IO Stream に path メソッドが必要なようなので、特異メソッドとして追加してます。

ネット上にあるサンプルコードでは Tempfileクラスを使って、テンポラリーファイルに書くコードを多く見かけましたが、それほど大きな画像は送られてこないので、ファイルを作らない StringIO を使ってみました。

  def create
    @timeline = Timeline.new(timeline_params_with_image_data)
    if @timeline.save
      .....

    def timeline_params_with_image_data
      parameters = timeline_params
      if params[:timeline][:image_data]
        parameters[:photo] = parse_image_data(params[:timeline][:image_data])
      end
      parameters
    end

    def parse_image_data(image_data)
      tempfile = StringIO.new(Base64.decode64(image_data))

      def tempfile.path
        "/tmp/upload.png"
      end

      ActionDispatch::Http::UploadedFile.new(tempfile: tempfile, content_type:'image/png', filename:'iphone.png')
    end
6. Basic認証

Rails側でiOSアプリを認証するの部分は、今回は実装が簡単な Basic認証を使ってみました。生パスワードを送っているので実際のアプリで使う場合は SSL 通信が必要ですね。

NSRailsには Basic認証用ヘッダーを送る機能を持っているのでNSRConfigに ユーザー、パスワードを設定するだけです。

    [NSRConfig defaultConfig].appUsername = login.email;
    [NSRConfig defaultConfig].appPassword = login.password;

Rails側は 定番の devise を使っています。コードは deviseのHow To にあったものです。

class TimelinesController < ApplicationController
  before_action :authenticate

  ....

  private
    ....
    def authenticate
      if from_iphone?
        authenticate_or_request_with_http_basic do |username,password|
          resource = User.find_by(email: username)
          if resource && resource.valid_password?(password)
            sign_in :user, resource
          end
        end
      else
        authenticate_user!
      end
    end
end
7. ログの制御

デフォルトではコンソールに通信内容も表示されます、画像を送ったりすると大変な量のログが表示されるので、ログを制限した方が良いと思います。手っ取り早いのは NSRailsの NSRails.h ファイルの以下の定義を変える事です

//					As undefined, NSRails will log nothing
// #define NSRLog 1	//As 1, NSRails will log HTTP verbs with their outgoing URLs, as well as any server errors
#define NSRLog 2	//As 2, NSRails will also log any JSON going out/coming in
8. 通信中表示

NSRConfigのmanagesNetworkActivityIndicator を YES に設定すると通信中にステータスバーに 通信中のグルグルを表示してくれるので便利

[NSRConfig defaultConfig].managesNetworkActivityIndicator = YES;


どんな物が出来たか興味のあるかたは 「クラウドxスマフォ時代のRuby on Rails入門」 セミナー にご参加下さい。転職・人材系会社でのセミナーですが・・・あまり気にしなくて良いと思います。

Feature specs で、たくさんの手順があるWebアプリの受け入れテストを書いてみた

ログインして、販売伝票ボタンを押し、商品追加ボタンを押し、表示された商品ページでカテゴリーを選択し、商品の一覧を表示し選択ボタンを押し・・・ ・・・ ・・・ 確定ボタンを押す。

のようなたくさんの手順で一つの作業が完結するようなWebアプリの受け入れテスト(顧客テスト、総合テスト…)を RSpec + Capybara の Feature specs を使って書いてみました。

http://imagery.pragprog.com/products/140/achbd_xlargecover.jpg

通常このようなテストには Cucumber や Turnip が使われる事が多いですが、どちらも feature(テスト記述) と sptep(コード)に分かれていています。 顧客や開発者以外の人が feature の作成やレビューに関わる場合はメリットがありますが、開発者だけがテストに関わる場合は面倒なだけです。
そこでプログラマーの大好きな RSpec だけで、受け入れテストを書ける Feature specs なのですが、Feature specs には最初に書いたようなたくさんの手順を踏むテストを書くのは苦手です。

RSpec は テストの it "..." do ... end に簡潔なテストを書くのが基本で、通常は DBの値やセッション(Feature specsやController specsなど)は it 単位で初期化されます。rspec:install でセットアップすると it は乱数で毎回違う順に実行されます。
したがって、 受け入れテストの手順を it (Feature specs では scenario) 単位で書いて並べる分けにはいきません。長い手順を一つの it に書かないと行けません。

しかし、長い手順を一つの it に書いてはメンテナンスが極度に下がります・・・・・ そこで、ネットを探していたら RSpec Steps というものを見つけましたが、まだ RSpec 3.0 には対応してないようです。

そこで、設定やパッチ(!?)を駆使して、手順を scenario (= it ) 単位で書けるようにしてみました。 この際に問題になったのは

  1. scenario がランダムに実行される
  2. DBの値が scenario 単位で初期化される
  3. セッションが scenario 単位で初期化される

1. は設定で config.order = "defined" とすれば解決
2. は設定 config.use_transactional_fixtures = false とし before(:all) で FactoryGirlを実行すれば問題ありません。ただしrspec終了後も DBが残るの、 after(:all) でDB をクリアすれば解決しました。
3. は stackoverflow のコメントにあったヒントから Capybara のソースを見て、以下のようなパッチを書き解決しました :-)

def Capybara.reset_sessions!
end

ちなみに、環境は Ruby2.1.1, Ruby on Rails 4.1.0rc2, RSpec 3.0.0.beta2, RSpec Rails 3.0.0.beta2, Capybara 2.1.1, Poltergeist 1.5.0, Factory_girl_rails 4.4.1 です (他の環境では全く試していません)。

specファイル

長いので後半は省略しました

require 'features_helper'

feature "販売伝票作成", js: true  do
  include_context "seed_data"

  scenario "伝票入力ページが表示される" do
    login
    click_menu('販売伝票')
    expect(page.text).to match "販売伝票"
    expect(find_button("商品追加")).to be_truthy
  end

  scenario "商品追加をクリックすると商品選択が表示される" do
    click_on("商品追加")
    expect(find("h4", text: "商品選択").visible?).to be_truthy
  end

  scenario "商品分類を選択すると商品が表示される" do
    within ".modal-content" do
      select "キュロット・女性用", from: "商品分類"
      wait_css("table")

      data, count = parse_data(return_row_count: true)
      expect(count).to eq 5
      expect(data[0][3]).to eq "カバロダービー F マリーン 40"
    end
  end

  scenario "商品を2つ選択でき、商品選択を閉じる" do
    within ".modal-content" do
      click_within_table('選択', row:1)
    end
    wait_css("#slip_line_list tbody tr")
    within ".modal-content" do
      click_within_table('選択', row:2)
     end
    wait_css("#slip_line_list tbody tr:nth-child(2)")
    within ".modal-content" do
      click_within_table('選択', row:3)
     end
    wait_css("#slip_line_list tbody tr:nth-child(3)")
    click_on("閉じる")
    wait_not_css(".modal-content")
  end

  scenario "選択した商品が伝票に表示されている" do
    ・・・・
  end

  scenario "選択した商品の個数を変更できる" do
    ・・・・
  end

  scenario "選択した商品を削除できる" do
    ・・・・
  end

  scenario "お客様、値引、備考を入力し、確認画面へ" do
    ・・・・
  end

  scenario "確認画面に商品一覧と合計金額等が表示されている" do
    ・・・・
  end

  scenario "確定ボタンを押すと、伝票一覧ページが表示される" do
    click_on "確定"

    data = parse_data()
    expect(data.map{|e| [e[1], e[3]]}).to eq [
      ["販売", "山田次郎様 むけ ¥47,000"],
      ["販売", "山田太郎様 むけ ¥57,000"],
      ["発注", "C0016-0000 など 5件"]]
  end

end

設定ファイル features_helper

モデル等の rspec には影響が出ないように Features spec 用の設定ファイルを作って、その中でいろいろとやっています。
データの作成 setup_seed_data は FactoryGirl で初期データを作るメソッドで、cleanup_seed_dataは 全テーブルのデータを削除するメソッドです。

require 'spec_helper'
require 'capybara/poltergeist'
require_relative 'features_seed_data'

Capybara.javascript_driver = :poltergeist

# Force the ActiveRecord to use the same transaction for all threads monkey patch
# https://github.com/jnicklas/capybara/blob/master/README.md#transactions-and-database-setup
#
class ActiveRecord::Base
  mattr_accessor :shared_connection
  @@shared_connection = nil

  def self.connection
    @@shared_connection || retrieve_connection
  end
end
ActiveRecord::Base.shared_connection = ActiveRecord::Base.connection

RSpec.configure do |config|
  config.order = "defined"
  config.use_transactional_fixtures = false
end

def Capybara.reset_sessions!
  # ワイルドだろ〜
end

shared_context "seed_data" do
  before(:all) do
    setup_seed_data

  end
  after(:all) do
    cleanup_seed_data
  end
end

#
# helpers
#

def wait_css(css)
  until has_css?(css)
    sleep 0.5
  end
end

def wait_not_css(css)
  while has_css?(css)
    sleep 0.5
  end
end


def login(email: "xxxx@yyyy.com", password: "ppppp")
  visit root_path
  fill_in "メール", with: email 
  fill_in "パスワード",     with: password
  click_button "ログイン"
  page.text
end

def click_menu(label)
  within("ul.nav") do
    click_link(label)
  end
end

def screenshot(suffix = "")
  save_screenshot("/tmp/PhantomJS#{suffix}.png", :full => true)
end

def parse_data(return_row_count:false)
  row_count = page.all('table tbody tr').count
  col_count = page.all("table thead th").count
  all_td = page.all("table tbody td").map {|e| e.text()}

  table = []
  for row in 0..(row_count - 1)
    table << all_td.shift(col_count)
  end
  return_row_count ? [table, row_count] : table
end

def click_within_table(label, row:0)
  within("table tbody tr:nth-child(#{row})") { click_on(label) }
end

Mavericks(Mac OS X 10.9)にRuby1.8.7をインストールする方法

今さら最新のMac OSRuby 1.8.7 をインストールする人は少ないかもしれませんが、もろもろの理由でRuby 1.8.7が必要なのでインストールしてみました。

特に新しい情報はありませんが、brew, rbenv を使ってインストールしました。

% brew install apple-gcc42
% CC=/usr/local/bin/gcc-4.2 RUBY_CONFIGURE_OPTS="--without-tcl --without-tk" rbenv install 1.8.7-p374
% rbenv shell 1.8.7-p374

Ruby on Rails4.0.0正式版でJSON関連コードが無いキレイなscaffoldを生成する方法

Ruby on Rails4.0.0が正式リリースされましたが、4.0.0RC1 までと JSON関連のコードが無いscaffoldを生成する方法が変わりました ^^;

http://rubyonrails.org/images/rails.png

4.0.0RC1 までは、以下のオプションで JSON関連のコードが無い、きれいな controller と views の *.json.jbuilder が生成されませんでした。

% rails g scaffold todo due:date task:string -c scaffold_controller

しかし、Ruby on Rails4.0.0正式版 (4.0.0RC2から)は上のオプションでは JSON関連のコードが生成されてしまいます。--jbuilder=false を指定すれば *.json.jbuilder は生成されなくなりますが、controller には 醜い respond_to 〜 format.json があります ^^;

コードを見て判った事は、JSON関連のコード生成は jbuilder Gem が行っています。 したがって、Gemfile から jbuilder Gem をコメントアウトしてしまえばOKです。

結論

Ruby on Rails4.0.0が正式でJSON関連のコードが無いscaffoldを生成するには

Gemfile を以下のように変更します

gem 'jbuilder', '~> 1.2'

↓ 変更

# gem 'jbuilder', '~> 1.2'

Rails4.0で rake db:fixtures:load FIXTURES_PATH=spec/fixtures が deprecated と表示された際の対処法

Rails4.0RC2もリリースされましたね!
Rails4.0 で rake db:fixtures:load FIXTURES_PATH=spec/fixtures を実行すると以下のようなワーニングが表示されます。fixtuesの読み込みは出来てますが、気持ち悪いですよね。

% rake db:fixtures:load FIXTURES_PATH=spec/fixtures 
Using FIXTURES_PATH env variable is deprecated, 
please use ActiveRecord::Tasks::DatabaseTasks.fixtures_path = '/path/to/fixtures' instead.

色々と検索したのですが、適切な回答が見つからなかったので、ここに書いて置きます。

暫定的な対応

2013/12/19 Rails 4.0.2 では動作しないようです

fixturesのディレクトリーを指定する FIXTURES_PATHは deprecated ですが、fixtures ディレクトリーの下のディレクトリーを指定する FIXTURES_DIR は、まだ有効です。そこで、これを ../../ と悪用(?) して本来のfixturesのディレクトリー (デフォルトは test/fixures) の外のディレクトリーを指定しています。

% rake db:fixtures:load  FIXTURES_DIR=../../spec/fixtures

たぶん正しい対応


ワーニングに書かれているように、ActiveRecord::Tasks::DatabaseTasks.fixtures_path にfixturesのディレクトリーを指定すれば良いのですが、どこにこれを書いたら良いのかを見つけるのに手間取ってしまいました。

実は、 rake db:〜 の rakeタスクは lib/tasks に db.rake ファイルを書くことで機能を追加・変更できます。

namespace :db do
  task :load_config do
    ActiveRecord::Tasks::DatabaseTasks.fixtures_path = File.join(Rails.root, 'spec', 'fixtures') 
  end
end

2013/12/19 修正
2013/12/25 修正

そこで、 lib/tasks/db.rake ファイルに上のように書いてあげると、通常の rake db:fixtures:load で spec/fixtures から読み込んでくれます。