Tech Racho エンジニアの「?」を「!」に。
  • 開発

Rails: Event Storeライブラリの非推奨APIをparser gemで書き直す(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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.selectorclient.('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アプリを構築するための弊社の日々の取り組みのノウハウを届けします。

以下の関連記事もお楽しみいただけるかと思います。

弊社の最新刊『Domain-Driven Rails』もぜひどうぞ。特に、巨大で複雑なRailsアプリを扱ってる方に有用です。


blog.arkency.comより

関連記事

Rails: Event Storeの新しいAPIを解説する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。