MP3ファイルのID3タグを一度に変更できるツールを作ってみた

昔にリッピングしたMP3ファイルには下のように iTunesで曲名が 01 AudioTrack 01.mp3 などと表示されています。これはiTunesの「情報を見る」ダイアログで変更できますが、いちいちGUIから変更していくのは大変です。そこで、ファイル名、曲名を書いたファイルから一括で変更できるツールを作ってみました。

f:id:yuum3:20140820170453p:plain

iTunes, MP3 などに付いて・・・

iTunesは曲名等の情報は iTunes Library.itl (それを書き出した可読性のある iTunes Music Library.xml)というファイルで管理しています、このファイルを変更するとiTunesに表示される曲名を変更できます(この話しは次回にでも書きます)。

しかし、そこで曲を再生すると、なんと元の 01 AudioTrack 01.mp3 に戻ってしまいます!

実は wikipediaのID3タグ項目 にあるようにMP3,4(.mp3, .m4a)ファイルには アーティスト・作成年・曲名等の情報を書かられていて、iTunesは再生する時に、この情報から曲名を戻して(変更して)しまいます。

ID3タグを編集するツールは有料、オープンソースを含めたくさんあります。中には Amazonを検索し曲名等を一括で設定すてくれそうなツールがあったのですが、なぜか上手く行きませんでした。また、ID3タグのバージョンやエンコード(文字化け)の問題がありMacで動作するオープンソースツールは良い物が見つけられませんでした。

TagLib, taglib-ruby

ID3タグを扱うライブラリーとしては、C++で書かれた TagLibが良いようで、Rubyから使える taglib-ruby があったので Rubyツールを書く事にしました。

ちなみに、id3lib というライブラリーもあります。こちらはHome brewコマンドラインツールもインストール出来るのですが、最近はメンテされてないようで曲名に日本語を入れる事が出来ないので断念しました。

ツール

下のコードのように taglib-rubyを使う事で、MP3ファイルを読み込み、タイトル (曲名)を設定し保存するツールが簡単にできました。MP3ファイルのパス名、曲名をタブ区切りで書いたファイルを用意する事で複数のMP3ファイルを一度に変更できます。

#!/usr/bin/env ruby
 
require 'taglib'
 
MP_FILE_CLASSES = {'.mp3' => TagLib::MPEG::File, '.m4a' => TagLib::MP4::File}
 
def set_mp_audio_file_to_title(path, title)
  MP_FILE_CLASSES[File.extname(path)].open(path) {|mp| mp.tag.title = title; mp.save}
  puts "  set #{path} title=#{title}"
rescue
  puts "  error: #{$!}"
  exit(-1)
end
 
if ARGV.count < 2
  puts "Usage: #{$0} MPEG_AUDIO_FILE TITLE  or"
  puts "       #{$0} -f FILE_AND_TITLE_LIST"
elsif ARGV[0] == '-f'
  IO.readlines(ARGV[1]).each do |line| 
    path_title = line.split("\t")
    set_mp_audio_file_to_title(*path_title) if path_title.length == 2
  end
else
  set_mp_audio_file_to_title(ARGV[0], ARGV[1])
end
 
exit(0)

Ruby開発者を増やすための教育について (8年間のRuby教育で得た知見)

わけあって昔作ったKeynoteを眺めていたら、かなり良い資料だと思い再掲載してみました (はてなダイアリー|ブログには初めてです)。 RubyWorld Conference 2013 の発表資料です。

RubyRuby on Rails開発者を増やしたいと考えている開発マネージャーやリーダーの方は是非読んでみて下さい。社内で教育いくして行く際に役立つヒントがたくさん書かれていると思います (自画自賛 ^^;)。

Dockerを使いRuby on Railsアプリ、PostgreSQL、Nginxなどのコンテナーをクラウドサービスで動かしてみた

Docker

環境構築

普通に Boot2DockerMacにインストールしました。Boot2Dockerは ここのブログの中ほどの画像のようにVitualBox上でDockerサーバーを動かし、Macの dockerコマンドがDockerサーバーと通信して動作します。

