jrubyからApache POIを使う超手抜きモジュール

rubyからExcelファイルを読み書きするのは、Spreadsheet(pure ruby)とかwin32oleを使う方法がある。
たまたま、とあるExcelテンプレートファイルを読んで、ごにょごにょ編集して、別ファイルに出力するという機会があった。
この処理をLinux上で動かしたかったため、Spreadsheetを試してみたが、運悪くそのテンプレートファイルには
シートの保護がなされていて、出力したExcelファイルがどうしても壊れてしまう。
このへんは、Spreadsheetが対応できていないようだ。

ほかに似たようなライブラリがないか探してみたが、どうにも見つからない。
仕方がないので、実績のあるApache POIをjrubyから使ってみることにした。
さすがにPOI。難なくやりたいことができてしまった。

それにしてもこのPOI、値をセットするときは型を気にする必要はないが、
取得するときはいちいちセルの型を調べないといけないのがちょっと面倒…

poi_workbook.rb:

require 'java'
require './poi-3.7-20101029.jar'
require './poi-ooxml-3.7-20101029.jar'

java_import java.io.FileInputStream
java_import java.io.FileOutputStream
java_import org.apache.poi.ss.usermodel.WorkbookFactory
java_import org.apache.poi.ss.usermodel.DataFormatter

class Workbook

  def initialize(path)
    @fis = FileInputStream.new(path)
    @book = WorkbookFactory::create(@fis)
  end

  def self.open(path, &block)
    book = Workbook.new(path)
    return book unless block_given?
    yield book
    ensure
      book.close
  end

  def write(path)
    fos = FileOutputStream.new(path)
    @book.write(fos)
    ensure
      fos.close
  end

  def close
    @fis.close
  end

  def select_sheet_at(index)
    @sheet = @book.get_sheet_at(index)
    @sheet.set_force_formula_recalculation(true)
  end

  def []=(row_idx, col_idx, value)
    @sheet.create_row(row_idx) if @sheet.get_row(row_idx).nil?
    row = @sheet.get_row(row_idx)
    row.create_cell(col_idx) if row.get_cell(col_idx).nil?
    row.get_cell(col_idx).set_cell_value(value)
  end

  def [](row, col)
    return nil if (row = @sheet.get_row(row)).nil? or (cell = row.get_cell(col)).nil?
    return DataFormatter.new.format_cell_value(cell)
  end
end

if __FILE__ == $0
  # sample usage
  Workbook.open('template.xls') do |book|
    book.select_sheet_at(0)
    book[0, 0] = book[0, 0] + ' new value'
    book.write('new.xls')
  end
end

※細々としたセルの値に対応していません。

drubyで作る超適当なRackセッション

Rack::Session::Memcacheを参考にdrubyで同じようなことをやってみたら、意外と簡単にできてしまった。
あとは、セッション管理側に適切な排他制御を実装すれば、それなりに使えるかな。

セッションを保持するdrubyサーバ
drb_session_manager.rb:

require 'drb'

class DRbSessionManager
  attr_reader :pool
  def initialize
    @pool = {}
  end
  def set(session_id, session)
    @pool[session_id] = session
  end
  def get(session_id)
    @pool[session_id]
  end
end

DRb.start_service('druby://localhost:11211', DRbSessionManager.new)
sleep

drubyサーバにアクセスするRackのセッション
drbpool.rb:

require 'rack/session/abstract/id'
require 'drb'

module Rack
  module Session
    class DRbPool < Abstract::ID
      attr_reader :pool

      DEFAULT_OPTIONS = Abstract::ID::DEFAULT_OPTIONS.merge \
        :namespace => 'rack:session', :druby_server => 'druby://localhost:11211'

      def initialize(app, options={})
        super
        @pool = DRbObject.new_with_uri(@default_options[:druby_server])
      end

      def generate_sid
        loop do
          sid = super
          break sid unless @pool.get(sid)
        end
      end

      def get_session(env, session_id)
        unless session_id and session = @pool.get(session_id)
          session_id, session = generate_sid, {}
          @pool.set(session_id, session)
        end
        return [session_id, session]
      end

      def set_session(env, session_id, new_session, options)
        @pool.set(session_id, new_session)
        return session_id
      end
    end
  end
end

sinatraを使ってセッションのテスト
main.rb:

require 'sinatra'
require './drbpool'

use Rack::Session::DRbPool

get('/') do
  session[:count] = (session[:count] || 0) + 1
  "count: #{session[:count]}"
end

実行してみる。

hiro@neptune% ruby drb_session_manager.rb &
[1] 4144
hiro@neptune% ruby main.rb
== Sinatra/1.1.2 has taken the stage on 4567 for development with backup from Thin
>> Thin web server (v1.2.7 codename No Hup)
>> Maximum connections set to 1024
>> Listening on 0.0.0.0:4567, CTRL+C to stop

別ターミナルで。

hiro@neptune% curl --cookie-jar cookie.txt http://localhost:4567
count: 1
hiro@neptune% cat cookie.txt
# Netscape HTTP Cookie File
# http://curl.haxx.se/rfc/cookie_spec.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_localhost     FALSE   /       FALSE   0       rack.session    d0673073b2f257040f22a0757d3e67f8
hiro@neptune% curl --cookie rack.session=d0673073b2f257040f22a0757d3e67f8 http://localhost:4567
count: 2
hiro@neptune% curl --cookie rack.session=d0673073b2f257040f22a0757d3e67f8 http://localhost:4567
count: 3

DRbSessionManagerの中身を確認してみる。

