sinatraとActiveRecordとERBでBBS作ったのでソースを公開してみる - だるろぐに触発されて俺もBBSを作ってみようと、ここ数日Sinatraをいじっていた。Sinatraさんは最近バージョンアップしてた気がするけど、どうやらちゃんとRuby1.9.1でも動くようだ。素敵。

で、順調に行くかと思ったんだけど、どうにも書き込みのspecが通らない。そこで初めて、POSTやGETでパラメータにマルチバイトの文字列が入ってると何かおかしいことに気付いた。

最初に書いたspecとアプリ側のコードを抜粋。

# coding: utf-8
require 'rubygems'
require 'rack/test'
require 'routes.rb' #sinatraアプリ

set :environment, :test

include Rack::Test::Methods

def app 
  Sinatra::Application
end

describe '投稿するとき' do
  it 'は投稿に成功したら一覧にリダイレクトすること' do
    @props = { 
      'user'  => 'セト',
      'body'  => 'べ、別にアンタの論文が心配なんじゃないんだから!',
    }
    Entry.should_receive(:create).with(@props)
    post '/add', @props
    last_response.status.should be_equal(302)
  end 
end
# coding: utf-8
require 'rubygems'
require 'sinatra'
require 'haml'
require 'sequel'

post '/add' do
  Entry.create(params)
  redirect '/'
end

DB = Sequel.connect('sqlite://db/trabbs.db')
class Entry < Sequel::Model; end

実際にはget '/'とかEntryのスキーマとかindex.hamlとかも用意してありますがそれは心の目で補完しておくれ。んで、期待してるのはこれでセトがツンデレな投稿をしてそれがDBに記録され、その後トップにリダイレクトされることなんだけど、これがコケる。

1)
Spec::Mocks::MockExpectationError in '投稿するとき は、投稿に成功したら一覧にリダイレクトすること'
 expected :create with ({"user"=>"セト", "body"=>"べ、別にアンタの論文が心配なんじゃないんだから!"}) but received it with ({"user"=>"\xE3\x82", "body"=>"\xE3\x81\xB9\xE3\x80\x81\xE5\x88\xA5\xE3\x81\xAB\xE3\x82\xA2\xE3\x83\xB3\xE3\x82\xBF\xE3\x81\xAE"})

ありゃ。UTF-8の文字列がくるべきところに、ASCII-8BIT、つまりバイト列がそのまま来ちゃってる。んむ、えーと、実際のバイト列を変更せずにエンコーディングだけ変えるのはforce_encodingだったよね。ということでforce_encoding('UTF-8')してみる。

1)
Spec::Mocks::MockExpectationError in '投稿するとき は、投稿に成功したら一覧にリダイレクトすること'
 expected :create with ({"user"=>"セト", "body"=>"べ、別にアンタの論文が心配なんじゃないんだから!"}) but received it with ({"user"=>"\xE3\x82", "body"=>"べ、別にアンタの"})

あ、あれ?なんか足りなくない?これはどういうこと?

Rack::Utils#escape

ところで、上では端折ったけど、実はspecを実行する際にこんなwarningが出ている。

/usr/local/lib/ruby/gems/1.9.1/gems/rack-1.0.0/lib/rack/utils.rb:13: warning: regexp match /.../n against to UTF-8 string

UTF-8に対してエンコーディングを無視して正規表現にマッチさせてる…うん、どうもこれが怪しい。さてこれはどこから出てるかというと、こんなコードから出ている。

# rack-1.0.0/lib/rack/utils.rb
module Rack
  # Rack::Utils contains a grab-bag of useful methods for writing web

  module Utils
    # Performs URI escaping so that you can construct proper
    # query strings faster.  Use this rather than the cgi.rb
    # version since it's faster.  (Stolen from Camping).
    def escape(s)
      s.to_s.gsub(/([^ a-zA-Z0-9_.-]+)/n) {
        '%'+$1.unpack('H2'*$1.size).join('%').upcase
      }.tr(' ', '+')
    end
    module_function :escape

    # Unescapes a URI escaped string. (Stolen from Camping).
    def unescape(s)
      s.tr('+', ' ').gsub(/((?:%[0-9a-fA-F]{2})+)/n){
        [$1.delete('%')].pack('H*')
      }
    end
    module_function :unescape

    # 以下省略
  end
end

なるほどですねー。要するに、

  1. escape中でunpackするときに、String#sizeを使っている。1.8まではこのメソッドはバイト数を返してたけど、1.9では文字数を返すように仕様が変更されている。マルチバイトな文字列だと当然バイト数と文字数は一致しないので、途中で切れちゃうことになる。
  2. packして返ってくるのは単なるバイト列、つまりASCII-8BITのString。
  3. 1.8までは文字列がどんなエンコーディングであれ、unpackしてpackすればまた文字列に戻る。でも1.9ではunpackからpackの過程で元のエンコーディング情報は失なわれているので、明示的にエンコーディング情報を付加してやる必要がある

ということかしら。まず1に関してはString#sizeを使ってるところをString#bytesizeを使うように書き換えればいい。ちなみにRack::Utilsでは1.8でも1.9でも動くようにRack::Utils#bytesizeというメソッドが定義されているので、それを使うといい。というか、なんで使ってないんだろう。単に忘れてるんだろうなぁ…。

module Rack
  module Utils                                                                          
    def escape(s)
      s.to_s.gsub(/([^ a-zA-Z0-9_.-]+)/) {                                              
        '%'+$1.unpack('H2'*bytesize($1)).join('%').upcase                               
      }.tr(' ', '+')
    end
  end
end

これをrackをrequireした後に書いてRack::Utils#escapeを上書きしてやると、ちゃんとescapeされるようになる。

次はASCII-8BITになっちゃってるのをforce_encodingしてエンコーディングを変える。これも本当はRack::Utils#unescapeを上書きして、その中でエンコーディングを推測してforce_encodingしておいて、アプリ側のフックでString#encodingを使って内部で使うエンコーディングに変換してやるのがいいかなぁとか思ったんだけど、それでいいのかなー。

んでもまぁ、今回はUAに応じてviewのエンコーディング変えるみたいなことはしてないし、UTF-8決め打ちで良いよね☆…というわけで、Sinatraのbeforeの中で、RUBY_VERSIONが1.9以上だったときはparamの文字列をforce_encoding('UTF-8')するようにしたった。良い子はちゃんとして下さい。

はい、これでちゃんとspec通りました。ふー。

…で。

そしたら、今度はRackをforkして直してpull request送ればいいんだよね。いいんだけど、やんなきゃなーとは思うんだけど、めんどいその辺のあんま自信ないので、誰か1.9でもちゃんと動くように直してRackの開発スタッフに教えてあげてもらえると助かります。助けてエロい人。