昨年末、待望の 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 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 に記述する。
- フォームから来たデータが正しいかどうかチェック。
- 現在時間とともにデータベースに追加。
- 通常画面にリダイレクト。
最初に確認した仕様では上記のようになっているが、これを更に現在の状況に適用できるように書き直してみよう。フォーム入力で書いた View には text フィールドとして 'deadline', 'body'、 hidden フィールドとして 'isdone' が入るようになっている。つまり、それ以外の id, uid, added, finished をここで追加し、さらに deadline に added(現在時刻)分を加算しなければならない。また、データベースにデータを保存するために ActiveRecord::Base#save を呼び出さなくてはならない。
まとめるとこうなる。
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' と入力してみよう。
正常にデータベースにデータを追加することができた。さて、次にいこう。
データが正常に保存できることは確認できた。しかし、もちろん今のままでは役に立たない。
task.isdone != 0
のものだけを表示しなければならない。順に解決していこう。
task.isdone != 0
のものだけを表示しなければならない。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(あまりいい名前ではない)を実装しよう。
- 項目が存在しているかチェック。
- 達成・未達成のフラグ、終了時間について更新。
- 通常画面にリダイレクト。
コードはこうなる。
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 はともかくとして、きちんと動いているようだ。
残る最後の機能。全消去を実装する。
- ユーザのデータを全部消す。
- 通常画面へリダイレクト。
迷うことなく 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 を変更したり)
一応動くようになったが、まだまだ本家の機能にはほど遠い。次に細部を詰めていくことにする。
もうひとつのテーブル 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 を検索してみて)損はない。
作っていて気づいたが、 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。本家と同じようになった。
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.
任意の場所に展開し、 mv Task-0.1 Task && ruby Task/script/server -d
後に
http://localhost:3000/Task/ で利用可能。