Rack でセッションを使う

Rack を使って Web サーバで統一されたインターフェイスの利用する
http://d.hatena.ne.jp/secondlife/20070307/1173253661
Rack:a Ruby Webserver Interface
http://rack.rubyforge.org/

Rack を使えば生 RubyWEBrickMongrelFastCGI やいろいろあっても統一したインターフェースで書けるよ!
デバッグの時は WEBrick の方が挙動が落ち着いてるしスタックトレースも見やすいし。
でも本番はやっぱりせめて Mongrel か、ちょっと面倒だけど FastCGI がええな〜。


と啓蒙されたので最近 Rack を使い始めたんだが、ドキュメントがまるっきりない……
とりあえずセッションを使いたくて、Rack::Session::Cookie というのがそれっぽいのだが、ドキュメントがかけらもない。
しかたなくソースを読んで、しばし瞑想。うーん、こうかな?

require 'rubygems'
require 'rack'

test_app = Proc.new do |env|
  session_data = env["rack.session"]
  session_data["counter"] ||= 0
  session_data["counter"] += 1
  Rack::Response.new("counter : #{session_data["counter"]}").finish
end

app = Rack::URLMap.new([['/test', Rack::Session::Cookie.new(test_app)]])
Rack::Handler::Mongrel.run app, :Port => 11080

お、いけたいけた。リロードするたびにカウントアップ。
でも、Rack::Session::Cookie はセッションの内容を Marshal して cookie に積んでるだけなので、ちょっと苦しい。
やっぱりちゃんとセッションIDを発行してもきょもきょやりたいところ。


というわけで、Rack::Session::CookieCGI::Session を参考に、セッションIDに対応した Proc を作ってくれるクラスを書いてみた。
まだプチ試作なので、最適化が足りなかったり、セッションデータの expire が考慮されていなかったりするあたりは勘弁。あーあとちゃんとスレッドセーフかどうかも検証しきれてないかも( Hash#[] がスレッドセーフならおおむね大丈夫だと思うんだが)。

[3/18] Proc を継承する必要ないんじゃない、とのご指摘をいただいたので修正。

require 'rubygems'
require 'rack'

module Rack
  class SessionProc
    @@sessiondata = {}
    def initialize(opt={}, &proc)
      @proc = proc
      @key = opt[:key] || "rack.session.id"
      @default_options = {:domain => nil,
        :path => "/",
        :expire_after => nil}.merge(opt)
    end
    def call(env)
      load_session(env)
      status, headers, body = @proc.call(env)
      commit_session(env, status, headers, body)
    end

    private

    def load_session(env)
      request = Rack::Request.new(env)
      session_key = request.cookies[@key]
      env["rack.session.options"] = @default_options.dup
      session_data = env["rack.session"] = (@@sessiondata[session_key] || {}).dup

      def session_data.create_new_id(renew=false)
        return @old_session_id if !renew && @old_session_id
        require 'digest/md5'
        md5 = Digest::MD5::new
        now = Time::now
        md5.update(now.to_s)
        md5.update(String(now.usec))
        md5.update('foobar')
        md5.update($$.to_s)
        md5.update(String(rand(0)))
        @new_session_id = md5.hexdigest
      end
      def session_data.new_session_id
        @new_session_id
      end
      def session_data._init(id)
        @new_session_id = nil
        @old_session_id = id
        puts "@old_session_id: #{@old_session_id}"
        self
      end
      session_data._init session_key
    end

    def commit_session(env, status, headers, body)
      session_data = env["rack.session"]
      request = Rack::Request.new(env)
      session_key = request.cookies[@key]

      if session_data.new_session_id 
        @@sessiondata.delete(session_key) if session_key
        session_key = session_data.new_session_id
        puts "session_key: #{session_key}"
        @@sessiondata[session_key] = session_data

        options = env["rack.session.options"]
        cookie = {:value => session_key}
        cookie[:expires] = Time.now + options[:expire_after] unless options[:expire_after].nil?
        response = Rack::Response.new(body, status, headers)
        response.set_cookie(@key, cookie.merge(options))
        return response.to_a

      elsif session_key && @@sessiondata[session_key] != session_data
        @@sessiondata[session_key] = session_data
      end
      [status, headers, body]
    end
  end
end

こんな風に使う。
普通に Proc.new するところを Rack::SessionProc.new して、session_data に対して create_new_id してあげるだけ。

require 'rubygems'
require 'rack'
require "sessionproc.rb"  # 追加

test_app = Rack::SessionProc.new do |env|
  session_data = env["rack.session"]
  session_data.create_new_id  # セッションID生成。すでに生成されている場合は何もしない(引数に true を指定で強制生成)
  
  session_data["counter"] ||= 0
  session_data["counter"] += 1
  Rack::Response.new("counter : #{session_data["counter"]}").finish
end

app = Rack::URLMap.new([['/test', test_app]])
Rack::Handler::Mongrel.run app, :Port => 10080

WEBrickMongrel で動作確認。FastCGI はいけるんじゃないかと思うんだけど、未確認。
セッションデータはオンメモリで保持しているので、CGI はアウトな気がする。