決定版! Rails用画像添付プラグインpurl

投稿者 nanki 2008-10-23 10:15:00 GMT

Rails pluginとして動く言語、purlを公開しました。

purl はクライアントサイドから柔軟な画像処理を行うために開発されました。 サムネイルの生成などを行うプラグインはいくつかありますが、そのどれよりも強力で汎用性、拡張性があります。

セットアップ

Rails2.1以上では、

$ script/plugin install git://github.com/nanki/purl.git

それ未満では、

$ git clone git://github.com/nanki/purl.git -- vendor/plugins/purl

gitが無い環境なら、githubのdownloadからファイルを落として、vendor/plugins/ に配置してください。

依存ライブラリ

  • 必須: ImageMagick, rmagick
  • あった方がいい: cairo, rcairo

その他

purl のデフォルトの環境では、画像の保存にデータベースを利用します。 imagesテーブルに、blob型のdataカラムを追加してください。

# app/models/image.rb
class Image < ActiveRecord::Base
  attr_readonly :data
end

その上で、ImageController#upload などに以下のように記述します。

# app/controllers/image_controller.rb
class ImageController < ApplicationController
  def upload
    Image.create(:data => params[:data].read)
  end
end

さらに、config/routes.rbを書き替え、purl が実行されるようにします。 書く場所は先頭に近い方がいいと思います。

# config/routes.rb
  map.connect 'purl/:commands',
    :controller => 'purl', :action => 'filter', :commands => /.*/
# app/controllers/purl_controller.rb
class PurlController < ApplicationController
  caches_page :filter

  def filter
    begin
      result = Purl::StackMachine.new(
        params[:commands], Purl::Purl.new).run
      send_data(result[-2], :type => result[-1], :disposition => 'inline')
    rescue => e
      render :status => 500, :text => 'error'
    end
  end
end

そんなこんなでセットアップは終わり。

言語

purl はURL上で記述されることを前提としています。 常にワンライナーであれ!

文法

purl は逆ポーランド記法を採用しています。 データと命令を書かれた順番にスタックマシンにプッシュしていき、命令が実行された結果がスタックマシン上につまれていきます。 命令列は最初に:で区切られ、数字としてパースできるものは数字へ、()で囲われた部分は文字列へと変換されます。

ポリシー

デフォルトで用意される全ての命令は、外部の値に依存しません。 つまり、命令列が決まれば実行結果は確定します。 このポリシーにより、全ての実行結果がキャッシュ可能になります。

サンプルコード

画像をそのまま表示

次の例では、load はスタック上に既にある数字1を消費して、ID=1のImage#dataをデータベースからロードして、スタック上にプッシュします。 to.png では、スタック上の画像データをPNG形式のバイト列に変換し、mimetypeとともにスタック上に積みます。

/purl/1:load:to.png

画像の形式を変換

次の例では、画像をGIF形式に変換しています。

/purl/1:load:to.gif

画像のリサイズ

次の例では、画像を100x100にリサイズしています。 リサイズ用の命令は、何種類か用意されており、高さを一定に保つ resize.heightや、上限を指定する、resize.upto などがあります。

/purl/1:load:100:100:resize:to.png

画像の合成

次の例ではふたつの画像を合成しています。

/purl/1:load:9:load:composite:to.png

次の例では順番を入れ替えています。

/purl/1:load:9:load:swap:composite:to.png

コメント

スタックに積んだ後すぐにpopすると、単純に無視されるので、コメントとして使えます。 キャッシュのライフサイクルコントロールなどに使えると思います。

/purl/1:load:(daily):pop:to.png

図形の描画

詳しくは解説しませんが、試験的にcairoの描画機能をベースにした図形描画命令を用意しています。 ctx命令で、指定したサイズのcairoコンテキストを準備し、xtc命令で実際にサーフェースへの描画を実行します。

/purl/80:80:ctx:40:40:moveto:40:40:40:-20:20:arc.ccw:1:0.3:0:rgb:fill:60:20:4:circle:0:0:0:rgb:fill:xtc:to.png

制限

実行結果をファイルとしてキャッシュする場合、ファイルシステムによる字数制限を受けます。

拡張

