back

task*pad.jp Imitation with Ruby on Rails

何ですか ?

昨年末、待望の 1.0 がリリースされた Ruby on Rails。それを利用したウェブアプリケーション製作記であり、個人的な覚書。

各所のチュートリアルを済ませた後、何か簡単なものを作ろうと思っていたが、これがなかなかいい題材がない。そんな折、 perl 版 Rails とも言える Catalyst を使って、task*pad.jp を実装しているページを見つけ、これを参考にしつつ Rails で実装してみた、その経過である。

task*pad.jp の簡易版ということで、プロジェクトネームは Task とした。

各ソフトウェアのバージョン

今回使用した各ソフトウェアのバージョンは次の通り。 ruby 1.8.4 / rails 1.0 を install すれば、下記と同じになるはずだ。

[ys@humming]-[02:15 PM]-[~/tmp/rails/Task]
 ::: ruby script/about |grep version
Ruby version                 1.8.4 (i386-dragonfly1.2.5)
RubyGems version             0.8.11
Rails version                1.0.0
Active Record version        1.13.2
Action Pack version          1.11.2
Action Web Service version   1.0.0
Action Mailer version        1.1.5
Active Support version       1.2.5

また、データベースには SQLite 3.2.7、 Binding は SQLite3/Ruby の 1.1.0 を使用した。

Rails の設定

何はともあれ、ディレクトリを作らなければならない。ということで、rails Task

[ys@humming]-[11:51 PM]-[~/tmp/rails]
 ::: rails Task
      create
      create  app/controllers
      create  app/helpers
      create  app/models
      ...
      create  log/server.log
      create  log/production.log
      create  log/development.log
      create  log/test.log

次はデータベースの設定。 sqlite3 を使用するので、 config/database.yml を下記のように編集する。

config/database.yml:

development: 
  adapter: sqlite3
  database: db/Task.db

今回は rails を学習することが目的のため、 development 以外は使用していない。 production 環境を作る必要があるのならば、 config/database.yml に depelopment と同じように production 環境の項を書き、 config/emviroment.rb の該当箇所を production になるように設定するか、あるいは script/server で起動させるのならば ruby script/server -e=production で production 環境にすることができる。

データベースの準備

データを保持するデータベースを sqlite3 で db/Task.db に作成する。

Catalyst/MySQL 版を参考に sqlite3 / rails 用に変更。 sqlite では、SQLite 本家 FAQ にある通り、INTEGER PRIMARY KEY になってるものは自動的に auto_increment になる。また、この入力は Case Sensitive なので、大文字で入力すること(いや、ここだけの話ハマりましたが ?)。

[ys@humming]-[11:55 PM]-[~/tmp/rails/Task]
 ::: sqlite3 db/Task.db
SQLite version 3.2.7
Enter ".help" for instructions
sqlite> create table tasks (
   ...> id INTEGER PRIMARY KEY,  # TODO の id
   ...> uid INTEGER,             # ユーザ id
   ...> body VARCHAR(256),       # 本文
   ...> isdone INTEGER,          # 0: 未評価, 1:達成, 2:未達成
   ...> added INTEGER,           # 追加時刻
   ...> deadline INTEGER,        # 期限 (追加時刻 + 指定時間)
   ...> finished INTEGER         # 実際の終了時間
   ...> );

足場を確保し、動作を確認

script/generate を利用して model と controller の雛型を生成する。

Rails は生成するファイル名の単数系/複数系自動変換を行うことがあるので混乱しがちだが、基本的には単数系で問題ない様子。 ruby script/generate scaffold を使うと複数系になるみたいだ。

[ys@humming]-[11:59 PM]-[~/tmp/rails/Task]
 ::: ruby script/generate model task
      exists  app/models/
      exists  test/unit/
      exists  test/fixtures/
      create  app/models/task.rb
      create  test/unit/task_test.rb
      create  test/fixtures/tasks.yml
