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つあって
構成
- サーバー側は 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"} }
サーバー側
全てのコードは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.js の Fake 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のCookbook にiOS側モデルに付いて色々と書かれています。 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 等を調べ以下のようなコードを書きました。
.... 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 があり、簡単に使う事ができます。しかし、いざ自分のやりたいプログラムを書こうと思ったら・・・・
- APIドキュメントはあるが、内容は今ひとつ
- サンプルコードが少ない
- ネット上 (英語の情報を含め)にも情報が少ない
という事で、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です。
サンプルコードを動かすまでの一般的な手順
- Xcode をインストール (Xcode5.1 を使いました)
- Cocos2d-x (v2.2.3) を http://www.cocos2d-x.org/download からダウンロード、適当なフォルダーに展開
- 下のように create_project.py でプロジェクトを作成、このプロジェクトは HelloWorld画像を表示できる雛形です
- Xcode出来たプロジェクトをオープンしビルド、 HelloWorldが表示される事を確認
- 入門書のサンプルコードをダウンロード
- 上で作ったプロジェクトの Classes内のファイルを消し、書籍のサンプルコードの Classes 内のファイルをコピー
- 上で作ったプロジェクトの 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)
- 作者: 清水友晶
- 出版社/メーカー: マイナビ
- 発売日: 2013/06/18
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (5件) を見る
Cocos2d-x by Example Beginner's Guide
- 作者: Roger Engelbert
- 出版社/メーカー: Packt Publishing
- 発売日: 2013/04/25
- メディア: Kindle版
- この商品を含むブログを見る
とても良い本です。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 を使って書いてみました。
通常このようなテストには 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 ) 単位で書けるようにしてみました。 この際に問題になったのは
- scenario がランダムに実行される
- DBの値が scenario 単位で初期化される
- セッションが 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
さくらのクラウドでサーバーのIPアドレスを変えずにサーバーを入れ替える方法
クラウドでサービスインしているサーバーのOSやサーバー環境(RDB、Webサーバー、言語・・・)を更新するには、新たにサーバーを準備し更新した環境を作りサーバーを入れ替えるのがダウンタイムが少なく良いですよね。Amazon EC2 には Elastic IP アドレス という機構があり、保持しているIPアドレスに任意の EC2サーバーを割り当てられるので、同一IPでのサーバー入れ替えは簡単です。
しかし、さくらのクラウド にはElastic IP アドレスのような機構は無く、同一IPでのサーバー入れ替えは簡単には出来ませんが、短時間のダウンタイムで入れ替える事が出来る事が分かりました。
方法
さくらのクラウドは、サーバーを作成すると(グロバールな)IPアドレスが付いてきますが、既に使っているサーバーのIPアドレスを再割り当ては出来ません。サーバーを作成する際にストレージ(ディスク、SSD)も作成しますが、ストレージはサーバーから切り離し別々に管理で来ます。
また、ストレージ管理メニューの「ディスク修正」でストレージ内のOSに設定されているホスト名、IPアドレス等を書き換える事が出来ます。この「ディスク修正」を使う事で新たなサーバー用に作ったストレージのIPアドレスを書き換え、従来のサーバーにつなぎ替える事で同一IPで新しいOS/サーバー環境に入れ替える事が出来ます。
手順
1. 新しいサーバーを作成し、新しい環境を構築する
クラウドの管理画面でサーバーを作成し、起動したら Chef などで新しい環境を構築します。
4. サービスインしているサーバーを止め、ストレージを付け替える
クラウドの管理画面で現在サービスインしているサーバーを止め、 2.と同様にストレージを外し、下の画像のように 新サーバーに付いていたストレージを割付け、サーバーを起動。
この作業の間はサービスが停止しますが、予め練習しておけば時間は数分だと思います。
以上です。
おまけ、さくらのクラウドの感想
お客様のサービスをさくらの専用サーバーからクラウドに移行して運用していますが、Amazon EC2と比べた感想は
良い点
- 値段が安い。一時的な利用ではなく、基本的に動かしたままの場合はサーバーの利用料が安い
- 同一価格で比較すると、サーバーが早くてインストール等が早く済む。CPUやメモリー容量も多い上、SSDも使えるので早いです。
- 質問にはちゃんとした回答がある (Amazonでは質問したことが無いので比較はわかりません)
悪い点
- 管理コンソールが使いにくい。Webベースの管理コンソールはバージョンアップし、使いやすさは向上していますが、まだまだAmazonの比べると使い勝手が良くない
- 技術情報が圧倒的に少ない! 管理コンソールのヘルプは画面操作のみですし、FAQも簡単な解説のみです。ちゃんとした解説はさくらのナレッジ ぐらです(?)
- Linuxの知識がより必要。Amazonにくらべ管理コンソールで出来ない事もあり、Linuxのシステム管理の知識がAmazonより必要になります。
最後に、今のところ大きな問題もなく安くて快適なさくらのクラウドには満足しています。