Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

週刊Railsウォッチ: ArelでCAST関数サポート、webdrivers依存を解消、YJIT高速化ほか(20230824後編)

こんにちは、hachi8833です。5日ぐらい前から、iPhoneアプリのボタンでツイートリンクをコピーしたときのドメインがx.comに変わりましたね。

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

🔗 ArelでSQLのCASTをサポート

例:

product_table = Product.arel_table
product_table.cast(product_table[:position], "integer")

Produces SQL:

CAST("products"."position" as integer)

動機/背景

CAST(field as type)は広くサポートされているSQL関数。
このプルリクは、cast(field, type)というヘルパーで、この名前付き関数に対するネイティブなarelサポートを追加する。

同PRより


つっつきボイス:「arel_tableを取ってきてcastするとSQLのCASTを使えるようになるのか」「Arel大好きな私としては、Arelでできるようになったのは嬉しいですね😋」

参考: PostgreSQL: Documentation: 15: 4.2. Value Expressions

「ただCASTはDBMS側で実行されるのでインデックスが効かなくなりますよ」「ありゃ、そうなったら遅くなっちゃうか」「CASTは便利なんですが、念のためみたいなノリでカジュアルに使うとインデックスが効かなくなって遅くなるので、乱用注意ですね」「気をつけないといかんヤツ」「というようなことを昔経験しました」

🔗 to_jsonのパフォーマンスを改善

このプルリクは、.to_jsonのパフォーマンスを現在よりもさらに2倍高速にする(現在はRails 7.0よりも約4.5倍高速)。

この場合、文字列引数を用いてgsub!を5回実行する方が、正規表現とハッシュで1回実行するよりも速いらしい(他の場合はそうでもなさそう)。

正規表現にマッチするものがある場合(エスケープすべき文字がある場合)は、部分的に高速(CRubyは新しいマッチオブジェクトと文字列をアロケーションして、提供されたmapのハッシュ内で検索キーとして利用するため)。より上流で最適化が行われる可能性もあるが、現時点ではこの方法はそれらのアロケーションを回避している。

この方法は驚くべきことに(少なくとも私は驚いた)、置換が不要な場合でも非常に高速である。テストでは、短い約200バイトの文字列では約3倍速く、事前エスケープ済み約600kのtwitter.jsonでは約5倍速くなった。

ベンチマーク

▶JSONエスケープ(クリックすると展開します)
  • スクリプト
$ FILE=twitter.json be ruby benchmark_escaping2.rb
Warming up --------------------------------------
            original     9.000  i/100ms
     original (noop)    10.000  i/100ms
       gsub(str) x 5    44.000  i/100ms
gsub(str) x 5 (noop)   229.000  i/100ms
gsub(ascii_re) + gsub(utf8)
                        25.000  i/100ms
gsub(ascii_re) + gsub(utf8) (noop)
                        32.000  i/100ms
     json_escape gem   919.000  i/100ms
Calculating -------------------------------------
            original     94.573  (± 0.0%) i/s -    477.000  in   5.043877s
     original (noop)    100.715  (± 0.0%) i/s -    510.000  in   5.063885s
       gsub(str) x 5    496.012  (± 7.9%) i/s -      2.464k in   5.002329s
gsub(str) x 5 (noop)      2.302k (± 0.7%) i/s -     11.679k in   5.074632s
gsub(ascii_re) + gsub(utf8)
                        257.878  (± 0.8%) i/s -      1.300k in   5.041332s
gsub(ascii_re) + gsub(utf8) (noop)
                        321.248  (± 0.6%) i/s -      1.632k in   5.080333s
     json_escape gem      8.958k (± 0.3%) i/s -     45.031k in   5.026965s

Comparison:
     json_escape gem:     8958.0 i/s
gsub(str) x 5 (noop):     2301.5 i/s - 3.89x  slower
       gsub(str) x 5:      496.0 i/s - 18.06x  slower
gsub(ascii_re) + gsub(utf8) (noop):      321.2 i/s - 27.88x  slower
gsub(ascii_re) + gsub(utf8):      257.9 i/s - 34.74x  slower
     original (noop):      100.7 i/s - 88.94x  slower
            original:       94.6 i/s - 94.72x  slower
  • short.json
{"locale":"en","featureFlags":["redactedredactedredact","redactedredactedredac","redactedredactedre","redactedredactedred","redactedredact","redactedredactedredacted","redactedredactedredactedr"]}
$ FILE=twitter.json be ruby benchmark_escaping2.rb
Warming up --------------------------------------
            original     9.000  i/100ms
     original (noop)    10.000  i/100ms
       gsub(str) x 5    44.000  i/100ms
