Parser gemを用いたプロファイルコードの挿入
Rubyで簡易なプロファイル用のコードを挿入し、プロファイリングするにはどうしたらいいのか興味が出たのでやってみました。最終的にはwhitequark/parser を用いたRewrite処理で実現できました。
JavaScriptの関数の開始と終了にプロファイル用のコードを挿入し、シンプルなプロファイリングをするという記事が以下:
ASTの一つの使い方としてとても面白いです。
tapでええやん
Rubyには便利なtap
メソッドが存在します。
tapメソッドは、ブロック変数にレシーバ自身を入れてブロックを実行します。戻り値はレシーバ自身です。メソッドチェーンの中にtapメソッドをはさみ込み、ソースコードを簡潔にする目的で使われます。
この便利なメソッドを使い、
- 関数の開始で開始時刻を記録するコードを挿入し、
- 関数の戻り値を返すコードにtapメソッドを挿入し、tapの中でかかった時間を表示する
という形で厳密ではないかもしれませんが行けそうです。 これを実現するには以下の場所がわかればよさそうですね。
def
の場所return
の場所def
の最後のコード
1., 2.はテキストから簡単に把握できそう。
def
の最後のコードに関しては、def
に対応するend
の位置を見れば良さそうですが、どのend
がdef
に対応してるのか判定が難しい...
そこでsjspと同じくASTを用いることにします。
Parser gemでのコードの書き換え
RubyでASTを使い、コードを書き換えるにはどうしたらいいのか。
最初は標準ライブラリのripperをさわりました。
ripperにはイベントドリブンスタイルでコードを書き換えるRipper::Filter
クラスがあります。
Ripper::Filter
のon_kwでend
がやってきたときのイベントは取得できるのですが、どのend
がdef
に対応したものなのかわからず...
そこで以下参考に別のgemを検討。
Rewritingをサポートし、ソースコードの位置情報を取得できる利点からParser gemを使用することに。
ParserはRubocopでも使用されています。
Parser 概要
以下のような形でparseすると、
require 'parser/current' code = <<EOS def test puts "Hello world" end EOS puts Parser::CurrentRuby.parse(code)
S式でASTの結果を取得できます。
(def :test (args) (send nil :puts (str "Hello world")))
このASTはAST gemのAst::Nodeクラスをベースとしています。
実際にParseしたときの戻り値としてはAst::Node
にlocation
というメソッドを追加したParser::AST::Nodeクラスが帰ってきます。
このlocationメソッドでコードの位置情報を取得できる便利なやつです。
今回の書き換え処理ではではこのAST::Node
クラスの処理と追加されたlocation
メソッドをガンガン使っていきます。
locationメソッドについて
location
メソッドはASTに対応するコードの位置情報を管理するParser::Source::Map
クラスのサブクラスを返します。
例: 上記コードでParser::CurrentRuby.parse(code).location
を実行するとParser::Source::Map::Definitionを返します。
さらにParser::Source::Map::Definition
の各アクセサを実行してみると、
puts Parser::CurrentRuby.parse(code).location.expression # Parser::Source::Mapのアクセサ。対象ASTに関連するコード全体の範囲情報 puts Parser::CurrentRuby.parse(code).location.keyword puts Parser::CurrentRuby.parse(code).location.operator puts Parser::CurrentRuby.parse(code).location.name puts Parser::CurrentRuby.parse(code).location.end
そうすると以下の結果が出力されます。
(string):1:1 (string):1:1 (string):1:5 (string):3:1
各アクセサメソッドを実行するとソースコードの範囲情報を保持するParser::Source::Rangeクラスを返します。空の部分があるように、条件によってはこの範囲情報は空になる場合があります。
Parser::Source::Range
クラスではコード開始行数を返すline
メソッド、列を返すcolumn
メソッドや、他にもzero-lengthの直前/直後を返すbegin
/end
メソッドなどがあります。
これらのメソッドを用いてコードの位置情報を詳しく制御していく形になります。
コード書き換えクラスTreeRewriter
ripperのRipper::Filter
クラスと似たように、イベントドリブンスタイルでコードを書き換えるParser::TreeRewriterを用いて特定のコードが来たときに反応してコードを書き換えることができます。対応しているイベントはParser::AST::Processor中のon_*
です。
Parser::TreeRewriter
クラスにはいくつかのコード書き換え用メソッドが用意されており、Parser::Source::Range
を引数にとります。
直前にコードを挿入するinsert_before
メソッド、直後にコードを挿入するinsert_after
メソッド、削除するremove
メソッドなどがあります。
TreeRewriterをつかった書き換え
というわけで今回のプロファイル用コード挿入クラスが以下になります。
class TimeProfiler < Parser::TreeRewriter def on_def(node) insert_profile_on_def(node: node, args: node.children[1].location.expression, code: node.children[2]) super end def on_defs(node) insert_profile_on_def(node: node, args: node.children[2].location.expression, code: node.children[3]) super end def on_return(node) insert_after node.location.expression, "\n#{puts_time}" super end private def end_definition(node, args) if args args else node.location.name end end def insert_profile_on_def(node:, args:, code:) return unless code insert_after end_definition(node, args), "\n#{start_time}" unless node.children[-1].type == :return insert_after node.children[-1].location.expression, "\n#{puts_time}" end end def puts_time '.tap { puts "#{ __method__.to_s }: #{Time.now - __time}" }' end def start_time '__time = Time.now' end end
コードを書き換えるには以下のようにruby-rewrite
コマンドを実行するか、
ruby-rewrite -l time_profiler.rb -m sample.rb
以下のようにParser::Source::Buffer
クラスを用意してあげてrewrite
メソッドを使う方法もあります
code = <<EOS def test 'Hello world' end EOS buffer = Parser::Source::Buffer.new('(rewriter)') buffer.source = code puts TimeProfiler.new.rewrite(buffer, Parser::CurrentRuby.parse(code))
例えば以下のようなコードを書き換えると、
class Sample def initialize(a) @a = a end def self.method1 'Hello world' end def self.method2(b) return b if b 0 end def self.method3(b) c = b.map do |elm| elm + 1 end c | [0] end def method4 'Hello world' end def method5(b) return b if b 0 end def method6(b) c = b.map do |elm| elm + 1 end c | [0] end end
以下のようになります。
class Sample def initialize(a) __time = Time.now @a = a .tap { puts "#{ __method__.to_s }: #{Time.now - __time}" } end def self.method1 __time = Time.now 'Hello world' .tap { puts "#{ __method__.to_s }: #{Time.now - __time}" } end def self.method2(b) __time = Time.now return b .tap { puts "#{ __method__.to_s }: #{Time.now - __time}" } if b 0 .tap { puts "#{ __method__.to_s }: #{Time.now - __time}" } end def self.method3(b) __time = Time.now c = b.map do |elm| elm + 1 end c | [0] .tap { puts "#{ __method__.to_s }: #{Time.now - __time}" } end def method4 __time = Time.now 'Hello world' .tap { puts "#{ __method__.to_s }: #{Time.now - __time}" } end def method5(b) __time = Time.now return b .tap { puts "#{ __method__.to_s }: #{Time.now - __time}" } if b 0 .tap { puts "#{ __method__.to_s }: #{Time.now - __time}" } end def method6(b) __time = Time.now c = b.map do |elm| elm + 1 end c | [0] .tap { puts "#{ __method__.to_s }: #{Time.now - __time}" } end end
ちゃんとendの位置わかってますね。 インデントを意識して書き換える場合、locationメソッドを駆使して頑張ればできるかと思います。
動作を確認してみるとうまくいってそう。
[7] pry(main)> Sample.method1 method1: 3.0e-06 => "Hello world" [8] pry(main)> Sample.method2(2) method2: 4.0e-06 => 2 [10] pry(main)> Sample.method3([1,2]) method3: 6.0e-06 => [2, 3, 0] [11] pry(main)> test = Sample.new(1) initialize: 3.0e-06 => #<Sample:0x00007fe4b01dfaf0 @a=1> [12] pry(main)> test.method4 method4: 6.0e-06 => "Hello world" [13] pry(main)> test.method5(2) method5: 2.0e-06 => 2 [14] pry(main)> test.method6([1,2]) method6: 5.0e-06 => [2, 3, 0]
ためしにいくつか仕事のコードを書き換えてspecを実行してみましたが今の所エラーもなく時間のかかってるメソッドがわかって結構便利。 以上Rubyでシンプルなプロファイルコードを挿入する話でした。