PythonからRubyのメソッドを呼べるようにするライブラリを作った。メソッドチェーンやイテレータなどもある程度自然に使えるので紹介する。
作った経緯
科学技術計算のジョブ管理をするRailsアプリを開発しており、RubyのAPIで挙動を制御できる様になっている。しかし、科学技術計算の分野の人はPythonユーザーが多いのでRubyではなくPythonのAPIがほしいというリクエストが多かった。
何ができるか?
例えば、以下のようなRubyのコードがあるとする。
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で以下のように書けるようになる。
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のコードに組みあわせるとこんな具合に書ける。
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のオブジェクトへのポインタだけ持っているようなイメージ。
後は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では
- Rubyの一部ライブラリで問題が起きる場合がある。
- 例えばMongoidでは、メタプログラミングのためにほとんど全てのpublic_methodをundefしているクラスがある。
- そのような場合、rb_call内で定義した
to_msgpack_ext
もundefされてしまい、適切に動作しない。- Mongoidをrequireした後に、該当のクラスに
to_msgpack_ext
を再定義すれば回避できる。
- Mongoidをrequireした後に、該当のクラスに
- 未確認だがActiveRecordも同じ問題を引き起こすかもしれない。
実装してみてわかったのはPythonとRubyには非常によく似た対応関係があり、対応するメソッドをうまく定義するだけで非常にきれいに実装できることだった。
実質200行程度のコード片なので興味のある人はソースを読んでみてほしい。