gsub(str) x 5 (noop)   229.000  i/100ms
gsub(ascii_re) + gsub(utf8)
                        25.000  i/100ms
gsub(ascii_re) + gsub(utf8) (noop)
                        32.000  i/100ms
     json_escape gem   919.000  i/100ms
Calculating -------------------------------------
            original     94.573  (± 0.0%) i/s -    477.000  in   5.043877s
     original (noop)    100.715  (± 0.0%) i/s -    510.000  in   5.063885s
       gsub(str) x 5    496.012  (± 7.9%) i/s -      2.464k in   5.002329s
gsub(str) x 5 (noop)      2.302k (± 0.7%) i/s -     11.679k in   5.074632s
gsub(ascii_re) + gsub(utf8)
                        257.878  (± 0.8%) i/s -      1.300k in   5.041332s
gsub(ascii_re) + gsub(utf8) (noop)
                        321.248  (± 0.6%) i/s -      1.632k in   5.080333s
     json_escape gem      8.958k (± 0.3%) i/s -     45.031k in   5.026965s

Comparison:
     json_escape gem:     8958.0 i/s
gsub(str) x 5 (noop):     2301.5 i/s - 3.89x  slower
       gsub(str) x 5:      496.0 i/s - 18.06x  slower
gsub(ascii_re) + gsub(utf8) (noop):      321.2 i/s - 27.88x  slower
gsub(ascii_re) + gsub(utf8):      257.9 i/s - 34.74x  slower
     original (noop):      100.7 i/s - 88.94x  slower
            original:       94.6 i/s - 94.72x  slower
$ FILE=short.json be ruby benchmark_escaping2.rb
Warming up --------------------------------------
            original    26.846k i/100ms
     original (noop)    27.998k i/100ms
       gsub(str) x 5    82.923k i/100ms
gsub(str) x 5 (noop)    83.683k i/100ms
gsub(ascii_re) + gsub(utf8)
                       103.509k i/100ms
gsub(ascii_re) + gsub(utf8) (noop)
                       103.445k i/100ms
     json_escape gem     1.424M i/100ms
Calculating -------------------------------------
            original    279.761k (± 1.6%) i/s -      1.423M in   5.087410s
     original (noop)    279.118k (± 2.1%) i/s -      1.400M in   5.017966s
       gsub(str) x 5    835.196k (± 0.5%) i/s -      4.229M in   5.063709s
gsub(str) x 5 (noop)    832.362k (± 0.9%) i/s -      4.184M in   5.027225s
gsub(ascii_re) + gsub(utf8)
                          1.028M (± 0.7%) i/s -      5.175M in   5.035134s
gsub(ascii_re) + gsub(utf8) (noop)
                          1.032M (± 0.5%) i/s -      5.172M in   5.014163s
     json_escape gem     14.476M (± 0.7%) i/s -     72.617M in   5.016768s

Comparison:
     json_escape gem: 14475588.1 i/s
gsub(ascii_re) + gsub(utf8) (noop):  1031551.3 i/s - 14.03x  slower
gsub(ascii_re) + gsub(utf8):  1027913.4 i/s - 14.08x  slower
       gsub(str) x 5:   835196.3 i/s - 17.33x  slower
gsub(str) x 5 (noop):   832361.5 i/s - 17.39x  slower
            original:   279761.3 i/s - 51.74x  slower
     original (noop):   279117.6 i/s - 51.86x  slower

json_escapeは同じエスケープ処理をCで実装している(このgemは使わないこと: 必要に思えても別の使い所を見つけるべき)。

短い文字列では、ASCII部分のエスケープを正規表現として実行し、その後Unicode部分のエスケープを文字列として実行する方法が最適に見えるが、gsub!を5回実行する方法よりもわずかに速い程度なので、シンプルにするため、そして長い文字列でパフォーマンスを大幅に向上させるため、この方法にこだわった。

  • to_json

#48614と同じベンチマークを使う。JSON.generateRapidJSON.generateはスケール用であり、ここで説明している余分なエスケープ処理は行わない。

to_json(クリックすると展開します)
  • 改修前
Calculating -------------------------------------
 source_data.to_json     74.671  (± 1.3%) i/s -    378.000  in   5.062521s
source_data_sym.to_json
                         76.969  (± 0.0%) i/s -    385.000  in   5.002087s
JSON.generate(source_data)
                        576.164  (± 1.2%) i/s -      2.916k in   5.061908s
RapidJSON.generate(source_data)
                        852.714  (± 3.2%) i/s -      4.316k in   5.067053s
  • 改修後
