Sinatra + ActiveRecord + sqlite3覚書

はじめに

sinatra + activerecord + sqlite3 の覚書なぞ。掲示板作りを例に取りながら。

準備

必要な環境

  • ruby 1.9x 以上推奨:1.8.7 だと tag: content 形式が使えないため、いろいろエラーに悩まされます。ここでは 2.1.2を使ってます。
  • sqlite3:データベース。手軽にこれでいきましょう。なければあらかじめ sudo yum install sqlite3 しておいてください。

その他必要な gem パッケージは、その都度追加していきます。

bundle による gem のインストール

まずは必要なgem をインストールする bundle から

% bundle init
Writing new Gemfile to /home/tutorial/bbs/Gemfile
% vi Gemfile
source "https://rubygems.org"

gem 'sinatra'
gem 'sinatra-contrib'   # for Sinatra/Reloader

group :development do
  gem 'shotgun'
  gem 'tux'
end
% bundle install --path vendor/bundle
...

sinatra と、開発環境要にサーバー起動用 shotgun とインタプリタtux も入れておきます(使わなければ入れなくても可)。

bundle は --path vendor/bundle をつけてプロジェクトごと管理することを強く推奨。じゃないと後で conflict してワケワカになる未来が見えます。VM上で開発してる時にエラーが出たら Resolving bundle install Text file busy error | JoeQueryを参照。

アプリひな形の作成

アプリ本体のひな形を作っておきます。

% vi app.rb
require 'bundler'
Bundler.require

module MyBbs
  class Application < Sinatra::Base
    configure :development do
      register Sinatra::Reloader
    end
    
    # routing
    get '/' do
      "hello world"
    end
  end
end

モジュール方式の方が後々いいので、そっちで。 Bundler.require とすると、さきほど作った Gemfile 通りにrequire してくれるので便利です。

また、Sinatra::Reloader を register すると、ソースを書き換えると自動的にサーバーが読み込んでくれます。shotgunを使うのなら無くても可。

続いて config.ru

% vi config.ru
require './app'
run MyBbs::Application

決まり文句ですね。app.rb の方で run! if app_file == $0 させて ruby app.rb もいいのですが、一応作法に則ったほうがいいでしょう。

動作確認

ここまで準備ができたらとりあえず起動

% bundle exec rackup
[2014-07-22 20:01:05] INFO  WEBrick 1.3.1
[2014-07-22 20:01:05] INFO  ruby 2.1.2 (2014-05-08) [x86_64-linux]
[2014-07-22 20:01:05] INFO  WEBrick::HTTPServer#start: pid=xxxx port=9292

bundle を使ってるので、通常のコマンドの前に "bundle exec" を付けなければなりません。毎回打つの面倒だったら、alias しておくといいかも。rackup も面倒なら、

% vi ~/.bashrc
alias be="bundle exec"
alias ru="bundle exec rackup"

などとするといいですね。以下これを使います。入り直すか source ~/.bashrc; hash -r するのを忘れずに。

localhost:9292 にアクセスすると "hello world" されるはず。rackup -p 8888 でポート指定。

掲示板アプリの作成

仕様

簡単な仕様:

  • フロート式。新しいポストから順に表示
  • 追加するだけ。最大1000件
  • / で全部表示
  • /l100 などで最新100件
  • /23 で 23番のポスト表示

実装としては、

  • データベースは sqlite3 で ActiveRecord を介して使用
  • ビューは erb

データベースのレコードはpost として;

  • id: integer primary key autoincrement
  • username: string # ポストしたユーザ名
  • content: string # 内容
  • created_at, updated_at: datetime # 作成日、更新日。activerecord がよしなにしてくれる

必要パッケージの追加インストール

各種gemは bundle で入れます。2回目からは --path をつけなくても ./bundle/config を見るので不要です。

% sudo yum install sqlite3
% vi Gemfile
...
gem 'sqlite3'
gem 'activerecord'
gem 'sinatra-activerecord'
gem 'rake'

...
% bundle install

sinatra-activerecord と rake は、データベースをマイグレートするのに使います。後で触れます。

大枠の実装(Controller, View)

データベースに入る前に、やっぱ外見から、つまりどのリクエストを受ける(controller)とどのように表示される(view)かを頭に置いておいたほうがやりやすいので、そちらから。

