なんとなく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)
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