Calculating -------------------------------------
 source_data.to_json    142.762  (± 2.1%) i/s -    715.000  in   5.010974s
source_data_sym.to_json
                        150.613  (± 2.0%) i/s -    756.000  in   5.021137s
JSON.generate(source_data)
                        575.412  (± 1.0%) i/s -      2.915k in   5.066603s
RapidJSON.generate(source_data)
                        873.174  (± 2.9%) i/s -      4.368k in   5.006853s

#48614の約2倍高速で、Rails 7.0 よりも4.5倍高速。

次はどうする?

これまでよりはずっと高速になったが、エスケープ処理はまだパフォーマンスに影響する(ただし、もはや主要な原因ではない)。私たちにできることとして、エスケープ処理をC拡張で書き直す方法が考えられる(理想としては標準ライブラリにアップストリームしたい)。しかし、2023年以降も本当にこの方法でエスケープ処理を行うべきかどうか確認する必要があると思われる。

そのあたりを@matthewdと私で調査したが、おそらく必要なのは<script</script>、および <!--のエスケープだけでよい可能性がある(ただし、おそらくXHTMLを対象にせずHTML5のみを対象にすることが前提)。

ES2019以降は、JSON/JavaScriptの相互運用性に関する"クセ"であるU+2028U+2029の処理を気にかける必要はなくなった。現在このバージョンは広く利用可能になっている

ただし今の話はどれもこのプルリクの範疇ではない。このプルリクはエスケープ動作を変更せずにパフォーマンスを向上できる。

同PRより


つっつきボイス:「これは最適化ですね」「to_jsonをさらに速くしたとは凄い」

参考: § 2.11 JSONのサポート -- Active Support コア拡張機能 - Railsガイド

# activesupport/lib/active_support/json/encoding.rb#L37
        def encode(value)
          unless options.empty?
            value = value.as_json(options.dup)
          end
          json = stringify(jsonify(value))
+
+         # Rails does more escaping than the JSON gem natively does (we
+         # escape \u2028 and \u2029 and optionally >, <, & to work around
+         # certain browser problems).
-         if Encoding.escape_html_entities_in_json
-           json.gsub! ESCAPE_REGEX_WITH_HTML_ENTITIES, ESCAPED_CHARS
-         else
-           json.gsub! ESCAPE_REGEX_WITHOUT_HTML_ENTITIES, ESCAPED_CHARS
+           json.gsub!(">", '\u003e')
+           json.gsub!("<", '\u003c')
+           json.gsub!("&", '\u0026')
          end
+         json.gsub!("\u2028", '\u2028')
+         json.gsub!("\u2029", '\u2029')
          json
        end

「この改修では、複雑な正規表現1回よりシンプルなgsub!を5回実行する方が速かった、なるほど」「ありそうな話」「ベンチマーク回してみないとわからないヤツ」「そういえば専用メソッドの方が正規表現より速いことも多いですよね↓」

Ruby: 文字列マッチは正規表現より先に専用メソッドを使おう

「今後Rubyのコードが進化したらまた変わってくるかも」「プルリクメッセージの"次はどうする"でもそのあたりを心配しているっぽいですね」

🔗 webdriversへの依存を解消

動機/背景

Selenium 4.6以降、Selenium ManagerでChrome Driverのインストールと統合を管理できるようになった
Selenium 4.11以降、Selenium Managerでテスト用のChromeのインストールパスを解決可能になった

詳細

