200行でできるPythonからRubyのメソッドを呼ぶ仕組み

More than 1 year has passed since last update.

PythonからRubyのメソッドを呼べるようにするライブラリを作った。メソッドチェーンやイテレータなどもある程度自然に使えるので紹介する。

https://github.com/yohm/rb_call


作った経緯

科学技術計算のジョブ管理をするRailsアプリを開発しており、RubyのAPIで挙動を制御できる様になっている。しかし、科学技術計算の分野の人はPythonユーザーが多いのでRubyではなくPythonのAPIがほしいというリクエストが多かった。


何ができるか?

例えば、以下のようなRubyのコードがあるとする。


minimal_sample.rb

class MyClass

def m1
"m1"
end

def m2(a,b)
"m2 #{a} #{b}"
end

def m3(a, b:)
"m3 #{a} #{b}"
end

def m4(a)
Proc.new { "m4 #{a}" }
end

def m5
enum = Enumerator.new{|y|
(1..10).each{|i|
y << "#{i}" if i % 5 == 0
}
}
end
end

if $0 == __FILE__
obj = MyClass.new
puts obj.m1, obj.m2(1,2), obj.m3(3,b:4) #=> "m1", "m2 1 2", "m3 3 4"
proc = obj.m4('arg of proc')
puts proc.call #=> "m4 arg of proc"
e = MyClass.m5
e.each do |i|
puts i #=> "5", "10"
end
end


これと同じことがPythonで以下のように書けるようになる。


minimal_sample.py

from rb_call import RubySession

rb = RubySession() # Execute a Ruby process
rb.require('./minimal_sample') # load a Ruby library 'sample_class.rb'

MyClass = rb.const('MyClass') # get a Class defined in 'sample_class.rb'
obj = MyClass() # create an instance of MyClass
print( obj.m1(), obj.m2(1,2), obj.m3(3,b=4) )
#=> "m1", "m2 1 2", "m3 3 4"
proc = obj.m4('arg of proc')
print( proc() ) #=> "m4 arg of proc"

e = obj.m5() # Not only a simple Array but an Enumerator is supported
for i in e: # You can iterate using `for` syntax over an Enumerable
print(i) #=> "5", "10"


PythonからRubyのライブラリをほとんどそのまま呼べる。

メソッドチェーンもできるし、forを使ったイテレーションもできる。サンプルには無いがリスト内包表記も期待通りに使える。

Rubyの例外にもアクセスできる。

例えば、Railsのコードに組みあわせるとこんな具合に書ける。


rails_sample.py

author = Author.find('...id...')

Book.where( {'author':author} ).gt( {'price':100} ).asc( 'year' )

メタプログラミングと外部ライブラリをうまく使うとPython 約130行、Ruby 約80行というそれぞれ1ファイルに収まるコンパクトなコードで実現できる。

もちろん後述するように制限はあるが、大概の用途では問題なく動く。


どのように実装したか?


通常のRPCの問題点

PythonからRubyのメソッドを呼ぶためにRPCを使っている。今回はMessagePack-RPCというライブラリ(仕様?)を使った。

MessagePackRPCの基本的な使い方についてはこちらを参照していただきたい。 http://qiita.com/yohm13/items/70b626ca3ac6fbcdf939

PythonのプロセスのサブプロセスとしてRubyを起動して、RubyとPythonでsocketでプロセス間通信している。

ざっくりと言うと、PythonからRubyに、呼びたいメソッド名、引数を与えて、返り値をRubyからPythonに返す。その際に送受信データをシリアライズする仕様がMessagePack-RPCで定められている。

「単純に引数与えて値を返す」という処理であれば、このやり方で全く問題ない。

しかし、Rubyは往々にしてメソッドチェーンをやりたくなるわけで、それが前提のライブラリも多数ある。例えばRailsであれば以下のようなコードを頻繁に書くことになる。

Book.where( author: author ).gt( price: 100 ).asc( :year )

このようなメソッドチェーンは普通のRPCでは実現できない。

この問題は本質的には、メソッドチェーンの途中の状態を保存できないことに由来している。

RubyとPythonの間のRPCでやりとりできるのはMessagePackでシリアライズできるオブジェクトだけなので、Book.whereした後のオブジェクトがRubyのプロセスからPythonのプロセスに返されるときにシリアライズされてしまうので、さらに別のメソッドをを呼びたくても呼べないことになる。

つまりRubyのオブジェクトを何かしらRubyのプロセス内で保持しておく必要があり、あとで必要に応じて参照する仕組みが必要となる。


解決方法

そこで今回はRubyからの返り値でシリアライズできないRubyのオブジェクトは、Rubyプロセス内に保持しておきつつPython側にはそのオブジェクトのIDとクラスだけを返すようにする。

Pythonの側で RubyObject というクラスを定義し、Ruby側から来た(ID,クラス)のペアをメンバーとして保持しておき、そのRubyObjectへのメソッド呼び出しはRubyプロセス内のオブジェクトへ委譲するようにする。

Ruby側で値を返す時の処理はだいたい以下の様なことをしている。

