「クラウド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との通信

などです。

JavaScriptの勉強に、Node.js, Angula.js を使って簡単ツールを作った

片手間でJavaScript書いてる Yuumi3 です。

時間が出来たので、今さらながら Node.js や 流行の Angula.js を学んでました。学ぶためには何かアプリを作るのが一番ということで、簡単なWebアプリの状態監視ツールNode.js, Socket.IO, Koa, Angula.js, Mocha を使い作ってみました。

作るアプリについて

サーバー1台で運用しているサービスで、ソフトの更新などでアプリケーションサーバーをリスタートするタイミングは、今までは tail -f production.log でアクセス状況を見ながら行ってきました。しかし、面倒なので、ログをチェックしながらアプリのステータスを教えてくれるアプリを作ってみました。

ステータスは3つあって

Red

ユーザーが重要な事を行っている、例えばネットショップでは注文手続きを行っている(注文手続のURLをアクセスしてからの一定時間)。 この状態ではアプリをリスタートしてはいけない。


Green

検索エンジンクローラーや監視ツール以外のアクセスが無い、この状態ならリスタートOK。


Yellow

ユーザーからアクセスはあるけど見てるだけなので、まあ一瞬なら止めても良いかなという状況。急ぎならリスタートしても良い ^^;



構成

  • サーバー側は Node.js
  • クライアントは Angula.js
  • サーバーから情報を Socket.IO を使いクライアントに push
  • クライアントから最初のアクセスは http
  • サーバー側のフレームワークは Koa
  • サーバー側のテンプレートは Jade
  • サーバー側ロジックのテストを Mocha で記述
  • 判定に使うログは Ruby on Railsのログに UserAgentを追加したもの UserAgenのログ追加に付いては、こちら

クライアント側のAngula.js や Koa などこの程度のアプリではオーバースペックですが勉強のために使ってみました。

設定ファイル

  "log_path" : "/home/rails_apps/sample/current/log/production.log",
  "effective_time" : 120,
  "crucial_condition" : {"path" : "^/orders"},
  "pointless_condition" : {"user_agent" : "Googlebot|Yahoo!|Y!J|msnbot|bingbot|Baidu|Wget|Pingdom"}
}
  • ログファイルのパス
  • アクセスの有効時間: 例えば注文関連のURLのアクセスが在ってから指定時間はユーザーが処理を行ってると判断する時間
  • 重要(Red)条件: ユーザーが重要な作業をしてる判断条件、パスやUserAgentなどの正規表現文字列を指定します
  • 無視(Green)条件: クローラーや監視ツールなどの判断条件、パスやUserAgentなどの正規表現文字列を指定します

サーバー側

全てのコードはGitHub https://github.com/yuumi3/app_status に置きました。

メインのコード(Koa)

行っている事は

  • Koaの設定
  • / へのアクセスがあるとログファイルの最後8Kbyteを読み込み、サーバーにindex.htmlを配信
  • クライアントとの間にSocketが繋がると、現在の状態、最新のログをpush
  • ログファイルを file-tail を使い監視、変更があったら読み込み、現在の状態、最新のログをpush
  • アクセスの有効時間以上なにもなかったら、現在の状態、最新のログをpush
  • CSS, JS等の配信 koa-static
use strict';

var app = require('koa')();
var router = require('koa-router');
var serve = require('koa-static');
var views = require('koa-views');

var fileTailer = require('file-tail');
var railsLog = require('./rails-log');

app.use(serve('./public'));
app.use(views('views', {default: 'jade'}));
app.use(router(app));

app.get('/', function *(next) {
  yield log.readTail(8 * 1024);
  yield this.render('index', {});
});

var server = require('http').Server(app.callback());
var io     = require('socket.io')(server);
var refreshTimeout;

if (process.argv.length < 3) {
  console.log("Usage: " + process.argv[0] + " --harmony " + process.argv[1] + " CONFIG_PATH");
  process.exit(1);
}
var config = require(process.argv[2]);
var log = railsLog(config);
var tail = fileTailer.startTailing(config.log_path);

function sendStatus(log) {
  var status = log.isCrucial() ? 'red' : (log.isIdle() ?  'green' : 'yellow'); 

  if (log.logs.length == 0) {
    console.log(" - status: " + status);
  } else {
    var lastLog = log.logs[log.logs.length - 1];
    console.log(" - status: " + status + " log: " + lastLog.path + " " + lastLog.time.toISOString());
  }

  io.sockets.emit('update', {status: status, logs: log.logs});
  setAutoRefresh();
}

