概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Rewriting deprecated APIs with parser gem | Arkency Blog
- 原文公開日: 2018/05/14
- 著者: Robert Pankowecki
- サイト: Arkency Blog
Rails: Event Storeライブラリの非推奨APIをparser gemで書き直す(翻訳)
次にリリースされるRails Event Storeでは、既存のリーダーメソッドを非推奨にする予定です。これらのメソッドはもっとスムースなクエリインターフェイス(Active Recordで有名になった方式)に置き換わります。移行を少しでも楽にするため、指定のコードベースで新しいAPIを使うよう移行するスクリプトを用意しました。
手短に申し上げると、従来の6種類のクエリメソッドはイベントのストリームを順方向/逆方向で読み出し、イベントの特定の限界に達したらそこで終わるか、あるいは終わらずにストリームの最初や指定の位置に戻ることができました。以下はコード例です。
client.read_events_backward('Order$1', limit: 5, start: :head).each do |event|
# Order$1ストリームから逆方向に最大5つのイベントを読み出して何かする
end
今回、このインターフェイスを次のように変更することに決めました。
spec = client.read.stream('Order$1').from(:head).limit(5).backward
spec.each do |event|
# Order$1ストリームから逆方向に最大5つのイベントを読み出して何かする
end
APIの非推奨化は難しくなさそうでした。古いメソッド呼び出しでwarningを表示し、そしておそらく新しい利用法を示せばよさそうです。
specify do
expect { client.read_events_backward('some_stream') }.to output(<<~EOS).to_stderr
RubyEventStore::Client #read_events_backward は非推奨化
Use following fluent API to receive exact results:
client.read.stream(stream_name).limit(count).from(start).backward.each.to_a
EOS
end
しかしエンドユーザーからすれば、移行作業は見た目よりも面倒になります。
- コードベース全体で多数使われている
- 非推奨warningを表示するために、関係のあるコードのパスをすべて調べて回らないといけない可能性がある
- 利用法がすべて同じとは限らず(リーダーメソッドのキーワード引数が異なる)、デフォルト値も考慮に入れなければならない可能性もある(暗黙で最大100の制限があるなど)
もちろん、コードベースでの利用法を調べ上げて手動で置き換えるかsed
を少々駆使すれば何とかなるかもしれませんが、もっとよいやり方があります。すなわちRubyを使ってRubyを書き換えられるのです。この素晴らしいparser
gemを導入しましょう。
gem ins parser
このgemは、置き換えたいコードがAST(抽象構文木)でどのような形になるかを最初にすべて分析します。先ほどの例を食わせてみましょう。
ruby-parse -e "client.read_events_backward('Order$1', limit: 5, start: :head)"
(send
(send nil :client) :read_events_backward
(str "Order$1")
(hash
(pair
(sym :limit)
(int 5))
(pair
(sym :start)
(sym :head))))
上の結果から、:read_events_backward
は何らかのクライアントレシーバーと思われるものに送信されたメッセージであることがわかります。引数、位置、キーワードの表現方法もASTノードとして示されます。
パズルの次のピースはParser::Rewriter
(最新リリースのparser
ならParser::TreeRewriter
)です。これは以下を用いてASTノードを改変できます。
insert_after(range, content)
insert_before(range, content)
remove(range)
replace(range, content)
引数は何でしょうか?(引数の)内容はコードの文字列です。この場合、client.read.stream('Order$1').from(:head).limit(5).backward.each.to_a
になるでしょう。range
を用いると少々複雑になります。ruby-parse -L
を用いてさらに秘密に迫ってみましょう。
ruby-parse -L -e 'client.read_events_backward(\'Order$1\', limit: 5, start: :head)'
s(:send,
s(:send, nil, :client), :read_events_backward,
s(:str, "Order$1"),
s(:hash,
s(:pair,
s(:sym, :limit),
s(:int, 5)),
s(:pair,
s(:sym, :start),
s(:sym, :head))))
client.read_events_backward('Order$1', limit: 5, start: :head)
~ dot
~~~~~~~~~~~~~~~~~~~~ selector ~ end
~ begin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ expression
s(:send, nil, :client)
client.read_events_backward('Order$1', limit: 5, start: :head)
~~~~~~ selector
~~~~~~ expression
s(:str, "Order$1")
client.read_events_backward('Order$1', limit: 5, start: :head)
~ begin ~ end
~~~~~~~~~ expression
s(:hash,
s(:pair,
s(:sym, :limit),
s(:int, 5)),
s(:pair,
s(:sym, :start),
s(:sym, :head)))
client.read_events_backward('Order$1', limit: 5, start: :head)
~~~~~~~~~~~~~~~~~~~~~~ expression
s(:pair,
s(:sym, :limit),
s(:int, 5))
client.read_events_backward('Order$1', limit: 5, start: :head)
~ operator
~~~~~~~~ expression
s(:sym, :limit)
client.read_events_backward('Order$1', limit: 5, start: :head)
~~~~~ expression
s(:int, 5)
client.read_events_backward('Order$1', limit: 5, start: :head)
~ expression
s(:pair,
s(:sym, :start),
s(:sym, :head))
client.read_events_backward('Order$1', limit: 5, start: :head)
~ operator
~~~~~~~~~~~~ expression
s(:sym, :start)
client.read_events_backward('Order$1', limit: 5, start: :head)
~~~~~ expression
s(:sym, :head)
client.read_events_backward('Order$1', limit: 5, start: :head)
~ begin
~~~~~ expression
ruby-parse
に-L
スイッチを付けると、ASTノードごとに範囲を親切に示してくれます。パースされたコードの特定の位置を参照するのにこれを使えます。
たとえば、以下のASTではnode.location.selector
がclient.
と('Order$1', limit: 5, start: :head)
の間の領域を参照していることがわかります。
s(:send,
s(:send, nil, :client), :read_events_backward,
s(:str, "Order$1"),
s(:hash,
s(:pair,
s(:sym, :limit),
s(:int, 5)),
s(:pair,
s(:sym, :start),
s(:sym, :head))))
client.read_events_backward('Order$1', limit: 5, start: :head)
~ dot
~~~~~~~~~~~~~~~~~~~~ selector ~ end
~ begin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ expression
さらに、複数の範囲を結合することもできます。node.location.selector.join(node.location.end)
を呼び出すと、read_events_backward('Order$1', limit: 5, start: :head)
の範囲がわかります。探していたのはまさしくこれです!
ここまでは順調ですが、さてreplace
する正確なnode
を取得するにはどうしたらよいでしょうか。このParser::Rewriter
クラスはParser::AST::Processor
クラスの子孫です。このクラスにパース済みASTとソースバッファを与えると、一致するツリーが見つかったときに即座にメソッドハンドラを呼び出します。
class DeprecatedReadAPIRewriter < ::Parser::Rewriter
def on_send(node)
_, method_name, *args = node.children
replace_range = replace_range.join(node.location.end)
case method_name
when :read_events_backward
replace(replace_range, "read.stream('Order$1').from(:head).limit(5).backward.each.to_a")
end
end
end
上のコード例では、read_events_backward
メソッドに渡された引数について何も考慮していません。私たちはTDDフローで最初の例に着目し、より詳細なテストexampleを与えることでコードをさらに一般化できるので、これは好都合です。
ちゃんと動くよう、インフラを完成させます。
RSpec.describe DeprecatedReadAPIRewriter do
def rewrite(string)
parser = Parser::CurrentRuby.new
rewriter = DeprecatedReadAPIRewriter.new
buffer = Parser::Source::Buffer.new('(string)')
buffer.source = string
rewriter.rewrite(buffer, parser.parse(buffer))
end
specify 'take it easy' do
expect(rewrite("client.read_events_backward('Order$1', limit: 5, start: :head)"))
.to eq("read.stream('Order$1').from(:head).limit(5).backward.each.to_a")
end
end
この記事でやったことをまとめると、RubyコードをパースしてASTにし、それを別の新しいものに変換するための知識を読み取る方法を学びました。そしてこれはまだ氷山の一角でしかありません。
学習のためにRails Event Storeリポジトリにある完全なDeprecatedReadAPIRewriter
スクリプトとspecをご覧いただけます。
もっと知りたい方へ
本記事を気に入っていただけた方は、Arkencyのニュースレターの購読をお願いします。開発者を悪い意味で驚かせないRailsアプリを構築するための弊社の日々の取り組みのノウハウを届けします。
以下の関連記事もお楽しみいただけるかと思います。
- RubyパーサーとASTツリーで非推奨構文を検出する —
grep
では手が届かないときに -
One simple trick to make Event Sourcing click — イベントソーシングでAggregate Rootを用いたときの興味深い事例の解説
-
Process Managers revisited — イベントを長期に渡って調整し、すべてのイベントが発火したら中断する障壁を置く方法
弊社の最新刊『Domain-Driven Rails』もぜひどうぞ。特に、巨大で複雑なRailsアプリを扱ってる方に有用です。