カレーの恩返し

おいしいのでオススメ。

ISUCON12 予選で rbs-inline 使ってみた

ISUCON で型がパチパチっとハマった開発ができるとかなり開発体験変わってくるのでは?と思い、 ISUCON の過去問に型をつけていくのをやってみています。

モチベーションに対してもう少し詳しい記事はこちら

euglena1215.hatenablog.jp

ISUCON13 で rbs-inline 使ってみた - カレーの恩返し に引き続き ISUCON12予選 の Ruby 実装に対して rbs-inline を使ってみました。

やっぱりライブラリの型が足りない

ライブラリの型はいくつか足りないものがあったので追加しました。しかし、sinatra gem や mysql2 gem はちょっとした変更だけで済んだので ISUCON13 での型付けの恩恵が受けられました。
また、前回は stdlib への型定義は行っていなかったのですが、今回はチャレンジしてみました。gem_rbs_collection よりも周辺環境が手厚い印象を受けました。

ref.

rbs-inline の Data, Struct サポートで足りない表現がある

ISUCON13 の型付けでは色々頑張って Data.define によるクラス定義の型を書いていましたが、soutaro さんに熱意を伝えて rbs-inline で Data, Struct をサポートしてもらいました 🎉*1

ですが、ISUCON12 予選の Data, Struct の使い方は rbs-inline のカバー範囲を超えていたため、いくつか patch を投げました。これらは取り込まれて rbs-inline 0.8.0 で使えるようになっています。

ref.

上記の Struct の使い方に関連して、ISUCON12 予選の型検査を通すためにいくつか steep:ignore をつけて型検査を無効化しました。

# 大会を取得する
#: (SQLite3::Database[SQLite3::result_as_hash], String) -> CompetitionRow?
def retrieve_competition(tenant_db, id)
  # row は Hash[String, Integer | String | Float | nil]
  row = tenant_db.get_first_row('SELECT * FROM competition WHERE id = ?', [id])
  if row
    CompetitionRow.new(row) # steep:ignore
  else
    nil
  end
end

CompetitionRowStruct.new によって定義されたクラスであり、rowString を key に持ち Integer | String | Float | nilvalue とする Hash です。

また、Struct は keyword_init: true をつけたときのみ self.new に渡した Hash を展開し、キーワード引数を受け取ったような挙動をします。

irb(main):004> Foo = Struct.new(:x, :y, keyword_init: true)
=> Foo(keyword_init: true)
irb(main):005> Bar = Struct.new(:x, :y)
=> Bar
irb(main):006> Foo.new({x: 1})
=> #<struct Foo x=1, y=nil>
irb(main):007> Bar.new({x: 1})
=> #<struct Bar x={:x=>1}, y=nil>
irb(main):008> Baz = Struct.new(:x, :y, keyword_init: false)
=> Baz
irb(main):009> Baz.new({x: 1})
=> #<struct Baz x={:x=>1}, y=nil>

CompetitionRow.new は正確な Hash のリテラル{ ?tenant_id: String, ?id: String, ?title: String, ?finished_at: Integer, ?created_at: Integer, ?updated_at: Integer })を要求するのに対し、rowHash[String, Integer | String | Float | nil] であり具体的な attribute の情報を持っていません。
その結果、以下の型エラーが起きていました。

Cannot pass a value of type `::Hash[::String, ::SQLite3::row_value_type]` as an argument of type `{  }`
  ::Hash[::String, ::SQLite3::row_value_type] <: {  } (Ruby::ArgumentTypeMismatch)

それぞれの attribute に対して丁寧にチェックを行い、最終的にキャストするようにすれば ignore する必要はないのかもしれないですが ISUCON 本番でそこまでできるとは到底思えません。なので、 ignore を行う判断をしました。


総評して以前 ISUCON13 の型をつけたときよりも楽になっているなと感じました。これはライブラリの型が増えたことや rbs-inline に Data, Struct サポートが入ったことに起因していると思います。この調子で ISUCON 本番までにできる限りの過去問に型を付けられるようにしていきたいなと思います。

*1:自作 gem とかも作ってみてたんですがスッと入れてもらえたので出番はありませんでした https://github.com/euglena1215/rbs_inline_data

ISUCON13 で rbs-inline 使ってみた

ISUCON で型がパチパチっとハマった開発ができるとかなり開発体験変わってくるのでは?と思い、 ISUCON の過去問に型をつけていくのをやってみています。

モチベーションに対してもう少し詳しい記事はこちら

euglena1215.hatenablog.jp

まずは、初期実装の状態から挙動を変えずに型だけをつけてみることに取り組みます。
また、アプリケーションに対応する型は rbs ファイルは直接触らずに rbs-inline のみを使って生成することにしました。おそらく ISUCON 本番でも別ファイルをいじっている余裕はないと思うためです。

