Sinatraで作成するTODOアプリ

概要

小さなウェブアプリケーションをRubyと、その上で動くSinatraフレームワークで作成するまでの一連の流れを提示する。
この文書は、以下のサイトのエントリの和訳と注釈を加えたものである。
http://www.creativebloq.com/web-design/get-started-sinatra-9134565
僕自身が勉強する過程で書いているので、解説の粒度が場所によって異なる。意識的にステップごとに動作確認をしている。

解説する事

データベースがあって、webアプリっぽい動作をする一連の最小の仕組みと開発の流れ

解説しない事

  • 固有のOSごとの設定 (sinatra インストール で検索すると一杯出てくる https://it.typeac.jp/article/show/5 などを参照)
  • 固有の言語ごとの文法
  • ローカルではなく、外部公開の方法

Sinatraとは

(この項目については、様々な書籍、ウェブサイトにおいて既に解説が多い為、既知の場合は飛ばして構わない)

Sinatraを使ったアプリケーションは単一のファイルを作成するだけで動かすことが出来る。よってちょっとしたウェブアプリを作成するのに最適である。勿論APIやウェブインタフェースを駆使したアプリケーションを作成することも出来る。
Sinatraを始めるには、RubyRubygemsをインストールしておく。Sinatraはgemからインストールする。

gem install sinatra

これだけでSinatraのインストールは完了する。
hello.rbファイルを作成し、以下のコードを入力して動作を確認する。
特に断りが無い場合、以降このエントリで作成するファイルは utf-8 を前提とする。

require 'sinatra'
get '/' do
  "Hello World!"
end

ファイルを保存し、ターミナルから ruby hello.rbを実行する。

ブラウザを開きhttp://localhost:4567 にアクセスすると、”Hello World”の文字が見えたら動作している。
ターミナル上でctrl+cでウェブサーバーを停止させると、同じURLにアクセスしても何も起きないことを確認する。
先のhello.rbの末尾に以下のコードを追加する。

get '/hello/:name' do
  "Hello #{params['name']}"
end

同じようにruby hello,rbをしてwebサーバーを再び立ち上げる。
そしてhttp://localhost:4567/hello/Tim にブラウザでアクセスしてみると、”Hello Tim”と表示されるはずだ。

TODOアプリを作る

データベースはSQLiteを用いて、ウェブ上からタスクの追加、完了、削除を操作するTODOアプリケーションを作成する。

データベースへの接続

最初はデータベースを作って、接続できることを確認する。
SinatraはデータベースやORMを提供していないので、ユーザが任意のそれらをgemで追加インストールする。今回はsqliteを使用する。
また、自動でリロードを行いたい(sinatra/reloader)のでsinatra-contribも必要である。
ターミナルから以下のgemをインストールする。

gem install sinatra-contrib
gem install dm-sqlite-adapter
gem install data_mapper

これでDataMapperがインストールされる。DataMapperはRuby用のORMである。またDataMapper用のSQLiteアダプタもインストールされる(2行目)。
勿論SQLite以外のデータベースを使いたい場合はそれに応じたgemをインストールすることで使うことが出来る(MySQLPostgreSQLなど)
これから作るTODOアプリの為の新しいディレクトリを作成する(todo_list)、ディレクトリに新しいファイル(web.rb)を作り、以下のrequireを入力する。
時間を扱うのでdateも必要である。

require 'sinatra'
require 'data_mapper'
require 'sinatra/reloader'
require 'date'

そしてweb.rbに以下のコードを追加する。データベースのレコードを定義したItemクラスを作成している。

DataMapper::setup(:default, "sqlite3://#{Dir.pwd}/todo_list.db")
class Item
    include DataMapper::Resource
    property :id, Serial
    property :content, Text, :required => true
    property :done, Boolean, :required => true, :default => false
    property :created, DateTime
end
DataMapper.finalize.auto_upgrade!

初めてWebアプリにアクセスがあったときに、auto_upgrate!も実行されてtodo_list.dbファイルがローカルに生成される。 このデータベースはid,content,done,created(date)を含む。

DataMapperの動作確認

この時点でデータベースが作成、追加されるのかを確認する。
web.rbを以下のようにする。

require 'sinatra'
require 'data_mapper'
require 'sinatra/reloader'
require 'date'
DataMapper::setup(:default, "sqlite3://#{Dir.pwd}/todo_list.db")
class Item
    include DataMapper::Resource
    property :id, Serial
    property :content, Text, :required => true
    property :done, Boolean, :required => true, :default => false
    property :created, DateTime
end
DataMapper.finalize.auto_upgrade!

#動作テスト:ここから追加
debug_counter=0
before do
 debug_counter+=1
end
    
get '/debug_create' do
       Item.create(:content => "Hello #{debug_counter.to_s}!",:done => [true, false].sample  ,:created => Time.now)
end

get '/debug_show' do 
  Item.all.map {|r| "#{r.id}, #{r.content}, #{r.done}, #{r.created} <br>" }
end
#動作テスト:ここまで追加

ウェブブラウザで http://localhost:4567/debug_create に何度かアクセスした後に http://localhost:4567/debug_show にアクセスする。その時に

1, Hello 1!, false, 2014-02-11T22:38:40+09:00 
2, Hello 2!, false, 2014-02-11T22:38:42+09:00 
3, Hello 3!, true, 2014-02-11T22:38:43+09:00 

のような表示がされていたら、データベースは作成、書込がされていることが確認できる。
確認が終わったらweb.rbのあるディレクトリのtodo_list.dbは削除して良い。(このエントリでは削除しない方が動作確認が楽かもしれない)

todoアイテムの表示

動作テストが終わったので、通常の表示部分を作成していく。
以下のコードをweb.rbの末尾に追加する。

get '/' do
  @items = Item.all(:order => :created.desc)
  redirect '/new' if @items.empty?
  erb :index
end

これは、ルート( http://localhost:4567/ )に対してアクセスした時、データベースの中を調べて、もしデータベースが空なら、 http://localhost:4567/new に転送するというコードである。
現時点では http://localhost:4567/newの中身がないので無効なコードだが、このnewの中身は後に作成する。データベースの中身が空でないのであれば、index(現時点のtodoリストの一覧表示)を表示する。
この時点で http://localhost:4567/ にアクセスすると internal server errorの表示が起きる。このエラーを解消し、タスクを一覧するようにする。
そのために、このerb:indexの中身をこれから作成する。

erb部分の作成

Sinatra自体は厳密なMVC準拠を強制しないが、viewを分けることも出来る。
Sinatraのviewは幾つものテンプレート言語をサポートしている。
今回のアプリケーションはerbを使用する。これらviewに相当するファイルは、アプリケーションの直下にviewsディレクトリを設置し、その中に作成するのがSinatraのルールである。(html,cssなども同様である)
views/index.erb を作成し、以下のコードを書く。

<ul id="todo-list" class="unstyled">
  <% @items.each do |item| %>
  <li id="<%= item[:id] %>">
  <span class="item">
  <%= item[:done] ? "<del>#{item[:content]}</del>" : item[:content] %>
  </span>
  <span class="pull-right">
  <a href="#" class="btn done"><%= item[:done] ? "Not done" : "Done"%></a>
  <a href="/delete/<%= item[:id] %>" class="btn btn-danger">Delete</a>
  </span>
  </li>
  <% end %>
</ul>
<a href="/new" class="btn btn-primary">Add todo item</a>

erbは見ての通り極めてhtmlに近い書式である。上記web.rb内の @items(データベースの中身)だけが今回作成したviewであるindex.erbに対して渡される。
erb内で@items.eachのループを行い、todoリストの全項目を表示させている。
todoアイテムの表示が終わった後、HTMLでdelタグを使ってマークアップする処理も書いてある。これは後にタスクの処理済みかどうかをdoneボタンとnot doneでトグルさせるためである、条件分岐には簡潔に表す為三項演算子を使っている。(別に使わなくてもよい)
このdelタグを使うかどうかはweb.rbから持ってきた @itemsの中のproperty:doneを参照している。
このindex.erbが短くなっているのは、sinatra上では見た目のマークアップはlayoutとして別のファイルに分けてしまうのが王道だからである。
index.erbと同様、同じディレクトリにlayout.erbを作成する。このlayout.erbの中身はこのsinatraアプリ内の全てのviewsで読み込まれる共通である。
このlayoutは必ずyieldタグを持つ必要がある。その部分に各viewの中身が流し込まれる。以下に最小限のlayout.erbを示す。

<html>
  <head>
    <title>Todo List</title>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

jqueryを呼んだり文字コードを指定したりcssを呼んだりもする今回の最終的なlayout.erbはgithub上にある。 https://github.com/timmillwood/todo_list/blob/master/views/layout.erb
rubyの学習が主である場合は上記リンクをコピペして使うのも良い。
この時点で http://localhost:4567/ にアクセスすると、前回のデバッグの中身を消していなければ Hello が延々とリストとして並ぶページが表示されているはずである。

タスク追加処理の作成

さて、先のget /の時にtodoリストが空だった場合、 /newへと転送するというコードを書いたので、このnewの中身を同様に実装する。
web.rbの末尾に以下のコードを追加する。

get '/new' do
  @title = "Add todo item"
  erb :new
end
post '/new' do
  Item.create(:content => params[:content], :created => Time.now)
  redirect '/'
end

今回はgetとpostの二個の要素がある。これはhtmlの作法のgetとpostにそのまま対応している。getはレンダリング、postはpost requestである。
フォームをレンダリングするだけなのでロジックは何も必要ない。@title でタイトル名だけをnew.erbに対して引き渡している。
POSTを行うとき、POSTの内容をデータベースに対して追加しなければならない。
DataMapperの create メソッドがデータベースに対する追加をつかさどる。POSTフォームのparams[:content]がその追加内容である。そしてItemのcreatedに対してはTime.nowメソッドの結果を格納する。
この追加が終わったらルートにリダイレクトを行う。
以下にnew.erbの中身を示す。ごくありふれたHTMLのフォームである。

<form action="/new" class="form-inline" method="POST">
  <input type="text" placeholder="Todo item" name="content">
  <button type="submit" class="btn">Post</button>
</form>

ここまでの動作テスト

ここまでのコードで、TODOアイテムの追加と、それの表示が出来るようになっていることを確認する。 まずはctrl+cでSinatraのプロセスを一旦停止する。( http://localhost:4567/ にアクセス出来なくなっている事を確認する)
web.rbのあるフォルダのtodo_list.dbを削除して

ruby web.rb

等で再度Sinatraのプロセスを立ち上げ http://localhost:4567/ にアクセスする。

以上の動作を確認する。

この時点での各ファイルの中身

作成した順に列挙する。手元のファイルと比べて誤りが無いことを確認する。

web.rb

require 'sinatra'
require 'data_mapper'
require 'sinatra/reloader'
require 'date'
DataMapper::setup(:default, "sqlite3://#{Dir.pwd}/todo_list.db")
class Item
    include DataMapper::Resource
    property :id, Serial
    property :content, Text, :required => true
    property :done, Boolean, :required => true, :default => false
    property :created, DateTime
end
DataMapper.finalize.auto_upgrade!

#動作テスト:ここから追加
debug_counter=0
before do
 debug_counter+=1
end

get '/debug_create' do
     Item.create(:content => "Hello #{debug_counter.to_s}!",:done => [true, false].sample  ,:created => Time.now)
end

get '/debug_show' do 
  Item.all.map {|r| "#{r.id}, #{r.content}, #{r.done}, #{r.created} <br>" }
end
#動作テスト:ここまで追加

get '/' do
  @items = Item.all(:order => :created.desc)
  redirect '/new' if @items.empty?
  erb :index
end

get '/new' do
  @title = "Add todo item"
  erb :new
end
post '/new' do
  Item.create(:content => params[:content], :created => Time.now)
  redirect '/'
end

views/index.erb

<ul id="todo-list" class="unstyled">
  <% @items.each do |item| %>
  <li id="<%= item[:id] %>">
  <span class="item">
  <%= item[:done] ? "<del>#{item[:content]}</del>" : item[:content] %>
  </span>
  <span class="pull-right">
  <a href="#" class="btn done"><%= item[:done] ? "Not done" : "Done"%></a>
  <a href="/delete/<%= item[:id] %>" class="btn btn-danger">Delete</a>
  </span>
  </li>
  <% end %>
</ul>
<a href="/new" class="btn btn-primary">Add todo item</a>

views/layout.erb

<html>
  <head>
    <title>Todo List</title>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

views/new.erb

<form action="/new" class="form-inline" method="POST">
  <input type="text" placeholder="Todo item" name="content">
  <button type="submit" class="btn">Post</button>
</form>

タスクの削除

完了したタスクを削除する部分を作成する。 ルートのタスク一覧画面で、タスクの右側に表示されているdeleteボタンを押した時、 /delete へと遷移する。この時、タスクidを付加したurlに遷移させることとする。例えば1番目のタスクのdeleteボタンは /delete/1 へのリンクで、2番目のタスクのdeleteボタンは /delete/2 へのリンク、という風になっている。(このidは今回のアプリケーションでは通し番号、つまり通算番号である。タスクを削除したからと言って、そのidの再利用はしない)
この処理を行う為、web.rbの最下部に以下のコードを追加する。

get '/delete/:id' do
  @item = Item.first(:id => params[:id])
  erb :delete
end
post '/delete/:id' do
  if params.has_key?("ok")
    item = Item.first(:id => params[:id])
    item.destroy
    redirect '/'
  else
    redirect '/'
  end
end

削除部分もGETとPOSTの両方が含まれる。Get(普通にURLでアクセスした時)は削除して良いかを問い合わせるフォームをレンダリングする(delete.erbとして後で作成する)
削除予定のタスクも@itemとしてerb側に渡している。(delete.erbの中で、タスクの中身を表示して、削除して良いかを聞く為)

また、そのdelete.erbからフォームを通してPOSTが返って来た時の処理が

post '/delete/:id' do 

以下に書かれている。
返ってきたdelete.erbのフォーム情報の中に okが含まれているかどうかをチェックして、okが含まれていたらデータベースの該当タスクを削除する。含まれていなければ削除はキャンセルされたと見なせる。どちらの結果であっても最終的には ホーム( localhost:4567/)へとリダイレクトする。

delete.erbの中身を以下のように作成する。OKかCancelを問うだけのフォームである。

<p>Are you sure you want to delete:</p>
<blockquote><p><%= @item.content %></p></blockquote>
<form action="/delete/<%= @item.id %>" method="POST">
 <button type="submit" class="btn btn-primary" name="ok">OK</button>
  <button type="submit" class="btn" name="cancel">Cancel</button>
</form>

ここまでの追加によって、タスクの削除が動作することを確認する。

タスクの完了

最後にタスクの完了をマークする部分を作成する。これが終われば基本的なTODOアプリは完成である。
完了をマークする方法として、最後なので画面遷移を行う方法と、Ajaxを用いてその場で切り替わる方法の二通りの方法を示す。どちらか好みに応じて実装をする。

最小限の方法

index.erbの

 <a href="#" class="btn done"><%= item[:done] ? "Not done" : "Done"%></a>

という行を

<a href="/done/<%= item[:id] %>" class="btn done"><%= item[:done] ? "Not done" : "Done"%></a>
  

に書き換える。これは現在の http://localhot:4567/ のdeleteボタンにそれぞれ固有のURLへのリンクがあるのと同じように、 doneボタンにも固有URLを付加する物である。 そしてweb.rbの末尾に以下のコードを追加する。

get '/done/:id' do
  item = Item.first(:id => params[:id])
  item.done = !item.done
  item.save
    redirect '/'
end

doneボタンをクリックすると、データベース内の対象タスクを探し、doneフラグを反転させ、元のページへリダイレクトをするものである。それぞれのタスクのdoneボタンを押すたびに、画面遷移が起きることを確認する。

Ajaxを用いる方法

完了済タスクをマークする時、AjaxリクエストはPOSTを伴う必要がある。よってweb.rbの末尾に以下の新しいPOSTを追加する。イメージとしては /new と同じようなものである。

post '/done' do
  item = Item.first(:id => params[:id])
  item.done = !item.done
  item.save
  content_type 'application/json'
  value = item.done ? 'done' : 'not done'
  { :id => params[:id], :status => value }.to_json
end

このコードは、DataMapperを通してタスクをidで参照して、該当するタスクを抜出し、完了済みか、未完了かをデータベース上も、Ajaxで得た表示上も、両方をトグルしている。 Ajax(というかjavascript上)で処理を行うために、to_jsonメソッドを使ってデータベース内の対象タスクの中身をjavascript向きのjsonに変換している。このjsonの中身は対象となるタスクのidと完了か未完了かのフラグだけである。

to_jsonメソッドを使うためにターミナル上でrubygem jsonをインストールする。

gem install json

また、このjsonを使うためにweb.rbの先頭に

require 'json'

を追加する必要がある。

これらの動作を行うクライアント側のJavaScriptファイル( todo_list.js)を作成する。 これは/public 以下に配置する。JavaScriptCSSファイルは/publicに配置することがSinatraのルールである。これら/public のディレクトリ内はSinatraアプリケーションの開始時、ルートより先に読み込まれる。このtodo_list.jsはlayout.erbの中で読み込みを指定する必要がある。 今まではlayout.erbが最小限の物だったが、今回jqueryが必要になる。よってlayout.erbも以下のように変更する。

<html>
  <head>
    <title>Todo List</title>
  </head>
  <body>
      <%= yield %>

    <script src="http://code.jquery.com/jquery-1.8.3.min.js"></script>
    <script src="todo_list.js"></script>
  </body>
    
</html>

肝心の/public/todo_list.jsの中身は以下の通りである。

$(document).ready(function() {
  $(".done").click(function(e) {
    var item_id = $(this).parents('li').attr('id');
    $.ajax({
      type: "POST",
      url: "/done",
      data: { id: item_id },
      }).done(function(data) {
        if(data.status == 'done') {
          $("#" + data.id + " a.done").text('Not done')
          $("#" + data.id + " .item").wrapInner("<del>");
        }
        else {
          $("#" + data.id + " a.done").text('Done')
          $("#" + data.id + " .item").html(function(i, h) {
            return h.replace("<del>", "");
          });
        }
      });
      e.preventDefault();
    });
  });

このtodo_list.jsは、ルート上のタスク一覧の全てのdoneボタンのリンク先である /# へのクリックを受け取ったら、見えないように先のweb.rbに追加した /doneというURLへのPOSTを生成し、そのPOST内で含まれるタスクのIDを取得し、htmlを解析して見た目上のDoneとNot Doneの表示をhtmlを書き換えることで切り替えている。(DELタグを挿入、削除したり、DoneとNot doneの文字の切り替え) そしてtodo_list.jsの最後で通常の/# へのリンクをキャンセルしている。

最終動作確認

ここまでで、全てのコードが完成しているはずである。
http://localhost:4567
にアクセスして、以下の動作を確認する。

  • タスクの追加
  • タスクのdoneボタンのトグル
  • タスクの削除

また、Ajaxを用いた方のこの時点のコード例を以下に置いた。もしうまく動かない場合などは参考になるかもしれない。
https://github.com/neon-izm/sinatra_minimal_todo_app

さらに改良するなら

ここまでで、最小限のtodoアプリが完成したと言えるが、更なる改良も思いつく。
まずは認証だ。ユーザーごとに固有のtodoリストを作成したい時は sinatra-authentication というgemを使う。
また、このアプリをそのまま外部に公開した場合はセキュリティ上のリスクがある。これについては
https://github.com/rkh/rack-protection#readme
に詳しい。
また、削除や追加を行うときも、Ajaxを用いて行うようにすることも出来るだろう。 そして、今回は一切見た目を考慮していない。cssで装飾する事も必要だろう。

よくあるミス

sinatraのルーティング、最後に /hoge? をしておかないと localhost:4567/hoge は通じるが localhost:4567/hoge/ はダメとかになって死ぬ