はてなブログに引っ越しました。

今さらながら はてなブログに引っ越しました。

URLは http://yuumi3.hatenablog.com/ ですが、従来の http://d.hatena.ne.jp/yuum3/ をアクセスしても新URLにリダイレクトします。RSSも従来のままでもだいじょぶです。

f:id:yuum3:20140727115427p:plain

Herokuへのデプロイのデモで失敗しないための手順

先日の セミナーの中で Heroku を使えば Ruby on Rails アプリの公開(デプロイ)は超簡単! という話しをしデモを行ったのですが、見事に失敗してしまいました ^^;

2度と失敗しないように記事を書きました。


失敗する手順

1. セミナー等でデモを行う場合は事前に練習!!

と言うことで以下の手順で無事にデプロイ出来る事を確認

$ heroku create
$ git push heroku master
$ heroku run rake db:migrate
$ heroku open
2. heroku の dashboard でアプリを削除

上の画像のような dashboard で練習で作ったアプリを削除

3. セミナー本番

1. と同じ手順でデプロイを実行すると・・・・

$ heroku create
$ git push heroku master

 !  No such app as xxxx-yyyy-9999.

なんと、エラー! 何度かリトライする内に原因が判ったのですが、焦っていたので失敗し、時間切れでデモは中止 (v_v)

失敗しない手順 (1)

1. セミナー等でデモを行う場合は事前に練習!!

と言うことで以下の手順で無事にデプロイ出来る事を確認

$ heroku create
$ git push heroku master
$ heroku run rake db:migrate
$ heroku open
2. heroku コマンド でアプリを削除
$ heroku list
=== My Apps
xxxx-zzzzz-2222    ← アプリのID. destroyコマンドで指定
$ heroku destroy  xxxx-zzzzz-2222
 !    WARNING: Potentially Destructive Action
   ...
> xxxx-zzzzz-2222  ← アプリのID を再入力
3. セミナー本番

1. と同じ手順でデプロイを実行すれば 成功!

失敗しない手順 (2)

1. セミナー等でデモを行う場合は事前に練習!!

と言うことで以下の手順で無事にデプロイ出来る事を確認

$ heroku create
$ git push heroku master
$ heroku run rake db:migrate
$ heroku open
2. heroku のdashboard でアプリを削除

上の画像のような dashboard で練習で作ったアプリを削除、そして以下のgitコマンドを実行。

$ git remote remove heroku
3. セミナー本番

1. と同じ手順でデプロイを実行すれば 成功!

失敗した理由

失敗しない手順 (2) で判ると思いますが、heroku create コマンドは .git/config に heroku というリモートリポジトリーを設定するのですが、既にある場合は変更しません。
失敗した手順では git push heroku master を行った時点でアプリが無いのでエラーになったわけです。
したがって herokuコマンドでアプリを削除(コマンドはリモートリポジトリーの設定を削除してくれます)するか git コマンドで heroku リモートリポジトリーを削除すれば OK です。

「クラウド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