拡張は命令モジュールの追加という形で行います。 詳しくはソースコード見てください。

今後

  • 無名関数的なもの。
  • 外部から直接呼び出せない命令とか?
  • エラーメッセージとか?
参考:

named routesでtransit_sidが動かない

投稿者 nanki 2008-02-01 11:50:00 GMT

person_urlとかにsession_idがつかないと思ったら、最適化のためのコードが、defaulturloptionsを無視するらしい。

#10925 (Routing optimization, named routes missing default url options) - Rails Trac - Trac


BenchmarkForRails導入でrakeが動かない

投稿者 nanki 2007-12-28 11:10:00 GMT
 ♞ rake test:units
(in /Users/nanki/work/...../trunk)
rake aborted!
undefined method `watch' for BenchmarkForRails:Module

これは困った。

AutoLoadingがうまくいっていないようだが、この手の問題は追跡が難しい。 とりあえず適当な箇所で、require 'benchmark_for_rails'しておけば大丈夫だが、気持ち悪いので次の日原因を追ってみた。

p追跡の結果だけ書くと、rake 実行時に読み込まれるlogs.rake冒頭でのrequireで、BenchmarkForRailsモジュールが初期化されて、本来読まれて欲しいはずの、benchmark_for_rails.rbがロードされないのが問題。

# in vendor/plugins/benchmark_for_rails/tasks/logs.rake
require File.dirname(__FILE__) + '/../lib/parsing.rb'
require File.dirname(__FILE__) + '/../lib/report.rb'

対処法はこちらの通り。

と思ったら、今朝五時に直ってるじゃないの。

参考:


Railsアプリケーションの国際化 - Globalize

投稿者 nanki 2007-08-26 10:15:00 GMT

Globalizeを使ってみる。

セットアップ

適当なRailsアプリを作って、Globalizeをインストール。

$ script/plugin install http://svn.globalize-rails.org/svn/globalize/trunk
....
$ mv vendor/plugins/trunk vendor/plugins/globalize
$ rake globalize:setup

CSV::Cellがinspectされたようなエラーが出るので、vendor/plugins/globalize/tasks/data.rake を修正する。

--- ./vendor/plugins/globalize/tasks/data.rake.orig     0000-00-00 00:00:00.000000000 +0000
+++ ./vendor/plugins/globalize/tasks/data.rake  0000-00-00 00:00:00.000000000 +0000
@@ -17,3 +17,3 @@

-    columns = reader.shift.map { |column_name| cnx.quote_column_name(column_name) }
+    columns = reader.shift.map { |column_name| cnx.quote_column_name(column_name.data) }
     column_clause = columns.join(', ')
@@ -24,3 +24,3 @@
       raise "No header defined"     unless column_clause
-      values_clause = row.map { |v| cnx.quote(v).gsub('\\n', "\n").gsub('\\r', "\r") }.join(', ')
+      values_clause = row.map { |v| cnx.quote(v.data).gsub('\\n', "\n").gsub('\\r', "\r") }.join(', ')
       sql = "INSERT INTO #{table_name} (#{column_clause}) VALUES (#{values_clause})"

さらに、vendor/plugins/globalize/data/language_data.csv の空行がエラーを起こしているので修正。

$ rake globalize:setup
...

成功。

View翻訳

base_languageを英語に。

# config/environment.rb
Globalize::Locale.set_base_language("en-US")
$ script/console
>> "March [month]".t
=> "March"
>> Globalize::Locale.set("ja-JP")
=> #<Globalize::Locale...
>> "March [month]".t
=> "3月"
>> Time.now.loc "%A"
=> "日曜日"

ふむふむ。この辺は、gettextの方が使いやすそうかなぁ。

Model翻訳

適当なモデルを作成

$ script/generate model Product
...
# db/migrate/001_create_products.rb
  def self.up
    create_table :products do |t|
      t.column :name, :string
      t.column :manufacturer, :string
    end
  end
$ rake db:migrate
$ script/console
>> p = Product.create :name => "Pucchin Pudding", :manufacturer => "Glico"
=> #<Product:...
>> p.name
=> "Pucchin Pudding"
>> p.manufacturer
=> "Glico"

# jaで保存。
>> Globalize::Locale.set("ja-JP")
>> p.name = "プッチンプリン"
>> p.manufacturer = "グリコ"
>> p.save
=> true

# jaで読む。
>> p.reload
>> p.name
=> "プッチンプリン"
>> p.manufacturer
=> "グリコ"

# enで読む。
>> Globalize::Locale.set("en-US")
>> p.reload
>> p.name
=> "Pucchin Pudding"
>> p.manufacturer
=> "Glico"

ちゃんと動いてる。

この時、productsテーブルにはbase_languageで格納され、それとは別にglobalize_translationsテーブルに、翻訳情報を保持している。

sqlite> SELECT * FROM globalize_translations WHERE id > 7088;
id          type              tr_key      table_name  item_id     facet       built_in    language_id  pluralization_index  text                   namespace
----------  ----------------  ----------  ----------  ----------  ----------  ----------  -----------  -------------------  ---------------------  ----------
7089        ModelTranslation              products    18          name        t           2723                              プッチンプリン
7090        ModelTranslation              products    18          manufactur  t           2723                              グリコ

Object#_が定義されていたので、gettextとケンカするかと思ったけど、一緒に動いた。 偶然かもしれないけど。

screenshot

そんなこんなで、↑こんなアプリケーションができてしまう。
はたして、使う機会は訪れるのか?

追記:

  • eager loadingを行う時は、:include_translatedを使う。
  • が、対応が中途半端で、user.product_nameとアクセスしないとだめっぽい。
class User
  belongs_to :product
end

user = User.find(:first, :include_translated => :product)

user.product.name #=> NG
user.product_name #=> OK
参考:

OSC2007Kansai

投稿者 nanki 2007-07-22 07:30:00 GMT

に行ってきました。

今回は、Railsハンズオンセミナーの講師をやったけど、一日目は、プロジェクタへの接続を確認してなくて、みなさまにご迷惑をおかけしました。ごめんなさい。

ちゃんと設定したのに、二日目もやっぱりつながらなくて、okkezさんに迷惑かけました。ごめんなさい。

二日目の懇親会で見た楽器、ウダーがスゴかった。
販売してたら危うく購入するところだったのに。残念。
今後に期待しています。

あと、お子様連れのお子様たちがとってもかわいかったよぅ。子供ほしー。

帰りは、京都駅から四条まで、数人で散歩。
翻訳の仕事をしている外国の方(といってももうほとんど日本の方)も一緒で、東本願寺に掲げてあった看板のコピーをだしに、面白い話が色々聞けた。

OSCの内容の話が無い!

参考:


:conditions の遅延評価

投稿者 nanki 2007-07-05 15:38:00 GMT

泥臭い&あんまり使わないので忘れがちだが、Associationを定義する時に、シングルクォートを使うと、参照時のインスタンスで評価し直してくれる。

  class Road
    belongs_to :node, :foreign_key => [:meshcode, :nodeno], :conditions => 'nodes.linkno = #{linkno}'
  end

  Road.find(:first).node # => "SELECT * FROM nodes WHERE nodes.linkno = 1"
  class Road
    belongs_to :node, :foreign_key => [:meshcode, :nodeno], :conditions => 'nodes.linkno = #{table_name}.linkno'
  end

  Road.find(:first, :include => :node) # => "SELECT * FROM roads LEFT OUTER JOIN nodes ON ... WHERE nodes.linkno = roads.linkno"

ここでtable_nameが(やってみたら)使えるのは、

# lib/active_record/associations.rb
  class JoinBase
    attr_reader :active_record, :table_joins
    delegate    :table_name, :column_names, :primary_key, :reflections, :sanitize_sql, :to => :active_record

このdelegateのおかげか?

次のように書けば、どちらでもいける。

  belongs_to :node, :foreign_key => [:meshcode, :nodeno], :conditions => 'nodes.linkno = #{linkno rescue "#{table_name}.linkno"}'

いいのか、こんなんで。


Rails開発用便利スクリプト

投稿者 nanki 2007-06-18 17:01:00 GMT
#!/usr/bin/env ruby

EDITOR = ENV['EDITOR'] || 'vim'

files = `find . -type f ! -path './tmp/*' ! -path './log/*' ! -path '*/.svn/*' #{ARGV.map{|v| "#{/^-/ === v ? '!' : ''} -path '*#{v.gsub(/^-/, '')}*' "}.join}`
system("#{EDITOR} #{files.split.join(' ')}")
puts files