@@variables[ obj.object_id ] = obj          # オブジェクトを後からIDから参照できる様に保持しておく

MessagePack.pack( [self.class.to_s, self.object_id] ) # クラスとオブジェクトIDをPython側に返す

ただし、StringやFixnumなどMessagePackでシリアライズできるものはそのままPython側に送る。

Python側の受け取った時の処理は

class RubyObject():

def __init__(self, rb_class, obj_id): # RubyObjectはクラス名とIDを保持する
self.rb_class = rb_class
self.obj_id = obj_id

# RPCで返ってきた値の扱い
rb_class, obj_id = msgpack.unpackb(obj.data, encoding='utf-8')
RubyObject( rb_class, obj_id )

絵で書くとこんな感じで、Python側のオブジェクトはRubyのオブジェクトへのポインタだけ持っているようなイメージ。

RbCall.png

後はPythonの中でRubyObjectに対して行われるメソッド呼び出しを、Ruby側の実際のObjectに移譲すればOK。

存在しない属性が呼ばれた時に呼ばれる __getattr__ メソッド(Rubyでいうところのmethod_missing)をRubyObjectに対して定義する。

class RubyObject():

...
def __getattr__( self, attr ):
def _method_missing(*args, **kwargs):
return self.send( attr, *args, **kwargs )
return _method_missing

def send(self, method, *args, **kwargs):
# RPCでオブジェクトID, メソッド名、引数をRubyに送る
obj = self.client.call('send_method', self.obj_id, method, args, kwargs )
return self.cast(obj) # 返り値をRubyObjectにcastする

Ruby側で呼ばれるコード

  def send_method( objid, method_name, args = [], kwargs = {})

obj = find_object(objid) # objidから保存しておいたオブジェクトを取得
ret = obj.send(method_name, *args, **kwargs) # メソッドを実行
end

こうするとPythonでRubyObjectに対して呼ばれたメソッドが、Rubyのメソッドとして呼ばれるようになる。

これであたかもRubyのオブジェクトもPythonの変数に代入されたかのような振る舞いをし、Pythonから自然にRubyのメソッドが呼べる。


実装上気をつけた点


MessagePackのExtensionTypeを使う

MessagePackにはExtension typeというユーザー定義型を定義できる仕様がある。

https://github.com/msgpack/msgpack/blob/master/spec.md#types-extension-type

今回はRubyObject(つまり、クラス名のStringとオブジェクトIDのFixnum)をExtension typeとして定義して利用した。

Ruby側ではObjectにモンキーパッチしてto_msgpack_extメソッドを定義した。

ちなみに最新のmsgpack gemはExtension typeに対応しているものの、msgpack-rpc-rubyは開発が止まっているようで最新のmsgpackを使う様になっていなかった。フォークして最新のgemに依存する様にした。

https://github.com/yohm/msgpack-rpc-ruby

コードは以下の様になる。

Object.class_eval

def self.from_msgpack_ext( data )
rb_cls, obj_id = MessagePack.unpack( data )
RbCall.find_object( obj_id )
end

def to_msgpack_ext
RbCall.store_object( self ) # 変数内にobjectを保存しておく
MessagePack.pack( [self.class.to_s, self.object_id] )
end
end
MessagePack::DefaultFactory.register_type(40, Object)

Python側でも同様に40番のExtensionTypeをRubyObject型に変換するような処理を書いた。


オブジェクトの参照の数のカウント

このままだとRuby側からPython側にオブジェクトを返すたびに、Rubyのプロセス内で保存しておく変数が単調増加していきメモリリークする。

Python側で参照されなくなった変数は、Ruby側でも参照を外してあげる必要がある。

そのためにPythonのRubyObjectの__del__をオーバーライドした。

__del__はPythonの中でオブジェクトを参照する変数が0こでGCで回収可能になった時に呼ばれるメソッド。

このタイミングでRuby側の変数も削除する様にした。

http://docs.python.jp/3/reference/datamodel.html#object.__del__

    def __del__(self):

self.session.call('del_object', self.obj_id)

Ruby側では以下のコードが呼ばれる。

  def del_object

@@variables.delete(objid)
end

ただし単純にこの方法を使うと、2つのPythonの変数が一つのRubyのObjectを参照している場合に適切に動作しなくなる。そこでRuby側からPython側に返した参照の数もカウントしておき、ゼロになったタイミングでRuby側の変数も開放する。

class RbCall

def self.store_object( obj )
key = obj.object_id
if @@variables.has_key?( key )
@@variables[key][1] += 1
else
@@variables[key] = [obj, 1]
end
end

def self.find_object( obj_id )
@@variables[obj_id][0]
end

def del_object( args, kwargs = {} )
objid = args[0]
@@variables[objid][1] -= 1
if @@variables[objid][1] == 0
@@variables.delete(objid)
end
nil
end
end

@@variablesから削除されたオブジェクトは、RubyのGCで適切に開放される。

これでオブジェクトの寿命の管理は問題ないはず。


例外

Rubyの例外の情報も取得できる様にした。

公開されているmsgpack-rpc-rubyではRuby側で例外が起きると例外をto_sして送信しているのだが、この方法では例外のほとんどの情報が失われてしまう。

