@m_seki の

I like ruby tooから引っ越し

Tofuのエッセンス

なんとなくWebのセッション管理を書きたくなったので書いてたら、Tofuの劣化コピーになった。ついでにTofuを読み返したんだけど、expired?の処理が間違ってるみたいだったよ‥。

# やっぱり200行くらいだなあ。

require 'webrick/cgi'
require 'digest/md5'
require 'monitor'
require 'drb/drb'
require 'securerandom'

module Tofu
  class Session
    def initialize(bartender, hint=nil)
      super()
      @hint = hint
      make_session_id
      renew
    end
    attr_reader :session_id, :hint, :expires

    def service(context); end

    def renew
      @expires = Time.now + 24 * 60 * 60
    end

    def expired?
      return false unless @expires
      Time.now > @expires
    end

    def make_session_id
      @session_id = SecureRandom.hex(24) + ("%x" % object_id)
    end
  end

  class SessionBar
    class DuplicateSessionID < RuntimeError; end

    include MonitorMixin
    def initialize
      super
      @pool = {}
      @interval = 60
      @keeper = keeper
    end

    def store(session)
      key = session.session_id
      synchronize do
        prev = @pool[key]
        raise DuplicateSessionID if prev && prev != session
        @pool[key] = session
      end
      @keeper.wakeup
      return key
    end

    def fetch(key)
      return nil if key.nil?
      synchronize do
        session = @pool[key]
        return nil unless session
        if session.expired?
          @pool.delete(key)
          return nil
        end
        session.renew
        return session
      end
    end

    private
    def keeper
      Thread.new do
        loop do
          synchronize do
            @pool.delete_if do |k, v| 
              v.nil? || v.expired?
            end
          end
          Thread.stop if @pool.size == 0
          sleep @interval
        end
      end
    end
  end

  class Bartender
    def initialize(factory, name=nil)
      @factory = factory
      @prefix = name ? name : factory.to_s.split(':')[-1]
      @bar = SessionBar.new
    end
    attr_reader :prefix

    def service(context)
      session = retrieve_session(context)
      catch(:tofu_done) { session.service(context) }
      store_session(context, session)
    end

    private
    def retrieve_session(context)
      sid = context.cookie(@prefix + '_id')
      @bar.fetch(sid) || make_session(context)
    end

    def store_session(context, session)
      sid = @bar.store(session)
      context.add_cookie(@prefix + '_id', sid, session.expires)
      hint = session.hint
      if hint
	expires = Time.now + 60 * 24 * 60 * 60
	context.add_cookie(@prefix +'_hint', hint, expires)
      end
    end

    def make_session(context)
      hint = context.cookie(@prefix +  '_hint')
      session = @factory.new(self, hint)
      begin
        @bar.store(session)
        return session
      rescue SessionBar::DuplicateSessionID
        session.make_session_id
        retry
      end
    end
  end

  class Context
    def initialize(req, res)
      @req = req
      @res = res
    end
    attr_accessor :req, :res

    def cookie(name)
      found = @req.cookies.find {|c| c.name == name}
      found ? found.value : nil
    end

    def add_cookie(name, value, expires=nil)
      c = WEBrick::Cookie.new(name, value)
      c.expires = expires if expires
      @res.cookies.push(c)
    end

    def done
      throw(:tofu_done)
    rescue NameError
      nil
    end
  end

  class CGITofulet < WEBrick::CGI
    def initialize(bartender, *args)
      @bartender = bartender
      super(*args)
    end
    
    def service(req, res)
      @bartender.service(Context.new(req, res))
      nil
    end
  end
end

セッションIDの生成をSessionBarにもたせなかったのは、synchronizeのなかでSessionの生成が行われるのを嫌ったため。たいてい衝突しないので、衝突したときにがんばることにした。

Bartenderがああいう風に分割されているのは、ServletとBartenderを別プロセスにわけても良いように、だったかな。

自身をinspectした結果を印字するサービスはこんなの。Divをつかってないので、結果はtext/plainです。

if __FILE__ == $0
  class MySession < Tofu::Session
    def service(context)
      @hint = (@hint.to_i + 1).to_s
      context.res['content-type'] = 'text/plain'
      context.res.body = self.inspect
    end

    def renew
      @expires = Time.now + 10
    end
  end

  bartender = Tofu::Bartender.new(MySession)
  DRb.start_service('druby://localhost:12321', Tofu::CGITofulet.new(bartender))
  sleep
end

サービスを起動したらCGIインターフェイスで実験できます。まあ、誰もしないだろうけどさ。

#!/usr/local/bin/ruby

require 'drb/drb'

DRb.start_service
ro = DRbObject.new_with_uri('druby://localhost:12321')
ro.start(ENV.to_hash, $stdin, $stdout)

WEBrickServletベースにするときはこう。

  class Tofulet < WEBrick::HTTPServlet::AbstractServlet
    def initialize(bartender, *args)
      @bartender = bartender
      super(*args)
    end

    def service(req, res)
      @bartender.service(Context.new(req, res))
      nil
    end
  end