gem宣言をGemfile.ttからなくすことで、新しく生成されたアプリケーションや最新バージョンのRailsに合わせてGemfileを更新しているアプリケーションで、依存関係を取り除き、新しくリリースされたChromeのバージョンが導入されたときのテストの失敗を回避できる(例: titusfortner/webdrivers#247)。

同PRより


つっつきボイス:「webdriversが不要になるのは嬉しい👍」「今までテスト用のChromeが自動アップデートされるとテストが失敗するようになったりすることがありましたよね」

titusfortner/webdrivers - GitHub

🔗Rails

🔗 rspec-sidekiq: RSpecでSidekiqテストをサポート(Ruby Weeklyより)

wspurgin/rspec-sidekiq - GitHub


つっつきボイス:「名前の通り、RSpecでSidekiqのテストを書きやすくするgemですね」「★多いですね」「複数ジョブを組み合わせたテストがやりやすくなりそう👍」

# 同リポジトリより
# 基本
expect { AwesomeJob.perform_async }.to enqueue_sidekiq_job

# ジョブクラスを指定
expect { AwesomeJob.perform_async }.to enqueue_sidekiq_job(AwesomeJob)

# 引数を指定
expect { AwesomeJob.perform_async "Awesome!" }.to enqueue_sidekiq_job.with("Awesome!")

# キュー名を指定
expect { AwesomeJob.set(queue: "high").perform_async }.to enqueue_sidekiq_job.on("high")

# 日時を指定
specific_time = 1.hour.from_now
expect { AwesomeJob.perform_at(specific_time) }.to enqueue_sidekiq_job.at(specific_time)

# インターバルを指定(freezeや時間の管理に注意すること)
freeze_time do
  expect { AwesomeJob.perform_in(1.hour) }.to enqueue_sidekiq_job.in(1.hour)
end

# 組み合わせやチェインも自由に行える
expect { AwesomeJob.perform_at(specific_time, "Awesome!") }.to(
  enqueue_sidekiq_job(AwesomeJob)
  .with("Awesome!")
  .on("default")
  .at(specific_time)
)

onでSidekiqのキューオプションも渡せるんですね」

# 同リポジトリより
class AwesomeJob
  include Sidekiq::Job

  sidekiq_options queue: :low
end

AwesomeJob.perform_async("a little awesome")

# test with..
expect(AwesomeJob).to have_enqueued_sidekiq_job("a little awesome").on("low")

# Setting the queue when enqueuing
AwesomeJob.set(queue: "high").perform_async("Very Awesome!")

expect(AwesomeJob).to have_enqueued_sidekiq_job("Very Awesome!").on("high")

🔗Ruby

🔗 YJITの例外ハンドラを高速化(Ruby Weeklyより)


つっつきボイス:「YJITは継続的に高速化と改良が進んでいて頼もしい🎉」「speed.yjit.orgでも成果がわかりますね↓」

参考: speed.yjit.org


speed.yjit.orgより

YJIT: CRuby向けの新しいJITコンパイラを構築する(翻訳)

🔗 Short Ruby Newsletter: Rubyの技術情報ニュースレター


つっつきボイス:「YassLabの安川さんに教わったサイトです」「サイトを開いたけどニュースレター登録のフォームしか見えない?」「あ、右上の✗で閉じてください」

「なるほど、いわゆるRubyの技術情報をニュースレターで配布するサービスなのね」「本編は有料コンテンツですが、1年前にスタートしてから#55号も出していて、かなり熱心に運営しているみたいです」「週1.6ドルまたは月6.5ドルなのね」「有料で運営する理由の説明もありますね: 運営を持続可能にする、読者が学習費用を経費で落とせるなど」「興味のある方は購読してみてもいいかも👍」

訂正(2023/08/25)

以下のご指摘をいただきました🙇。ありがとうございます!

「有料ニュースレターは昔よく見かけたな〜」「英語圏だと今でもニュースレター形式の技術情報が割りと多いですね」

🔗 設計・セキュリティ

🔗 ソルト付きハッシュのソルトはどこに保存するのが一般的か


つっつきボイス:「例のpictLandとpictSQUAREの件をきっかけに徳丸先生がソルトの保存場所についてクイズを出して↓、その後で上の記事を書いていました」

「記事はまさに定番の内容ですね: ソルトはユーザーのパスワードハッシュとともにデータベースに保存するのが基本で、ソルトを環境変数やハードウェアセキュリティモジュール(HSM)に保存する方法は"ユーザーのパスワードハッシュごとにユニークである"という要件を満たせなくなるので普通やらない」「クイズの方は思ったより正答率低いですね」「ソルトとペッパーの違いをこの記事で思い出しました😅」

参考: Hardware security module - Wikipedia

記事にもあるように、Unixのパスワードにはハッシュとともにソルトも保存されていますね」「Modular Crypt Format(MCF)っていう形式なのか: 以前パスワード周りを調べたときに、Deviseなどもたしかこういうフォーマットになっていたのを覚えてます↓」

$ openssl passwd -6 -salt=SALT pokemon
$6$SALT$ULXzhDaWogf6Q3KHTtpYdqKKEIaFPML8gl5wpHpvPJVkGgiKGubqkogwvqoVn3eDsrJuRB22w.RPWzAdEu1xD.

参考: Modular Crypt Format — Passlib v1.7.4 Documentation

「既存のパスワードハッシュを別フレームワークに移行しなければならなくなったことがあったんですが、そのときに現行のパスワードハッシュがどのフォーマットなのかを自力で解析しました」「フレームワークが変わるのは大変そう...」


以下はつっつき後に見つけたツイートです。


後編は以上です。

バックナンバー(2023年度第3四半期)

週刊Railsウォッチ: Rails 7.0.7に含まれているRails 7.0.6のバグ修正ほか(20230823前編)

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース

Rails公式ニュース


CONTACT

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