Hatena::ブログ(Diary)

しばそんノート

2010-09-19

いろんな画像をAA Quine化するよ!

この辺の流れを見て楽しそうだなぁと思ったので真似してみました。

ミクさんQuine

まずはこちらから。

※2010年9月20日追記:ミクさんを更新しました。Gistのコードも更新済みです。詳しい更新内容は記事末尾の追記欄にて。

                                                            eval$s=%w~          
                   a=->(b,c,       d,e,f){%`#{(c)?"re       quire'zlib';":'     
                  '}g=Marshal.load(#{                (c)?'Z lib::Inflate.infl   
                 ate(':''}'#{b}'.                      unpack('m')[0]#{(c)?')': 
                 ''});h=  'eval$s                     =%w'<<126<<($s*#{d}  );i='
                ';j=-          1;                    #{   e*f}.times{|k|i<<  (g[
               k]==                 1?                    h[j+=1]:32);i<<10i   f
              (k%                   #{e}                   ==#{e-1})};i[-7,6]   
             ='       '      <      <126<<                 '.join';puts(i)#`};  
           $*[     0]?       (      require'                RMagick';include(Ma 
          gi      ck               );l=$*[0];m              =($*[1]||80).to_i;n=
         Qu                 a      ntu  mRange*(             $*[2]||0.5).to_f;o=
        Im      a          g        eL   ist.new(l           ).flatten_images;p=
       o.                 c                olu mns;          q=o.rows;r=->(o){(p
       >      q           )  ?        o     .re  size    (m   ,m*q/p/2):o.resize
      (m     *      p    /            q      ,m   /2)};  o=(  p>m||q>m)?r[o.bile
      v    el_ch   an   ne     l  (n )]       :r   [o].b  ile  vel_channel(n);e=
     o.  columns  ;f=  o.r o    w s;rais      e('INVALID_  IMAGE_DATA')if(e<0||f
     <0  );s=''; o.ea  ch          _pixel   {|t|s<<((t.red< n)?'1':'0')};s[0,10]
     ='1 '*10;s[ -6,6  ]=          '1'*6;  u=  s.count('1');v=Marshal.dump(s.rev
     erse.to_i(2 ));r  eq    u       ire'z l    ib';w=Z  lib::Deflate.deflate(v)
     ;c= v.size> w.siz e+37;b=[(c)    ?w:v]      .pack('m').tr(10.chr,'');d=u/b.
     size+1;x=a[b,c,d,e,f]; raise('     INSU     FFICIENT_ CAPACITY')if(u<x.size
      +15);e val($s=x)):eva  l(a['         eJ      wlkLFL  w1AQxr/XlKbQQjpmaiq4u
      XSz4JD 8KQHXDs4iJOIf0   FmoHZ3                        ddJJIoHYodXUMLnVo8Yl
       gXjHm  81694fhx7+5731   2zPT5S                   C/ yHDiUZzzDQdIGKAYpSEmo
        qpGQiVClk5A4IUoW44hqp   i6Ft                       niBT0CIgE3E00I5QiNFoo
          D1ome12YUIhooXeiCiEH                             N  Ue1o6QiD  Xx4FqKcB
            id9edkNug1ovhiRUbGv  x                        pn  ZkrG9bTzm 3FnaabMJ
           0u ysHRNiYKzg6KT1Lb2y                        N yt 9zWWrlf/iN4H145vdnt
           6 9mC9pC+cyILyCjLwrHvK                      L  vY Qi5DWdwNYJsSr1IGt5H
          faa2z2ffY4my3xbX96KkGcW0f3d                     Cq ccC2 dQ cukN8yT6tLr
         mm xJJ1 jlbttEc6p+fer4On6juuXxna+HBVX+lW     hf/wF n292        m',true,
         4 ,80, 40])#a=->(b ,c,d,e,f){%`#{(c)?"requ ir e'zlib';":'        '}g=Ma
        rs hal  .load(#{(c)  ?'Zlib::Inflate.infl       ate('    :'        '}'#{
       b} '.u  npack('m')[    0]#{(c)?'   )':''})      ;h='e      va       l$s=%
      w'  <<  126<<($s*#{d      });i=    '';  j=-1    ;#{e*        f       }.tim
      e  s{   |k|i<<(g[k]        ==1?    h[j+=1]:3   2) ;i         <<       10if
     (k  %   #{e}==#{e-1}        )}  ; i[-7,6]=''<< 12      6< <   '.       join
    ';       puts(i)#`};$       *[    0]?(  require'RMa     gick   ';     ~.join

ミクさんです。

元イラストはこちらからお借りしました。この場を借りて御礼申し上げます。

f:id:shibason:20100919145702p:image

AA Quine を生成する AA Quine

Quineというからには、普通に実行すれば当然自分自身を出力します。

$ ruby mikusan.rb | diff mikusan.rb -
$

が、親切なミクさんは、さらに引数として画像ファイルを指定すると、その画像のQuineも生成してくれます。

例えばこんなかんじ。

$ ruby mikusan.rb mikusan2.gif | tee mikusan2.rb
eval$s=%w`

               d=Marshal.load
             ('BA   hsKwHI/wM                                 AAAAAAAAAAAA
           AAAA   AAAAAAAAAgP8fAAAAAAAAAODxHwAAAMD/Aw       B4/P/  //x/w+Q
         MAHv   9/AADg+/wHAN//B                      wAAAH 7+PwD  /fwAAAAA/
        ///A/ x8AAACAnz+AYP                              wDIAAA  gP8fABD4ACCAA
        ID/DwAMfAA44AEA                                 /w8ABh  wAf2BuAP4PAAMOgF
      9wCAD8HwABB8DHM                                  CAA+B8  AgAPwgz         C
     AI   fAfAMAD+           I                         E5HCTwHwDAIfwc
    O      QAw8D             8         A               4Dn+fzr+P/A/A
  PA      9Pv88            +D/       4PwD               wPx//OPg//D8
 A+       D8f           +HDIP/w      fA  Pi/ D+          MAGDD+EwD8/
w/       DAB           gw/hEA /     P8H    f              gBwOP8YAP7/
B       wAA           gL9/G   AD    +/       w             8AAADYHxgA
       /v9          /AAAA+     A    MI         AP    /      //w9wAP8A
      DAD/         ///j/x      88  AAw    A//     /  /      Az4fDgAEA
      P//    /    wGzcQA  ABg   D  ///              +B      8/AAAAYA//
     //wf  vYA   AACAP////HP/wE  A AwD   ////xzL8BAAEA      ////+fz4A4
    ABAP/ ///3   E+APA  AAD///9/  ZvgH     YAAA////f2P     wB2AA/A=='.
    unpack('m'  )[0])   ;c='eval   $s=     %w'<<96<<($    s*3);r='';j=
   -1;3200.tim  es{|i      |r<<(    d[i    ]  ==1?c[j+    =1]:32);r<<
   10if(i%80== 79)};    r[   -7,           6]       ='   '<<96<<'.  j
  oin';puts(r)#d=Mar    sh    al           .l       oa   d('BAhsK   w
  HI/wMAAAAAAAAAAAA      AAAAAA             AAA    AAA  AgP8fAAA   AA
 AAAAODxHwAAAMD/AwB                            4/P///x /w+QMAHv    9/
 AADg+/wHAN//BwAAAH7                               +P wD/fwAA      AA
 A////A/x8AAACAnz+AYPwD                            IAAAgP8         f
ABD4ACCAAID/DwAMfAA44AEA/w8A        Bhw         Af2BuAP4          PA
AMOgF9wCAD8HwABB8DHMCAA+B8   AgAPwgzCAIfAfAMA     D+IE            5H
CTwHwDAIfwcOQAw8D8A4Dn+fzr       +P/A/  APA9P    v88              +
D/4PwDwPx//OPg//D8A+D8f+H       DI  P/ wf   APi                  /D
+MAGDD+EwD8/w/DABgw/hEA/P      8Hf  gBwO    P8YA                 P7
/BwAAgL9/GAD+/w8AAADYHxgA     /v9/ AAAA+   AM IA                 P
///w9wAP8ADAD////j/x88AAw   A////Az4  fDgAEAP///w               Gz
cQAABgD///+B8/AAAAYA////w   fvYA  AA  CAP////H P/               w
EAAwD////xzL8BAAEA////+fz  4A4AB  AP////   3E+APAA             AD
///9/ZvgHYAAA////f2PwB2AA /A=='.  u   np   ack('m'            )[
0]);c='eval$s=%w'<<96<<($s*3);r  ='  ';    j=-1;320          0.
times{|i|r<<(d[i]==1?c[j+=1]:32 );   r<     <10if(i          %8           `.join
$ ruby mikusan2.rb | diff mikusan2.rb -
$

元イラストはこちらからお借りしました。ありがとうございます。

f:id:shibason:20100919145703g:image:medium

使い方

$ ruby mikusan.rb [画像ファイルのパス] [長辺のサイズ(デフォルト:80)] [二値化のしきい値(デフォルト:0.5)]

ざっくりとした使い方は以下の通りです。

  • 実行には RMagick 2 が必要です。*1
  • 引数をつけずに実行すると、自身を出力します。
  • 画像ファイルのパスを指定すると、その画像からAA Quineを生成して出力します。認識可能な画像の形式は RMagick にリンクされた ImageMagick に依存します。
  • 2つ目の引数に長辺のサイズを指定できます。画像はこのサイズまで縮小あるいは拡大してからAA化されます。横長の画像の場合は横サイズ、縦長の画像の場合は縦サイズがここで指定した値に合わせられます。
  • 3つ目の引数には、画像を白黒二値化する際に、どれくらいの濃さの色までを黒として判定するかを0.0〜1.0までの値で指定します。1.0に近づけるほど、より多くの色が黒として判定されるようになります。

さらに注意事項として。

  • どんな画像でもQuine化できるわけではありません。AA化した際に、文字を配置できる領域…つまり黒と判定される領域が十分に存在しないと、自分自身のコードを格納しきれないため、エラーを吐いて止まります。引数で与えるサイズやしきい値を変更すると黒の領域が増減しますので、色々調整してみてください。*2
  • 実際には画像の縦サイズは指定された値よりもさらに半分に縮小されます。一般的な1バイト文字が縦長なためです。それでもまだアスペクト比に違和感がある場合、フォント*3を変更してみるといいかもしれません。
  • 線の細い画像や色の薄い画像はQuine化しにくいです。しきい値をあげるなどして無理やりQuine化することもできますが、あまり綺麗な結果になりません。Quine化に適した画像は、線が黒くて太くてはっきりとしていて、画像全体に大きく対象が描かれているタイプのものとなります。複雑で大きなイラストはどうしても難しく、アイコン画像系が比較的適していると思います。

中身の話もさらっと。

  • 生成されるQuineには、左上に "eval$s=%w`" が、右下に "`.join" が必ず挿入されます。この辺も上手いこと調整できるようにしたかったのですが、コードが長くなりすぎて常識的なサイズのAAに収まらなくなりそうだったので断念しました。一番最初のミクさんだけは手動で調整してあります。
  • 二値化したAAデータは簡単なランレングス符号化を行ってサイズを圧縮しています。画像のタイプによっては符号化しない方がサイズが小さいケースもありますので、よりサイズの小さいものを採用するようになっています。
  • その他は、記事の最初にはった元ネタリンク先とほぼ同じ手法を使わせてもらっています。

いろいろ変換してみる

いちいちQuine化したコードをそのまま貼り付けると長くなってしまうので、元画像と、Quine化したものを画像化したもの*4を並べます。

おぜうさま

例えば私のアイコン。ゆっくりお嬢様ですね。比較的Quine化しやすいタイプの画像です。

$ ruby mikusan.rb ozeusama.png 80 0.6

f:id:shibason:20100919145704p:image:medium:left

f:id:shibason:20100919145705p:image:medium

早苗さん

ドット絵もQuine化に適しています。というかこのAA Quine自体、文字によるドット絵なので当然といえば当然ですね。*5

$ ruby mikusan.rb sanaesan.png 80 0.49

f:id:shibason:20100919145706p:image:medium:left

f:id:shibason:20100919145707p:image:medium

ドット絵はこちらからお借りしました。ありがとうございます。

自重しろ

完全に一致。*6

$ ruby mikusan.rb jichoushiro.jpg 200

f:id:shibason:20100919145708j:image:medium:left

f:id:shibason:20100919145709p:image:medium

画像はこちらからお借りしました。ありがとうございます。

大きなミクさん

頑張ればこんな大きなイラストだって!*7

$ ruby mikusan.rb mikusan3.png 1024 0.6

f:id:shibason:20100919145710p:image:medium:left

f:id:shibason:20100919145711p:image:medium

画像はこちらからお借りしました。ありがとうございます。

こんなサイズでもちゃんとQuineになってます。

$ ruby mikusan.rb mikusan3.png 1024 0.6 > ookinamikusan.rb
$ ruby ookinamikusan.rb | ruby | ruby | ruby | ruby | ruby | diff ookinamikusan.rb -
$

最後に

一番最初のミクさんQuineを生成したコードをはっておきます。

こいつを以下のように実行して、一番最初のミクさんを作りました。いわば原初のミクさんとその創造主。

$ ruby quineaa.rb mikusan.png 90 0.6 > mikusan.rb

こちらはジェネレータとしてのコードサイズが結構ある*8ため、ある程度キャパシティが大きい*9画像でないと、生成は難しいかもしれません。

最後の最後に

元ネタの元ネタ的な資料もはっておきます。

本当に凄いQuineは意味がわかりません。*10

※追記(2010年9月20日)

後から見直してみたら色々と無駄が多かったので、 mikusan.rbquineaa.rb を更新しました。

処理部のコード量が減った*11ため、ミクさんがスリム化してます。

新しいミクさんは

$ ruby quineaa.rb mikusan.png 80 0.6 > mikusan.rb

で生成してます。

更新内容は以下の通りです。

  • 大抵の場合において、ランレングス符号化よりZlibを使って圧縮したほうがデータサイズが小さくなったため、素直にZlibを使うようにしました。
  • ラムダ式の構文をより短いものに変更しました。
  • if文を三項演算子に置き換えました。(if識別子は除く)
  • その他、同じ機能を持つより短い名称のメソッドを呼ぶようにするなど、文字数を削減する調整を行ないました。
  • 生成するコード中にバックスラッシュが含まれており、特定のAAパターンで正しく動作しなくなっていたため、それを修正しました。

あと、書き忘れていましたが、Ruby 1.9以降でないと動作しないと思います。

*1:バージョン1系では動作確認していません。

*2:ただし、元々真っ白な画像はどう頑張ってもQuine化できません。

*3:あるいはline-heightなどの値

*4:ややこしい

*5:なので、日本で一般的なプロポーショナルフォントを活用したAAとは根本的に性質が異なります。あっちは本当にアート。

*6:「アスペクト比がずれているが大丈夫か?」「大丈夫だ、問題ない」

*7:ターミナルでは大きすぎて何が何だかわかりません。

*8:1300byte弱くらい

*9:つまり黒と判定できる領域の占める割合が大きい

*10:思考ルーチン組み込みとかクリスマスソング演奏機能組み込みとか。それをやろうと思った発想も含めて。

*11:1300byte弱から1000byte強くらい

2010-03-04

SequelのTipsのようなもの

Rubyにおける軽量なデータベースツールキットとして、なかなかお手軽なSequelですが、実際に使うにあたっていくつか悩んだポイントがあったので、備忘録としてその解決法をメモしておきます。

なお、この記事はRuby 1.9.1p378とSequel 3.8.0の組み合わせを対象として書いています。

モデルの定義前にDB接続をしたくない

SequelにもActiveRecordパターンに基づいたモデルの仕組みが用意されていますが、「モデルを定義した時点でデータベースへの接続が存在していなければならない」という制約があります。

つまり、

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model; end

p User.all

は動きますが、

require 'sequel'

class User < Sequel::Model; end # Sequel::Error

Sequel.connect('sqlite://test.db')

p User.all

は動きません。"No database associated with Sequel::Model"といって怒られます。

この制約がどういうときに不便かというと、例えば

require 'sequel'

class App
  def self.connect_database(option)
    Sequel.connect(option)
  end

  class User < Sequel::Model; end # Sequel::Error
end

App.connect_database('sqlite://test.db')
p App::User.all

のように、データベース関連の定義をクラスやモジュールの中に押し込めようとしたとき、ちょっと綺麗に書けなくなってしまいます。

これはmodule_evalを使うことで解決できます。

require 'sequel'

class App
  class << self
    def connect_database(option)
      Sequel.connect(option)
      define_models
    end

    def define_models
      module_eval %{
        class User < Sequel::Model; end
      }
    end
  end
end

App.connect_database('sqlite://test.db')
p App::User.all

実際にはモデル定義のタイミングを遅らせているだけなのですが、とりあえず「それっぽく書きたい」という目的は達成できるかな、と。

なお、Sequel::Modelを継承したクラスは、放っておくと勝手に「それまでに接続した一番最初のデータベース」と関連付けられてしまいます。例えば複数のクラスでそれぞれ別のデータベースを参照させたい場合は、以下のようにモデルクラスの継承時にデータベースのインスタンスを渡してやればOKです。

require 'sequel'

module App
  def connect_database(option)
    db = Sequel.connect(option)
    define_models(db)
  end

  def define_models(db)
    module_eval %{
      class User < Sequel::Model(db); end
    }
  end
end

class AppA
  extend App
end
class AppB
  extend App
end

AppA.connect_database('sqlite://testa.db')
AppB.connect_database('sqlite://testb.db')

p AppA::User.all
p AppB::User.all

あまりこういうケースがあるかどうかはわかりませんが…。

文字列型のデータをforce_encodingしたい

デフォルトでは、取得した文字列型データのエンコーディングはASCII-8BITになっています。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model; end

p User.first.name.encoding # => #<Encoding:ASCII-8BIT>

逐一String#force_encodingをかけてもいいのですが、面倒な場合はForceEncodingプラグインを使うことで、全ての文字列型カラムのエンコーディングを強制的に指定できます。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  plugin :force_encoding, 'UTF-8'
end

p User.first.name.encoding # => #<Encoding:UTF-8>

ちなみに、モデルではなくデータセット経由で操作する場合は、このような全体的にエンコーディングの指定をする方法はなく、それぞれにforce_encodingをかけるしかないようです。

require 'sequel'

db = Sequel.connect('sqlite://test.db')

p db[:users].first[:name] # => "\xE5\xA4\xAA\xE9\x83\x8E"
p db[:users].first[:name].force_encoding('UTF-8') # => "太郎"

プライマリキーを指定したい

Sequelのモデルは、デフォルトではプライマリキーはRestrictedな値として、明示的な指定が許可されていない状態になっています。

require 'sequel'

db = Sequel.connect('sqlite://test.db')

# データセット経由の場合は関係ない
db[:users].insert(:id => 123, :name => 'John', :age => 18) # ok

class User < Sequel::Model; end

User.create(:id => 456, :name => 'Bob', :age => 17) # Sequel::Error
# => ...method id= doesn't exist or access is restricted to it...

モデル定義時にunrestrict_primary_keyを宣言することで、プライマリキーの明示的な指定が可能となります。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  unrestrict_primary_key
end

User.create(:id => 456, :name => 'Bob', :age => 17) # ok

同名のカラムが存在するテーブル同士をJOINしたい

例えばusersとpostsというテーブルがあり、双方がcreated_atという名前でレコードの生成日時を記録しているとします。このとき、

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  one_to_many :posts
end

class Post < Sequel::Model
  many_to_one :users
end

p Post.join(User, :id => :user_id).first.created_at # => User's created_at

のように単純にJOINすると、得られたレコードのcreated_atはUserのそれとなります。

こういうケースではgraphメソッドを使うことでカラム名の衝突を防ぐことができます。graphメソッドは、テーブル毎に個別のレコードを格納したハッシュとして結果を返します。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  one_to_many :posts
end

class Post < Sequel::Model
  many_to_one :users
end

record = Post.graph(User, :id => :user_id).first
p record[:posts].created_at # => Post's created_at
p record[:users].created_at # => User's created_at

3つ以上のテーブルをJOINしたい

SequelはメソッドチェインでさくさくSQLを生成できるのが便利なのですが、たまにそこがハマりポイントになることがあります。

例えば今説明したgraphメソッド。これはJOINの左側のテーブルとして、「直前までに生成されたデータセットのうち、最後にJOINされたテーブル」を使用します。

つまり、以下のようなコードを実行すると、

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  one_to_many :posts
end

class Category < Sequel::Model
  one_to_many :posts
end

class Post < Sequel::Model
  many_to_one :users
  many_to_one :categories
end

p Post.graph(User, :id => :user_id)
      .graph(Category, :id => :category_id)
      .first # Sequel::DatabaseError

categoriesテーブルをJOINする段階で*1"no such column: users.category_id"と怒られます。postsとcategoriesをJOINして欲しいのに、usersとcategoriesをJOINしようとしてしまっているわけです。

このようなケースでは、:implicit_qualifierオプションによってJOINの左辺を指定してやります。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  one_to_many :posts
end

class Category < Sequel::Model
  one_to_many :posts
end

class Post < Sequel::Model
  many_to_one :users
  many_to_one :categories
end

p Post.graph(User, :id => :user_id)
      .graph(Category, { :id => :category_id }, :implicit_qualifier => :posts)
      .first # ok

{}によって第2引数のJOIN条件と第3引数のオプションを明示的に区別することを忘れずに。

気を付けたいSequelのクセ

その他、場合によっては悩みポイントとなるかもしれない、ちょっと変わったSequelのクセについてもメモを残しておきます。

Sequel::DATABASESの存在

Sequelでは、生成したデータベースを全てSequel::DATABASESという配列に格納して保持しています。

注意したいのは、ブロック内でデータベースを扱う場合は、ブロックを抜けたタイミングでSequel::DATABASESからインスタンスを削除してくれるのに、ブロックを使わずにdisconnectメソッドで接続を切った場合は、Sequel::DATABASESからインスタンスを削除してくれず、残ったままになる…という点です。

require 'sequel'

puts Sequel::DATABASES.length # => 0

Sequel.connect('sqlite://test.db') do |db|
  puts Sequel::DATABASES.length # => 1
end

puts Sequel::DATABASES.length # => 0

db = Sequel.connect('sqlite://test.db')

puts Sequel::DATABASES.length # => 1

db.disconnect

puts Sequel::DATABASES.length # => 1

Sequel::DATABASES.delete(db)

puts Sequel::DATABASES.length # => 0

この場合、当然参照が残ったままになるのでGCの対象になりませんし、また前述のように「デフォルトではモデルは最初に接続したデータベース*2と関連付けられる」ため、思わぬところで変な挙動をする危険性があります。*3

必要であれば、上のコードの最後のように自分でSequel::DATABASESからインスタンスを取り除いてやると良いかと思います。

モデルに定義されるアクセサメソッド

前述のように、Sequelのモデルは定義時にデータベースに接続済みである必要があります。

何故かというと、モデルの定義時にテーブルに存在するカラムを取得し、各カラムに対応するアクセサメソッドを定義しているためです。

次のようなコードを実行してみるとわかりやすいです。

require 'sequel'
require 'logger'

db = Sequel.connect('sqlite://test.db')
db.loggers << Logger.new($stderr)

class User < Sequel::Model; end # この時点でSQLを発行している
# SQLiteの場合は PRAGMA table_info('users')

この場合に何に注意したら良いかというと、「モデルの定義以降に追加・変更されたカラムはアクセサメソッドによるアクセスができない」という点です。

require 'sequel'

db = Sequel.connect('sqlite://test.db')

class User < Sequel::Model; end

puts User.instance_methods.include?(:name) # => true
puts User.first.name # ok

db.alter_table(:users) do
  add_column :country, :string, :default => 'jp'
end

# ハッシュによるアクセスはいつでも可能
puts User.first[:country] # => jp

puts User.instance_methods.include?(:country) # => false
puts User.first.country # NoMethodError

これもあまり問題になるケースは無さそうな気がしますが、万一これが問題になる場合は、alter_table後に再度モデル定義をしてやればOKです。その際はremove_constで一度モデルクラスを削除することを忘れずに。

*1:実際に例外が発生しているのは生成したSQLを実行した時ですが

*2:つまりSequel::DATABASES.first

*3:特に単体テストなどで要注意。

2010-02-07

Twitterにおけるニコニコ動画の人気度をランキングするサービスを作ってみた

まだα版ですが、とりあえず見られるレベルにはなったので公開してみます。

Twitterの公開ツイートの中から、ニコニコ動画のURLを含むものを探し出し、ツイート数が多い順に動画をランキングするサービスです。

ニコニコ動画本家のランキングとはだいぶ違う結果になるようなので、見る人が見ればそれなりに楽しいのではないかと…多分。

ツイート数自体がさほど多くありませんが、その分ランキングの入れ替わりは頻繁に起きるようです。

機能面、デザイン面ともにまだまだ作りたてといったところですが、とりあえず仕組みの簡単な説明を書いておこうと思います。

技術的にたいした話はありませんので、日記的な感覚で捉えていただければ幸いです。

開発言語など

Rubyです。データベース操作にSequel、テンプレートエンジンにHaml、memcached操作にmemcache-clientのgemを使用しています。後は細々したものを色々。

ツイートの収集方法

TwitterのStreaming APIを使っています。

結構前にStreaming APIの使い方について記事を書いたのですが、あれから正式版としてリリースされるにあたり、細部がいくらか変更されています。正式版Streaming APIの使い方については、また機会があれば記事にまとめてみたいと思っています。

今回は”statuses/filter”を使い、trackキーワードとして"nico"を指定しています。これはニコニコ動画自体のドメイン="www.nicovideo.jp"と、ニコニコ動画専用のURL短縮サービス”nico.ms”の両方を引っ掛けたかったためです。

[2010年2月10日修正] trackキーワードによる検索は(単語単位での)完全一致で行われますので、上の指定だと"nico.ms"のURLしか引っ掛かりません。"www.nicovideo.jp"も引っ掛けるためには、"nicovideo,nico"というように指定する必要があります。

当然関係ないツイートもかなり紛れ込んでいますので、こんな感じの正規表現でさらにフィルタリングしています。

PATTERN = %r{https?://.*(?:nicovideo\.jp|nico\.ms).*/sm(\d+)}

これにマッチすれば、$1に動画IDが入りますので、それを後々色々と使いまわします。

とってきたツイートは必要な情報だけ取り出して、とりあえずMySQLにぶち込んでいます。ランキング生成などを全部SQLクエリで行えますので、この辺はやはりリレーショナルデータベースが適しています。

ランキングの生成方法

上述の通りSQLクエリで実現しています。Sequelで書くとこんな感じ。テーブル名、カラム名とかの説明は面倒なので省かせてください。

@statuses.filter { |o| o.created_at > Time.now - @target_period }
         .group_and_count(:video_id.as(:video_id))
         .order(:count.desc, :video_id.desc)
         .limit(@max_video_count)

"group_and_count"メソッドが便利ですね。個人的に結構わかりやすく書けるのでお気に入りです、Sequel。

各動画につける最近のツイートは次のような感じで取り出しています。

@statuses.graph(:users, :id => :user_id)
         .filter { |o| o.created_at > Time.now - @target_period }
         .where(:video_id => video_id)
         .order(:created_at.desc)
         .limit(@max_tweet_count)

JOINする双方のテーブルに同名のカラムがある場合、"join"メソッドではなく"graph"メソッドを使うと、結果がテーブル毎のハッシュとして返ってきますので、カラム名の重複を回避することができます。

動画情報の取得

ランキングとして表示するからには、動画の名前やサムネイルなど、色々な情報が必要になってきます。

この辺はニコニコ動画APIとしてきちんと用意されていますので、これを使わせてもらいます。

とりあえず"getthumbinfo" APIを使っておけば、通常必要となる情報は全部取得できます。

今の実装では、トップページの「最新のツイート」を一分ごとに更新していますので、そのままだとかなり頻繁にAPIへアクセスすることになってしまいます。

そこで、取ってきた情報はmemcachedにも保存しておき、memcachedに情報がある場合はAPIへはアクセスしないようにしています。

こういう「とりあえず入れておくだけ」なデータにはリレーショナルデータベースは大げさになってしまいますので、この辺のkey/value式のデータストアが重宝します。

ちなみに、動画情報にはある程度の新鮮さを持たせておきたいので、memcachedに入れたデータも30分経ったら捨てるようにしています。

モジュールの可動場所

サービスのURLはさくらのレンタルサーバですが、ここには静的なHTMLと各種リソースしか置いてありません。上で説明したようなスクリプトは全て自宅で動かしています。

さくらのレンタルサーバではStreaming APIで使うような長寿命のプロセスは許可されていませんし、かといって自宅で固定IPをとってサービスを公開するのもちょっと億劫です。…ということで、こんな構成になりました。

自宅サーバではStreaming APIによるトラッキングの他に、cronで定期的にランキングHTML生成プロセスを動かしており、そいつがさくらサーバ上にアップロードするようになっています。

ちょっと面倒な構成ではありますが、さくらサーバ側の負荷も減りますし、まぁそんなに悪くもないのかな…なんて思っています。

そんなわけで、自宅サーバの能力はフルに使えますので、本当はやろうと思えば1分毎に毎時ランキングを更新する、といったこともできるのですが、それは果たして“毎時ランキング”なのか?という思いもあり、今のような1時間毎の更新としています。

この辺、他の方のご意見も伺いたいところです。

テンプレート

前述の通りテンプレートエンジンはHamlを使っていますが、フォーマットはHTML5で書いています。

といっても新しいAPIとかと使っているわけではなく、タグがHTML5的なだけですが。そのおかげでIEではJavaScriptを無効にしているとレイアウトがガタガタになります。

しかもW3Cのバリデータに通すとエラーになります。どうもHTML5ではmetaタグのhttp-equiv属性に"pragma"や"cache-control"などが使えない*1らしいのですが、さくらサーバでは.htaccessでのキャッシュコントロールはできませんし、仕方なくエラーとなることを承知でそのままにしてあります。

あとはCSS3のborder-radiusとかgradientとかもちょっと使ってみたり。Firefox, Safari, Chromeあたりでしか反映されませんが、その辺のブラウザで見るとちょこっと見た目が変わります。元々のデザインが味気ないので、たいした違いはありませんが。

今後について

Twitterで軽く確認してみましたが、どうやら今の時点では被っているサービスはないようなので*2、もう少し発展させてみようかと思っています。

せっかくStreaming APIを使ってリアルタイムにツイートが取れているのですから、それを活かした機能が欲しいところです。

ニコ動風のUIで、動画のサムネをバックに、取ってきたツイートをリアルタイムで*3流す…みたいなものも考えたのですが、そんなのあまり見ない気がして。どうでしょうね。どちらかというとニコ生風ってことになるのでしょうか。

bit.lyなど、他のURL短縮サービスにも対応できないかと考えましたが、いい案が浮かばないので実現できていません。良い策をご存じの方がいらっしゃいましたらご教示お願い致します。

あとはランキングの更新間隔とか、動画やツイートの表示数とか、細かい部分もあるのですが、なんといってもまずなんとかしたいのはデザインです。

自分デザインセンスは本当に無いもので…。もうちょっと見栄えの良いデザインにしたいなぁ。

そんな感じで、色々迷っているところですので、ご意見ご要望などありましたら是非@shibasonまでご連絡ください。よろしくお願い致します。

あ、あと、名前も募集しています!

*1:まだ定義されていない?

*2:それが一番心配でした。

*3:前述のようにちょっと変わった構成なので、「擬似リアルタイム」的なことしかできないかもしれませんが。

2009-10-18

Google App Engine/JRubyでTwitterの自動フォロー返し

先日Google App EngineのSDKが1.2.6にバージョンアップし、アプリケーションがメールを受信できるようになりました。

Twitterの自動フォロー返しを実現するには、おおまかに分けて

  1. フォロー通知のメールを受信したタイミングでプログラムを呼び出す
  2. 定期的にメールサーバにフォロー通知メールを確認しに行く
  3. 定期的にfollowingとfollowersの差分をチェックする

という3パターンがあると思います。

GAEのメール受信機能を使えば、この中の1番が手軽に実現できそうです。

ということで、ごく簡単な自動フォロー返しの仕組みをGAE/JRubyで作ってみました。

下準備

GAE/JRubyの環境を整えるところまでは以下の記事を参照して下さい。

gemでインストールできるApp Engine SDKは2009/10/18現在まだ1.2.5ですが、特に問題なく動くようです。開発サーバでのテストメール送信機能が使えないのは痛いですが…。

環境が整ったら、ディレクトリを作って必要なgemのインストールをしておきます。

$ mkdir sample-twitter-follow-back
$ cd sample-twitter-follow-back
$ appcfg.rb gem install sinatra appengine-apis

設定ファイルの作成

rackupの設定ファイルを書きます。

$ vim config.ru
require 'appengine-rack'

AppEngine::Rack.configure_app(
  :application => 'your-application-id',
  :version => 1,
  # 1. メール受信サービスを有効化する
  :inbound_services => [ :mail ]
)

# 2. 管理者アカウント以外からのアクセスを禁止する
use AppEngine::Rack::AdminRequired

require 'main'

run Sinatra::Application

ソース中のコメント部分を少し補足しておきます。

1. メール受信サービスを有効化する

AppEngine::Rack.configure_appメソッドは:applicationと:version以外にもいくつかオプション引数を取ります。*1

各種サービスに関しては、上のように:inbound_servicesオプションに使いたいサービス名を指定することでWEB-INF/appengine-web.xmlにその内容が反映され、サービスが使えるようになります。

2. 管理者アカウント以外からのアクセスを禁止する

今回はメール受信サービスから呼び出される以外の使い方を想定していませんので、全体に“管理者のみ許可”のアクセス制限をかけてしまいます。

他にもAppEngine::Rack::LoginRequiredやAppEngine::Rack::SSLRequiredなどのセキュリティオプションがあり、Rack::Builder#mapメソッドと組み合わせることで適用範囲を指定することもできます。

アプリケーションの作成

アプリケーション本体のソースを書きます。

$ vim main.rb
require 'sinatra'
require 'yaml'
require 'appengine-apis/logger'
require 'appengine-apis/urlfetch'

# 1. メールが届くと、メールアドレスを含んだURLが呼び出される
post '/_ah/mail/:account@:domain' do |account, domain|
  logger = AppEngine::Logger.new

  # 2. Twitterアカウント情報を読み込む
  accounts = YAML.load_file('accounts.yaml')
  unless accounts[account]
    # 3. 不明なアカウントの場合はログに記録しておく
    logger.warn("Received a mail for an unkonwn account.")
    logger.warn("Account Name: #{account}")
    logger.warn("Domain: #{domain}")
    halt 'unkonwn account'
  end
  username = accounts[account]['username']
  password = accounts[account]['password']

  # 4. POSTデータとして送られくるメールを読み込む
  mail_data = env['rack.input'].read

  # 5. メールを解析する
  props = java.util.Properties.new
  session = javax.mail.Session.get_default_instance(props)
  stream = java.io.ByteArrayInputStream.new(mail_data.to_java_bytes)
  message = javax.mail.internet.MimeMessage.new(session, stream)

  # 6. Twitter通知メールの固有ヘッダから必要な情報を得る
  mail_type = message.get_header('X-TwitterEmailType').to_a.first
  sender_id = message.get_header('X-TwitterSenderID').to_a.first

  if mail_type == 'is_following' && sender_id
    # 7. フォロー通知メールなのでフォロー返しをする
    url = "https://twitter.com/friendships/create/#{sender_id}.json"
    request = Net::HTTP::Post.new('/')
    request.basic_auth(username, password)
    options = {
      :method => request.method,
      :payload => request.body,
      :headers => { 'Authorization' => request['Authorization'] }
    }
    response = AppEngine::URLFetch.fetch(url, options)
    unless response.code.to_i == 200
      # 8. リクエスト失敗時はログに記録して500エラーを返す
      logger.error("Reguest failed of following back.")
      logger.error("Status: #{response.code} #{response.message}")
      logger.error("User ID: #{sender_id}")
      halt 500, 'request failed'
    end
  else
    # 9. それ以外のメールはログに記録しておく
    subject = message.subject
    from = message.from.to_a.join(', ')
    logger.warn("Received a mail which is not 'is_following'.")
    logger.warn("Subject: #{subject}")
    logger.warn("From: #{from}")
  end

  'ok'
end

先ほどと同じように、コメント部分について少し補足しておきます。

1. メールが届くと、メールアドレスを含んだURLが呼び出される

メール受信サービスのドキュメントを読むと、メールを受信する毎に"/_ah/mail/<address>"というURLに対してPOSTリクエストが発行されるようです。

URLに含まれるメールアドレスからアカウント名を取得しておきます。

2. Twitterアカウント情報を読み込む

Twitterのアカウント情報は別ファイルにまとめておくことにしました。こうすれば、複数のアカウントの自動フォローを1つのアプリケーションで担当することができます。

アカウント情報ファイルの詳細は後述します。

3. 不明なアカウントの場合はログに記録しておく

メールは誰でも送信できますので、想定していないアカウントに対してメールが送られてくることもあるかもしれません。どうもGAEのメール受信サービスはリクエストが失敗するとメールの再送をかけるようになっているようなので、ここはログに書き残しておくだけで正常なレスポンスを返しておきます。そうしないと延々と再送をかけられてしまいます。

4. POSTデータとして送られくるメールを読み込む

先ほどのドキュメントにもありましたが、メールソースはPOSTデータとして送られてきますので、一旦全てメモリ上に読み込んでしまいます。

5. メールを解析する

読み込んだメールソースをJavaのMimeMessageクラスを使って解析します。この辺は詳しい説明は省きます。こういう流れなのだと思って下さい。あるいはJavaのリファレンスを参照して下さい。

6. Twitter通知メールの固有ヘッダから必要な情報を得る

Twitterからのフォロー通知メールには、プログラムがメールを処理しやすいように、いくつか固有のヘッダが付加されています。

ここから情報を取得すれば、わざわざ面倒なメール本文の解析を行う必要はありません。

7. フォロー通知メールなのでフォロー返しをする

フォロー返しはAppEngine::URLFetch.fetchメソッドを使って行っています。Basic認証なのでHTTPSでリクエストします。

Net::HTTP::PostクラスはBasic認証に使うAuthorizationヘッダのエンコードと、POSTパラメータ*2のエンコードのために使っているだけで、直接リクエストには使用しません。自力でエンコードするならNet::HTTP::Postクラスを使う必要はありません。

8. リクエスト失敗時はログに記録して500エラーを返す

前述の通り、リクエスト失敗時はメールの再送をかけてくれるようなので、フォローに失敗した場合はわざと500エラーを返しておきます。

ただ、例えば「パスワードが間違っている」などの「再送しても成功しないケース」だった場合、再送のループに陥ってしまいます。きちんと作るならその辺りの配慮も必要になります。

9. それ以外のメールはログに記録しておく

これも今まで出てきたパターンと同じように、ログに記録した上で再送を防ぐために正常終了しています。

アカウント情報ファイルの作成

これでアプリケーションは完成ですので、最後にアカウント情報ファイルを同じディレクトリに保存しておきます。

$ vim accounts.yaml
user1:
  username: Sample_User1
  password: 1234ABCD
user2:
  username: testuser2
  password: foobarbaz

上の例で言うと"user1", "user2"の部分が「アカウント名」になります。「アカウント名」とTwitterへのログインに使う「ユーザ名」は同じでも異なっていても構いません。

言うまでもありませんが、これらのアカウント情報をクラウド上にアップロードすることはそれなりのリスクを伴います。その辺りは自己責任で行って下さい。

メールアドレスの登録

自動フォローしたいTwitterアカウントの「設定」ページでメールアドレスを登録します。メールアドレスは

<アカウント名>@<アプリケーションID>.appspotmail.com

になります。ドメインが"appspotmail.com"なのに注意して下さい。

アプリケーションのアップロード

本来ならここで開発サーバでのテストを行いたいところですが、前述の通り、2009/10/18現在はまだgoogle-appengine gemの各モジュールがSDK 1.2.6の機能に対応していないため、さっさと実環境にアップロードして、そちらで動作確認してしまいます。*3

config.ruの:applicationと:versionが正しいことを確認しておいて下さい。

$ appcfg.rb update .

アップロードが完了したら、試しに先ほど登録したメールアドレスに対して適当なメールを送信してみたり、実際に他のアカウントからフォローしてみたりして動作を確認しておくと良いと思います。

問題点

これでとりあえず自動フォローの仕組みが完成しました。しかし、このアプリケーションにはいくつかの重大な欠陥が存在します。

届いたメールがそのまま失われてしまうのが最たる問題でしょうか。フォロー通知メールなら別に消えたって構わないかもしれませんが、ダイレクトメッセージのメールやその他のメールまで消えてしまうのは問題です。転送するなり、データストアやログに記録しておくなりしたほうが良いでしょう。

パスワードを平文でアップロードしなければいけないのも気になる点です。この点はOAuthの仕組みを実装することで解決できそうです。

最もどうにもならない問題は、「GAEの1リクエスト30秒制限」と「JRubyインスタンスのロードの遅さ」の組み合わせによる「タイムアウトの頻発」でしょうか。

ログを監視しているとわかりますが、ぶっちゃけた話、JRuby環境をロードするだけで普通に30秒制限を突破してDeadlineExceededException例外が発生していたりします。幸い何度も再送をかけてくれるおかげで、そのうち成功してくれますが…。

今後GAE/JRubyの最適化が進めば解決するかもしれませんが、やはりGAEのRubyネイティブ対応が欲しいところです。

更なるフォロワー管理

さて、これで「フォロー通知メール反応型の自動フォロー」は実現できましたが、フォロワーの完全自動管理を行うためには、これだけでは全然足りません。

皆さんもご経験があるかもしれませんが、フォロー通知メールは100%信頼のおけるものではなく、実際には「フォローされたのにメールが送られてこない」というパターンもよくあります。

それに、bot等のプログラムではフォローを解除された場合の「自動フォロー外し」機能も必要なことがあるかもしれません。

これらはGAEのCronサービスと、Twitterの"friends/ids", "followers/ids" APIを組み合わせることで解決可能ですが、それはまた別の機会に。

簡単にやり方だけ書いておきますと、Cronで一日一回タスクを起動し、friends/idsとfollowers/idsをチェックして、差分IDに対してフォロー/アンフォローを実行していくだけです。

実際には前述の30秒制限もあり、確実に動作させるにはTask Queueサービスを利用するなど、色々工夫が必要になりそうな気がします。

…まぁ、レンタルでも自前でも、自由に使えるサーバ環境を用意できるならば、GAEではなくそちらを使ったほうが簡単だったりもしますが…。

*1:具体的に何をとるかは、ドキュメントが見当たらなかったのでソースを読むしかないのかもしれませんが…。

*2:今回はPOSTパラメータは存在しませんが。

*3:実際はコードのほうを変更して開発サーバでも動作確認をしています。

2009-10-04

HTTP通信を伴うライブラリのRSpecを書く

伴う、というかそれがメインの機能だったりもするわけですが。

少し前、以下の記事でごく単純なOAuthコンシューマの実装を行いました。

この小さなライブラリの使い方は以下の通りです。

require 'simple-oauth'
simple_oauth = SimpleOAuth.new('COMSUMER-KEY', 'COMSUMER-SECRET',
                               'ACCESS-TOKEN', 'TOKEN-SECRET' )
response = simple_oauth.get('http://example.com/')
response = simple_oauth.post('http://example.com/', :foo => 'bar')

これで全機能です。*1

getやpostメソッドでは、内部でNet::HTTPを利用してHTTP通信を行っています。

さて、このライブラリのRSpecを書こうとしたとき、どのように書けばよいのでしょうか?*2

テストを実行する度に、実際にどこかのWebサーバにアクセスするというのもなんだか気持ち悪いです。

SimpleOAuthに限って言えば、キモはAuthorizationヘッダの生成部分なので、最悪そのメソッドだけをテストするようなコードにして済ませてしまう、という選択肢もあるかもしれません。

しかしそれではRSpecの「振舞を記述する」という目的に沿いませんし、やはりちゃんとHTTP通信が行えるかどうか、せめてNet::HTTPに処理が渡るところまでは正しく動くことを確認できるようにしたいですよね。

このようなケースでは、次の2パターンの解決策があると思います。

  1. Net::HTTPをモックオブジェクト化する
  2. WEBrickを使ってダミーサーバを立てる

以下、それぞれの手法で実際にスペックを書いていきます。

Net::HTTPをモックオブジェクト化する

ライブラリの中でNet::HTTP及びその関連クラスのメソッドをどう呼び出しているかにも関わってくるのですが、幸いSimpleOAuthではリクエストの送出はNet::HTTP#requestメソッドに集約されています。

つまり、このメソッドをフックしてそこにスタブを仕込むことで、実際のHTTP通信を伴うことなく、かつライブラリの全機能を漏れなくテストすることができます。

この手法を用いて、SipmleOAuth#getメソッドに対する1つのexampleが定義されただけの最小のスペックファイルを書いてみます。

require 'rubygems'
require 'json'
require 'simple-oauth'

describe SimpleOAuth do
  before do
    @simple_oauth = SimpleOAuth.new('CONSUMER-KEY', 'CONSUMER-SECRET',
                                    'ACCESS-TOKEN', 'TOKEN-SECRET')
  end

  def create_http_mock(request_class)
    http = Net::HTTP.new('localhsot')
    http.should_receive(:request)
        .with(kind_of(request_class))
        .and_return do |request|
      response = Net::HTTPOK.new(nil, 200, nil)
      response.stub!(:body).and_return({
        :method => request.method,
        :path => request.path,
        :body => request.body,
        :authorization => request['Authorization'],
      }.to_json)
      response
    end
    Net::HTTP.should_receive(:new).at_least(1).and_return(http) # Point
  end

  it 'should access a protected resource by using #get method' do
    create_http_mock(Net::HTTP::Get)
    response = @simple_oauth.get('http://localhost/')
    response.should be_an_instance_of(Net::HTTPOK)
    body = JSON.parse(response.body)
    body['authorization'].should match(/^OAuth/)
    # other expectations...
  end

  # other examples...
end

create_http_mockメソッドでNet::HTTPをモックオブジェクト化し、その後にSimpleOAuth#getメソッドを実行することで、HTTP通信を伴うことなくテストを実行します。また、モックオブジェクト化したNet::HTTPが、その呼び出しを検証可能なパラメータを含んだレスポンスを返すようにすることで、きちんと結果に対するexpectationを書くことができます。

この手法のポイントとなるのは、「Net::HTTP.newをフックする」というところです。実際にフックしたいのはNet::HTTP#requestなのですが、Net::HTTPのインスタンスはライブラリの内部で生成されているため、直接は手が出せません。そこでNet::HTTP.newをフックし、予めNet::HTTP#requestをスタブ化したモックオブジェクトを返すようにすることで、ライブラリ内部で呼び出されるNet::HTTP#requestもフックすることができます。

WEBrickを使ってダミーサーバを立てる

テストの実行中にのみローカルにダミーのWebサーバを立て、そこに向けてリクエストを送るようにすることで、実際のHTTP通信まで含めた挙動をチェックすることもできます。ダミーサーバにはWEBrickを利用するのが簡単でしょう。

この手法を用いて、先程と同じSimpleOAuth#getメソッドのexampleのみのスペックファイルを書いてみます。

require 'webrick'
require 'rubygems'
require 'json'
require 'simple-oauth'

describe SimpleOAuth do
  before :all do
    @server_thread = Thread.new do                          # (1)
      server = WEBrick::HTTPServer.new(
        :Port => 10080,
        :Logger => WEBrick::Log.new('/dev/null'),           # (7)
        :AccessLog => [],                                   # (7)
        :StartCallback => Proc.new { Thread.main.wakeup }   # (3)
      )
      server.mount_proc('/test') do |request, response|
        response.content_type = 'application/json'
        response.body = {
          :method => request.request_method,
          :query => request.query,
          :authorization => request['Authorization'],
        }.to_json
      end
      Signal.trap(:INT) { server.shutdown }                 # (4)
      server.start
    end
    Thread.stop                                             # (2)
  end

  after :all do
    Process.kill(:INT, Process.pid)                         # (5)
    @server_thread.join                                     # (6)
  end

  before :each do
    @simple_oauth = SimpleOAuth.new('CONSUMER-KEY', 'CONSUMER-SECRET',
                                    'ACCESS-TOKEN', 'TOKEN-SECRET')
  end

  it 'should access a protected resource by using #get method' do
    response = @simple_oauth.get('http://localhost:10080/test')
    response.should be_an_instance_of(Net::HTTPOK)
    body = JSON.parse(response.body)
    body['authorization'].should match(/^OAuth/)
    # other expectations...
  end

  # other examples...
end

先程のモックオブジェクト化したNet::HTTPと同様、レスポンスとして検証可能なパラメータを含んだJSONオブジェクトを返すことで、呼び出し側のexampleの中できちんとexpectationを書くことができます。

先程と比較して注意すべきポイントが多くなりますので、以下に箇条書きします。サンプルコード中のコメントの番号と対応しています。

  1. ダミーサーバをメインスレッドで動かすとそこで処理が止まってしまいますので、別スレッドで動かします。
  2. スレッドを立ち上げた後、WEBrickがstartするまでメインスレッドを停止しておきます。こうしないと、別スレッドでWEBrickがstartする前にメインスレッドでテストが始まってしまいます。
  3. StartCallbackに渡したProcオブジェクトは、WEBrickがstartしたタイミングで呼び出されます。ここで停止しておいたメインスレッドを再開します。
  4. WEBrickはSIGINTをキャッチしたら終了するようにしておきます。
  5. 全てのテストが完了したら、自プロセスに対してSIGINTを投げます。これを上で設定したtrapに捕捉させ、WEBrickを終了します。
  6. スレッドをjoinして後始末完了です。
  7. ログは邪魔なので全部捨てるようにしておきます。

この手法の利点は、ダミーサーバ側にいくつか定義の追加が必要ですが、401や404など、リクエストが失敗した場合の振舞も手軽にチェックできることでしょうか。

HTTP通信以外の場合

HTTP以外のソケット通信を伴うライブラリの場合は、Net::HTTPの代わりにTCPSocketをモックオブジェクト化したり、WEBrickの代わりにGServerを使ったりする必要があると思います。実際に試してはいませんが、今回書いたサンプルよりは複雑になってしまうでしょう。

多分、探せばその辺の面倒な部分をサポートしてくれるgemはどこかにありそうな気はしますが…。

HTTP通信に関しても、もっとエレガントな手法があるかもしれません。その辺りをご存知の方がいらっしゃいましたら、是非ご教授お願い致します。

*1:一応他にhead, put, deleteメソッドもありますが、使い方は変わりません。

*2:Test::Unitでも構いません。