というスクリプトをvifという名前でコマンドにしておいて、$ vif .jsでJavaScriptファイルを全て開いたり、$ vif controller -testとかで、テストを除くコントローラ関係のファイルをすべて開いたり。

EclipseのGoToFileみたい。


JavaScript URLMapper

投稿者 nanki 2007-02-13 15:03:00 GMT

ちょっと複雑なパス体系を持つRailsアプリでJSONでAJAXする人には役に立つかもしれない。 GoogleMapsとかね。

要 prototype.js

使い方はこんな風。

URLMapper.connect(
  "/user/:user_id/category/:category_id/entry/:action/:entry_id",
  {controller: 'entry', action: 'index', entry_id: null});
URLMapper.connect(
  "/user/:user_id/category/:action/:category_id",
  {controller: 'category', action: 'index', category_id: null});
URLMapper.connect(
  "/user/:action/:user_id",
  {controller: 'user', action: 'index', user_id: null});
URLMapper.connect(
  "/:controller/:action/:id",
  {action: 'index', id: null});

URLMapper.url_for({controller: 'entry'});
// -> "/entry/index/"
URLMapper.url_for({controller: 'entry', action: 'new'});
// -> "/entry/new/"
URLMapper.url_for({controller: 'category', action: 'new'});
// -> "/category/new/"
URLMapper.url_for({controller: 'category', action: 'new', user_id: 3});
// -> "/user/3/category/new/"
URLMapper.url_for({controller: 'category', action: 'update', user_id: 3, category_id: 2});
// -> "/user/3/category/update/2"
URLMapper.url_for({controller: 'entry', action: 'update', user_id: 3, category_id: 2, entry_id: 1});
// -> "/user/3/category/2/entry/update/1"