function setAutoRefresh() {
  if (refreshTimeout) clearTimeout(refreshTimeout);
  refreshTimeout = setTimeout(sendStatus, config.effective_time * 1000, log);
}

io.on('connection', function(socket){
  sendStatus(log);

  tail.on('line', function(line) {
    if (log.addOneLine(line)) {
      sendStatus(log);
    }
  });
});

server.listen(3000);
console.log("Start server.");
Railsログのライブラリー

node.js のモジュールとして RailsLogクラス(rails-log.js)を作ってみました。ログファイルの読み込み readLogFile は ES6の Generator (コルーチン、継続?) を使っています。Generator を手軽に使える Co ライブラリーや Co を fs (ファイルを扱うライブラリー)に組み込んだ co-fs などを使っています

'use strict';

var co = require('co');
var fs = require('co-fs');

module.exports = railsLog;

function railsLog(config) {
  return new RailsLog(config);
}

function RailsLog(config) {
  this.path = config.log_path;
  this.crucial_condition = prepareCondition(config.crucial_condition);
  this.pointless_condition = prepareCondition(config.pointless_condition);
  this.effective_time = config.effective_time || 120;
  this.max_log_size = config.max_log_size || 100;  // not implemented yet
  this.logs = [];
  this.lastAddedLine = '';
}

RailsLog.prototype.readTail = function*(len) {
  this.logs = []
  var content = yield readLogFile(this.path, len);
  content.split("\n").forEach(function(log) {this.addOneLine(log)}, this);
}