[ys@humming]-[12:04 AM]-[~/tmp/rails/Task]
 ::: ruby script/generate controller task
      exists  app/controllers/
      exists  app/helpers/
      create  app/views/task
      exists  test/functional/
      create  app/controllers/task_controller.rb
      create  test/functional/task_controller_test.rb
      create  app/helpers/task_helper.rb

動作確認の際、何もしていないと結構寂しい。よって、仮に controller に scaffold を入れておく。

app/controllers/task_controller.rb:

class TaskController < ApplicationController
    scaffold :task
end

そして WEBrick を起動。

[ys@humming]-[12:04 AM]-[~/tmp/rails/Task]
 ::: ruby script/server -d
=> Booting WEBrick...
=> Rails application started on http://0.0.0.0:3000
[2006-01-07 14:46:42] INFO  WEBrick 1.3.1
[2006-01-07 14:46:42] INFO  ruby 1.8.4 (2005-12-24) [i386-dragonfly1.2.5]

確認終了。さて、これからが本番だ。

仕様の確認

仕様を task*pad.jp みたいなのを Catalyst で作ってみるから引っ張ってきてまとめる。データベースには入れたが、今回はユーザ認証をやるつもりがないので、下記のようになる。

また、task*pad.jp を見ると View は index の 1 画面のみのようだ。

通常画面

task*pad.jp を見ながら、おもむろに通常画面を書く。

app/controllers/task_controller.rb:

class TaskController < ApplicationController
    scaffold :task

    def index
        @tasks = Task.find_all
    end
end

app/views/task/index.rhtml:

<html><head>
<title>Get Back to Work.</title>
</head><body>

<table>
 <tr>
  <th>Result</th>
  <th>Task</th>
  <th>Time</th>
 </tr>
<% @tasks.each {|task| %>
 <tr>
  <td><%= task.isdone %></td>
  <td><%= task.body %></td>
  <td><%= task.finished %></td>
 </tr>
<% } %>
</table>

</body></html>

非常に寂しいので、入力フォームも付け加えよう。

app/views/task/index.rhtml:

<html><head>
<title>Get Back to Work.</title>
</head><body>

<%= start_form_tag :action => 'create' %>
<%= text_field 'task', 'deadline' %>
<%= text_field 'task', 'body', 'value' => 0 %>
<%= hidden_field 'task', 'isdone', 'value' => 0 %>
<%= submit_tag 'Start' %>
<%= end_form_tag %>

<table>
 <tr>
  <th>Result</th>
  <th>Task</th>
  <th>Time</th>
 </tr>
<% @tasks.each {|task| %>
 <tr>
  <td><%= task.isdone %></td>
  <td><%= task.body %></td>
  <td><%= (Time.now.to_i - task.finished) / 60 %> min ago</td>
 </tr>
<% } %>
</table>

</body></html>

ちょっとは見れるようになった。だが、controller には入力を受け取るコードがないので、当然このままでは error になる。次に、フォーム入力を受け取る部分を controller に記述する。

フォーム入力(項目追加)

  1. フォームから来たデータが正しいかどうかチェック。
  2. 現在時間とともにデータベースに追加。
  3. 通常画面にリダイレクト。

最初に確認した仕様では上記のようになっているが、これを更に現在の状況に適用できるように書き直してみよう。フォーム入力で書いた View には text フィールドとして 'deadline', 'body'、 hidden フィールドとして 'isdone' が入るようになっている。つまり、それ以外の id, uid, added, finished をここで追加し、さらに deadline に added(現在時刻)分を加算しなければならない。また、データベースにデータを保存するために ActiveRecord::Base#save を呼び出さなくてはならない。

まとめるとこうなる。

  1. データの整合性チェック
  2. ActiveRecord のインスタンスを生成、各値を代入。
  3. ActiveRecord::Base#save
  4. redirect

rails では、例えば <%= text_field 'task', 'body' %> に入力された値を params[:task][:body] で受け取ることができる。よってデータ(task)の新規追加 action/method の create はこうなる。