url_for は可変長引数をとって、引数をHashとしてマージするので、

//サーバからJSONを取得。
//[{user_id: 3, category_id: 2, entry_id: 1}, ...]
var entries = getJSON();

URLMapper.url_for({controller: 'entry', action: 'update'}, entries[0]);
// -> "/user/3/category/2/entry/update/1"

とか。

ソースはこんなの。

Hash.prototype.subtract = function(op2) {
  var result = $H().merge(this);
  this.remove.apply(result, $H(op2).keys());
  return result;
};


var URLMapper = {
  url_options: $A(),

  UrlOption: function(url, required, defaults, match) {
    this.url      = url.gsub(/\/:([A-z_][A-z0-9_]*)/, function(match) {return '/#{' + match[1] + '}'});
    this.required = required;
    this.defaults = defaults;
    this.match    = match;
  },

  extractParameters: function(url) {
    var params = $H();
    url.scan(/\/:([A-z_][A-z0-9_]*)/, function(match) {params[match[1]] = true});
    return params;
  },

  connect: function(url, defaults) {
    url = url.gsub(/%3A/, ':');
    var params = this.extractParameters(url);
    var required = params.subtract(defaults);
    this.url_options.push(new this.UrlOption(url, required, $H(defaults), $H(defaults).subtract(params)));
  },

  url_for: function() {
    var options = $A(arguments).inject($H(), function(r, v){return r.merge(v)});
    var detected = this.url_options.select(
      function(url_option) {
        return url_option.required.subtract(options).size() == 0 && url_option.match.all(function(pair){return options[pair.key] == pair.value});
      }
    ).sortBy(function(url_option) {
      return $H(options).subtract(url_option.required).subtract(url_option.defaults).size();
    }).first();


    if (!detected) {
      throw "no URL matches.";
    }

    return (new Template(detected.url)).evaluate($H().merge(detected.defaults).merge(options));
  }
}

発展課題の解答を用意してみました。
一解答例にすぎませんが、参考にしてください。

急いで作ったので間違ってたらツッコんでください。

:per_page に指定した数字に合わせて、前のン件, 次のン件としてみよう。

ActionController::Pagination::Paginator#items_per_pageを使って、
$ vi app/views/bbs/list.rhtml