そこでRubyの例外オブジェクトもPythonのRubyObjectのインスタンスとして送る様にした。

ここでもmsgpack-rpc-rubyに手を入れて、to_sを必ず行うのではなく、Msgpackでシリアライズできる場合はシリアライズするような変更を入れた。

例外の発生時の処理は以下の様になる。

Ruby側で例外が発生するとPython側ではmsgpackrpc.error.RPCErrorが発生する。これはmsgpack-rpc-pythonの仕様。

その例外のargs属性にRubyObjectのインスタンスを入れる。

RubyObjectが入っていたら、Python側で定義したRubyExceptionを投げる様にする。その際にrb_exceptionという属性にRuby側で発生した例外オブジェクトへの参照を格納する。

これでRuby側の例外にアクセスできる様になる。

Python側の処理を簡略化して書くと以下の様になる。

class RubyObject():

def send(self, method, *args, **kwargs):
try:
obj = self.session.client.call('send_method', self.obj_id, method, args, kwargs )
return self.cast(obj)
except msgpackrpc.error.RPCError as ex:
arg = RubyObject.cast( ex.args[0] )
if isinstance( arg, RubyObject ):
raise RubyException( arg.message(), arg ) from None
else:
raise

class RubyException( Exception ):
def __init__(self,message,rb_exception):
self.args = (message,)
self.rb_exception = rb_exception

例えば、RubyのArgumentErrorを発生させた場合のPythonの処理は以下の様にかける。

try:

obj.my_method("invalid", "number", "of", "arg") # RubyObjectのmy_methodの引数の数が不正
except RubyException as ex: # RubyException という例外が発生
ex.args.rb_exception # ex.args.rb_exceptionにRubyの例外を参照するRubyObjectがある


generator対応

例えば以下の様なRubyの処理を行うことを考える。

articles = Article.all

Article.allはArrayではなくEnumerableで、実際にはメモリ上に配列として展開されていない。Eachで回した時に初めてデータベースへのアクセスが起きて、各レコードの情報を取得できる。

これをPython側でも for a in articles という形でループを回すためにgeneratorを定義する必要がある。

そのためにはPython側のRubyObjectクラスに__iter__メソッドを定義する。

__iter__はイテレータを返すメソッドでfor文の中ではこのメソッドが暗黙的に呼ばれている。

これはRubyのeachに直接対応するので、__iter__の中でeachを呼ぶ様にする。

http://anandology.com/python-practice-book/iterators.html

https://docs.ruby-lang.org/ja/latest/class/Enumerator.html

Pythonではループを回す際、__iter__の返り値に対して__next__メソッドが呼ばれる。Rubyでも全く同じ対応関係がありEnumerator#nextが該当のメソッドとなる。

イテレーションが終端に達したときにはRuby側では StopIteration という例外が投げられる。Pythonも同じ仕様になっていて、例外が発生したらStopIterationという例外が投げられる。(たまたま同名の例外になっている。)

class RubyObject():

...
def __iter__(self):
return self.send( "each" )
def __next__(self):
try:
n = self.send( "next" )
return n
except RubyException as ex:
if ex.rb_exception.rb_class == 'StopIteration': #RubyでStopIterationの例外が投げられた場合
raise StopIteration() # PythonでもStopIterationの例外を発生させる
else:
raise

これでPythonからRubyのEnumerableに対するループが使える様になる。


そのほか

Pythonの組み込み関数が適切に動作する様にRubyObjectにメソッドを定義する。対応するRubyのメソッドは以下のとおり



  • __eq__==


  • __dir__public_methods


  • __str__to_s


  • __len__size


  • __getitem__[]


  • __call__call


制限事項


  • Pythonのオブジェクト(MessagePackでシリアライズできないもの)をRubyのメソッドの引数に渡せない


    • (本質的には上記と同じ問題だが)blockを受けるRubyのメソッドにPythonの関数を渡せない

    • PythonのオブジェクトをRubyのプロセスから参照できる様にすることは原理的には同様の手法でできるはずだが、実装が複雑になりそうなのでそのままにしている。



  • Pythonのsyntax上使えないメソッド名がある。


    • 例えば、Rubyでは .class, is_a?などのメソッド名が使えますが、Pythonでは使えまない。

    • この問題は .send('class'), .send('is_a?') というように呼ぶことで回避できる。



  • Rubyの一部ライブラリで問題が起きる場合がある。


    • 例えばMongoidでは、メタプログラミングのためにほとんど全てのpublic_methodをundefしているクラスがある。



    • そのような場合、rb_call内で定義したto_msgpack_extもundefされてしまい、適切に動作しない。


      • Mongoidをrequireした後に、該当のクラスにto_msgpack_extを再定義すれば回避できる。



    • 未確認だがActiveRecordも同じ問題を引き起こすかもしれない。



実装してみてわかったのはPythonとRubyには非常によく似た対応関係があり、対応するメソッドをうまく定義するだけで非常にきれいに実装できることだった。

実質200行程度のコード片なので興味のある人はソースを読んでみてほしい。