hiro@neptune% irb
irb(main):001:0> require 'drb'
=> true
irb(main):002:0> d = DRbObject.new_with_uri('druby://localhost:11211')
=> #<DRbSessionManager:0x10164bc0>
irb(main):003:0> d.pool
=> {"d0673073b2f257040f22a0757d3e67f8"=>{:count=>3}}
irb(main):004:0>

マルチプロセスとマルチスレッド

unicornを使ったときのセッション管理をいろいろ調べていたが、
よくよく考えるとunicornはマルチプロセスなわけで…。
じゃ、Rack::Session::Poolは使えないのかと思って、
起動プロセス数を増やして試してみたら案の定、うまく動かなかった。
プロセスが違うんだから、確かに動くわけないよなぁ。
今まで動いていたように見えたのは、たまたま同じプロセスにリクエストが飛んでただけだったか。

ということで、マルチプロセスなWEBサーバでは、Rack::Session::Memcacheとか
使うことになるのかな。それか、druby使ってセッション管理してしまうとか。

普段は、Javaばっかりなものだから、すっかりマルチスレッドな考え方が染みついてる。
頭堅くなってるわ。

Rackとミドルウェア

ここしばらく、Rackのソースと格闘している。
インスタンス化されたRack::Session::Poolはどのようにして参照するのかと思い、
server.rbとかbuilder.rbらへんをつらつらと読んでいたが、
おそらくアプリケーション側からは参照できないんじゃないかという気になってきた。

config.ruで定義されたミドルウェアは、Rack::Builder#to_appでインスタンス生成されるが、
runしているクラスがアプリケーションとなり、順次ミドルウェアのコンストラクタに渡っているっぽいから、
直接ミドルウェアインスタンスをを参照する手段ってないような…
それにしても、処理の流れが難しいなぁ。

jrubyからJavaのクラスを扱う

jrubyJavaのクラスを扱うときはどうしたらよいのか?
最初、ドキュメントがぱっと見つけられなくてちょっと困った。
https://github.com/jruby/jruby/wiki/CallingJavaFromJRuby

まず最初のおまじない、require 'java' しておく。

・Jarファイルの読み込み
requireを使えばよい。直接ファイルパスを書くか、グローバル変数$LOAD_PATH
またはシェルの環境変数CLASSPATHにJarファイルを置いて、ファイル名のみ指定する。
・classファイルの読み込み
コマンドラインまたは環境変数CLASSPATHでクラスパスを指定する。このあたりはjavaと同じ。

$ jruby -J-cp [クラスパス] ... または $ export CLASSPATH=/path/to

グローバル変数 $CLASSPATH も使える。この変数は上記で指定された内容と関係ない(初期状態は空の配列)。

$CLASSPATH << '/path/to'

クラス名だけで参照したい場合はimportする。
importもjava_importも同じだが、jrubyの新しいバージョンではjava_importがおすすめのよう。
javaやorgで始まるパッケージなどは、文字列にしなくてもよい(このへんでちょっとはまった)。

import 'foo.bar.Baz' または java_import 'foo.bar.Baz'

試してみる。

hiro@Mac-mini% find ./classes ./lib -type f
./classes/foo/bar/Baz.class
./lib/servlet-api.jar
hiro@Mac-mini% cat a.rb
require 'pp'
require 'java'
require '/Users/hiro/devel/ruby/sandbox/lib/servlet-api.jar'
$CLASSPATH << './classes'

p java.lang.System
p javax.servlet.Servlet
p Java::foo.bar.Baz  # foo.bar.Bazだとエラーになる

java_import java.lang.System
java_import javax.servlet.Servlet
java_import 'foo.bar.Baz'

p System
p Servlet
p Baz
p Java.class
pp Java.constants
hiro@Mac-mini% jruby a.rb
Java::JavaLang::System
Java::JavaxServlet::Servlet
Java::FooBar::Baz
Java::JavaLang::System
Java::JavaxServlet::Servlet
Java::FooBar::Baz
Module
["JavaLang",
 "JavaProxyClass",
 "JavaNio",
 "JavaConstructor",
 "JavaField",
 "Javax",
 "JavaProxyMethod",
...

Sinatra::Baseのインスタンス

Sinatra::Baseはcallメソッド内でdupされているから、リクエスト毎にインスタンスが作成されるということか。
リクエスト毎に毎回hashの値が異なるから、config.ruでuseしているクラスはリクエスト毎にインスタンスが生成されると思っていたけど違うのか。
セッションプールが毎回新しいインスタンスになってたら、どう考えてもうまく動かないと思っていたけど、
Rack::Session::Poolのコンストラクタにデバッグ文を入れて確認してみると、インスタンス生成は起動時の1度きりだった。
ただ、セッションプールのインスタンスをどのように取得したらよいのかがわからない…

rubyとjrubyのエンコーディング

hiro@Mac-mini% cat a.rb
p File.read('foo.txt').encoding
hiro@Mac-mini% cat foo.txt
foo
hiro@Mac-mini% jruby --1.9 -E UTF-8 a.rb
#<Encoding:ASCII-8BIT>
hiro@Mac-mini% ruby -E UTF-8 a.rb
#<Encoding:UTF-8>

rubyjrubyって挙動はいっしょと思っていたけど、エンコーディングで微妙に動作が違う。これって仕様なんかなぁ。
REXMLでSJISXMLを解析した文字列と、YAMLから読み込んだ文字列をhamlのテンプレートを使って出力する、という処理をしていたが、
Rubyだとそれほどエンコーディングを意識しなくてすんだのが、そのままjrubyで動かすと途端にエラーの嵐が…
すべてUTF-8に固めて出力を試みたが、最後のhamlでうまくUTF-8になってくれなくて、あえなく断念。
結局、最後はascii-8bitに統一して出力したらうまくいったけど、う〜ん、どうも気持ち悪い。