<%= link_to "前の#{@comment_pages.items_per_page}", { :page => @comment_pages.current.previous }
            if @comment_pages.current.previous %>
<%= link_to "次の#{@comment_pages.items_per_page}", { :page => @comment_pages.current.next }
            if @comment_pages.current.next %>

リファレンスマニュアルでは、Attributesのところにあって、なんの説明もないので、見つけにくいかもしれません。

http://localhost:3000/bbs/edit/1 と言う風に直接アクセスすると、誰の発言でも自由に編集できてしまう。コントローラから使ってないメソッドを削除して、編集ができないようにしよう。

app/controllers/bbs_controller.rb中の、edit,show,updateメソッドを削除し、verify の行にある、:updateも消す。
app/views/bbs/edit.rhtmlapp/views/bbs/show.rhtml二つのファイルも消します。

投稿フォームを /list/bbs の一番上に移動して、直接投稿できるようにしてみよう。

app/views/bbs/list.rhtmlの上の方に、

<div>
  <%= start_form_tag :action => 'create' %>
    <%= render :partial => 'form' %>
    <%= submit_tag "投稿" %>
  <%= end_form_tag %>
</div>

を追加。

# app/controllers/bbs_controller.rb
   def list
     @comment_pages, @comments = paginate :comments, :per_page => 10, :order => 'created_at desc'
     @comment ||= Comment.new # ||= は、create から呼ばれた時に@commentを上書きしないため。
   end
  
   def create
     @comment = Comment.new(params[:comment])
     if @comment.save
       flash[:notice] = 'Comment was successfully created.'
       redirect_to :action => 'list'
     else
       list # この行追加
       render :action => 'list' # エラーがあった場合の戻り先を変更
     end
   end

   # new アクションは削除

app/views/bbs/new.rhtmlも消してしまいましょう。

Commentにvalidatesを追加して、必須項目を決めよう。

title, body, name を必須項目に、email, homapage は空じゃなかったら、正規表現で判定をしています。 email の方の正規表現は、 validates_format_of のサンプルコードから、homepageの方は先頭がhttp://またはhttps://かをチェックしています。 凝りたい人はどうぞ。

# app/models/comment.rb
class Comment < ActiveRecord::Base
  validates_presence_of [:title, :body, :name]
  validates_format_of :email, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :if => proc{|comment| !comment.email.blank?}
  validates_format_of :homepage, :with => %r|^https?://|, :if => proc{|comment| !comment.homepage.blank?}
end

また、次のようなテストコードを書いて、

$ rake test:units

を実行することで、正しそうに動いているかチェックできます。

テストコード↓を書いてから、実装↑を書くと、TDD.

# test/unit/comment_test.rb
require File.dirname(__FILE__) + '/../test_helper'
  
class CommentTest < Test::Unit::TestCase
  def setup
    @comment = Comment.new(:title => 'hi!', :body => 'hello!', :name => 'bob', :email => "", :homepage => "")
  end
  
  def test_validation
    assert_valid @comment
  end
  
  def test_presence_on_title
    @comment.title = nil
    @comment.save
    assert_not_nil @comment.errors.on(:title)
  end
  
  def test_presence_on_body
    @comment.body = nil
    @comment.save
    assert_not_nil @comment.errors.on(:body)
  end
  
  def test_presence_on_name
    @comment.name = nil
    @comment.save
    assert_not_nil @comment.errors.on(:name)
  end
  
  def test_email
    @comment.email = 'bob@rails6.com'
    @comment.save
    assert_valid @comment
  
    @comment.email = 'bob.rails6.com'
    @comment.save
    assert_not_nil @comment.errors.on(:email)
  end
  
  def test_homepage
    @comment.homepage = 'https://bob.rails6.com'
    @comment.save
    assert_valid @comment
  
    @comment.homepage = 'http://bob.rails6.com'
    @comment.save
    assert_valid @comment
  
    @comment.homepage = 'ftp://bob.rails6.com'
    @comment.save
    assert_not_nil @comment.errors.on(:homepage)
  end
end

削除キーを入力しないと削除できないようにしてみよう。

まず、migration でdelete_key というカラムを追加します。