app/controllers/task_controller.rb:

def create
    task = Task.new(params[:task])
    task.uid = 1
    task.added = Time.now.to_i
    task.deadline = Time.parse(params[:task][:deadline]).to_i
    task.finished = 0
    task.save
    redirect_to :action => 'index'
end

では、ためしに '11:15', 'task' と入力してみよう。

正常にデータベースにデータを追加することができた。さて、次にいこう。

通常画面 2

データが正常に保存できることは確認できた。しかし、もちろん今のままでは役に立たない。

順に解決していこう。

index action/method で Task.find_all となっている部分を変更する。

app/controllers/task_controller.rb:

def index
    @tasks = Task.find(:all, :conditions => ['not isdone =?', 0])
end

これで、 task.isdone == 0 のものは表示されなくなった。

task*pad.jp の似せることにこだわらなければ、 create した後に index ではなく他のページに redirect し、その controller/view で制御するのもいいかもしれない。しかし、ここではあくまで 1 View にこだわることにしよう。

そのためには、 controller に「入力」か「実行中」かを判断させるフラグを用意させる必要がある。上記に続いて、更に index メソッドを変更する。現在行っている task、つまり task.isdone == 0 になっているものがあれば、 @editable フラグを false にし、 task.isdone == 0 の ActiveRecord オブジェクトを @current_task に入れる。

app/controllers/task_controller.rb:

def index
    @editable = true
    @current_task = Task.find(:first, :conditions => ['isdone =?', 0])
    @editable = false if @current_task
    @tasks = Task.find(:all, :conditions => ['not isdone =?', 0])
end

あわせて View も変更。入力フォーム部分を @editable で分岐するように書き換える。

app/views/task/index.rhtml:

<% if @editable %>
    <%= start_form_tag :action => 'create' %>
    <%= text_field 'task', 'deadline', 'value' => 0 %>
    <%= text_field 'task', 'body', 'value' => 0 %>
    <%= hidden_field 'task', 'isdone', 'value' => 0 %>
    <%= submit_tag 'Start' %>
    <%= end_form_tag %>
<% else %>
    <%= start_form_tag :action => 'eval' %>
    <% current_time =  Time.at(@current_task.deadline).strftime("%H:%M") %>
    <%= text_field 'task', 'deadline', 'value' => current_time, 'disabled' => 'disabled' %>
    <%= text_field 'task', 'body', 'value' => @current_task.body, 'disabled' => 'disabled' %>
    <%= hidden_field 'task', 'id', 'value' => @current_task.id %>
    <%= submit_tag 'Done!', 'name' => 'action=done' %>
    <%= submit_tag 'Failed...', 'name' => 'action=failed' %>
    <%= end_form_tag %>
<% end %>

これで要求されたことは実装できた。しかし、まだ入力はできない。次に、ここで追加した結果を評価する action/method である eval(あまりいい名前ではない)を実装しよう。

結果の評価

  1. 項目が存在しているかチェック。
  2. 達成・未達成のフラグ、終了時間について更新。
  3. 通常画面にリダイレクト。

コードはこうなる。

app/controllers/task_controller.rb:

def eval
    task = Task.find(params[:task][:id])
    task.finished = Time.now.to_i
    params.each {|key, value|
        case key
        when 'action=done'
            task.isdone = 1
        when 'action=failed'
            task.isdone = 2
        end
    }
    task.save

    redirect_to :action => 'index'
end

ここまでやれば、やっと入力できるようになる。 UI はともかくとして、きちんと動いているようだ。

全消去

残る最後の機能。全消去を実装する。

  1. ユーザのデータを全部消す。
  2. 通常画面へリダイレクト。

迷うことなく controller に clear action/method を追加。

app/controllers/task_controller.rb:

def clear
    Task.destroy_all(['uid =?', 1])

    redirect_to :action => 'index'
end