Dockerサーバーの作成・起動などは boot2docker コマンドで行います。

作成・起動は、

% boot2docker init
% boot2docker up

これで、dockerコマンドが使えるようになりますが、通信用のDOCKER_HOST環境変数を設定する必要があります。

% export DOCKER_HOST=tcp://192.168.59.103:2375
% docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZE
nginx               latest              c43e4a994b02        4 hours ago         231.7 MB
app                 latest              4b465cc33c7c        4 hours ago         602.7 MB
pg                  latest              8f1dd8d424ad        4 hours ago         380.3 MB
....

dockerコマンドの詳細はドキュメント等を参考にして下さい。Dockerでば docker build コマンドで Docefile に従いイメージ(ファイルシステムのアーカイブ?)を作成します。そして、 docker run コマンドでコンテナー(仮想マシン?)を作成しイメージを実行します。

コンテナーの割り振り

Ruby on Railsのアプリケーションは通常、アプリケーションを動かすサーバー(Unicron等)、データベース(PostgreSQL, MySQL等)、画像CSS等の配信を行うWebサーバー(Nginx,Apache等)のサーバーソフトが必要になります。これらを1つのコンテナーの中で動かす事も出来ますが、今回はメンテナンス性等を考え、3つのコンテナーで動かす事にします

ちなみに、今回参考にした Deploy Rails Applications Using Docker では RDBサーバー、アプリ+Nginxの2つのコンテナーで動いていいます。

Ruby on Rails アプリケーションの作成

シンプルな TODOアプリです。コードは GitHub あります。

作成手順

% rails new rails_app_with_docker
% cd rails_app_with_docker
% rails g scaffold todo due:date task:string
% rake db:migrate
% ruby s

その後 unicorn, pgをインストルし、production環境ではRDBPostgreSQLになるように、またサーバーはUnicornに変更しました。

pgコンテナー

pgコンテナーは DockerのドキュメントにあるDockerizing a PostgreSQL serviceを、ほぼそのまま使っています。

行っているのは、ubuntu14.04にPostgreSQLをインストールし docker というデータベースを作成し、runPostgreSQLサーバーが起動するようにしています。また、VOLUME 命令でバックアップ等で使うディレクトリーを他のコンテナーからマウント出来るようにしています。

  • config/docker/pg/Dockerfile
FROM ubuntu:14.04
RUN apt-get update
RUN apt-get install -y -q postgresql-9.3 libpq-dev postgresql-client-9.3 postgresql-contrib-9.3

USER postgres
RUN /etc/init.d/postgresql start &&\
    psql --command "CREATE USER docker WITH SUPERUSER PASSWORD 'docker';" &&\
    psql --command "CREATE DATABASE docker WITH OWNER docker TEMPLATE template0 ENCODING 'UTF8';"

RUN echo "host all  all    0.0.0.0/0  md5" >> /etc/postgresql/9.3/main/pg_hba.conf
RUN echo "listen_addresses='*'" >> /etc/postgresql/9.3/main/postgresql.conf

EXPOSE 5432
VOLUME  ["/etc/postgresql", "/var/log/postgresql", "/var/lib/postgresql"]

CMD ["/usr/lib/postgresql/9.3/bin/postgres", "-D", "/var/lib/postgresql/9.3/main", "-c", "config_file=/etc/postgresql/9.3/main/postgresql.conf"]

appコンテナー

appコンテナーrbdock Gem が生成するDockerfile を参考に作りました。内容は

  1. ubuntu14.04 + ruby2.1.2のインストール(後ほど説明します)
  2. Gemfileをコピーしてbundleコマンドで必要な Gem をインストール
  3. アプリケーションのコピー
  4. 不要なファイルの削除

やはり、VOLUME 命令でアプリ全体を他のコンテナーからマウント出来るようにしています、この一部は nginxコンテナーで使います。

  • Dockerfile
FROM yuumi3/ruby:2.1.2