ISUCON13 で rbs-inline を使って steep check が通るところまで行けたので、やっていく中で感じたことやこうだったらもっと便利なのにと思ったことをまとめてみます。ちょっとしたスクリプトに対して使ってみた記事はたまに見かけますが、ちゃんとしたアプリケーションで使ってみた記事はあまり見たことがないので何かの参考になれば幸いです。

初期実装からの差分はこちらです。差分を眺めながら記事を読むと内容が掴みやすくなると思います。
https://github.com/euglena1215/isucon13-rbs/compare/3bb0e7c..main?diff=unified&w=

ISUCON でよく使われるライブラリに型が全然書かれてない

まあこれはそうだろうなと思って始めました。これに関しては型を書いていけばいいことです。ISUCON13 で使われているライブラリに関しては一通り型を書きました。

ISUCON で頻出ライブラリは限られているので次の過去問に型をつけるときはもっと楽になっているはずです。

Enumerable#firstnil チェックをめっちゃ書く必要ある

正直型を書くだけで型チェックが通ると思ってました。

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 を立てておきました。

github.com

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サービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトルです

https://isucon.net/

 

突然ですが、最新の 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%

 

https://isucon.net/archives/57995340.html

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%

 

https://isucon.net/archives/51000131.html

なんと、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 とどう向き合っていくかに頭を悩ませているところが多いようです。
他の会社の事例が聞けて大変参考になりましたし、正直最近モジュラモノリスの取り組みが停滞気味だったのでしっかり進捗を出していかないとなと良い刺激にもなりました。

話し込んでいると、聞く予定だったセッションを聞き逃すこともありました。が、色々とためになる話ができたので気にしません。

drink up は常に参加していなくても良い

各日終了後の毎日の drink up は RubyKaigi の恒例行事です。各社があの手この手で RubyKaigi 参加者を楽しませてくれます。
Rubyist と色々話せるし基本的には参加した方が良いんですが、今回 day2 は drink up に参加せずにホテルに籠もって色々考えを整理してみたり、コードを触ってみてました。

RubyKaigi が終わってからでも良いかもと一瞬頭をよぎったものの、忘れないうちに考えをまとめておきたいというのとモチベーションは生ものなので、やりたいときにやっておいた方が良いだろうと考えました。

おかげで、day2 の mame さんの発表 Good first issues of TypeProf - RubyKaigi 2024 で紹介があった good first issue に取り組むことができました。

github.com

また、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 の夕方には沖縄を出発してしまったのであんまり観光できてないですが、国際通りでお土産を買ったあとウミカジテラスを散策しました。海が見れてよかったです。


来年は少し早くて4月に松山ですね。*2次回もどんな話が聞けるか楽しみです。
ChatGPT の進化も楽しみです。同時通訳できるようになってることを信じたい。

あと、RubyKaigi にプロポーザルを出せるような成果を1年間で出せるといいな...

*1:今回に関しては「どうせノベルティもらっても飛行機だし持って帰れないんだよな」と考えていたのも大きかったかもしれません

*2:横に座っていた自社のVPoEが「(評価期間と被ってなくて)助かる...」とぼそっと呟いていたのが記憶に残ってます

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_botcreate(:xxxx)

object_id...?なんのことだ...?と思って調べてたら作成対象の model の association に object_id という column を持つ model が存在することが分かった。 ActiveRecord によって object_id メソッドを上書きされるの危険そうだなと思いつつ、なぜ Rails 7.0 ではエラーにならず Rails 7.1 で問題になるのか分からなかったので調べてみて原因を特定した。

github.com

変更が入ったのは上記 PR。column 名に hash という名前を使うと ActiveRecordObject#hash をオーバーライドしてしまい、問題が発生していた。 その問題と今後発生しそうな問題を防ぐために Object クラスでオーバーライドされると困りやすい dup, freeze, hash, object_id, class, clone を AR が定義しようとするとエラーにする変更を加えたとのこと。

github.com

後続の変更で 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_destroyafter_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 でも過去に議論になったもののバグではなく仕様であると判断された様子。

github.com

解決方法

has many through association に dependent: :destroy オプションを渡せば delete_all ではなく destroy 相当の処理が行われ、after_destroyafter_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

www.rea-group.com

対して、マイクロサービスアーキテクチャ 第2版ではサイズに関するセクションはあるものの、2週間で作り直せるサイズという記述は削除されていました。代わりに以下のような主張が行われています。

  • サイズという概念は最も関心の低い特性の1つ
  • 最適なコードベースのサイズはチームや個人など状況に強く依存する
  • コードベースのサイズではなく、インターフェースのサイズを小さくすべき

感想

このように第1版と第2版で主張がガラッと変わる書籍はなかなかないのではないでしょうか。 それだけここ数年でマイクロサービスアーキテクチャに関する知見が集まってきたのではないかと思います。

*1:自分が見逃しているだけだったら教えてください、すぐにこの記事を消します

*2:手元に第1版がないので確証はないものの、こういった主張だったと記憶しています

*3:筆者の名前と言及内容だけで推測しているので間違っている可能性があります