2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Bullet TrainでRubyソースコードの分析と編集

Posted at

Bullet Train

こんにちは、Bullet Train(Railsアプリを作るためのSaaS)のコアチームメンバーのガブリエルです。2020年の12月からBullet Trainのチームに加わり、普段のRailsの開発であんまり出てこない問題を解決できる機会がたくさん出てきますし、楽しく仕事をしています。我々Bullet Trainのサービスがご存知の方は分かると思いますが、Railsの開発の過程をより速く行えるための強力なツールが提供されており、私は主にそういったツールの開発・修正をしています。(Super Scaffoldingについてはこちらのリンク(英語)、またはこちらのQiitaの投稿(日本語)をご参照ください。)そのために裏では、モデル、コントローラーやビューのテンプレートを読み込んだり編集したりしてから、そのファイルをBullet Trainユーザーが開発しているアプリに適用することが多いです。例えば、Projectというモデルを作るにはこう書きます:

> rails generate model Project team:references title:string
> bin/super-scaffold crud Project Team title:text_field

この二つのコマンドを実行するだけで、Teamにネストされるモデル、コントローラー、そしてTailwindのビューが生成されます。テストも自動的に更新され、新しいエンドポイントをすぐに使えるように適切なルーティングも全部config/routes.rbに適用されます。普通のrails generate scaffoldに加えて、他に色んな働きもしています。

こう言った機能がBullet Trainに備わっている分、裏でこれを実現するのに解決しないといけない問題がたくさん潜んでいます。この記事では、それの開発にあたって特に触れたい課題は下記の通りです。

  1. ルーティングのファイルを編集するコードで出てきたバグをどう解決したか
  2. その問題を解決した後に似たようなバグを避けるためのgemの紹介

頭がパンク寸前...

このルーティングのバグなんですが、これは(筆者にとって)結構難しい問題で、作業がかなり楽しかった(笑 変なやつだと十分自覚しております、お許しください)。実際に作業を始める前に、頭の整理をするためコードを見ないでメモを取りましたが、その作業だけで1時間ぐらいかかりました。Pull Requestを見て読者にはそんなに難しい問題だとは思わないかもしれませんが、僕は結構苦労しました。興味ある方はPull Requestの方をご覧ください。

要するにどういうバグかというと、新しい名前空間を作る時、同じ名前を持っている名前空間が別の親の下に存在していても、新しい名前空間は正しい箇所に挿入されなくて、もう既に存在している方の名前空間に入ってしまうバグです。例えば、こちらのモデルを作りたいとします:

> rails g model Insight team:references name:string
> rails g model Personality::CharacterTrait insight:references name:string
> rails g model Personality::Disposition team:references name:string

Personality::CharacterTraitInsightに属しているのでInsightresourcesブロックに入っているはずです。それに対して、Personality::Dispositionは直接Teamに属しているので、完全に新しいnamespaceのブロックを作らないといけないです。こんな感じになるはずですね:

resources :teams
  resources :insights do
    namespace :personality do
      resources :character_traits
    end
  end

  namespace :personality do
    resources :disposition
  end
end

大分ややこしいコードなりそう、ということを想像できると思います。FIXの前にSuper Scaffoldingのコマンドを実行したら、実際の結果はこうでした:

resources :teams
  resources :insights do
    namespace :personality do
      resources :character_traits
      resources :disposition
    end
  end

統合テストでこのように名前空間がかぶっているモデルがいくつもあって、めちゃめちゃ大変でした。テストのセットアップを実行して出来上がったルーティングのファイルを見るだけでまさに頭がパンク寸前でした(泣)...

具体的な説明を割愛しますが、どう解決したかというと、名前空間を作る度にそのリソースを自分の親にスコープしてからファイルに挿入するようにしました。

で、現在はちゃんと動いていますが、そのようなバグが出てくるからやっぱりこれからどうやってRubyのソースコードを分析して編集したらいいのかが自分の中で次第に大きな疑問となっていきました。例えば、上記のPull Requestにはこのコードがあります:

if lines[line_number].match?(/^#{" " * (parent_indentation_size + 2)}namespace/)

チラッと見るだけでこのコードは脆いというのが分かるでしょう。この正規表現では、親のインデントを取ってそれに2を足して、その空白の大きさに一致している名前空間にマッチしています。

しかし、開発者は自分のエディターのインデントの大きさを4にするとどうなる?それか、意図せずに空白をもう一個行の冒頭に入れてしまったらどうなる?自分のせいじゃないのにSuper Scaffoldingが上手く動作しなくなり、デバッグするのに時間がかかってしまうでしょう。

同じPull Requestにはこのコードもあります:

namespace_line_number if lines[namespace_line_number].match?(/ +namespace :#{namespace}/)

この時点で、もう既にリソースの親が分かるので正規表現の/ +namespace :#{namespace}/を使っても問題はありませんが、こう言った正規表現はやっぱりミスが起こりやすいと思います。もし:project_sitesという名前空間がもう既に存在しているけど:projectという名前空間を新しく作ろうとしたら、上のコードは違う名前空間にマッチしてしまい、:projectは上手く挿入されないでしょう。

抽象構文木の存在について初めて知る

それで、このような問題を解決するのに積極的に答えを探していた訳ではないけど、やっぱり頭のどこかでmatch?gsubなど、正規表現だけを使ってソースコードを修正するというやり方がどうしても腑に落ちませんでした。新しいgemを開発しはじめたきっかけは実はRubyのしくみという本を読み始めたからです。抽象構文木とか、LALRとか色んなことについて学びました(まだ読書中なんですが)。Ruby Kaigi 2023では何人かのRubyコミッターと話しましたが、彼ら曰くRipperは大体非推奨のライブラリーであってsyntax_treeを使った方がいいよというアドバイスをいただきました。また、Bullet Trainの他のチームメンバーがreferralを勧めました。で、両方を見て確かに素晴らしいツールだと思いますが、したいことがまた違うなと感じて自分のgemを作り始めました。

syntax_tree

例えば、syntax_treeで気に入った機能の一つは、CLIで抽象構文木のノードを分かりやすい形で出力できることです。add.rbを作って中身をこう書いて...

2 + 2

CLIにかけるとこうなります:

> stree expr add.rb
SyntaxTree::Binary[
  left: SyntaxTree::Int[value: "2"],
  operator: :+,
  right: SyntaxTree::Int[value: "2"]
]

これはすごい。Ripperではノード毎の情報があんまり読みやすくはない。

しかもRipperの抽象構文木はこんな感じで、ちょっぴり読みにくいです。

irb(main):001:0> Ripper.sexp("2 + 2")
=> [:program, [[:binary, [:@int, "2", [1, 0]], :+, [:@int, "2", [1, 4]]]]]

syntax_treeではこういう風にキレイに表示されます:

> stree ast add.rb
(program (statements ((binary (int "2") + (int "2")))))

ただし、syntax_treeではRipperみたいに行数は書いてませんね([1, 4]とか)。syntax_tree自体がものすごく良くできていると思うし、他にどんな機能があるか完全に分かってない自分がいるというのも事実ですが、とにかくこれはやりたいこととは違うなと感じました。僕は実現したかったのは、Rubyのコード内で他のファイルを読み込み、文字列なり変数名なり、その場で適切なデータ型やメソッドにピッタリ合う物を見つけてそれ編集することでした。

Masamune

ということで、Masamuneの作りはじめました。Ripperという単語の意味は「引き裂く」のに対して、Masamuneはソースコードを切る(激しい...)。このgemはRipperから適切なノードとそれに結びついているトークンや行数の情報を引っ張り出して、データ型やメソッドなどを見つけやすくしてくれます。次のコード例を見てください。

require "masamune"

code = <<CODE
java = "java"
javascript = java + "script"
puts java + " is not " + javascript
# java
CODE

msmn = Masamune::AbstractSyntaxTree.new(code)

msmn.variables
#=> [{:line_number=>1, :index_on_line=>0, :token=>"java"},
#=> {:line_number=>2, :index_on_line=>0, :token=>"javascript"},
#=> {:line_number=>2, :index_on_line=>13, :token=>"java"},
#=> {:line_number=>3, :index_on_line=>5, :token=>"java"},
#=> {:line_number=>3, :index_on_line=>25, :token=>"javascript"}]

msmn.strings
#=> [{:line_number=>1, :index_on_line=>8, :token=>"java"},
#=> {:line_number=>2, :index_on_line=>21, :token=>"script"},
#=> {:line_number=>3, :index_on_line=>13, :token=>" is not "}]

msmn.variables(name: "java")
#=> [{:line_number=>1, :index_on_line=>0, :token=>"java"},
#=> {:line_number=>2, :index_on_line=>13, :token=>"java"},
#=> {:line_number=>3, :index_on_line=>5, :token=>"java"}]

Bullet Trainではこの情報がとても貴重です。完全に一致するトークンを見つけることができたら、ミスが起こりやすい正規表現や他の似たコードを避けることができます。

Bullet Train自体は何年前からあるサービスですので、この辺りのロジックを全部Masamuneに切り替えるのは結構時間がかかるだろうから、ちょっとずつ入れていこうと思います。しかもRubyのファイル以外にはyamlやerbのテンプレートも扱っているので、そこも考えないといけません。すぐにはなりませんが、Masamuneを使うことで正確性の向上にはつながると思います。最近はこのPull Requestを作りましたが、そこで適切な名前空間を検知して、その名前を正確に取得するコードが載っています。

# `@msmn`はMasamune::AbstractSyntaxTreeから作られた
# インスタンス変数で、Railsの`config/routes.rb`の中身を分析しています。
namespaces = @msmn.method_calls(name: "namespace")
namespace_line_numbers = namespaces.map { |namespace| namespace[:line_number] }

namespaceが呼ばれる行数を見つけてから、その後に来る最初のシンボルを返しています。

if namespace_line_numbers.include?(line_index)
  namespace_name = @msmn.symbols.find { |sym| sym[:line_number] == line_index }[:token]
  # …
end

これで一安心。Masamuneでは実装していない機能がまだあるし、Bullet TrainではMasamuneをどこまで使えるかまだ分かりませんが、このような問題を解決できるのが楽しいですし、プログラミングがもっと好きになるし、もっと正確にコード生成のロジックを書くことで他の開発者が楽しくアプリの開発ができたら、僕はそれで嬉しいです。

syntax_treeをもう一度

Masamuneでは抽象構文木のノードのクラスがいくつかがありますが、それらを全部Ripperから生成しています。しかし、syntax_treeは本当にいいgemだと思うので、将来にノードのクラスをsyntax_treeをベースにして生成するように書き換えるかもしれません。まだやらないといけない作業がたくさん残っているけど、もしBullet Trainに興味を持っていたら、ぜひ使ってみてSuper Scaffoldingを楽しんでください!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?