RailsLog.prototype.addOneLine = function(logString) {
  if (logString == this.lastAddedLine) return false;
  this.lastAddedLine = logString;

  var m = logString.match(/Started\s+(\w+)\s+\"(.*?)\"\s+for\s+([\d\.]+)\s+at\s+([\d: +-]+)\s+by\s+(.*)$/);
  if (!m) return false;
  var log = {method: m[1], path: m[2], ip: m[3], time: new Date(m[4]), user_agent: m[5]};
  if (isMatchCondition(log, this.pointless_condition)) return false;

  this.logs.push(log)
  return true;
}

RailsLog.prototype.isCrucial = function() {
  var cond = this.crucial_condition;
  addEffectiveTimeCondition(cond, this.effective_time);

  return this.logs.some(function(e) {
    return isMatchCondition(e, cond);
  })
}

RailsLog.prototype.isIdle = function() {
  var cond = {}
  addEffectiveTimeCondition(cond, this.effective_time);

  return !this.logs.some(function(e) {
    return isMatchCondition(e, cond);
  })
}

RailsLog.prototype.last = function(lines) {
  return this.logs.slice(-lines);
}

exports.railsLog = function(config) {new RailsLog(config);}

function *readLogFile(path, len) {
  var fd = yield fs.open(path, 'r');
  var stat = yield fs.fstat(fd);

  var buff = new Buffer(len);
  var pos = stat.size - len;
  if (pos < 0) {
    pos = 0;
  }
  var len_buff = yield fs.read(fd, buff, 0, len, pos);
  return len_buff[1].toString();
}

function isMatchCondition(e, conds) {
  for (var key in conds) {
    if (key == 'time') {
      if (e[key].getTime() < conds[key]) return false;
    } else {
      if (!e[key].match(conds[key])) return false;
    }
  }
  return true;
}

function prepareCondition(hash) {
  return Object.keys(hash || {}).reduce(function(conds, key){
      conds[key] = new RegExp(hash[key]);
      return conds
    }, 
    {});
}

function addEffectiveTimeCondition(conds, effective_time) {
  var d = new Date();
  conds['time'] = d.setTime(d.getTime() - effective_time * 1000);
}

クライアント側

index.jade

実はスタティックなページですが、テンプレートエンジンを使ってみたかったので使用しました。Angula.js はごく基本的な部分しか使っていません。

doctype html
html(ng-app='statusApp')
  head
    title application status
    script(src='/socket.io/socket.io.js')
    script(src='assets/javascripts/angular.min.js')
    script(src='assets/javascripts/ng-socket-io.min.js')
    script(src='assets/javascripts/status.js')
    link(rel='stylesheet' href='assets/stylesheets/bootstrap.min.css')
    link(rel='stylesheet' href='assets/stylesheets/bootstrap-theme.min.css')
    link(rel='stylesheet' href='assets/stylesheets/status.css')
  body
    div(ng-controller="StatusController")
      div(class="panel {{status_class}}")
        div.panel-heading {{status_text}}
        div.panel-body
          div.logs
            table(class='table table-striped')
              tbody
                tr(ng-repeat="log in logs")
                  td {{log.time | date : 'yyyy-MM-dd HH:mm:ss'}}
                  td {{log.method}}
                  td {{log.path}}
                  td {{log.ip}}
                  td {{log.user_agent}}
status.js

クライアント側のJS, サーバーからのステータス、ログを受け取り Angulaへ渡すだけの簡単なお仕事。Socket.IOは ngSocketIO を使ってます。

angular.module('statusApp', ['socket-io'])
  .controller('StatusController', function($scope, socket) {
    socket.on('update', function(data) {
      
      $scope.logs = data.logs;
      switch(data.status) {
        case 'red':
          $scope.status_class = 'panel-danger';
          $scope.status_text = 'Busy';
          break;
        case 'yellow':
          $scope.status_class = 'panel-warning';
          $scope.status_text = 'Warning';
          break;
        case 'green':
          $scope.status_class = 'panel-success';
          $scope.status_text = 'Idle';
          break;
        default:
          $scope.status_class = 'panel-info';
          $scope.status_text = '???';
          break;
      }
    });
  });

テスト

サーバー側の rails-log.js のテストを Mocha + expect.js でテストを書いてみました。

  • Co を使ったコードもあるので co-mocha を入れています。
  • 時間に関係したテストには Sinon.jsFake timers を使い new Date() の戻り時間を設定しています。
'use strict';

require('co-mocha');
var sinon = require('sinon');
var expect = require('expect.js');
var railsLog = require('../rails-log');

describe('RailsLog', function(){
  var log;
  var config = {
    "log_path" : "./test/sample.log",
    "effective_time" : 120,
    "crucial_condition" : {"path" : "^/(cart|order)"},
    "pointless_condition" : {"user_agent" : "Googlebot|bingbot"}
  };

  beforeEach(function(){
    log = railsLog(config);
  })

  describe('addOneLine', function(){
    var accessLine = 'I, [2014-07-06T08:45:33.076218 #1314]  INFO -- : Started GET "/products?s=3"' +
        ' for 203.140.209.190 at 2014-07-06 08:45:33 +0900 by Mozilla/4.0 (compatible; MSIE 8.0;' +
        ' Windows NT 5.1; Trident/4.0; GTB7.5; .NET CLR 1.0.3705; .NET CLR 1.1.4322; YTB730; ' +
        '.NET CLR 2.0.50727; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; .NET4.0C)';

    it('指定したログ文字列がアクセスログなら追加されtrueが戻る', function(){
      expect(log.addOneLine(accessLine)).to.be.ok();
      expect(log.logs.length).to.eql(1);
    })
    it('指定したログ文字列がアクセスログでなければ追加されずfalseが戻る', function(){
      expect(log.addOneLine('I, [2014-07-06T08:44:01.643727 #1311]  INFO -- : Completed 200 OK in 12ms ' +
        '(Views: 6.9ms | ActiveRecord: 1.6ms)')).to.not.be.ok();
      expect(log.logs.length).to.eql(0);
    })
    it('ログは解析されている', function(){
      log.addOneLine(accessLine);
      expect(log.logs[0]).to.eql({ip: "203.140.209.190", method: "GET", path: "/products?s=3", time: {},
        user_agent: "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; GTB7.5; .NET CLR 1.0.3705; .NET CLR 1.1.4322; " + 
                    "YTB730; .NET CLR 2.0.50727; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; .NET4.0C)"});
      expect(log.logs[0].time).to.eql(new Date(2014,7 - 1,6,8,45,33));
    })
    it('同じログ文字列ならアクセスログなら追加されない', function(){
      expect(log.addOneLine(accessLine)).to.be.ok();
      expect(log.addOneLine(accessLine)).to.not.be.ok();
      expect(log.logs.length).to.eql(1);
    })
  })

  describe('readTail', function(){
    it('ログファイルが読み込まれる', function*(){
      yield log.readTail(2000);
      expect(log.logs.length).to.eql(2);
      expect(log.logs[0].path).to.eql("/admin/scheduled_tasks/beat");
      expect(log.logs[1].path).to.eql("/products/211");
    })

    it('pointless_conditionに該当するログは読み込まれない', function*(){
      config["pointless_condition"] = {"user_agent" : "Googlebot|Wget"};
      log = railsLog(config);
      yield log.readTail(2000);
      expect(log.logs.length).to.eql(1);
    })
  })

  describe('isCrucial', function(){
    var accessLineIn = 'I, [2014-07-06T08:45:33.076218 #1314]  INFO -- : Started GET "/order/confirm"' +
        ' for 203.140.209.190 at 2014-07-06 08:45:33 +0900 by Mozilla/4.0';
    var accessLineOut = 'I, [2014-07-06T08:35:33.076218 #1314]  INFO -- : Started GET "/order/confirm"' +
        ' for 203.140.209.190 at 2014-07-06 08:35:33 +0900 by Mozilla/4.0';
    var clock;

    beforeEach(function(){
      clock = sinon.useFakeTimers((new Date(2014,6,6, 8,45,35)).getTime());
    })
    afterEach(function(){
      clock.restore();
    })
    it('crucial_conditionに該当するログが無ければfalseを戻す', function(){
      expect(log.isCrucial()).to.not.be.ok();
    })
    it('crucial_conditionに該当しeffective_time以内のログがあればtrueを戻す', function(){
      log.addOneLine(accessLineIn);
      expect(log.isCrucial()).to.be.ok();
    })
    it('crucial_conditionに該当しeffective_time以外のログがあればfalseを戻す', function(){
      log.addOneLine(accessLineOut);
      expect(log.isCrucial()).to.not.be.ok();
    })
  })

  describe('isIdle', function(){
    var accessLine = 'I, [2014-07-06T08:45:33.076218 #1314]  INFO -- : Started GET "/products"' +
        ' for 203.140.209.190 at 2014-07-06 08:45:33 +0900 by Mozilla/4.0';
    var clock;

    beforeEach(function(){
      clock = sinon.useFakeTimers((new Date(2014,6,6, 8,45,35)).getTime());
    })
    afterEach(function(){
      clock.restore();
    })
    it('effective_time以内のログが無ければtrueを戻す', function(){
      expect(log.isIdle()).to.be.ok();
    })
    it('effective_time以内のログがあればfalseを戻す', function(){
      log.addOneLine(accessLine);
      expect(log.isIdle()).to.not.be.ok();
    })
  })

  describe('last', function(){
    var accessLine = 'I, [2014-07-06T08:45:33.076218 #1314]  INFO -- : Started GET "/products"' +
        ' for 203.140.209.190 at 2014-07-06 08:45:33 +0900 by Mozilla/4.0 no=';
    var clock;

    beforeEach(function(){
      for(var i = 0; i < 10; i++) { log.addOneLine(accessLine + i); }
    })
    it('指定した行数のログを戻す', function(){
      expect(log.last(5).length).to.eql(5);
      expect(log.last(5)[0].user_agent).to.eql("Mozilla/4.0 no=5");
      expect(log.last(5)[4].user_agent).to.eql("Mozilla/4.0 no=9");
    })
    it('ログの終わりから指定した行を戻す', function(){
      expect(log.last(5)[0].user_agent).to.eql("Mozilla/4.0 no=5");
      expect(log.last(5)[4].user_agent).to.eql("Mozilla/4.0 no=9");
    })
  })

})

感想とか

良い点

  • npm, Mocha などRubyに似たツールがあり、Ruby プログラマーには入りやすい世界でした
  • Node.js は ブラウザーでJSを書くのに比べ書きやすい気がする
  • Generatorを使うとコールバック地獄にならない(??)
  • Mochaは幾つかのスタイルが選べるがexpect.jsと組み合わせると RSpec と似た表記で書けうれしい
  • Angula.jsのデータバインディングは素晴らしい、チャンスがあれば Rails + Angula.js でアプリを作ってみたい

良くない点

  • いろいろと覚える事がある、またどのライブラリーを使ったら良いのかが判らない。Rubyも同じだけど ^^;
  • JSはRubyに比べると、いろいろと面倒くさい。たとえば null, undefined に対して .toString() 出来ないなど細かい事でコードが増えがち。またイテレータ系のメソッドも少ない
  • function() を書くのが面倒! CoffeeScriptはまだGeneratorに対応してないようなので今回は見送りましたが、 早くCoffeeScript か、ES6 の Arrow functions が使えるようになると良いな
  • Generatorは使うと非同期なコードが書きやすくなるが、やはり非同期で動作するので色々とハマりやすい
  • Angula.js 素晴らしいが、ちょっとしたhtmlタグのミスでも動かなくなったりする。慣れの問題かな? それかIDE WebStorm を使えば良いのだろうか?


次のステップは、Rails + Angula.js でView は全てJSのアプリを書いてみたい。

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入門」 セミナー にご参加下さい。転職・人材系会社でのセミナーですが・・・あまり気にしなくて良いと思います。

GitLab API (gitlab gem)を使ってみました

今年の新人研修で課題の提出場所に GitLabを使ってみました。生徒の人数分 x 課題数のリポジトリーが作られるので、課題結果を自分の環境にcloneするのを手動で行っているとたいへんです。そこで、GitLab APIを使い、簡単なツールを作ってみました。

GitLab APIには gitlab gem があり、簡単に使う事ができます。しかし、いざ自分のやりたいプログラムを書こうと思ったら・・・・

  1. APIドキュメントはあるが、内容は今ひとつ
  2. サンプルコードが少ない
  3. ネット上 (英語の情報を含め)にも情報が少ない

という事で、gitlab gem の RSpecコードなどを見ながら勉強しました。また、APIの設計があまりオブジェクト指向的でなく、最初は戸惑いました。

以下のコードは、 あるグループに所属する全員の レポジトリーの一覧を表示し、そのリポジトリをアクセスするために自分のアカウントを管理者として登録し、全リポジトリーを clone するコードです。

PRIVATE_TOKEN は管理者(root)アカウントの PRIVATE_TOKEN を指定して下さい。

#!/usr/bin/env ruby
require 'gitlab'

GUEST_LEVEL       = 10
MASTER_LEVEL      = 40
GITLAB_SERVER_URL = 'http://GITLAB_SERVER'
GITLAB_API_URL    = "#{GITLAB_SERVER_URL}/api/v3"
PRIVATE_TOKEN     = 'xxxXXXXXXXXXXxxXXXXn'
GROUP_NAME        = 'student'
MY_NAME           = 'yuumi3'


def get_group_members(gitlab_root, group_id)
  gitlab_root.group_members(group_id).select{|e| e.access_level == GUEST_LEVEL}.map(&:username)
end

def add_member_to_projects(gitlab_member, user_id)
  gitlab_member.projects.each do |project|
    unless gitlab_member.team_members(project.id).find {|m| m.id == user_id}
      gitlab_member.add_team_member(project.id, user_id, MASTER_LEVEL)
      puts " add users to #{project.path_with_namespace}"
    end
    
  end
end

def list_all_projects(gitlab_member)
  gitlab_member.projects.each do |project|
    puts "  #{project.path_with_namespace}"
  end
end

def clone_all_projects(gitlab_member)
  gitlab_member.projects.each do |project|
    unless File.exists?(project.path_with_namespace)
      cmd = "git clone git@#{GITLAB_SERVER_URL}:#{project.path_with_namespace} #{project.path_with_namespace}"
      puts "  #{cmd}"
      system cmd
    end
  end
end


gitlab_root = Gitlab.client(endpoint: GITLAB_API_URL, private_token: PRIVATE_TOKEN)
username_to_id  = Hash[*gitlab_root.users.map {|u| [u.username, u.id]}.flatten]
groupname_to_id = Hash[*gitlab_root.groups.map {|u| [u.name, u.id]}.flatten]

members = get_group_members(gitlab_root, groupname_to_id[GROUP_NAME])

# 生徒のrepository一覧
members.each do |member|
  gitlab_member = Gitlab.client(endpoint: GITLAB_API_URL, private_token: PRIVATE_TOKEN, sudo: member)
  list_all_projects(gitlab_member)
end

# 生徒のrepositoryに私を管理者として追加
members.each do |member|
  gitlab_member = Gitlab.client(endpoint: GITLAB_API_URL, private_token: PRIVATE_TOKEN, sudo: member)
  add_member_to_projects(gitlab_member, username_to_id[MY_NAME])
end

# 生徒のrepositoryをclone
members.each do |member|
  gitlab_member = Gitlab.client(endpoint: GITLAB_API_URL, private_token: PRIVATE_TOKEN, sudo: member)
  clone_all_projects(gitlab_member)
end

Cocos2d-xのサンプルコードを試すさいに注意すること!

わけあって Cocos2d-xを勉強しています、入門書を買いサンプルコードをダウンロードしたのですが、コンパイルエラーや画像(Sprite)の大きさがおかしい問題に2日間も悩まされましたが、やっと解決出来たので書いておきます。

Cocos2d-xのバージョンは v2.2.3 、開発環境は Mac OS 10.9.2です。

http://www.cocos2d-x.org/images/orgsite/logo.png

サンプルコードを動かすまでの一般的な手順

  1. Xcode をインストール (Xcode5.1 を使いました)
  2. Cocos2d-x (v2.2.3) を http://www.cocos2d-x.org/download からダウンロード、適当なフォルダーに展開
  3. 下のように create_project.py でプロジェクトを作成、このプロジェクトは HelloWorld画像を表示できる雛形です
  4. Xcode出来たプロジェクトをオープンしビルド、 HelloWorldが表示される事を確認
  5. 入門書のサンプルコードをダウンロード
  6. 上で作ったプロジェクトの Classes内のファイルを消し、書籍のサンプルコードの Classes 内のファイルをコピー
  7. 上で作ったプロジェクトの Resources に書籍のサンプルコードの Resources 内のファイル、フォルダーをコピー
% cd cocos2d-x-2.2.3
% cd tools/project-creator
%  ./create_project.py -project SampleGame -package com.MyCompany.SampleGame -language cpp

ところが・・・・

  • 4. でコンパイルエラー! → Classes内のヘッダーファイルが見つからないエラーなので、ヘッダーサーチーのパスを追加
  • 5. でXcode上でファイルがコピーできない!→ ターミナル上で cp でコピー
  • サンプルーコードが動き出したのですが、なぜか画像やSpriteの大きさが本来の画像より大きかったり小さかったり・・・・ ???

これから2日弱の格闘が・・・

注意点

Macのディスクのフォーマットが 大文字/小文字を区別する場合の注意

Macのディスクはフォーマット時に Mac OS 拡張(大文字/小文字を区別、ジャーナリング を選択すると unixのように大文字と小文字を区別します。Unix系のツールを多く使う人はこの方が幸せになれます。たぶんデフォルトは区別しないファイルシステムです(ただしMac OS が工夫して一見 区別してるように振る舞っています)。

create_project.py が作ったプロジェクトのClassesグループは実は下の画像のように 小文字 classes フィルダーリンクしています!

viなどで SampleGame.xcodeproj/project.pbxproj を直接開き、../classes を ../Classes に書き換えたら 4, 5 の問題が解決できました。
project.pbxproj は テンプレートの template/multi-platform-cpp/proj.ios/HelloCpp.xcodeproj/project.pbxproj を変更してしまうのが良いと思います。

追加: pull request がマージされたので次ぎのバージョンでは直ってると思います。

Resources(リソース)のコピーは Create folder reference for any added folders を使う!

画像やSpriteの大きさが本来の画像より大きかったり小さかったりする問題の解決方法は、Cocos2d-x:絶対にわかるマルチ解像度(マルチディスプレイ)対応 に書かれていました。

cocos2d-x はいろいろな解像度のデースプレーに対応出来るように複数の解像度のリソースを用意し解像度により切り換える仕組みを持っています。サンプルコードにも複数の解像度用のリソース(画像)がフォルダー(例えば hd, sd)を分けて用意されています。

7. の Resources のコピーの際には何気なくXcodeの Create groups for any added folders を使っていました。この設定の場合、解像度別のフォルダーはXcode上ではグループになります(ディスク上ではフォルダーになっていますが)。これをiOSシュミレータにインストールするとフォルダーは無くなり、1つの解像度のファイルのみコピーされてしまいます (>_<)ゞ

まとめ

ということで、一般的な手順で無事にサンプルコードが動くようになりました。

今回参考にした書籍は

cocos2d-xによるiPhone/Androidアプリプログラミングガイド (for Smartphone Developers)

cocos2d-xによるiPhone/Androidアプリプログラミングガイド (for Smartphone Developers)

Cocos2d-x by Example Beginner's Guide

Cocos2d-x by Example Beginner's Guide

とても良い本です。Cocos2d-x by Example Beginner's Guideのサンプルは2.0用ですが 2.1用のアップデート情報が http://rengelbert.com/blog/cocos2d-x-book-update/ ここにあり、2.2.3 でも動きます(全て試したわけではありませんが)。

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