view は、haml の方が長期的にはいいのですが、ここは簡単のため erb で行きます。 また、いきなり views/*.erb にテンプレートファイルを作ってもいいのですが、最初はインラインで様子見します。長くなったりある程度固まってきたらファイルに移しましょう。

configure の中に enable :inline_templates を入れておくと、あとでファイルに分けた時でもちょこっとテストできるのでよいです。

% vi app.rb
...
  class Application < Sinatra::Base
    configure :development do
      enable :inline_templates
    ...
    get '/' do
      erb "view all"
    end
    get '/l:count' do
      erb "view latest"
    end
    get '/:id' do
      erb "view post"
    end
    post '/post' do
      # post
    end
  end

とルーティングを書きます。レイアウトのテンプレートを __END__の後に書きます。

__END__
@@layout
<html>
  <head><title>mybbs</title></head>
  <body>
    <h1>My BBS</h1>
    <div>
      [<a href="/l50">latest</a>]
      [<a href="/">all</a>]
      [<a href="/1">id:1</a>]
    <hr/>
    <div> <%= yield %> </div>
    <hr/>
    <form action="/post" method="post">
      <div>
        <label for="username">Name:</label>
        <input type="text" name="username" id="username" size="50">
      </div>
      <div>
        <textarea name="content" cols="80" rows="20"></textarea>
      </div>
      <div>
        <input type="submit" value="Post">
      </div>
    </div>
  </body>
</html>

localhost:9292 でアクセスすると、

My BBS

[latest] [all] [id:1]
---
view all
---
Name: [                       ]
+---------------------------+
|                           |
|                           |
|                           |
|                           |
|                           |
+---------------------------+
[Post]
powered by my bbs system

のように表示されるかと。イメージがだいぶわきますね。レイアウトで大枠を作って、リクエストごと切り分けて内容を yield に差し込む、という感じですね。実際にデーターベースにどうアクセスし、表示させるかは、次項より。

データベースの作成

さて、投稿内容を表示したり保存するために、データベースが必要です。 まずは model だけ先に作ってしまいましょう。といってもこれだけ:

% vi app.rb
...
module MyBbs
  class Post < ActiveRecord::Base
  end
  ...

ActiveRecord::Base から派生したクラスを通じてデータベースとアクセスできます。クラス名を Post としたので、データベースのテーブルは posts としましょう、rails的に。

で、データベースを作成します。 直接 sql を叩いてもいいんですが、せっかくですので rake db:migrate を使います。

...ruby
module MyBbs
  class Application < Sinatra::Base
    ...
    configure do
      register Sinatra::ActiveRecordExtension
      set :database, {adapter: "sqlite3", database: "db/bbs.db"}
    end
...

データベースへの接続は、ActiveRecord::Base.establish_connection("adapter" => "sqlite3", "database" => "./db/posts.db") と書くことが多いですが、Sinatra::ActiveRecordExtension を register して上げると set :database が使えるので、こっちのほうがスマートですね。これで、sqlite3を使って db/bbs.db にコネクトします。

次に、テーブルやらカラムを作らないと行けないのですが、rake の枠組みを利用します。gem 'sinatra-activerecord'に入っている 'sinatra/activerecord/rake' が助けてくれます。

% vi Rakefile
require 'sinatra/activerecord'
require 'sinatra/activerecord/rake'

require './app'

Rakefile を用意してあげ rake -T を走らせると、いろいろタスクが登録されているのがわかります。

% be rake -T
rake db:create              # Creates the database from DATABASE_URL or con...
rake db:create_migration    # Create a migration (parameters: NAME, VERSION)
rake db:drop                # Drops the database from DATABASE_URL or confi...
rake db:fixtures:load       # Load fixtures into the current environment's ...
rake db:migrate             # Migrate the database (options: VERSION=x, VER...
rake db:migrate:status      # Display status of migrations
...

この内、db:create_migration と db:migrate を使います。

% be rake db:create_migration NAME=create_posts VERSION=001
db/migrate/001_create_posts.rb
% cat db/migrate/001_create_posts.rb
class CreatePosts < ActiveRecord::Migration
  def change
  end
end

としてあげると、データベースのテーブル、カラムを作る用のスクリプトを吐いてくれます。NAMEで与えたのが camelize されてクラス名に、VERSION がファイル名の頭につきます。NAME は必須で、VERSION はつけなければ日付その他から自動的に作ってくれるのですが、001 などと指定したほうが扱いやすいでしょう。

んで、これをいじってテーブルとカラムを作っていきます。

% vi db/migrate/001_create_posts.rb
class CreateNotes < ActiveRecord::Migration
  def change
    create_table :posts do |t|
      t.string :username
      t.string :content
      t.timestamps
    end
  end
end

ってなかんじで作ります。プライマリーキーのid は自動的に入りますので書かなくてもよいです。t.timestamps を入れると、 updated_at と created_at を datetime で加えてくれます。

昔は self.up(), self.down() でージョンを上げた時下げた時の処理を書いてたようですが、今は自動的に解析してくれるようですので、up 処理のみを change() に書きましょう。

このスクリプトを元にデータベースを更新します。

% be rake db:migrate
== 1 CreatePosts: migrating ===================================================
-- create_table(:posts)
   -> 0.0273s
== 1 CreatePosts: migrated (0.0275s) ==========================================

すると ./db/bbs.db に作ってくれてるはずなので確認します。

SQLite version 3.6.20
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> .schema
CREATE TABLE "posts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "username" varchar(255), "content" varchar(255), "created_at" datetime, "updated_at" datetime);
CREATE TABLE "schema_migrations" ("version" varchar(255) NOT NULL);
CREATE UNIQUE INDEX "unique_schema_migrations" ON "schema_migrations" ("version");
sqlite> select * from posts;
sqlite> 

テーブル posts が作成されてますね。中身は当然空です。schema_migrations はバージョン管理用なのであまり気にしなくてよいかと。

データベースが出来たので、これを表示してみます。といってもまだ空だけど。

これでさきほど作成した model クラスの MyBbs::Post を使ってデータベースにアクセスできます。tux でいじくってみましょう。

% be tux
Loading development environment (Rack 1.2)
>> MyBbs::Post.all
=> #<ActiveRecord::Relation []>

>> post1 = MyBbs::Post.new(:username=>'nanashi1', :content=>'hogehoge')
=> #<MyBbs::Post id: nil, username: "nanashi1", content: "hogehoge", created_at: nil, updated_at: nil>
>> post1.save
=> true

>> post1
=> #<MyBbs::Post id: 1, username: "nanashi1", content: "hogehoge", created_at: "2014-07-22 05:28:47", updated_at: "2014-07-22 05:28:47">

>> post2 = MyBbs::Post.create
=> #<MyBbs::Post id: 2, username: nil, content: nil, created_at: "2014-07-22 05:29:17", updated_at: "2014-07-22 05:29:17">

>> MyBbs::Post.all
=> #<ActiveRecord::Relation [#<MyBbs::Post id: 1, username: "nanashi1", content: "hogehoge", created_at: "2014-07-22 05:28:47", updated_at: "2014-07-22 05:28:47">, #<MyBbs::Post id: 2, username: nil, content: nil, created_at: "2014-07-22 05:29:17", updated_at: "2014-07-22 05:29:17">]>

>> post2.delete
=> #<MyBbs::Post id: 2, username: nil, content: nil, created_at: "2014-07-22 05:29:17", updated_at: "2014-07-22 05:29:17">

>> MyBbs::Post.all
=> #<ActiveRecord::Relation [#<MyBbs::Post id: 1, username: "nanashi1", content: "hogehoge", created_at: "2014-07-22 05:28:47", updated_at: "2014-07-22 05:28:47">]>
>> quit

とこのように、記事一覧を出したり、記事を作ったり消したりできます。post1.content = "hogehogehoge" で内容を変更することも出来ますのでいろいろ試してみてください。

で、これらを app.rb に組み込んでいきます。

詳細処理の記述

さて、これである程度準備ができましたので、実際の処理を書き込んでいきます。まずは指定された記事を表示する:

% vi app.rb
module MyBbs

  class Application < Sinatra::Base
    configure do
      include ERB::Util     # h() 用
      ...
    get '/:id' do
      if post = MyBbs::Post.where(:id=>params[:id].to_i).first
        erb :view, :locals => {:post => post}
      else
        erb "no such post, id: #{h params[:id]}"
      end
    end

Post.where(:id=>id).first でid の記事を取り出します。find(:id)だと見つからないとエラー吐くので、where で。取り出したpost オブジェクトをerb に locals で渡してあげます。

あ、かならず出力はエスケープするように。ERB::Util の中にエスケープする関数 h() が入ってるので include しておきます。

これを受けてview では、

@@view
<div class="post">
  <div> [<a href="/<%= post.id.to_i %>"><%= h post.id %></a>] <%= h post.username %> ( <%= h post.updated_at %>)</div>
  <div> <%= h post.content %></div>
</div>

のように、username, content などを表示します。updated_at は activerecord が面倒みてくれてます。

後は同様に、データベースから引っ張ってきて view に渡すと、複数表示が出来ます。:layout=>false をつけると、レイアウトテンプレートを適用しません。入れ子にする時は指定すること。

    get '/' do
      erb MyBbs::Post.order("id desc").limit(1000).map {|post|
        erb :view, :locals => {:post => post}, :layout=>false
      }.join("")
    end
    get '/l:id' do
      erb MyBbs::Post.order("id desc").limit([1000, params[:id].to_i].max).map {|post|
        erb :view, :locals => {:post => post}, :layout=>false
      }.join("")
    end

ポストは、

    post '/post' do
      post = MyBbs::Post.new(:username=>params[:username], :content=>params[:content])
      post.save
      redirect '/'
    end

のように new して save してあげます。ほんとは validation やsave の確認が必要ですけどね。 終わったら / にリダイレクトします。

まとめ

  • gem はローカルに bundle で一括
  • rake db:migrate を活用
  • 単純なものから routing と view を作り上げていく

参考サイト