RUN mkdir /home/app
WORKDIR /home/app
ADD Gemfile /home/app/Gemfile
RUN bundle install
ADD . /home/app
RUN rake tmp:clear
RUN rake log:clear

VOLUME  ["/home/app"]

ENTRYPOINT bin/start_server.sh

run で実行されるスクリプトでは、Rails用の環境変数の設定、データベースのマイグレーションUnicornサーバーの起動です。

データベースのマイグレーションは、Dockerfileで指定したかったのですが、buildの際にはpgコンテナーと接続出来ないのでここで行っています。

  • bin/start_server.sh
#!/bin/bash -x
export RAILS_ENV=production
export SECRET_KEY_BASE=`rake secret`
rake db:migrate
unicorn_rails -c config/unicorn.rb

ubuntu14.04 + ruby2.1.2のインストールは既に Docker Hubに登録してあるイメージを使っています。 このDockerfileもrbdock Gem が生成するDockerfile ほぼそのままです。ありがとうございます! 内容は

  1. Rubyコンパイルするためのツール・ライブラリーのインストール
  2. Rubyコンパイル・インストール
  3. gemのアップデート、bundler gemのインストール

  4. yuumi3/ruby:2.1.2作成時のDockerfile

FROM ubuntu:14.04

# Install basic dev tools 
RUN apt-get update && apt-get install -y \
    build-essential \
    wget \
    curl \
    git

# Install package for ruby 
RUN apt-get install -y \
    zlib1g-dev \
    libssl-dev \
    libreadline-dev \
    libyaml-dev \
    libxml2-dev \
    libxslt-dev

# Install package for sqlite3
RUN apt-get install -y \
    sqlite3 \
    libsqlite3-dev

# Install package for postgresql
RUN apt-get install -y libpq-dev 

# Install ruby-build
RUN git clone https://github.com/sstephenson/ruby-build.git .ruby-build
RUN .ruby-build/install.sh
RUN rm -fr .ruby-build

# Install ruby-2.1.2
RUN ruby-build 2.1.2 /usr/local

# Install bundler
RUN gem update --system
RUN gem install bundler --no-rdoc --no-ri

nginxコンテナー

nginxコンテナーDeploy Rails Applications Using Docker に書かれているものを参考にしています。

ただし、sedを使いnginxの設定ファイルを書き換える力業は Server Fault を参考にしました。通常のnginxの設定ファイルに環境変数の値を使う事は出来ないのですね(luaを組み込んだnginxなら出来るようです)。

  • config/docker/nginx/Dockerfile
FROM ubuntu:14.04

RUN apt-get update
RUN apt-get install -y nginx

RUN echo "daemon off;" >> /etc/nginx/nginx.conf
ADD default  /etc/nginx/sites-available/default

EXPOSE 80
VOLUME  ["/var/log/nginx"]

ENTRYPOINT /bin/sed -i "s/[0-9]\+\.[0-9]\+\.[0-9]\+\.[0-9]\+/${APP_PORT_3000_TCP_ADDR}/" \
  /etc/nginx/sites-available/default &&\
  /usr/sbin/nginx

nginxの設定ファイルも、ほぼDeploy Rails Applications Using Docker に書かれているものです。

root に指定されている /home/app/public は appコンテナーでexportしている /home/app をマウント(共有)しています。これで画像やcssなどがnginxで配信できます。

  • config/docker/nginx/default
cat config/docker/nginx/default
server {
    server_name _;

    root /home/app/public;
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;

    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_redirect off;
        proxy_set_header Host $http_host;
        if (!-f $request_filename) {
            proxy_pass http://192.168.111.111:3000;
            break;
        }
    }
}

Dockerイメージの作成と実行

コンテナーは以下のRakeコマンドで作成 docker build 、実行 docker run しています。オプションの指定がたくさんあるので rakeコマンドにしてあります。

