環境によっては、ファイルのパスやSQLのログなどが若干異なりますが、適宜読みかえてください。
[MEMO] 各種環境でのインストール方法はhttp://wiki.rubyonrails.org/rails/pages/HowtosInstallationを参照してください。
その名も「rails」コマンドで作成します。
$ rails --help Usage: /usr/bin/rails /path/to/your/app [options] (略) $ rails bookmark -d postgresql create create app/controllers create app/helpers create app/models create app/views/layouts (略)
[MEMO] Railsに含まれる各種スクリプトは--helpオプションでヘルプを表示させることができます。
ディレクトリの中を確認します。
$ cd bookmark $ ls README app/ config/ doc/ log/ script/ tmp/ Rakefile components/ db/ lib/ public/ test/ vendor/
Ajaxとの相性からいっても、Railsアプリケーションの文字コードはUTF-8をお薦めします。
config/database.ymlを以下のように編集します。
config/database.yml
development: (略) encoding: UTF8 test: (略) encoding: UTF8 production: (略) encoding: UTF8
つづいて、データベースを作成します。
$ createdb -U bookmark -E UTF8 bookmark_development $ createdb -U bookmark -E UTF8 bookmark_test $ createdb -U bookmark -E UTF8 bookmark_production
以下をconfig/environment.rbの先頭に追加します。
$KCODE = "u"
[MEMO] $KCODEの変更はrubyスクリプトの読み込みにも影響をあたえるので、できるだけ早い段階で変更するために、先頭に追加しましょう。
app/controllers/applications.rbを以下のように編集します。
class ApplicationController < ActionController::Base after_filter :set_charset private def set_charset content_type = @headers["Content-Type"] || "text/html" if %r!\Atext/! =~ content_type @headers["Content-Type"] = "#{content_type}; charset=utf-8" end end end
[MEMO] ApplicationControllerは、各コントローラクラスのスーパークラスです。after_filterで指定されたメソッドは、コントローラによる処理の最後で呼ばれます。
script/serverで、WEBrickを利用したアプリケーション・サーバが起動します。
$ ruby script/server --help (略) $ ruby script/server => Booting WEBrick... => Rails application started on http://0.0.0.0:3000 => Ctrl-C to shutdown server; call with --help for options [2006-10-16 22:37:25] INFO WEBrick 1.3.1 [2006-10-16 22:37:25] INFO ruby 1.8.5 (2006-08-25) [i486-linux] [2006-10-16 22:37:25] INFO WEBrick::HTTPServer#start: pid=6074 port=3000
script/serverは、デフォルトでは3000/tcpで起動します。
[MEMO] Railsで使えるscript/server以外の主なアプリケーションサーバには以下のものがあります。
- Apache HTTPD + FastCGI
- LightTPD + FastCGI http://www.lighttpd.net/
- Mongrel http://mongrel.rubyforge.org/
ウェブブラウザでhttp://localhost:3000/にアクセスします。 この時に表示されているのは、public/index.htmlです。
[MEMO] public/がApacheのDocumentRootにあたるディレクトリで、その中にあるファイルへのアクセスは、アプリケーションを介さずに返されます(例:/robots.txtや/javascripts/prototype.jsなど)。
gemコマンドでRailsをインストールすると、Railsを構成する各パッケージのHTML形式のマニュアルもインストールされます。 このマニュアルは、gem_serverを起動することで、ブラウザからアクセスすることができます。
$ gem_server --help (略) $ gem_server & [2006-11-06 11:46:45] INFO WEBrick 1.3.1 [2006-11-06 11:46:45] INFO ruby 1.8.5 (2006-08-25) [i486-linux] [2006-11-06 11:46:45] INFO WEBrick::HTTPServer#start: pid=14875 port=8808
デフォルトでは8808/tcpで起動しますので、ブラウザでhttp://localhost:8808/にアクセスしてください。
「○○さん」が「○○というページ」に「○月○日に○○というコメントでブックマークする」というのが、今回のアプリケーションのモデリングです。 順にそれぞれ、User、Page、Bookmarkというモデルを割り当てます。
各モデルが最低限持つべき情報は以下のようになります。
あるユーザは複数のブックマークを持ち、あるページも複数のブックマークを持つので、モデル間には以下のような関係があります。
User ------ Bookmark ------ Page 1:多 多:1
また、あるユーザは複数のページをブックマークし、あるページも複数のユーザにブックマークされるので、以下のような関係もあります。
User ------- Page 多:多
最初に「ページ」のモデルを作成します。
"script/generate model"でモデルの雛型を作成します。
$ ruby script/generate model --help (略) $ ruby script/generate model Page create app/models/page.rb create test/unit/page_test.rb create test/fixtures/pages.yml create db/migrate create db/migrate/001_create_pages.rb
db/migrate/001_create_pages.rbを編集して、uriとtitleという二つのカラムをPageテーブルに設定します。
db/migrate/001_create_pages.rb
class CreatePages < ActiveRecord::Migration def self.up create_table :pages do |t| t.column :uri, :string, :limit => 1024 t.column :title, :string, :limit => 1024 end end def self.down drop_table :pages end end
self.upは、migrationのバージョンが上がる時に実行され、逆にself.downはmigrationのバージョンが下がる時に実行されます。
[MEMO] 詳しくは「マニュアル:ActiveRecord::Migration」を参照してください。
"rake db:migrate"で、テーブル定義が実行されテーブルが作成されます。
$ rake --help (略) $ rake db:migrate (in /home/rails/bookmark) == CreatePages: migrating ===================================================== -- create_table(:pages) -> 0.5079s == CreatePages: migrated (0.5272s) ============================================
[MEMO] Rakeはmakeやantのような自動化のためのツールです。
Rakeの具体的な各処理はタスクと呼ばれ、Rakeはタスク間の依存関係に従ってタスクを実行します。以下のコマンドでタスクの一覧を見ることが出来ます。
$ rake -T
[MEMO] Railsで使える標準のrakeのタスクは、/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/tasks/*.rakeで定義されています。なお、タスクを指定せずに実行すると、testタスクを実行します。
ページのコントローラの雛型を題材に、コントローラの基本を学びます。
$ ruby script/generate scaffold --help (略) $ ruby script/generate scaffold Page Page identical app/models/page.rb identical test/unit/page_test.rb identical test/fixtures/pages.yml create app/views/page/_form.rhtml create app/views/page/list.rhtml create app/views/page/show.rhtml create app/views/page/new.rhtml create app/views/page/edit.rhtml create app/controllers/page_controller.rb create test/functional/page_controller_test.rb create app/helpers/page_helper.rb create app/views/layouts/page.rhtml create public/stylesheets/scaffold.css
では、作成されたファイルをそれぞれ見てみましょう。
コントローラ中の一つの処理に、同名のビューファイルが一つ対応して表示されます。
Rails はリクエストの情報に基づいて、コントローラ名とそれに続くアクション名を取得し、コントローラのクラスの中から対象のアクションのメソッドを実行します。
例えば、http://localhost:3000/page/index(またはhttp://localhost:3000/page/)にアクセスすると、PageControllerクラス中のindexというアクションメソッドが呼ばれます。
app/controllers/page_controller.rb
class PageController < ApplicationController def index list # ←(1) render :action => 'list' # ←(2) end (略) end
indexメソッドでは、(1)で同クラスのlistメソッドを呼び出した後、(2)でlistという名前のビューファイル(app/views/page/list.rhtml)を出力しています。
もしhttp://localhost:3000/page/listにアクセスすると、PageControllerクラス中のlistというアクションメソッドが呼ばれます。
app/controllers/page_controller.rb(一部)
def list @page_pages, @pages = paginate :pages, :per_page => 10 end
ここでは、さきほどのindexメソッドの定義と違って、renderメソッドが呼ばれていませんが、その場合はアクションメソッドと同名のビューが呼ばれます。つまり、
render :action => 'list'
と書くのと同じ挙動になります。
indexメソッドやlistメソッドでrenderされるビューのファイルを見ましょう。
app/views/page/list.rhtml
<h1>Listing pages</h1> <table> <tr> <% for column in Page.content_columns %> <th><%= column.human_name %></th> # ←(1) <% end %> </tr> <% for page in @pages %> <tr> <% for column in Page.content_columns %> <td><%=h page.send(column.name) %></td> # ←(2) <% end %> <td><%= link_to 'Show', :action => 'show', :id => page %></td> # ←(3) <td><%= link_to 'Edit', :action => 'edit', :id => page %></td> <td><%= link_to 'Destroy', { :action => 'destroy', :id => page }, :confirm> 'Are you sure?', :post => true %></td> </tr> <% end %> </table> <%= link_to 'Previous page', { :page => @page_pages.current.previous } if @pagpages.current.previous %> <%= link_to 'Next page', { :page => @page_pages.current.next } if @page_pages.rrent.next %> <br /> <%= link_to 'New page', :action => 'new' %>
ビューで動的コンテンツを可能にしているのはERB(Embedded Ruby)です。ERBでは、以下のタグを使って、RubyのコードをHTMLなどのテキストファイルに埋め込むことができます。
まず、Pageモデルの各カラムの名前を見出しとして出力しています(1)。 つぎに、@pagesに含まれている各Pageごとにテーブルの行を作成し、各カラムの値を出力しています(2)。 (3)ではShowというリンク文字列に対して、コントローラのshowアクションを指定してhttp://localhost:3000/page/show/1のようなURIへのハイパーリンクを作成しています。showアクションに扱う項目が何かを指定するためにidを指定しています。
[MEMO] この例のように、「:id => page」とモデルのオブジェクトを指定すると、自動的に「:id => page.id」と書くのと同じ意味になります。
さまざまなアクションの出力に共通する部分は、レイアウトとよばれるビューファイルに書きます。
app/views/layouts/page.rhtml
<html> <head> <title>Page: <%= controller.action_name %></title> <%= stylesheet_link_tag 'scaffold' %> </head> <body> <p style="color: green"><%= flash[:notice] %></p> <%= yield %> # ←(1) </body> </html>
実際にHTMLが出力される際は、(1)のyieldの部分が、個別のrender結果に置き換わって出力されます。
Railsでは、モデルに対するテストをユニットテスト、コントローラを対象とするテストを機能テストと呼ばれています。
ユニットテスト用のフォルダとしては、test/unit、機能テスト用のフォルダとしてはtest/functionalが用意されています。そして、テスト実行に必要なデータを書いたymlファイルは、test/fixturesに入れます。
コントローラのテスト(test/functional/page_controller_test.rb)
def test_index get :index # ←(1) assert_response :success # ←(2) assert_template 'list' # ←(3) end
ここではindexメソッドをGETで呼び出し(1)、HTTPレスポンスがSuccessである事を確認し(2)、list.rhtmlがrenderされている事を確認します(3)。
def test_list get :list assert_response :success assert_template 'list' assert_not_nil assigns(:pages) # ←(1) end
このlistアクションのテストは、基本的にtest_indexと同じですが、最後に、renderする際に@pagesが定義されているかテストしています(1)。
test/fixtures/pages.ymlは、テスト用のモデルのデータで、YAML形式で記述します。 今回のアプリケーションのテスト用に、以下に変更します。
ruby: id: 1 uri: http://www.ruby-lang.org/ title: Ruby Programming Language ruby_no_kai: id: 2 uri: http://jp.rubyist.net/ title: Nihon Ruby no Kai
[MEMO] YAMLについての詳細は、Rubyist Magazineの記事「プログラマーのためのYAML入門」http://jp.rubyist.net/magazine/?0009-YAMLを参照してください。
Pageのuriというカラムは、ブックマークするウェブサイトのURIを記録するためのものですので、以下をチェックするようにしましょう。
ActiveRecordオブジェクトの値が、期待した値かどうか判定する機能がvalidationです。モデルのクラス内でvalidateメソッドを定義することで機能します。
またvalidateメソッドを定義する他に、ActiveRecordにはいくつかのvalidateを行うメソッドも用意されています。
テスト駆動開発(Test Driven Development)という、テストを先に書き、その後に目的のコードを書くスタイルで開発します。
まず初めに、validateが失敗する例から始めて、その後に成功する例を見ていきましょう。 では、test/unit/page_test.rbにユニットテストを書きます。
test/unit/page_test.rb
class PageTest < Test::Unit::TestCase fixtures :pages def test_validate # 不正なURI assert(Page.create.errors.invalid?(:uri)) assert(Page.create(:uri => "-").errors.invalid?(:uri)) assert(Page.create(:uri => "http:///").errors.invalid?(:uri)) # http/https以外のスキーム assert(Page.create(:uri => "ftp://example.com/").errors.invalid?(:uri)) # 正しいURI assert(Page.create(:uri => "http://example.com/").errors.empty?) end end
次に、ユニットテストのみを指定してrakeコマンドを実行します。
$ rake test:units (略) 1) Failure: test_validate(PageTest) [./test/unit/page_test.rb:8]: <false> is not true. 1 tests, 1 assertions, 1 failures, 0 errors
テストの失敗を確認したら、実際にapp/models/page.rbにvalidateメソッドを使ってモデルを実装します。
app/models/page.rb
require "uri" class Page < ActiveRecord::Base private def validate begin parsed_uri = URI.parse(uri) raise unless parsed_uri.host raise unless %w(http https).include?(parsed_uri.scheme) rescue errors.add(:uri, "invalid URI") end end end
[MEMO] validationの詳細は「マニュアル:ActiveRecord::Validations」「マニュアル:ActiveRecord::Validations::ClassMethods」を参照してください。
また同様にrakeコマンドを使ってテストを実行します。
$ rake test:units (略) 1 tests, 5 assertions, 0 failures, 0 errors
テストの成功を確認して、モデルのvailidationのテストは終わりです。
次にコントローラのvalidationのテストを行います。
$ rake test:functionals (略) 1) Failure: test_create(PageControllerTest) [./test/functional/page_controller_test.rb:56]: Expected response to be a <:redirect>, but was <200> 8 tests, 25 assertions, 1 failures, 0 errors
機能テストで新規ページを作成する際に失敗しているので、そこでも正しいURIで作成を試みるように修正します。
test/functional/page_controller_test.rb(一部)
def test_create (略) post :create, :page => { :uri => "http://www.rubyonrails.org/", :title => "Ruby on Rails" } (略) end
rakeコマンドでテストを実行します。
$ rake test:functionals (略) 8 tests, 28 assertions, 0 failures, 0 errors
テストの成功を確認して、コントローラのvalidationテストも終わりです。
Pageモデルのtitleというカラムは、値が空の場合はかわりにURIをそのまま表示するようにしてみましょう。
ここでもテスト駆動開発のスタイルで先にtest/unit/page_test.rbから書いていきます。
test/unit/page_test.rb
class PageTest < Test::Unit::TestCase (略) def test_title page = Page.new(:uri => "http://example.com/") assert_equal("http://example.com/", page.title) end end
rakeコマンドを使ってユニットテストを実行し、失敗することを確認します。
$ rake test:units (略) 1) Failure: test_title(PageTest) [./test/unit/page_test.rb:19]: <"http://example.com"> expected but was <nil>. 2 tests, 6 assertions, 1 failures, 0 errors
カラムを取得するメソッドをオーバーライドする際は、元になる値をself.column_nameではなく、self[:column_name]のように取得します。 テストが失敗する事を確認してから、app/models/page.rbにtitleメソッドを定義します。
app/models/page.rb
class Page < ActiveRecord::Base def title self[:title].blank? ? self[:uri] : self[:title] end (略) end
rakeコマンドを使って、ユニットテストおよび機能テストが通ることを確認します。
$ rake (略) 2 tests, 6 assertions, 0 failures, 0 errors (略) 8 tests, 28 assertions, 0 failures, 0 errors
つづいて「ユーザ」のモデルを作成します。 ここでは、認証の実装が鍵になります。
ユーザ認証を支援する方法はいろいろありますが、ここではacts_as_authenticatedプラグインを使います。
[MEMO] Acts as Authenticatedのホームページ:http://technoweenie.stikipad.com/plugins/show/Acts+as+Authenticated
script/pluginでインストールします。
$ ruby script/plugin --help (略) $ ruby script/plugin source http://svn.techno-weenie.net/projects/plugins $ ruby script/plugin install acts_as_authenticated
script/generateでauthenticatedジェネレータを呼び出します。 ここでは、モデル名:user、コントローラ名:accountで作成します。
$ ruby script/generate authenticated --help (略) $ ruby script/generate authenticated User Account create app/views/account create app/models/user.rb create app/controllers/account_controller.rb create lib/authenticated_system.rb create lib/authenticated_test_helper.rb create test/functional/account_controller_test.rb create app/helpers/account_helper.rb create test/unit/user_test.rb create test/fixtures/users.yml create app/views/account/index.rhtml create app/views/account/login.rhtml create app/views/account/signup.rhtml create db/migrate/002_create_users.rb
などが自動生成されます。
migrateを実行してusersテーブルを作成します。
$ rake db:migrate (in /home/rails/bookmark) == CreateUsers: migrating ===================================================== -- create_table("users", {:force=>true}) -> 1.0104s == CreateUsers: migrated (1.0358s) ============================================
rakeでテストを実行します。
$ rake (略) 12 tests, 23 assertions, 0 failures, 0 errors (略) 22 tests, 54 assertions, 0 failures, 0 errors
http://localhost:3000/account/にアクセスしてみましょう。 今はまだユーザがいないので、自動的にサインアップ画面にリダイレクトされます。
$ ruby script/generate model Bookmark create app/models/bookmark.rb create test/unit/bookmark_test.rb create test/fixtures/bookmarks.yml create db/migrate/003_create_bookmarks.rb
db/migrate/003_create_bookmarks.rb
class CreateBookmarks < ActiveRecord::Migration def self.up create_table :bookmarks do |t| t.column :user_id, :integer, :null => false t.column :page_id, :integer, :null => false t.column :comment, :string, :limit => 1024 t.column :created_at, :datetime end end def self.down drop_table :bookmarks end end
$ rake db:migrate (in /home/rails/bookmark) == CreateBookmarks: migrating ================================================= -- create_table(:bookmarks) -> 0.7434s == CreateBookmarks: migrated (0.7661s) ========================================
モデリングの章で記したように、Page、User、Bookmarkの三つのデータテーブルは、UserとBookmark、PageとBookmarkが1:多で、UserとPageはBookmarkを介して多:多の関係になっています。
app/models/page.rb
class Page < ActiveRecord::Base has_many :users, :through => :bookmarks # ←(1) has_many :bookmarks, :order => "created_at desc" # ←(2) (略) end
Pageから見るとUserは複数あり、PageはBookmarkを中間テーブルとして持ちます(1)。
そして、Pageから見たbookmarkは複数あり、bookmarkモデルをcreated_atの降順で並べます(2)。
app/models/user.rb
class User < ActiveRecord::Base has_many :pages, :through => :bookmarks # ←(1) has_many :bookmarks, :order => "created_at desc" # ←(2) (略) end
Userから見たPageは複数あり、UserはBookmarkを中間テーブルとして持ちます(1)。
(2)では、Userから見るとBookmarkは複数あり、Bookmarkモデルがcreated_atの降順で並ぶようにしています。
app/models/bookmark.rb
class Bookmark < ActiveRecord::Base belongs_to :user belongs_to :page # ←(1) validates_uniqueness_of :page_id, :scope => :user_id # ←(2) end
BookmarkはUserとPageを参照しています(1)。 また、同じuser_idを持つブックマークの中でpage_idの重複がないかどうか、validates_uniqueness_ofを用いて確認しています(2)。
モデル間のリレーションは、以下のように参照することができます。
モデル間のリレーションのテストは、それぞれ以下のように書きます。
test/fixtures/bookmarks.yml
bookmark1: id: 1 user_id: 1 page_id: 1 comment: cool created_at: 2006-01-02 12:34:56 +09:00 bookmark2: id: 2 user_id: 2 page_id: 1 comment: nice created_at: 2006-01-03 12:34:56 +09:00 bookmark3: id: 3 user_id: 2 page_id: 2 comment: great created_at: 2006-01-04 12:34:56 +09:00
test/unit/page_test.rb
class PageTest < Test::Unit::TestCase fixtures :bookmarks, :users, :pages def test_users assert_equal(2, pages(:ruby).users.size) assert_equal(1, pages(:ruby_no_kai).users.size) end def test_bookmarks assert_equal(2, pages(:ruby).bookmarks.size) assert_equal(1, pages(:ruby_no_kai).bookmarks.size) end (略)
test/unit/user_test.rb
class UserTest < Test::Unit::TestCase (略) fixtures :bookmarks, :users, :pages def test_pages assert_equal(1, users(:quentin).pages.size) assert_equal(2, users(:aaron).pages.size) end def test_bookmarks assert_equal(1, users(:quentin).bookmarks.size) assert_equal(2, users(:aaron).bookmarks.size) end (略)
test/unit/bookmark_test.rb
class BookmarkTest < Test::Unit::TestCase fixtures :bookmarks, :users, :pages def test_user assert_equal(users(:quentin), bookmarks(:bookmark1).user) end def test_page assert_equal(pages(:ruby), bookmarks(:bookmark1).page) end end
[MEMO] fixturesの引数は、関連するモデルについても追加します。
rakeでテストを実行します。
$ rake (略) 18 tests, 33 assertions, 0 failures, 0 errors (略) 22 tests, 54 assertions, 0 failures, 0 errors
すでにStep3でPageControllerにshowメソッドはありますので、それを変更します。
app/views/page/show.rhtml
<h1><%= link_to(h(@page.title), h(@page.uri)) %></h1> <ul> <% for bookmark in @page.bookmarks -%> <li> <%= bookmark.created_at.strftime("%Y/%m/%d") %> <%= link_to(h(bookmark.user.login), :controller => "user", :action => "show", :id => bookmark.user) %> <%= h(bookmark.comment) %> </li> <% end -%> </ul>
[MEMO] hはHTML上特別な文字(<, >, &, ")をエスケープするメソッドです。外部から入力された値を表示する際には必ず使うようにしましょう。
機能テストを実行します。
$ rake test:functionals (略) 22 tests, 54 assertions, 0 failures, 0 errors
同じ要領で、UserControllerにshowメソッドを追加します。
ユーザのコントローラの雛型を作成します。
$ ruby script/generate controller --help (略) $ ruby script/generate controller User create app/controllers/user_controller.rb create test/functional/user_controller_test.rb create app/helpers/user_helper.rb
機能テストを書きます。
test/functional/user_controller_test.rb
class UserControllerTest < Test::Unit::TestCase fixtures :users (略) def test_show get :show, :id => 1 assert_response :success assert_template 'show' assert_not_nil assigns(:user) assert assigns(:user).valid? end end
コントローラとビューを書きます。
app/controllers/user_controller.rb
class UserController < ApplicationController def show @user = User.find(params[:id]) end end
app/views/user/show.rhtml
<h1><%= h(@user.login) %>さんのブックマーク</h1> <ul> <% for bookmark in @user.bookmarks -%> <li> <%= bookmark.created_at.strftime("%Y/%m/%d") %> <%= link_to(h(bookmark.page.title), :controller => "page", :action => "show", :uri => bookmark.page.uri) %> <%= h(bookmark.comment) %> </li> <% end -%> </ul>
機能テストを実行します
$ rake test:functionals (略) 23 tests, 58 assertions, 0 failures, 0 errors
あるページへのブックマークの追加は、以下のような処理になります。
つまり、Pageモデルのオブジェクトの管理は、Bookmarkモデルのオブジェクトを介して行います。
$ ruby script/generate controller Bookmark create app/controllers/bookmark_controller.rb create test/functional/bookmark_controller_test.rb create app/helpers/bookmark_helper.rb
あるモデルを保存する際に、関連するモデルが新規オブジェクトの場合、同時に保存されます。 例えば今回のアプリケーションでブックマークを保存する場合は、そのブックマークに関連するページが新規オブジェクトであれば、ブックマークとページの両方が同時にデータベースに書き込まれます(ユーザは、すでにログインしているユーザに関連づけられているので、新規オブジェクトになることはありません)。
実行例:
$ ruby script/console --help (略) $ ruby script/console >> b = Bookmark.new >> b.page = Page.new(:uri => "http://notexisting.example.com/") # ← 新規ページ >> b.user = User.find(1) # ← 既存ユーザ >> b.save
[MEMO] script/consoleで対話的にモデルの操作が行えます。
実行例
$ ruby script/console >> b = Bookmark.new >> b.page = Page.new(:uri => "http://notexisting.example.com/") # ← validなページ >> b.save # ←ユーザなしのブックマークなので例外が起きる ActiveRecord::StatementInvalid: PGError: ERROR: null value in column "user_id" violates not-null constraint
SQLログの抜粋
BEGIN INSERT INTO pages ("uri", "title") VALUES('http://notexisting.example.com/', 'http://notexisting.example.com/') PGError: ERROR: null value in column "user_id" violates not-null constraint : INSERT INTO bookmarks ("page_id", "user_id", "comment", "created_at") VALUES(14, NULL, NULL, '2006-10-16 05:55:15') ROLLBACK
BookmarkControllerのaddメソッドで、GETによるアクセスの場合は確認画面を出し、POSTによるアクセスの場合は保存するようにします。
test/functional/bookmark_controller_test.rb
class BookmarkControllerTest < Test::Unit::TestCase def setup @controller = BookmarkController.new @request = ActionController::TestRequest.new @response = ActionController::TestResponse.new @request.session = ActionController::TestSession.new({:user => 1}) # ←(1) end def test_add_new_page get :add, :uri => "http://example.com/", :title => "example" assert_template "add" assert_equal("http://example.com/", assigns(:bookmark).page.uri) assert_equal("example", assigns(:bookmark).page.title) post :add, :uri => "http://example.com/", :title => "example" assert_redirected_to :controller => "user", :action => "show", :id => 1 end def test_add_existing_page get :add, :uri => "http://jp.rubyist.net/", :title => "overrided title" assert_template "add" assert_equal("http://jp.rubyist.net/", assigns(:bookmark).page.uri) assert_equal("overrided title", assigns(:bookmark).page.title) post :add, :uri => "http://jp.rubyist.net/", :title => "overrided title" assert_redirected_to :controller => "user", :action => "show", :id => 1 end def test_add_invalid_page get :add, :uri => "mailto:foo@example.com", :title => "test" assert_template "add" assert_equal("mailto:foo@example.com", assigns(:bookmark).page.uri) assert_equal("test", assigns(:bookmark).page.title) post :add, :uri => "mailto:foo@example.com", :title => "test" assert_template "add" assert(assigns(:page).errors.invalid?(:uri)) end end
ブックマークの追加は、ログインしたユーザごとの処理になるので、setupメソッドであらかじめセッション情報を設定しておきます(1)。
[MEMO] テストのsetupメソッドは、各メソッドの実行前に毎回呼ばれます。
app/controllers/bookmark_controller.rb
class BookmarkController < ApplicationController include AuthenticatedSystem before_filter :login_required def add @page = Page.find_by_uri(params[:uri]) || Page.new(:uri => params[:uri]) # ←(1) @page.title = params[:title] @bookmark = Bookmark.new @bookmark.user = current_user # ←(2) @bookmark.page = @page # ←(2) @bookmark.comment = params[:comment] if request.post? && (@bookmark.save! rescue false) # ←(3) redirect_to :controller => "user", :action => "show", :id => current_user else render(:action => "add") end end end
ブラウザから受け取るパラメータのうち、uriを元にページがあるか探し、なければ新規オブジェクトを作ります(1)。 つづいて、新規のブックマークオブジェクトに対して、そのページとログインしているユーザを関連付けます(2)。 そして、POSTによるアクセスの場合は、ブックマークオブジェクトの保存を試み、成功したらユーザのページにリダイレクトし、失敗した場合は再度同じページを表示します(3)。
app/views/bookmark/add.rhtml
<h1>ブックマークの追加</h1> <% if request.post? -%> # ←(1) <%= error_messages_for "page" %> <%= error_messages_for "bookmark" %> <% end -%> <%= secure_form_tag %> <dl> <dt>URI</dt> <dd><%= text_field_tag "uri", @page.uri, :size => 40 %></dd> # ←(2) <dt>タイトル</dt> <dd><%= text_field_tag "title", @page.title, :size => 40 %></dd> <dt>コメント</dt> <dd><%= text_field_tag "comment", @bookmark.comment, :size => 40 %></dd> </dl> <p><%= submit_tag %></p> <%= end_form_tag %>
(1)では、POSTアクセス時のみエラーメッセージを表示させています。 (2)では、text_field_tagメソッドを使って、パラメータ名とデフォルト値と、幅に関するオプションを指定しています。
では、機能テストを実行します。
$ rake test:functionals (略) 26 tests, 73 assertions, 0 failures, 0 errors
ページの管理はすべてブックマークを介して行いますから、Step3のscaffoldで作られたapp/controllers/page_controller.rbのindex、list、new、create、edit、update、destroyメソッドは不要なので削除します。
test/functional/page_controller_test.rbの該当するテストも削除します。
app/views/page/以下の_form.rhtml、edit.rhtml、list.rhtml、new.rhtmlも削除します。
削除したら、機能テストを実行します。
$ rake test:functionals (略) 19 tests, 49 assertions, 0 failures, 0 errors
PageControllerのtopメソッドで、人気順のページ一覧を出しましょう。
「PageControllerのtopメソッドにアクセスすると、top.rhtmlが表示され、その中に含まれるページの数は二つ」というのをテストしましょう。
test/functional/page_controller_test.rb
class PageControllerTest < Test::Unit::TestCase fixtures :pages (略) def top get :top assert_template "top" assert_equal(2, assigns(:items).size) end end
人気順のページ一覧を取得するので、bookmarksテーブルからuser_idの個数が多いpage_idを、user_idの個数の多い順に取得します。 つまり、SQLで書けば以下のようになります。
SELECT page_id, count(user_id) AS count FROM bookmarks GROUP BY page_id ORDER BY count DESC
これを、ActiveRecordのfindメソッドで書くと、以下のようになります。
Bookmark.find(:all, :select => "page_id, count(user_id) as count", :group => "page_id", :order => "count desc")
このように、SQLのSELECTやGROUP BYやORDER BYに相当する:select、:group、:orderといったオプションを指定することができます。
[MEMO] 詳しくは「マニュアル:find (ActiveRecord::Base)」を参照してください。
ですので、コントローラとビューは以下のようになります。
app/controllers/page_controller.rb
class PageController < ApplicationController (略) def top @items = Bookmark.find(:all, :select => "page_id, count(user_id) as count", :group => "page_id", :order => "count desc") end end
app/views/page/top.rhtml
<h1>人気順ページ一覧</h1> <ul> <% for item in @items -%> <li><%= link_to(h(item.page.title), :action => "show", :uri => item.page.uri) %> <%= item.count %> users</li> <% end -%> </ul>
では、rakeコマンドでテストを実行し、ブラウザでも確認しましょう(http://localhost:3000/page/top)。
findする際の検索条件や並び替えによく使われるカラムについて、インデックスを追加することで、findの速度を向上させることができます。 今回のアプリケーションでは、以下のカラムにインデックスを追加します。
インデックスの追加も、migration機能でできます。 まずは、migrationスクリプトの雛型を作成します。
$ ruby script/generate migration add_index create db/migrate/004_add_index.rb
作成されたファイルを以下のように編集します。
db/migrate/004_add_index.rb
class AddIndex < ActiveRecord::Migration def self.up add_index :pages, :uri add_index :users, :login add_index :bookmarks, :user_id add_index :bookmarks, :page_id add_index :bookmarks, :created_at end def self.down remove_index :pages, :uri remove_index :users, :login remove_index :bookmarks, :user_id remove_index :bookmarks, :page_id remove_index :bookmarks, :created_at end end
では、rake db:migrateを実行します。
$ rake db:migrate (in /home/rails/bookmark) == AddIndex: migrating ======================================================== -- add_index(:pages, :uri) -> 0.3602s -- add_index(:users, :login) -> 0.1894s -- add_index(:bookmarks, :user_id) -> 0.1422s -- add_index(:bookmarks, :page_id) -> 0.1597s -- add_index(:bookmarks, :created_at) -> 0.1453s == AddIndex: migrated (1.0936s) ===============================================
log/development.logを確認すると、以下のような行が出力されています。
CREATE INDEX "pages_uri_index" ON pages ("uri") CREATE INDEX "users_login_index" ON users ("login") CREATE INDEX "bookmarks_user_id_index" ON bookmarks ("user_id") CREATE INDEX "bookmarks_page_id_index" ON bookmarks ("page_id") CREATE INDEX "bookmarks_created_at_index" ON bookmarks ("created_at")
変更後、rakeコマンドでテストを実行しましょう。
UserControllerのshowメソッドのこれまでの実装では、まずparams[:id]からユーザを取得し、user.bookmarksでそのユーザのブックマークの一覧を取得、つぎに各ブックマークに対してbookmark.pageでページを取得しています。 そのため、もしあるユーザが100個のブックマークを持っている場合、1+1+100の計102回SQLが発行されます。
しかし、ActiveRecordのfindメソッドの:includeオプションを使えば、あらかじめ関連するオブジェクトを同時に取得することができますので、SQLの発行回数を減らせます。
user = User.find(1) → 1回SQL発行 user.bookmarks → 1回SQL発行 user.pages → 1回SQL発行 user.bookmarks.collect{|e| e.page} → user.bookmarks回SQL発行
user = User.find(1) → 1回SQL発行 user.bookmarks → SQL発行なし user.pages → 1回SQL発行 user.bookmarks.collect{|e| e.page} → user.bookmarks回SQL発行
user = User.find(1) → 1回SQL発行 user.bookmarks → 1回SQL発行 user.pages → SQL発行なし user.bookmarks.collect{|e| e.page} → user.bookmarks回SQL発行
user = User.find(1) → 1回SQL発行 user.bookmarks → SQL発行なし user.pages → SQL発行なし user.bookmarks.collect{|e| e.page} → user.bookmarks回SQL発行
user = User.find(1) → 1回SQL発行 user.bookmarks → SQL発行なし user.pages → 1回SQL発行 user.bookmarks.collect{|e| e.page} → SQL発行なし
user = User.find(1) → 1回SQL発行 user.bookmarks → SQL発行なし user.pages → SQL発行なし user.bookmarks.collect{|e| e.page} → SQL発行なし
[MEMO] 詳しくは「マニュアル:find (ActiveRecord::Base)」を参照してください。
ここでは、ユーザからブックマークを取得する際に、関連するページも同時に取得するように変更します。
app/views/user/show.rhtml(一部)
<% for bookmark in @user.bookmarks -%>
↓
<% for bookmark in @user.bookmarks.find(:all, :include => [:page]) -%>
同様に、ページからブックマークを取得する際に、関連するユーザも同時に取得するように変更します。
app/views/page/show.rhtml(一部)
<% for bookmark in @page.bookmarks -%>
↓
<% for bookmark in @page.bookmarks.find(:all, :include => [:user]) -%>
変更後、rakeコマンドでテストを実行しましょう。
これまでの例では、以下のようなURIにルーティングされています。
http://localhost:3000/コントローラ名/アクション名 http://localhost:3000/コントローラ名/アクション名/idの値
Railsでは、config/routes.rbを変更することで、URIのルーティングを変更することができます。
デフォルトでは、以下のようなファイルになっています(コメントは省略)。
ActionController::Routing::Routes.draw do |map| map.connect ':controller/service.wsdl', :action => 'wsdl' map.connect ':controller/:action/:id' end
上から順に見てマッチングした最初のルールにしたがってルーティングが決定されます。 つまり、"map.connect ':controller/:action/:id'"というの指定のため、上記のようなルーティングになっています。
[MEMO] 詳しくはhttp://wiki.rubyonrails.com/rails/pages/Routesを参照してください。
まず、トップページ(http://localhost:3000/)を、人気順ページ一覧に変更します。
デフォルトでは、トップページにアクセスするとpublic/index.htmlが返ります。つまり、public/以下の該当する場所に静的ファイルが存在すると、アプリケーションを介さずにそのファイルがそのまま返ってしまいますので、まずはそれを削除します。
$ rm public/index.html
つづいて、トップページへのアクセスがPageControllerのtopメソッドにルーティングされるように、config/routes.rbを変更します。
ActionController::Routing::Routes.draw do |map| map.connect "", :controller => "page", :action => "top" # ←(1) map.connect ':controller/:action/:id' # ←(2)
これで、トップページへのアクセスだけ(1)のルールが適用され、それ以外は従来どおり(2)のルールが適用されます。
ブックマーク追加のURIを、/bookmark/addから、はてなブックマークのように/addに変更します。
config/routes.rb
ActionController::Routing::Routes.draw do |map| map.connect "", :controller => "page", :action => "top" map.connect "add", :controller => "bookmark", :action => "add" # ←追加 map.connect ':controller/:action/:id' end
ユーザのブックマーク一覧を表示するURIを、/user/show/1ではなく/user/quentinのように変更します。
まずテストを変更します。
test/functional/user_controller_test.rb(一部)
def test_show get :show, :login => "quentin" # ←変更 (略)
test/functional/bookmark_controller_test.rb(一部)
def test_add_new_page (略) assert_redirected_to :controller => "user", :action => "show", :login => "quentin" end def test_add_existing_page (略) assert_redirected_to :controller => "user", :action => "show", :login => "quentin" end
次に、config/routes.rbを以下のように変更します。
config/routes.rb
ActionController::Routing::Routes.draw do |map| map.connect "", :controller => "page", :action => "top" map.connect "add", :controller => "bookmark", :action => "add" map.connect "user/:login", :controller => "user", :action => "show" # ←追加 map.connect ':controller/:action/:id' end
最後に、コントローラとビューを変更します。
app/controllers/user_controller.rb(一部)
def show @user = User.find_by_login(params[:login]) # ←変更 end
app/controllers/bookmark_controller.rb(一部)
def add (略) if request.post? && (@bookmark.save rescue false) redirect_to :controller => "user", :action => "show", :login => current_user.login # ←変更 else render(:action => "add") end (略) end
app/views/page/show.rhtml(一部)
<li> <%= bookmark.created_at.strftime("%Y/%m/%d") %> <%= link_to(h(bookmark.user.login), :controller => "user", :action => "show", :login => bookmark.user.login) %> <%= h(bookmark.comment) %> # ←変更 </li>
このように、redirect_toやlink_toの引数を、:id指定から:login指定に変更しています。
ページのブックマーク一覧のURIを、/page/show/1ではなく、はてなブックマークのように/entry/http://example.comのように変更します。
まずテストを変更します。
test/functional/bookmark_controller_test.rb(一部)
def test_show get :show, :uri => "http://www.ruby-lang.org/" # ←変更 (略)
次に、config/routes.rbを以下のように変更します。「/」が含まれる内容をパラメータとして渡したい場合は、*uriのように「*」を付ける必要があります。
config/routes.rb
ActionController::Routing::Routes.draw do |map| map.connect "", :controller => "page", :action => "top" map.connect "add", :controller => "bookmark", :action => "add" map.connect "user/:login", :controller => "user", :action => "show" map.connect "entry/*uri", :controller => "page", :action => "show" # ←追加 map.connect ':controller/:action/:id' end
最後に、コントローラとビューを変更します。
app/controllers/page_controller.rb(一部)
def show @page = Page.find_by_uri(decode(request.path.sub(%r!\A/entry/!, ''))) # ←変更 end (略) private def decode(str) # ←追加 (str || "").gsub(/%([0-9a-f]{2})/i){|a| [$1].pack("H*")} end end
params[:uri]でパラメータを取得しようとすると、「/」で区切った配列になってしまいますので、URIのような文字列を取得するのには使えません。 そこで、request.pathでパス全体を取得してから、必要な部分を取り出して、「%xx」という表記をデコードしています。
app/views/user/show.rhtml(一部)
<li> <%= bookmark.created_at.strftime("%Y/%m/%d") %> <%= link_to(h(bookmark.page.title), :controller => "page", :action => "show", :uri => bookmark.page.uri) %> <%= h(bookmark.comment) %> # ←変更 </li>
このように、link_toの引数を、:id指定から:uri指定に変更しています。
ログイン、サインアップ、ログアウトした際のリダイレクト先が、AccountControllerのindexメソッドになっているのを、それぞれユーザのページ、ユーザのページ、トップページに変更します。
app/controllers/account_controller.rb(一部)
def login (略) redirect_back_or_default(:controller => '/user', :action => 'show', :login => current_user.login) (略) end def signup (略) redirect_back_or_default(:controller => '/user', :action => 'show', :login => current_user.login) (略) end def logout (略) redirect_back_or_default(:controller => '/page', :action => 'top') (略) end
HTMLの<head>タグや、ページのヘッダやフッタのように、さまざまなページで共通の部分は、app/views/layout/以下にテンプレートを置くことで共通化することができます。
デフォルトでは、app/views/layout/コントローラ名.rhtmlがあればそれを使い、なければapp/views/layout/application.rhtmlが使われます。
Step3のscaffoldで作成されたapp/views/layout/page.rhtmlがありますので、それを参考にapplication.rhtmlを削除し、元のpage.rhtmlは削除します。
app/views/layout/application.rhtml
<html> <head> <title>Bookmark: <%= controller.action_name %></title> <%= stylesheet_link_tag 'scaffold' %> </head> <body> <ul> <li><%= link_to_unless_current("トップ", :controller => "page", :action => "top") %></li> # ←(1) <% if session[:user] %> # ←(2) <li><%= link_to("ホーム", :controller => "user", :action => "show", :login => User.find(session[:user]).login ) %></li> <li><%= link_to("追加", :controller => "bookmark", :action => "add") %></li> <li><%= link_to("ログアウト", :controller => "account", :action => "logout") %></li> <% else %> <li><%= link_to("ログイン", :controller => "account", :action => "login") %></li> <li><%= link_to("サインアップ", :controller => "account", :action => "signup") %></li> <% end %> </ul> <p style="color: green"><%= flash[:notice] %></p> <%= yield %> </body> </html>
(1)のlink_to_unless_currentメソッドは、基本的にはlink_toと同じですが、現在のページとリンク先が同じ場合はリンクにせずに文字列だけを表示します。 ログインしているかどうかをif文で分岐して、表示するメニューを変えています(2)。
[MEMO] コントローラやアクションメソッドによって使うレイアウトを変えたり、レイアウトを使わないようにしたりすることもできます。詳しくは「マニュアル:layout (ActionController::Layout::ClassMethods)」を参照してください。
外部から、http://localhost:3000/add?title=this_is_title;uri=http://example.com/page.rhtmlのようなURIにアクセスすると、あらかじめパラメータに値をセットした状態でブックマークの追加画面に行くことができます。 そこで、ブラウザ上で今見ているページからJavascriptで動的に上記のようなURIを生成するブックマークレットを作ります。
以上より、ブックマークレットのリンクは以下になります(一行につなげてください)。
javascript:window.location='http://localhost:3000/add?title='+ encodeURIComponent(document.title)+';uri='+ encodeURIComponent(location.href);
[MEMO] Javascriptでエスケープする関数はescape()やencodeURI()もありますが、前者は%uXXXXのような文字列になって、WEBrickを含む一部の環境で正しく受け付けられません。また、後者は「&」や「?」をエスケープしないので、今回のようにクエリーの一部に使うのには問題があります。
動的にページを生成するシステムを利用し、サイト間を横断して悪意のあるスクリプトが混入される事をXSS(Cross Site Scripting)といいます。
意図しないサイトからスクリプトが混入され、混入したスクリプトが実行されてしまう様な攻撃を防ぐためには、外部からの入力値をもとに動的にHTMLを出力する際に、適切にエスケープ処理をする必要があります。
<script>〜</script>等の文字列を実行できないように、入力された値を表示するビューのコードで以下のヘルパーメソッドを使ってエスケープします。
[MEMO] XSSについての詳細はhttp://ja.wikipedia.org/wiki/XSSを参照してください。
外部のページからのHTTPリクエストを受け付けるよう仕向け、ユーザーの意図しない操作をWebアプリケーション上で行わせる事をCSRF(Cross Site Request Forgeries)といいます。
意図したサイトとは異なるサイトからのリクエストの受信を拒否して防ぐには、security_extensionsというプラグインが便利です
インストール方法:
$ ruby script/plugin install security_extensions
使い方は、以下のとおりです。
今回は、BookmarkControllerの全てのPOSTリクエストを検証するために、以下のように変更します。
app/controllers/bookmark_controller.rb
class BookmarkController < ApplicationController verify_form_posts_have_security_token (略)
app/views/bookmark/add.rhtml(一部)
<%= secure_form_tag %> # ←変更 <dl> (略)
[MEMO] CSRFについての詳細は、http://e-words.jp/w/CSRF.htmlを参照してください。
SQL文を含む引数を持つHTTPリクエストを使って、データーベースシステムを不正に操作することをSQLインジェクションと言います。
SQLの中に直接パラメータを入れると、SQLインジェクション脆弱性につながりますので、ActiveRecordが提供するプレースフォルダの機能を使います。
ActiveRecordのfindメソッドでは、以下の二つの書き方ができます。
なお、find_by_nameのようなメソッドでは、内部で自動的にエスケープされますので、使えるケースでは積極的に使うのがいいでしょう。
@page = Page.new(params[:page])
のようなコードだと、本来フォームから入力されないはずのフィールドまで設定されてしまう可能性があります。 その対策として、以下のような方法があります。
今回は、
@page.title = params[:title]
のように、個別に設定しているので、パラメータの改竄による問題はありません。