ISUCON13 で rbs-inline 使ってみた
ISUCON で型がパチパチっとハマった開発ができるとかなり開発体験変わってくるのでは?と思い、 ISUCON の過去問に型をつけていくのをやってみています。
モチベーションに対してもう少し詳しい記事はこちら
まずは、初期実装の状態から挙動を変えずに型だけをつけてみることに取り組みます。
また、アプリケーションに対応する型は rbs ファイルは直接触らずに rbs-inline のみを使って生成することにしました。おそらく ISUCON 本番でも別ファイルをいじっている余裕はないと思うためです。
ISUCON13 で rbs-inline を使って steep check が通るところまで行けたので、やっていく中で感じたことやこうだったらもっと便利なのにと思ったことをまとめてみます。ちょっとしたスクリプトに対して使ってみた記事はたまに見かけますが、ちゃんとしたアプリケーションで使ってみた記事はあまり見たことがないので何かの参考になれば幸いです。
初期実装からの差分はこちらです。差分を眺めながら記事を読むと内容が掴みやすくなると思います。
https://github.com/euglena1215/isucon13-rbs/compare/3bb0e7c..main?diff=unified&w=
ISUCON でよく使われるライブラリに型が全然書かれてない
まあこれはそうだろうなと思って始めました。これに関しては型を書いていけばいいことです。ISUCON13 で使われているライブラリに関しては一通り型を書きました。
- https://github.com/ruby/gem_rbs_collection/pull/561
- https://github.com/ruby/gem_rbs_collection/pull/562
- https://github.com/ruby/gem_rbs_collection/pull/563
- https://github.com/ruby/gem_rbs_collection/pull/564
- https://github.com/ruby/gem_rbs_collection/pull/565
- https://github.com/ruby/gem_rbs_collection/pull/566
- https://github.com/ruby/gem_rbs_collection/pull/567
ISUCON で頻出ライブラリは限られているので次の過去問に型をつけるときはもっと楽になっているはずです。
Enumerable#first
の nil チェックをめっちゃ書く必要ある
正直型を書くだけで型チェックが通ると思ってました。
Enumerable#first
はリストの要素数が 1個以上であればその1つ目を返し、0個であれば nil を返すメソッドです。この nil が返ってくる挙動を無視して first を使った際に常に nil 以外が返ってくることを前提とした実装になっていたので nil チェックを書いていく必要が結構ありました。
具体的には以下のような実装です。
user_model = Mysql2::Client.new.xquery('SELECT * FROM users WHERE id = ?', 1).first user_model.fetch(:id) # ここで user_model 変数は nil が返ってくる可能性があるのに nil でない前提の実装になっている
上記の実装で steep check を実行すると下記のようなエラーが表示されます。
app.rb:208:19: [error] Type `(::Hash[::Symbol, ::Mysql2::row_value_type] | nil)` does not have method `fetch` │ Diagnostic ID: Ruby::NoMethod │ └ user_model.fetch(:id) ~~~~~
上記を解消するには下記ような nil チェックが必要です。
user_model = Mysql2::Client.new.xquery('SELECT * FROM users WHERE id = ?', 1).first raise if user_model.nil? # ここ user_model.fetch(:id)
nil チェックを追加すればいいだけなので難しいことはないんですが、ISUCON13 の初期実装で20箇所くらい対処した気がします。塵も積もれば山となるので、時間が惜しい ISUCON においては Enumerable#first!
のような nil を返さないメソッドを用意しておいた方がいいかもしれません。
自分はこんな感じのお手製 Enumerable#first
を app.rb に添えておきました。これで変更差分がかなり減りました。
# @rbs generic unchecked out Elem module Enumerable def first! #:: Elem first || raise('empty') end end
instance(T) 的なものがほしい
ISUCON13 には request body を Data.define
を使って定義したクラスにマッピングしてくれる便利メソッドがあります。
def decode_request_body(data_class) body = JSON.parse(request.body.tap(&:rewind).read, symbolize_names: true) data_class.new(**data_class.members.map { |key| [key, body[key]] }.to_h) end ReserveLivestreamRequest = Data.define(...) # こんな感じで使える。req には request body の中身がマッピングされた # ReserveLivestreamRequest クラスのインスタンスが入っている。 req = decode_request_body(ReserveLivestreamRequest)
この便利メソッドである decode_request_body
メソッドの型をどうつけるか悩みました。
これにどう対処したかというと、引数として取りうるデータクラスの Union を作って、返り値としてはその Union に対応するクラスのインスタンスを返すようにしました。ただ、これだとこのメソッドの呼び出し側で毎回データクラスのチェックをしないといけないんですよね。
# :: (singleton(ReserveLivestreamRequest) | singleton(PostLivecommentRequest) | singleton(ModerateRequest) | singleton(PostReactionRequest) | singleton(PostIconRequest) | singleton(PostUserRequest) | singleton(LoginRequest) data_class) -> (ReserveLivestreamRequest | PostLivecommentRequest | ModerateRequest | PostReactionRequest | PostIconRequest | PostUserRequest | LoginRequest) def decode_request_body(data_class) body = JSON.parse(request.body.tap(&:rewind).read, symbolize_names: true) data_class.new(**data_class.members.map { |key| [key, body[key]] }.to_h) end req = decode_request_body(ReserveLivestreamRequest) # 毎回 ReserveLivestreamRequest のインスタンスであることをチェックして Union を外す必要がある raise unless req.is_a?(ReserveLivestreamRequest)
どうなってると嬉しかったかというと、以下のように instance()
のようなインスタンスを表現するものがあれば良いのにと思いました。こうできれば呼び出し側でデータクラスのチェックを行う必要はないですし、仮にデータクラスをパターンとして増やしたくなったときもメソッドの型を修正する必要はありません。
#:: [T < Data::_DataClass] (T data_class) -> instance(T) def decode_request_body(data_class) body = JSON.parse(request.body.tap(&:rewind).read, symbolize_names: true) data_class.new(**data_class.members.map { |key| [key, body[key]] }.to_h) end
2024/05/28 追記
(singleton(A) | singleton(B)) -> A | B
といった型ではなく、(singleton(A)) -> A | (singleton(B)) -> B
という型にすれば呼び出し側でチェックする必要はないのでは?というアドバイスを pocke さんからもらいました。ありがとうございます。
decode_request_body の返り値の型が一意に定まるように Union の使い方を修正 · euglena1215/isucon13-rbs@5278c44 · GitHub
修正する中で rbs-inline の #::
を使った記法ではメソッドのオーバーロードをサポートしていないことが分かったので issue を立てておきました。
2024/05/31 追記
issue にて以下の構文はサポートしていないものの、
#:: (singleton(A)) -> A | (singleton(B)) -> B def decode_request_body(data_class) ... end
以下の構文はサポートしているということを教えてもらいました。
#:: (singleton(A)) -> A #:: (singleton(B)) -> B def decode_request_body(data_class) ... end
Syntax-guide にも書いてありましたね...
Data.define からクラス定義を生成してほしい
rbs-inline では Data.define
によって定義されたクラスは untyped な定数として宣言されます。これを定数としてみなしたい rbs-inline の気持ちも分かりつつ、実用上結構不便でした。
ReserveLivestreamRequest = Data.define( :tags, :title, :description, :playlist_url, :thumbnail_url, :start_at, :end_at, ) => ReserveLivestreamRequest: untyped
これにどう対処したかというと、 @rbs skip
を使って rbs-inline による定義を無視しつつ @rbs!
でコメントで型を直接書きました。@rbs skip
を使う必要に迫られたのはここくらいでした。
# @rbs! # class ReserveLivestreamRequest # extend Data::_DataClass # attr_reader tags: Array[Integer] # attr_reader title: String # attr_reader description: String # attr_reader playlist_url: String # attr_reader thumbnail_url: String # attr_reader start_at: Integer # attr_reader end_at: Integer # def self.new: (*untyped) -> ReserveLivestreamRequest # | (**untyped) -> ReserveLivestreamRequest | ... # end # @rbs skip ReserveLivestreamRequest = Data.define( :tags, :title, :description, :playlist_url, :thumbnail_url, :start_at, :end_at, )
self.members
メソッドが必要だったので Data::_DataClass
を include しています。Data::_DataClass
を include した状態で self.new
を呼ぶと Data::_DataClass
に定義されている self.new
の定義が利用されて Data
クラスを返す型になり困りました。このため、自身のクラスを返すように self.new
をオーバーロードしています。
これがどうなってると嬉しかったかというと、以下のような記述をすると上記の @rbs!
を使って記述した型定義が生成されると嬉しかったです。
ReserveLivestreamRequest = Data.define( :tags, #:: Array[Integer] :title, #:: String :description, #:: String :playlist_url, #:: String :thumbnail_url, #:: String :start_at, #:: Integer :end_at, #:: Integer )
2024/05/28 追記
soutaro さんから「ISUCONなら実行時に生成してしまえばいいのでは?」というアドバイスをもらって Data.define
を拡張した Data.typed_define
を用意しました。
class Data # @rbs self.@classes: Hash[untyped, Hash[Symbol, String]] @classes = {} #:: [KLASS < ::Data::_DataClass] (**untyped) ?{ (KLASS) [self: KLASS] -> void } -> KLASS def self.typed_define(**attrs, &block) k = define(*attrs.keys, &block) @classes[k] = attrs k end def self.generate_rbs(class_name, attrs) <<~RBS class #{class_name} extend Data::_DataClass #{attrs.map { |k,v| "attr_reader #{k}: #{v}" }.join("\n ")} def self.new: (*untyped) -> #{class_name} | (**untyped) -> #{class_name} | ... end RBS end at_exit do body = '' @classes.each do |klass, attrs| body += generate_rbs(klass.name, attrs) + "\n" end File.write('sig/generated/typed_data.rbs', body) end end
下記のように Data.define
を書き換えた上で bundle exec ruby app.rb
を実行すると sig/generated/typed_data.rbs
が生成されます。このコマンドを rake rbs:setup
に追加しておくことで常に更新されるようになりました。
# Before PostLivecommentRequest = Data.define( :comment, :tip, ) # After PostLivecommentRequest = Data.typed_define( comment: 'String', tip: 'Integer', )
業務ではもう少し体裁を整える必要がありそうですが、ISUCON では問題なく使えそうです。
Data.define に対応するクラスを自動生成した · euglena1215/isucon13-rbs@11a2230 · GitHub
Sinatra の helpers が型との相性が悪い
Sinatra の helper は「helper は別の helper を呼び出せる」「HTTP method 系ブロックで helper を呼び出せる」という挙動になっています。
class MyApp < Sinatra::Base helpers do def foo = 'foo' def bar foo # helper は別の helper を呼び出せる end end get '/foo' do foo # リクエストをハンドリングする HTTP method 系のブロックでも helper が呼び出せる end end
これ、静的な型チェックにおいては「helper は別の helper を呼び出せる」という文脈で呼び出した際はインスタンスメソッドとしての foo
を探しに行くんですが、「HTTP method 系ブロックで helper を呼び出せる」という文脈で呼び出した際は特異メソッドとしての self.foo
を探しに行くんですよね。そのため、helpers で定義したメソッドはインスタンスメソッドとして定義するのと同時に特異メソッドとしても定義しないと意図した挙動になりませんでした。
これにどう対処したかというと、メソッド定義の少し下に @rbs!
を使って型を直接コメントで記述しました。
helpers do def db_conn #:: Mysql2::Client[Mysql2::ResultAsHash] Thread.current[:db_conn] ||= connect_db end # @rbs! # def self.db_conn: () -> Mysql2::Client[Mysql2::ResultAsHash] end
ただ、この方法だと特異メソッドに対応する実装は存在しないため Ruby::MethodDefinitionMissing
で steep check でエラーになります。そのため、このエラーだけはエラーレベルを error から warning に落としてお茶を濁しています。
正直この問題に対してはどう対処すべきなのかがあまり見えていません。Sinatra で型を書こうと思った人はみんなハマる問題なんじゃないかと思ってるので何かしら対処できた方が良さそうな気はしているものの、どうしたらいいんでしょうね…
2024/05/28 追記
ブロックを引数に取る場合はブロック引数に [self: instance]
とアノテーションするとブロック内はインスタンスメソッドを実装しているのと同じ扱いをされるようになると soutaro さんから聞きました。ありがとうございます。
get
, post
といった HTTP メソッド系のメソッドのブロック引数に [self: instance]
をつけてみると、ISUCON13 で型のために必要な記述量が33行も減りました 🎉
不要な helper メソッドに対するシングルトンメソッドの定義の削除 · euglena1215/isucon13-rbs@a041faa · GitHub
型による恩恵は十分にありそう
型のある状態で問題を解いたわけではないので「ありそう」という表現にはなってしまうものの、十分に恩恵が感じられる手応えがありました。
例えば Mysql2::Client#xquery
は引数に as: :array
をつけるかどうかで返す型が変わります。
Mysql2::Client.new(...).xquery('SELECT * FROM users') # => Array[Hash] Mysql2::Client.new(...).xquery('SELECT * FROM users', as: :array) # => Array[Array]
型がない場合は、そもそもこの仕様を知っているか注意深くコードを読まないと把握するのは困難です。ISUCON という焦った状況の中だと自分はミスをして何回もベンチマークを走らせて時間を浪費しながら原因を特定する自信があります。
ですが、型をつけたことによって xquery
の引数によってどちらが返ってくるかの型を決定し、誤った参照をしている場合には steep check やエディタの波線ですぐに怒ってくれるようになっています。これならすぐに気付いてベンチマークを走らせる前に修正ができそうです。
Mysql2::Client.new(...).xquery('SELECT id FROM users').each do |row| row[0] # app.rb:xx:xx: [error] Cannot pass a value of type `::Integer` as an argument of type `::Symbol` # │ ::Integer <: ::Symbol # │ ::Numeric <: ::Symbol # │ ::Object <: ::Symbol # │ ::BasicObject <: ::Symbol # │ # │ Diagnostic ID: Ruby::ArgumentTypeMismatch # │ # └ row[0] # ~ end Mysql2::Client.new(...).xquery('SELECT id FROM users', as: :array).each do |row| row[:id] # app.rb:xx:xx: [error] Cannot find compatible overloading of method `[]` of type #`::Array[::Mysql2::row_value_type]` # │ Method types: # │ def []: (::int) -> ::Mysql2::row_value_type # │ | (::int, ::int) -> (::Array[::Mysql2::row_value_type] | nil) # │ | (::Range[(::Integer | nil)]) -> (::Array[::Mysql2::row_value_type] | nil) # │ # │ Diagnostic ID: Ruby::UnresolvedOverloading # │ # └ row[:id] # ~~~~~~~~ end
Ruby で型のある開発がどのような体験になるのかを知りたい方は手元に clone してみてちょっと触ってみるといいのではないでしょうか。セットアップ手順は書いてないのでそこはよしなにお願いします...
GitHub - euglena1215/isucon13-rbs
さいごに
こうなっていてほしいという点はいくつかあったものの、rbs-inline を使って型を書き切ることができたのは素晴らしかったです。諸々を見越した @rbs skip
や @rbs!
という抜け道が事前に用意されていたからこそだと思います。ありがとうございます。
Ruby の静的型チェック × ISUCON という夢
RubyKaigi 2024 お疲れさまでした。楽しかったですね。
みなさん RubyKaigi で持ち帰ったものは色々あるんじゃないかと思いますが、自分が持ち帰ったものは Ruby の静的型チェック × ISUCON でした、という話を書こうと思います。ポエムです。
ISUCON の説明はこの記事では割愛します。ISUCON のサイトに書いてある説明の引用だけ貼っておきます。
ISUCONとはLINEヤフー株式会社が運営窓口となって開催している、お題となるWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトルです
突然ですが、最新の ISUCON である ISUCON13 の利用言語比率は以下のようになっています。
ISUCON13 利用言語比率
利用率の全体ランキングは以下の通りです
Go 465組 70.8%
Ruby 61組 9.3%
Nodejs 47組 7.2%
Python 34組 5.2%
Rust 25組 3.8%
PHP 19組 2.9%
Perl 5組 0.8%
Bun 1組 0.2%
上位30チームに限定すると以下となりました
Go 29組 96.7%
Ruby 1組 3.3%
Go の利用比率が 70% を超えてますね。すごい。近年の ISUCON は Go の一強と言えそうです。
それでは、自分が初めて参加した ISUCON である ISUCON7 の予選の利用言語比率を見てみましょう。
オンライン予選 利用言語比率
利用率の全体ランキングは以下の通りです。利用言語は自由記入で複数入力したチームもありますので合計が回答チーム数を超えます。
Ruby 68組 31.2%
Go 62組 28.4%
Python 28組 12.8%
PHP 25組 11.5%
Perl 19組 8.7%
Node.js 18組 8.3%
C# 1組 0.5%
本選出場が決まった30チームに限定すると以下となります。
Go 16組 53.3%
Ruby 6組 20.0%
Node.js 4組 16.7%
Python 2組 6.7%
未回答 1組 3.3%
なんと、Go よりも Ruby の方が利用比率が高くなっています。というよりも、満遍なく色々な言語が使われていますね。
この数年間でここまで取り巻く環境が変わったのは一体なぜなんでしょうか。これは Ruby から Go に乗り換えた人に話を聞いてみるしかありません。
…はい、私です。ISUCON7は Ruby で参加していましたが、ISUCON13 は Go で参加しました。
普段業務で使うのは Ruby ばかりだし、こういうときくらい他の言語でもかじっておくかという気持ちは多少あったりしますが、本当に勝ちたいと思ったときにどっちを使うかと問われたとしても結構悩みます。Ruby はずっと使い続けていて Go は業務ではほとんど使ったことがないにしても、です。
どうしてそう思うのでしょうか。
まず頭に思い浮かんだのは言語のパフォーマンスです。Ruby よりも Go の方が速そうです。
ただ、RubyKaigi でも話があったように Ruby は十分速い言語になっています。優勝争いをするのなら少しのパフォーマンスも気にするかもしれませんが、自分が ISUCON に参加するときはそのレベルまでチューニングができた試しがありません。また、例年 Ruby で参加している白金動物園は上位争いにおいても Ruby で戦えることを証明しています。
パフォーマンス面においては Ruby も十分速いことを理解しました。では次回の ISUCON は Ruby で参加しようと思え……ませんでした。
というのも、個人的には最も大きな要因は「ISUCONにおける開発生産性の差」にあるのではないかと考えています。
ISUCON は普段開発を行う環境とは異なる特殊な環境です。具体的には以下が特殊だと考えます。
時間制限がシビア
ISUCON は8時間という限られた時間の中で対処を行う必要があります。そのため、普段の業務では行わない判断も時には正しい判断となることがあります。
テストがない
普段業務で開発を行う際はテストがあるのが当たり前です。テストコードなしで本番にデプロイするのは怖くてできません。しかし、ISUCON はテストがない状態からスタートします。業務なら「テストがないならテストを書けばいいじゃない」とテストを書くかと思いますが、ISUCONにそんな時間はありません(少なくとも自分はそう捉えてしまっています)。
なので、ベンチマークをテスト代わりに使います。そのために、コード修正のフィードバックサイクルが数分かかることはザラです。ベンチが詰まっているときは10分くらいかかることもあります。
普段の仕事であまり使わないライブラリを使う
普段業務で使うライブラリはだいたいのクラスやメソッドが頭の中に入っています。そのため、ドキュメントをあまり読まなくても一筆書きである程度の確率で動くコードを書くことができます。しかし、ISUCON では普段使わないライブラリを使うことが求められます。業務では ActiveRecord を使う DB 操作が ISUCON では Mysql2 を使って操作を行う必要があります。
そのため、それらのメソッドを正しく使うにはドキュメントを読まないといけません。きちんとドキュメントを読みに行くのならいいのですが、ISUCONは時間制限がシビアなので「こんな感じで動くのでは?」とそれっぽい実装をしてみてテスト代わりのベンチマークを実行します。だいたい一筆書きでは動きません。ちょっと修正してベンチマークを実行して、ちょっと修正してベンチマークを実行して… と一回のコード修正のフィードバックサイクルが遅いのにも関わらず何度も実行する羽目になり時間を浪費してしまいます。
(このあたりは猛者は過去問を何度も何度も練習することにより、ISUCON 頻出ライブラリを頭に叩き込んでいるんだと予想しています。ただ、自分は ISUCON 本番までに 2,3回程度しか練習しないことが多いのでなかなか覚えるまでに至っていないことが多いです…)
要するに、コード修正のフィードバックサイクルが遅いのと、ライブラリの使い方を頭に叩き込んでいないといけないのが問題なわけです。
対して Go はどうでしょうか。Go もテストはないのでベンチマークを実行しないと正しく動くかは保証されませんが、Linter やコンパイルによって実行前に型チェックでコケる部分を教えてくれます。また、エディタのサポートによって関数にどんな引数を渡したらどんな返り値が返ってくるのかを教えてくれます。このときのコード修正に対するフィードバックサイクルは1秒かからないくらいなのではないでしょうか。
また、ベンチマークの結果が教えてくれるのは「このエンドポイント叩いたら500になったよ」という結果だけです。ベンチマークが失敗したら自分でシステムログを見に行ってどんなエラーが発生したのかをログの中から見つけ出す必要があります。
…と前振りがかなり長くなってしまいましたが、自分は ISUCON で Ruby でもパチパチっと型がハマった開発ができると開発体験が一変するのでは?と感じました。
上記の Go のような体験は gem_rbs_collection, rbs-inline, typeprof 周りが整ってくれば実現できると思います。そうなれば、自分があまり慣れていない Go を使うよりも Ruby を使った方が ISUCON で勝ちやすくなりそうです。
もちろん、Ruby の参考実装には十中八九型はついていないので ISUCON 競技開始の最初の 10~20分を使って rbs-inline でアプリケーションの型を書くのはオーバーヘッドとして必要です。ただ、コードの全体像を把握するのにも価値がある時間の使い方だと思いますし、全然許容範囲内です。
現状では、おそらく ISUCON の Ruby の型周りを整備しようと思った人はあまりいないと思うので型付けを10~20分で済ませるには色々やる必要があるはずです。
- ISUCON 頻出のライブラリの型を書く
- ISUCON で使う上での rbs-inline, steep, typeprof の足りない機能のフィードバック、patch を送る
- 型のためのコード量を減らすような gem を作る(?)
自分ができることを探して、自分が ISUCON で勝つために Ruby を使いたいと思える世界に近づけるようやっていきたいと思います。整備が終わるまで kaigieffect 続いてくれ…!
RubyKaigi2024 に参加してきました
2024/05/15~17で RubyKaigi2024 があったので行ってきました。開催場所は沖縄です。
会社の業務として参加させてもらったので宿泊費・交通費・チケット代は会社から出してもらい、勤務扱いということもしてもらってます。大変ありがたい。
つらつらと書きたいことを書きたい分だけ書いていこうと思います。なので、読んだからといって何か学びがあるわけではない日記のようなものだと思ってください。
今回の RubyKaigi の過ごし方
これまでは「全部のセッションを聞かないと!」「ブースも全部回り切らないと!」「毎日開催されているdrink upのどれかには常に参加して楽しみきらないと!」と思いながら参加をしていましたが、今回はちょっと肩の力を抜いてみました。
適度にセッションを休んで空いたスポンサーブースを回ってみる
全ての時間帯のセッションを聞こうとするのではなく、適度に休みつつ色々聞いていました。休んでいる間は比較的空いているスポンサーブースを回ってみたりします。
それも端っこから全てのスポンサーブースを回ろうとするのではなく、混んでいるブースは「まあまた後で来ればいいか」とスキップしていました。スキップしたブースに行き忘れたとしても「まあ多分来年も出してるだろう」と気にしません。*1
スポンサーブース前での各社との議論を楽しむ
自社のスポンサーブースでわちゃわちゃしていると、自社の発信を見かけた方が「〇〇どう進めてますか?今どんな感じですか?」と声をかけてもらえることがありました。
具体のトピックとしてはモジュラモノリスなんですが、やはりどの会社も大きくなって管理が煩雑になった大きな Rails とどう向き合っていくかに頭を悩ませているところが多いようです。
他の会社の事例が聞けて大変参考になりましたし、正直最近モジュラモノリスの取り組みが停滞気味だったのでしっかり進捗を出していかないとなと良い刺激にもなりました。
各社のモジュラモノリスへの考えを廊下で聞けて個人的にはモジュラモノリスkaigiになりつつある #rubykaigi
— Shintani Teppei (@euglena1215) 2024年5月15日
話し込んでいると、聞く予定だったセッションを聞き逃すこともありました。が、色々とためになる話ができたので気にしません。
drink up は常に参加していなくても良い
各日終了後の毎日の drink up は RubyKaigi の恒例行事です。各社があの手この手で RubyKaigi 参加者を楽しませてくれます。
Rubyist と色々話せるし基本的には参加した方が良いんですが、今回 day2 は drink up に参加せずにホテルに籠もって色々考えを整理してみたり、コードを触ってみてました。
今日は色々まとめたり触ってみたりしたくなったのでホテルに籠もります#rubykaigi
— Shintani Teppei (@euglena1215) 2024年5月16日
RubyKaigi が終わってからでも良いかもと一瞬頭をよぎったものの、忘れないうちに考えをまとめておきたいというのとモチベーションは生ものなので、やりたいときにやっておいた方が良いだろうと考えました。
おかげで、day2 の mame さんの発表 Good first issues of TypeProf - RubyKaigi 2024 で紹介があった good first issue に取り組むことができました。
今なら typeprof に1つ patch を送ってマージされるだけでトップページの Contributers に表示されるのでお得(?) pic.twitter.com/yagndTrnmj
— Shintani Teppei (@euglena1215) 2024年5月17日
また、drink up に参加しても2次会、3次会への参加はあまりしていませんでした。締めステーキを食べるタイミングを逃したことは後悔してなくはないですが、そのおかげ(?)で3日間とも胃腸が元気な状態で RubyKaigi に参加できたとポジティブに捉えています。
開発者体験の改善
セッションについても触れておきます。
自分が聞いた中で特に印象に残っているのは開発者体験の改善という切り口です。
個人的な感覚にはなりますが、Ruby でのプログラミング体験を向上させるような取り組みが多いように感じました。 且つ、少し良くなるものというよりはこれまでの開発体験を劇的に変化させるようなゲームチェンジャー的な技術要素があったように思います。 具体的には、型周りの話と namespace の話です。Ruby Committers and the World で行われた Matz の「本当に async / await を書きやすいと思ってる?」という問いかけもとても印象的でした。
特に typeprof, rbs-inline, gem_rbs_collection, steep といった型周辺の技術は Ruby を使った開発体験をより良いものにする可能性に満ち溢れているような気がしています。
この辺りをリードしてもらっている方々に感謝をしつつ、自分ができそうな貢献は積極的にやっていきたいと思います。
今回あまり parser の話は追えていませんが、盛り上がっていた parser 周りの話も Ruby 自体の開発体験の向上とも捉えられると思います。(parse.y をメンテできるのが nobu さんくらいしかいないという話があった気がする)
ちょこっと観光
day0 の夕方に沖縄に到着して、day4 の夕方には沖縄を出発してしまったのであんまり観光できてないですが、国際通りでお土産を買ったあとウミカジテラスを散策しました。海が見れてよかったです。
これは砂浜に打ち上げられてるなすび pic.twitter.com/UGRXR87Rsm
— Shintani Teppei (@euglena1215) 2024年5月18日
来年は少し早くて4月に松山ですね。*2次回もどんな話が聞けるか楽しみです。
ChatGPT の進化も楽しみです。同時通訳できるようになってることを信じたい。
あと、RubyKaigi にプロポーザルを出せるような成果を1年間で出せるといいな...
Rails7.1からcolumn,enum名に使えない名前が増える可能性が高いので注意
3行まとめ
- Rails 7.1 から
dup
,freeze
,hash
,object_id
,class
,clone
,frozen
は column 名や enum の種類として使えなくなる可能性が高い - 該当 column を持つ Model の initialize で
ActiveRecord::DangerousAttributeError
が発生する - 各位そういった名前を使わない、rename するなど身構えておきましょう
起きていた問題
会社の Rails アプリケーションに対して rails/rails の main branch を使ってテストを走らせていたら以下のようなエラーを数多く見かけるようになった。
ActiveRecord::DangerousAttributeError: object_id is defined by Active Record. Check to make sure that you don't have an attribute or method with the same name. # /usr/local/bundle/gems/factory_bot-6.2.1/lib/factory_bot/decorator/new_constructor.rb:9:in `new' # /usr/local/bundle/gems/factory_bot-6.2.1/lib/factory_bot/decorator.rb:16:in `send' ...
どうやらテストの前準備のレコード作成でコケている様子。(具体的には factory_bot の create(:xxxx)
)
object_id...?なんのことだ...?と思って調べてたら作成対象の model の association に object_id という column を持つ model が存在することが分かった。 ActiveRecord によって object_id メソッドを上書きされるの危険そうだなと思いつつ、なぜ Rails 7.0 ではエラーにならず Rails 7.1 で問題になるのか分からなかったので調べてみて原因を特定した。
変更が入ったのは上記 PR。column 名に hash
という名前を使うと ActiveRecord が Object#hash
をオーバーライドしてしまい、問題が発生していた。
その問題と今後発生しそうな問題を防ぐために Object クラスでオーバーライドされると困りやすい dup
, freeze
, hash
, object_id
, class
, clone
を AR が定義しようとするとエラーにする変更を加えたとのこと。
後続の変更で frozen?
も使えなくなっている。AR は boolean の column に対しては自動的に ?
付きのメソッドを定義するので boolean の frozen column も使えなくなってそう。
これは column だけではなく enum によるメソッド定義でも同様なので enum の種類に frozen を使っているアプリケーションも書き換えが必要になるんじゃないかな。
まだ Rails 7.1 はリリースされていないので確定ではないものの、主張としては真っ当なのでそのまま入りそうな気がしている。 上記に該当するアプリケーションをお持ちの方は身構えておくと良いのではないでしょうか。
感想
git bisect コマンド初めて使ったんですが、これ便利ですね...
Rails の has many through 経由でモデルを削除すると destroy callback が呼び出されない
has many through の挙動についてずっと勘違いしていたところがあったので忘れないように書き留めておきます。
3行まとめ
- has many through のデフォルトの挙動では削除時に delete_all を実行したのと同じ挙動になり、callback が発火しない
- has many through のオプションに
dependent: :destroy
をつけると destroy callback が呼び出され一般的に期待する挙動になる - has many through を使う際は常に
dependent: :destroy
をつけるようにした方が良いのでは?
起きていた問題
例えば、図書館のような誰がどの本を借りているかを管理するアプリケーションがあったとすると、以下のような model が存在しているはず。
# == Schema Information # # Table name: users # # id :bigint not null, primary key # created_at :datetime not null # updated_at :datetime not null # class User < ApplicationRecord has_many :bookings has_many :books, through: :bookings end # == Schema Information # # Table name: books # # id :bigint not null, primary key # content :text # created_at :datetime not null # updated_at :datetime not null # class Book < ApplicationRecord end # == Schema Information # # Table name: bookings # # id :bigint not null, primary key # user_id :bigint not null # book_id :bigint not null # created_at :datetime not null # updated_at :datetime not null # class Booking < ApplicationRecord belongs_to :user belongs_to :book end
user_id: 1 のユーザーが借りている本を取得したい場合は以下のように記述できる。
User.find(1).books # => [<Book>, <Book>, ...]
また、user_id: 1 のユーザーが新たに book_id: 100 の本を借りる場合は以下のように記述できる。
このとき、Booking
model に after_create
callback が定義されていれば該当 callback が発火し、期待する処理を行うことができる。
User.find(1).books << Books.find(100) # Booking Create (xxx ms) INSERT INTO `bookings` (`user_id`, `book_id`) VALUES (1, 100)
また、user_id: 1 のユーザーが借りている本を全て返したい場合は以下のように記述できる。
このとき、Booking
model に after_destroy
や after_commit on: :destroy
callback が定義されていても該当 callback は発火せず、期待する処理は行われない。
ログには Delete All
と表示されているのでどうやら delete_all
を実行したときと同じような挙動になってそう。困った。
User.find(1).books # => [<Book id:100>] User.find(1).books << [] # Booking Delete All (xxx ms) DELETE FROM `bookings` WHERE `bookings`.`user_id` = 1 AND `bookings`.`book_id` = 100
この挙動については、rails/rails でも過去に議論になったもののバグではなく仕様であると判断された様子。
解決方法
has many through association に dependent: :destroy
オプションを渡せば delete_all
ではなく destroy
相当の処理が行われ、after_destroy
や after_commit on: :destroy
を発火させることができる。
# == Schema Information # # Table name: users # # id :bigint not null, primary key # created_at :datetime not null # updated_at :datetime not null # class User < ApplicationRecord has_many :bookings # ここに `dependent: :destroy` を追加 has_many :books, through: :bookings, dependent: :destroy end
User.find(1).books # => [<Book id:100>] User.find(1).books << [] # Booking Destroy (xxx ms) DELETE FROM `bookings` WHERE `bookings`.`id` = 1
ちなみに has many through association に dependent: :destroy
オプションを渡しても多対多の関連先である Book が削除されないことは確認済み。*1
感想
https://github.com/rails/rails/issues/7618 にも書いてあったように、追加時は callback が走って削除時は callback が走らないのは直感的ではないように感じた。且つ、has many through に dependent: :destroy
オプションを渡せば destroy callback が発火するのもピンも来ていない。
destroy callback を発火させたくないケースが正直思いつかないので、 has many through を使う時には常にdestroy: :dependent
オプションをつけておいてもいいのでは?とさえ思った。
とはいえ、ここを Rails 側が変更するとなるとどうしても破壊的な変更になってしまうので Rails 側の仕様が変わることはなさそう。であれば Cop を作って未然に防ぐなどで自衛するしかないんだろうか...
*1:直感的にはこっちが削除されそうな気がしたので念の為確認した
マイクロサービスアーキテクチャ 第2版では2週間で作り直せるサイズが良いという記述が削除されている
社内でマイクロサービスのサイズについての議論になり、ふと気になってマイクロサービスアーキテクチャ 第2版を確認すると削除されていたことに気付いたよ、というのがこの記事で最も言いたかったことです。*1
以下蛇足です。
マイクロサービスアーキテクチャ 第1版ではマイクロサービスの特徴として、簡単に作り直しができる(2週間で作り直せる程度)ほど十分に小さい点が挙げられていました。*2
マイクロサービスは小さければ小さいほど良いという言説は、マイクロサービスアーキテクチャ 第1版の記述を根拠としていることが多かったように思います。
また、マイクロサービスアーキテクチャ 第1版で挙げられた際に引用していたのは以下のブログだと思います。*3
対して、マイクロサービスアーキテクチャ 第2版ではサイズに関するセクションはあるものの、2週間で作り直せるサイズという記述は削除されていました。代わりに以下のような主張が行われています。
- サイズという概念は最も関心の低い特性の1つ
- 最適なコードベースのサイズはチームや個人など状況に強く依存する
- コードベースのサイズではなく、インターフェースのサイズを小さくすべき
感想
このように第1版と第2版で主張がガラッと変わる書籍はなかなかないのではないでしょうか。 それだけここ数年でマイクロサービスアーキテクチャに関する知見が集まってきたのではないかと思います。
ここ1年くらいでモノリスからマイクロサービスへの書籍がグッと増えたような気がする。先行的に取り組んできた組織がだいたいひと段落して体系的にまとめられる段階に来たということなんだろうか
— てっぺー (@euglena1215) 2023年2月21日
rubocop-ast.wasm を作った
便利そうなツールを作ったという紹介です。
Ruby 30th LT に出したのですが落ちたので供養として書きました。
解決しようと思った課題
Ruby には RuboCop という linter 兼 formatter があります。 RuboCop に元々入っているルール(Cop と呼ばれます)だけでも十分に便利なのですが、自身の Ruby プロジェクトに合わせて独自のルール(Custom Cop)を作ることができます。
Custom Cop の作り方:
- Rubocop でカスタムルールを作る - MoneyForward Developers Blog
- https://sinsoku.hatenablog.com/entry/2018/04/24/02291
- RuboCop の Cop の実装について - Qiita
記事に書かれているように、 ちょっとした実装で Custom Cop が作れるようになっています。しかし、自分を含む普段 Web アプリケーションを作っているエンジニアには親しみの薄い概念が登場します。それは S式(AST)です。*1
RuboCop は対象となるプログラムを parse した AST に対して定義されているルールに一致する AST を見つけ、該当箇所に対して linter として怒るという仕組みになっているので、Custom Cop を作るためには AST を表現する必要があることは理解できます。しかし、難しい…
RuboCop に精通した人であれば以下のようなS式を見て
(send (send (const (cbase) :SomeKlass) :new (int 1) (str "foo") (splat (send nil :args))) :hoge_method)
「ああ、これはこんなプログラムだな」と理解できるものなのでしょうか。自分には難しすぎる…
::SomeKlass.new(1, "foo", *args).hoge_method
こういった難しさには既視感があるなと思い、思い出したのが正規表現でした。自分も最初は正規表現にとっつきにくさを感じ悪戦苦闘しながら使っていましたが、今ではある程度簡単な正規表現であれば「ああ、こういう文字列とマッチしそうだな」と理解できるようになっています。
なぜそうなったかで思い出すと、体が覚えるまで 正規表現を書いてみる → テスト文字列と一致しているか確かめる → 正規表現を修正する → ... といったトライアンドエラーを繰り返すのが有効だったように思います。
とっつきにくい RuboCop の AST(S式)に対して楽にトライアンドエラーを繰り返せるようになることで、正規表現と同様に「気付いたらなんとなく理解できるようになっている」状態にするにはどうしたらいいか?を考え始めました。
既存の解決方法
既存の方法としては、RuboCop の Cop の実装について - Qiita で紹介されている pocke/rpr
が挙げられます。これはプログラムを渡すとS式を返すコマンドラインツールで、以下のような使い方ができます。
% rpr -e "::SomeKlass.new(1, "foo", *args).hoge_method" -p rubocop s(:send, s(:send, s(:const, s(:cbase), :SomeKlass), :new, s(:int, 1), s(:send, nil, :foo), s(:splat, s(:send, nil, :args))), :hoge_method)
上級者がパッとS式を確認するのにピッタリなツールのように感じました。しかし、初学者観点だと以下のような特性も持ち合わせているとより使い勝手の良いツールになりそうだと考えました。
- プログラムからS式への変換だけでなく、S式から一致するテストプログラムを確かめられる双方向性
- 入力するたびに変換が行われるようなインタラクティブ性
1,2 の特性を踏まえると、コマンドラインツールよりも Web ツールの方が良さそうです。 そこで、1,2 の特性の持つ Web ツールを作成してみることにしました。 また、個人的なチャレンジとして Ruby 3.2 からの wasm サポートを利用し frontend で完結するスタンドアローンな Web ツールを目指しました。
作ったもの
rubocop-ast.wasm を作成しました。
ソースコード: https://github.com/euglena1215/rubocop-ast.wasm
プログラムを渡すとS式に変換する機能とS式とテストプログラムを渡すと一致するテストプログラムを教えてくれる機能を提供しています。どちらの機能も一文字入力を行うごとに変換・検証が行われるので、楽にトライアンドエラーを繰り返すことができます。 このツールを使うことで、S式も正規表現と同様に「気付いたらなんとなく理解できるようになっている」になれると考えています。
ツール名の通り、このツールは wasm を使ってブラウザ上で Ruby を動かしているのでバックエンドの管理が必要ないことも個人開発をする上では嬉しいポイントの1つです。 wasm を使ってブラウザで Ruby を動かす流れについては別途ブログに書く予定です。
(TODO: wasm を使ってブラウザで Ruby を動かす流れについての記事リンクを貼る)
感想
Ruby 3.2 で wasm がサポートされたことにより、ちょっとした CLI の Web ツール化が進むのではないかと感じました。
初学者にとって、コマンドラインツールよりも Web ツールの方が嬉しいことは色々あるんじゃないかと思っています。Web ツール化が進むことで Ruby コミュニティの裾野が広がったらいいですね。