利用するリンクを View に追加。

app/views/task/index.rhtml:

<p>
<% msg = 'Are you sure to clear history?' %>
<%= link_to "[Clear history]", { :action => 'clear' }, :confirm => msg %>
</p>

ということで、とりあえず動かせる task*pad.jp imitation with RoR が完成してしまった。

さらにさらに

(Rails とあまり関係がないので割愛するが、ここで CSS を書いたり View を変更したり)

一応動くようになったが、まだまだ本家の機能にはほど遠い。次に細部を詰めていくことにする。

'State' で表示されている結果部分がそっけなさすぎ

もうひとつのテーブル states をデータベースに追加し、リレーションシップを利用して値を取得することにしてみよう。もちろん、 isdone が取る値は 「0: 未評価, 1:達成, 2:未達成」 だけなので、本来はどこかしらで適当に変換をかければいい。しかしここでは Rails に慣れることを目的とし、あえてこの方法で行ってみる。

Rails でリレーションシップを実現する belongs_to などを使用する場合、通常であれば命名規則にそってデータベースのテーブルなどを作成しなければならない。しかし、 belongs_to のオプション、foreign_key を使用することでこれは回避することができる。

まず、テーブルを追加し、 model の雛型を生成する。

sqlite> CREATE TABLE states (
   ...> id INTEGER PRIMARY KEY,
   ...> value VARCHAR(25)
   ...> );
sqlite> INSERT INTO states VALUES ('0', 'not yet');
sqlite> INSERT INTO states VALUES ('1', 'Done!');
sqlite> INSERT INTO states VALUES ('2', 'Ooups!');
sqlite> .quit

[ys@humming]-[10:53 PM]-[~/tmp/rails/Task]
 ::: ruby script/generate model state
      exists  app/models/
      exists  test/unit/
      exists  test/fixtures/
      create  app/models/state.rb
      create  test/unit/state_test.rb
      create  test/fixtures/states.yml

そして model で belongs_to を使用する際に、 foreign_key にキーにしたい isdone を指定する。これで isdone の値から state を引っ張り、正しい値を得ることができる。

app/models/task.rb:

class Task < ActiveRecord::Base
    belongs_to :state, :foreign_key => 'isdone'
end

そして View(app/views/task/index.rhtml) 中の task.isdone となっている部分を task.state.value に変更。

正常に値が表示された。まともな UI も良い具合。

もちろん、 DB のスキーマ設計の段階から命名規則を頭に入れておくのが最善だ。しかし、そうでない場合でも、きちんと rails は逃げ道を用意している。覚えていて(また Rails の API を検索してみて)損はない。

入力欄と history の task 欄が本家と微妙に違う

作っていて気づいたが、 task*pad.jp 本家は時間欄に '===' とかの適当な文字を入れても error もなしに通る。ということは、どうも時刻は保存していないようだ。確かに、task*pad.jp そのままの UI であれば、時刻を保存する必要はない。データベースの deadline が無駄になってしまうが、task*pad.jp のようにしてみよう。

まず controller の create メソッドを見直して、deadline は使わず、body にまとめる。

app/controllers/task_controller.rb:

def create
    task = Task.new(params[:task])
    task.uid = 1
    task.body = params[:task][:deadline] + ' ' + task.body
    task.added = Time.now.to_i
    task.deadline = 0  # FIXME!!: obsoleted
    task.finished = 0
    task.save

    redirect_to :action => 'index'
end

そして View から、deadline を呼び出して表示しているフォームを消す。

OK。本家と同じようになった。

history の順序が逆。 history の数に関わらず、 table は一定の項目数。

ActiveRecoad#find(:all) は Array を返すので、index action/method の中でそれを利用して返す値(Array)を制御すればいい。

app/controllers/task_controller.rb:

def index
    @editable = true
    @current_task = Task.find(:first, :conditions => ['isdone =?', 0])
    @editable = false if @current_task
    @success = Task.count(['isdone =?', 1])
    @failures = Task.count(['isdone =?', 2])
    @history = []

    ary = Task.find(:all, :conditions => ['not isdone =?', 0]).reverse
    if ary.size >= 8
        ary[0..7].each {|ar|
            x = "#{(Time.now.to_i - ar.finished) / 60 }  min ago"
            @history << {'state' => ar.state.value, 'body' => ar.body, 'time' => x }
        }
    else
        ary.each {|ar|
            x = "#{(Time.now.to_i - ar.finished) / 60 }  min ago"
            @history << {'state' => ar.state.value, 'body' => ar.body, 'time' => x }
        }
        (8 - ary.size).times { @history << {'state' => ' ', 'body' => ' ', 'time' => ' ' } }
    end
end

View も、それにあわせた形に変更する。

app/views/task/index.rhtml:

<table>
 <tr class="header">
  <th class="result">Result</th>
  <th class="task">Task</th>
  <th class="time">Finished</th>
 </tr>
<% @history.each {|task| %>
 <tr>
  <td><%= task['state'] %></td>
  <td class="body"><%= task['body'] %></td>
  <td><%= task['time'] %></td>
 </tr>
<% } %>
</table>

あまり綺麗なコードではないが、きちんと動いているみたいだ。

時間の表示が美しくない

「91 min ago」 という表示はバカバカしい。大体でかまわないので、hour や day を使ってほしいものだ。また、92 min_s_ ago になればさらによろしい。

さて、実装だが、これはやること自体は簡単だが結構行数をくいそうだ。本質的でないコードを controller、ましてや view に書くのは気がひける。ということで、これは Helper module に追いやってしまおう。ファイルは、app/helpers/*_helper.rb にある。

app/helpers/task_helper.rb

module TaskHelper
    def TaskHelper::time_convert(int_time)
        x = int_time / 60
        s = ''

        if x > 60 * 24
            x = 'over 1'
            s = ' day'
        elsif x >= 120
            x = x / 60
            s = ' hours'
        elsif x >= 60
            x = x / 60
            s = ' hour'
        elsif x == 1 or x == 0
            s = ' min'
        else
            s = ' mins'
        end

        x.to_s + s + ' ago'
    end
end

あとは index action/method でこれを呼び出すだけだ。

app/controllers/task_controller.rb:

def index
    @editable = true
    @current_task = Task.find(:first, :conditions => ['isdone =?', 0])
    @editable = false if @current_task
    @success = Task.count(['isdone =?', 1])
    @failures = Task.count(['isdone =?', 2])
    @history = []

    ary = Task.find(:all, :conditions => ['not isdone =?', 0]).reverse
    if ary.size >= 8
        ary[0..7].each {|ar|
            x = TaskHelper::time_convert(Time.now.to_i - ar.finished)
            @history << {'state' => ar.state.value, 'body' => ar.body, 'time' => x }
        }
    else
        ary.each {|ar|
            x = TaskHelper::time_convert(Time.now.to_i - ar.finished)
            @history << {'state' => ar.state.value, 'body' => ar.body, 'time' => x }
        }
        (8 - ary.size).times { @history << {'state' => ' ', 'body' => ' ', 'time' => ' ' } }
    end
end

非常によろしい。

まとめ

まずまず利用できるものができあがったので、ひとまずこれで終了とする。一応、使えるものにはなっているはずだ。

今回の文書で取り上げなかったものとして上記のものなどがあげられるが、これは是非自分で実装してみてほしい。この 3 つを実装すれば、 task*pad.jp と見た目以外はほとんど変わらないものができあがるはずだ。そしてそのアプリケーションは、修正も機能追加も思うがまま、あなたが望むところで望む形で走らせることができる。

May the Rails be with you.

今回作成したファイル群

task-0.1.tgz

任意の場所に展開し、 mv Task-0.1 Task && ruby Task/script/server -d 後に http://localhost:3000/Task/ で利用可能。