$ script/generate migration AddDeleteKey
      exists  db/migrate
      create  db/migrate/004_add_delete_key.rb
# vi db/migrate/004_add_delete_key.rb
class AddDeleteKey < ActiveRecord::Migration
  def self.up
    add_column :comments, :delete_key, :string
  end
  
  def self.down
    remove_column :comments, :delete_key
  end
end

$ rake db:migrate

次に、投稿フォームに、削除キー入力フィールドを作り、

<!-- app/views/bbs/_form.rhtml -->
<p><label for="comment_delete_key">削除キー</label><br/>
<%= text_field 'comment', 'delete_key'  %></p>

削除ボタンのとなりにテキストフィールドを追加。

<!-- app/views/bbs/list.rhtml -->
     <td>投稿日時:<%= comment.created_at.strftime("%Y/%m/%d %H:%M:%S") %></td>
     <td>
       <%= start_form_tag :action => 'destroy', :id => comment %>
       <%= text_field_tag 'delete_key' %>
       <%= submit_tag "削除" %>
       <%= end_form_tag %>
     </td>

その値を受けて、保存された削除キーと、入力された削除キーが一致しないとdestroyしないように、コントローラを書き換える。

# app/controllers/bbs_controller.rb
   def destroy
     c = Comment.find(params[:id])
     c.destroy if !c.delete_key.blank? && c.delete_key == params[:delete_key]
     redirect_to :action => 'list'
   end

今回のテストコードは、コントローラのテストなので、以下の通り。

まず、テストデータを二つ用意。 片方は、削除キー’bob123’、片方は削除キーなし(削除できない)

# test/fixtures/comments.rb
bob:
  id: 1
  title: hi
  body: hello
  name: bob
  delete_key: bob123
undeletable:
  id: 2
  title: hi
  body: hello
  name: bob
  delete_key: ''
# test/functional/bbs_controller_test.rb
require File.dirname(__FILE__) + '/../test_helper'
require 'bbs_controller'
  
# Re-raise errors caught by the controller.
class BbsController; def rescue_action(e) raise e end; end
  
class BbsControllerTest < Test::Unit::TestCase
  # テスト用データである、fixture をロード。(上で書いたやつ)
  fixtures :comments
  
  # test_* の前に実行される。
  def setup
    @controller = BbsController.new
    @request    = ActionController::TestRequest.new
    @response   = ActionController::TestResponse.new
  end
  
  # 削除キーを指定しないと削除されない
  def test_destroy_without_delete_key
    id = comments(:bob).id
    assert_not_nil Comment.find(id)
    post :destroy, :id => id
    assert_not_nil Comment.find(id)
  end
  
  # 削除キーを指定すると削除される
  def test_destroy_with_delete_key
    id = comments(:bob).id
    assert_not_nil Comment.find(id)
    post :destroy, :id => id, :delete_key => comments(:bob).delete_key
    assert_raise(ActiveRecord::RecordNotFound) {
      Comment.find(id)
    }
  end
  
  # 削除キーが空のレコードは、空の削除キーで削除できない
  def test_destroy_record_having_empty_delete_key
    id = comments(:undeletable).id
    assert_not_nil Comment.find(id)
    post :destroy, :id => id, :delete_key => comments(:undeletable).delete_key
    assert_not_nil Comment.find(id)
  end
end

Rubyist SNS のURL

投稿者 nanki 2007-01-22 18:34:00 GMT

勉強会の途中で最新版にupdateして、コミュニティの新規作成機能でも実装しよう、と思って、 /community/new というURLを使おうとしたら、/community/:community_name という風にコミュニティに名前でアクセスする仕様になっているために使えなくなっている、どうしましょうか。

という話を、懇親会でかずひこさんとしていました。

解決方法はいくらでもあるけど、routes.rbで頑張りすぎると、機能が増える度にURLを考えないといけないし、Rails アプリケーションのサンプルコード的な、というお役目も考えると、お約束を積極的に破る、というのはどうなんだろう、ということもあって、ちょっと迷う。

acts_as_sluggableというのを使うと、link_toの結果が、/community/show/1-ruby-kansai みたいになるそうなんですが、これなんてどうでしょう。

参考

Rubyist SNS Trac