例えば、 nginxコンテナーの作成では -t nginx でイメージの名前を付け、次にDockefileのあるディレクトリーのパスを指定しています。 実行は * -d でバックグラウンド実行 * -p 80:80 はコンテナーのポート80を外部から80でアクセス出来るように設定 * --link app:app はappコンテナーの情報をnginxコンテナー内で app(例 APP_PORT_3000_TCP_ADDR 環境変数はappコンテナーIPアドレス)で参照出来るようにする * --volumes-from app appコンテナーVOLUME で指定されたディレクトリーをこのコンテナーでマウント(共有) * --name nginx 起動されたコンテナーに nginx という名前を付ける

namespace :docker do
  desc "Build Postgresql container"
  task :build_pg do
    sh "docker build  -t pg config/docker/pg"
  end

  desc "Run Postgresql container"
  task :run_pg do
    sh "docker run -d -p 5432:5432 --name pg pg"
    sh "docker ps"
  end

  desc "Build Rails application container"
  task :build_app do
    Rake::Task['assets:precompile'].invoke
    sh "docker build  -t app ."
  end

  desc "Run Rails application container"
  task :run_app do
    sh "docker run -d -p 3000:3000 --link pg:db --name app app"
    sh "docker ps"
  end

  desc "Build Nginx container"
  task :build_nginx do
    sh "docker build  -t nginx config/docker/nginx"
  end

  desc "Run Nginx container"
  task :run_nginx do
    sh "docker run -d -p 80:80 --link app:app --volumes-from app --name nginx nginx"
    sh "docker ps"
  end
end

DigitalOceanへdeploy

激安なクラウドサービス DigitalOcean に出来た Docker Image をデプロイしてみましょう。

1. DigitalOcean に SignUpし、サーバー(droplet)を作成

SIGN UPで登録し $ 0.015 / Hour のサーバーを選んでみました、OSはUbuntu 14.04 x64

メールでサーバー(droplet)のIPやアカウント情報が送られてきます。

2. Dockerのインストールと確認

Ubuntu - Docker Documentation の手順で最新版のDockerのインストールし、確認。

$ sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 36A1D7869245C8950F966E92D8576A8BA88D21E9
$ sudo sh -c "echo deb https://get.docker.io/ubuntu docker main > /etc/apt/sources.list.d/docker.list"
$ sudo apt-get update
$ sudo apt-get install -y lxc-docker
$ sudo docker run -i -t ubuntu /bin/bash
/# ....
/# exit
$

3. 作ったDocker ImageをDocker Hubから取り込み実行

DockerコマンドでDocker HubからImageを取得し実行

$ sudo docker run -d -p 5432:5432 --name pg yuumi3/postgres:docker
$ sudo docker run -d -p 3000:3000 --link pg:db --name app yuumi3/rails_app:todo
$ sudo docker run -d -p 80:80 --link app:app --volumes-from app --name nginx yuumi3/nginx

ブラウザーでアクセスしTODOアプリの動作確認。

f:id:yuum3:20140806111148p:plain

動いた ^^)/

DigitalOceanではサーバー(droplet)を停止しても、存在すれば課金されるのようなので 終わったらDestroyしてしまいましょう :-)

Docker感想

既にChefは使っていますが、今回Dockerを使って思った事は

  • Chefはサーバー構築手順をコード化するものですが、Dockerはイメージを構築し利用するものなので、Chefのような考え方は切り換えないと行けない。例えば実行時に決まるIPアドレスなどはイメージ/Dockerfileには書けない。
  • Dockerfileはシンプルな記述しか出来ないので、shell script等に頼る事になり、Rubyプログラマーの私には色々な事ができるChefの方が良いな
  • イメージが出来てしまえば確かにサーバーへのデプロイは早いが、大きなイメージファイルをやり取りするので思ったほど早くはない。イメージをネットワーク的にデプロイ先に近いところ置けば良いのだろうか?
  • 一つのサーバー上で複数コンテナーが作れるので、今回のようにRDB,Web,アプリを分けておけばメンテナンス等でのダウンタイムが減らせるかも。
  • 現在はクラウド+ Chefで満足しているので直ぐにDockerに移ろうとは私は思わないですが、Dockerをベースにしたサービスや新たな技術が生まれてきているので将来が楽しみな技術ですね。

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

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

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のアプリを書いてみたい。