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に備わっている分、裏でこれを実現するのに解決しないといけない問題がたくさん潜んでいます。この記事では、それの開発にあたって特に触れたい課題は下記の通りです。
- ルーティングのファイルを編集するコードで出てきたバグをどう解決したか
- その問題を解決した後に似たようなバグを避けるための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::CharacterTrait
はInsight
に属しているのでInsight
のresources
ブロックに入っているはずです。それに対して、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を楽しんでください!