おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

savanna.io をRails 7.2にアップグレードした

お仕事SNSsavanna.ioの開発を空いた時間でやっています。先日Rails 7.2のbetaが出たので試しにCIを回してみたらそれほど問題なくアップグレードできそうなのでサッと対応してアップグレードしました。以下対応した内容を書いています。

bulletを外す

bulletの依存でbundle installができなかったので、一旦bulletを外しました。 PRは出ているので取り込まれたら戻すのを検討します。

Support active record 7.2.0 by hatsu38 · Pull Request #707 · flyerhzm/bullet

enumの形式変更に対応

Rails7.0からenumの引数の渡し方が新しくなりました。7.2からは古い渡し方はdeprecatedになり、8.0からは新しい方式だけになる模様です。

# 旧
enum status: { unlooked: 0, looked: 10, read: 20 }
# 新
enum :status, unlooked: 0, looked: 10, read: 20

Allow new syntax for enum to avoid leading _ from reserved options by kamipo · Pull Request #41328 · rails/rails

savanna.ioではconfig/environments/test.rbで次のようにしてdeprecation warningのときに例外を発生させるようにしているので、Rails7.2にアップグレードしたことでCIが失敗しました。

config.active_support.deprecation = :raise

Rails7.2にアップグレードする際に必須の修正ではないですが、早く対応するに越したことはないので一緒に修正しました。

prepared statementの実行でエラー

Rails7.2にアップグレードすると、次のように文字列でクエリを渡している箇所でActiveRecord::StatementInvalidエラーになりました(コードは簡略化しています)

def not_todays_target
  User.joins(:conditions).where(
    'conditions.transfer_on - interval :duration = :today',
    today: Time.zone.today,
    duration:
  )
end

コードを追いかけて調査したところ、Rails7.1まで上記のクエリはprepared statementの対象外だったのがRails7.2から対象になったことが原因のようでした。intervalリテラルの部分をプレースホルダとして利用することはできないらしいので、クエリの書き方が悪いと判断してdurationの部分を文字列展開させることで対応しました*1

def not_todays_target
  User.joins(:conditions).where(
    'conditions.transfer_on - interval #{duration} = :today',
    today: Time.zone.today,
    duration:
  )
end

prepared statementの対象が変更になったのはこのPRが原因のようです。

Relation#where build BoundSqlLiteral rather than eagerly interpolate · rails/rails@8e6a5de

このPRは、DBのconnectionを使う機会をなるべく減らす一環で作られたものです。

これまでは例えばwhereメソッドの実行時にActiveRecord::Base.sanitize_sqlを利用してプレースホルダの置換を行っていました。このとき、利用するDBによるクォートの付け方の差異をconnectionを利用することで解決しています。このPRではプレースホルダの置換、つまりconnectionの利用を遅延させて、prepared statementを利用する設定であればそれを利用するようにコードを修正しています。結果として、これまでprepared statementを利用していなかったクエリが、prepared statementの対象になっています。

所感

思っていたよりも簡単にアップグレードできました。次はconfig.load_defaults 7.2にチャレンジしようと思います。

*1:durationはユーザ入力文字列ではないのでセキュリティの問題はありません

RubyKaigi 2024に行ってきました

  • ちゃんと書こうとすると一生書けなさそうなのでざっくりとまとめています
  • めっちゃ楽しかったです
  • 5/11~19まで沖縄を満喫しました
  • 前入りで旅行をしてからRubyKaigiに臨むと体力が0に近い状態からのスタートになるので、懇親会は欲張らずに1次会だけの参加にしてサッとホテルへ戻り体力温存を優先するのが良いな、という知見を得ました
  • 発表はだいたい一番大きい会場にいました
    • 3階席が空いていて居心地が良かった
    • こばじゅんさんやydahさんのパーサ関連の発表だけそれぞれB、C会場で聞きました
      • 僕もいちおうドラゴンブック読書会の末席にいるので、仲間を応援する気持ちでした
  • 観た中ではモリスさんのNamespaceの発表が一番ワクワクしました
    • Namespaceによって僕ら(Railsアプリケーションをつくる人)の生活がまた一段階便利になるんじゃないかな〜
  • 久しぶりのひとと直接会って「RubyKaigiは同窓会みたいですねえ」みたいな話をしていました
    • RubyKaigiは同窓会感もあるんだけど、通信簿感もある
      • この一年なにをしてきたか、が反映される
    • 各発表内容で「なんもわからん」というのは(最初のぺんさんの発表を除いて)なかったのでインプットはそれなりにできている
    • が、アウトプットはそれほどない
      • 仕事の中ではちょこちょこやっているのだけど、それをもっと広く公開する手間を掛けられていない
    • 英語もリスニング、スピーキングがまだまだ
    • 昔ほどは自分の時間が取れないんだけど、来年の4月をもっと楽しむためにがんばっていこ(\( ⁰⊖⁰)/)という気持ちになりました
  • 松山も楽しみにしています!!

gimei v1.5.0をリリースしました

先月にgimei のv1.4.0を、昨日にv.1.5.0をリリースしていました。それぞれの変更についてまとめてみます。

v1.4.0

gimeiはランダムに生成した名前を返すライブラリです。姓や名だけがほしいときはそれぞれ次のようにlastメソッドとfirstメソッドが使えます。

Gimei.last.to_s #=> "藤田"
Gimei.first.to_s #=> "太志"

また、それらのエイリアスとしてfamily, givenもあります。

Gimei.family.to_s #=> "二宮"
Gimei.given.to_s #=> "徹二"

gimeiを普通に使うと、あらかじめ用意している配列からsampleで名前を選ぶので、同じ名前が続けて二回出力されることは確率的にはゼロではありません。それを避けたいときに使えるuniqueというメソッドを用意しています。

Gimei.unique.name.to_s #=> "堀内 七虹"

uniqueを使い続けて、gimeiが用意している名前が尽きるとエラーになります。なので適切なタイミングでGimei.unique.clearをして、「これまで利用した名前のリスト」をクリアしてあげると便利です。

clearに引数を渡さないとすべてのリストを消去しますが、次のように引数を渡すと特定の「これまで利用した名前のリスト」に絞って消去することができます。

Gimei.unique.clear(:first) # Gimei.unique.first の結果を消去

と、ここまでが変更に関する前提知識の紹介でした。

Gimei.unique.givenのように名前を生成したときにそれだけをクリアしたいときは、これまでGimei.unique.clear(:first)のようにエイリアス元を指定しないと意味がない状態だったのを、Gimei.unique.clear(:given)でもよいようにしたのがv1.4.0の変更です。

PRはこれ:family, :givenのaliasをclearでも使えるようにする by izumitomo · Pull Request #67 · willnet/gimei

v1.5.0

v1.5.0は内部的な改善です。きっかけは開発版RubyでCIを動かすと失敗していたことでした。

Ruby 3.4のテストが失敗する · Issue #68 · willnet/gimei

gimeiは漢字、フリガナ、ふりがな以外にもローマ字による出力が可能です。

Gimei.name.romaji #=> "Hitomi Ooba"

これまで、これはromajiというgemを利用してふりがなを変換して生成していました。そしてromajiはnkfに依存しており、nkfがRuby3.4からdefault gemではなくなることからromajiのrequireに失敗するようになっていたのでした。

そもそも漢字、フリガナ、ふりがなはYAMLでデータを持っているのにローマ字だけ動的に生成するのが一貫性がない、と感じていたところだったのでこの期にromajiの依存をなくしたいな、と思っていたところそのものズバリのPRを頂いたのでマージしてv1.5.0にしました。

PRはこれ Romajiによる変換後のデータをymlに追加 by atolix · Pull Request #69 · willnet/gimei

というわけで

ちょっとずつですが改善していっています。どうぞご利用ください。

15年ぶりにTOEICを受けた

前回のエントリはこちら(15年前!)

TOEIC伸びない - おもしろwebサービス開発日記

n回目の英語勉強するぞ期の中で、英語学習のモチベーションを上げるためになにか試験でも受けてみようかなとなり、とりあえずやり方のわかっているTOEICから始めてみました。

しかし、15年ぶりなので具体的な問題形式は忘れているし、そもそも試験の内容も当時から一部変更されているようだったので一冊だけ問題集をやってから臨みました。

Amazon.co.jp: [音声DL付]はじめて受けるTOEIC(R)L&Rテスト全パート完全攻略 eBook : 小石 裕子: 本

結果

listening355, reading 420で775点。15年前よりは伸びてますね。

listeningはとほぼ変わっていません。圧倒的に英語を聞く時間が足りてないですね…。readingは英語の技術書やドキュメントなどを地道に読み続けている成果が出ているみたいです。しかし、まだ英文を読んでいて何書いているのかよくわからんな…?となることはよくあります。こちらも引き続き精進が必要。

最近の英語勉強方法

アプリで進捗を管理されるとやらなきゃな、という気持ちになるのでアプリを利用しています。単語はWordUp。発音はELSAです。時間が取れない日もありますがとりあえず続いています。

ファイバーストレージの紹介と注意点

以前のエントリで、スレッドローカル変数とファイバーローカル変数について解説しました。このエントリはその続きになります。

ファイバーストレージとは

スレッドローカル変数やファイバーローカル変数を使うと複数(スレッド|ファイバー)環境で固有の値を持つことができて便利です。利用例としてはActiveSupport::CurrentAttributesなどがあります。

しかし、(スレッド|ファイバー)ごとに固有の値を持つことで不便を感じるケースがあります。例えばRailsなどでリクエストを受け付けている最中に別の(スレッド|ファイバー)を作り、その中で外部APIを叩くとします。このときに外部APIを叩く(スレッド|ファイバー)からリクエストを処理する(スレッド|ファイバー)で設定した(スレッド|ファイバー)ローカル変数を参照することはできません。これは不便ですね。

この問題を解決したのがRuby3.2から導入されたファイバーストレージです。ファイバーストレージとしてアサインされた変数は、子の(スレッド|ファイバー)を作ったときに親のコピーを子のファイバーストレージとして設定する、という動きをします。導入したPRはこれ *1。「ファイバーストレージ」という名前からスレッドとは関係なさそうな雰囲気を感じますが、スレッドを新規作成したときでも同様です。

具体的な使い方についてはRuby 3.2 - Fiber - tmtms のメモが詳しいです。

Railsの大半のユースケースにおいては、(スレッド|ファイバー)ローカル変数よりもファイバーストレージの方が使い勝手が良いように思います。しかし、扱いには注意が必要です。

RequestStore1.6.0でのファイバーストレージの利用例

RequestStoreというgemがあります。これはActiveSupport::CurrentAttributesと同じようにリクエストごとにリセットされるグローバルな変数を扱うためのものです*2。RequestStore内部ではファイバーローカル変数が使われていましたが、RequestStore 1.6.0で、ファイバーストレージが使える環境(Ruby >=3.2.0)であればそれを使うという変更が入りました

しかしそれによって、RequestStore1.6.0を使うとsidekiqでときどき設定したはずの値が消えるぞ、というIssueがたちました。なぜでしょうか。

v1.6.0のRequestStoreはこのファイルを見るとわかるのですがFiber[:request_store]{}をアサインしてそれをRequestStore用の変数として使う、という実装になっています。これは一見問題ないように見えますが、使い方によってはスレッドセーフではなくなってしまいます。

前提知識

  • ファイバーストレージの実態はハッシュ
  • ファイバーストレージの実装は、子の(スレッド|ファイバー)を作るときに「親のファイバーストレージをdupして子の(スレッド|ファイバー)のファイバーストレージにする」というもの
  • dupはshallow copyなので、ファイバーストレージのハッシュオブジェクトは(スレッド|ファイバー)ごとに別物になるが、ハッシュが持つオブジェクト自体は同じものを指してしまう

RequestStore1.6.0でスレッドセーフが壊れる使い方の例

まず、sidekiqなどでRailsアプリケーションを起動します。Rails起動時にRequestStore.storeとすると、Fiber[:request_store] = {}のようにファイバーストレージへの値のアサインが実行されます。

sidekiqはRails起動後にワーカスレッドを複数作ってジョブを処理していきます。するとすべてのスレッドでFiber[:request_store]は同じハッシュオブジェクトを指すので、一つのスレッドで行った変更が他のスレッドにも反映されてしまいます。

肝は、親(スレッド|ファイバー)にあたる箇所でFiber[:request_store] = {}を実行している、という点です。これを避ければ子(スレッド|ファイバー)でFiber[:request_store]が指すオブジェクトは別々になりスレッドセーフが保たれます。

おそらく近日中にファイバーストレージを使わない形に変更したRequestStore v1.7.0(もしくはv1.6.1)がリリースされるのではないかと思いますが、もしお手元のプロジェクトでRequestStore v1.6.0を使っていたら使い方を確認の上、場合によってはv1.5.1に切り戻しておいたほうがいいかもしれません。

所感

最新のRailsのActiveSupport::CurrentAttributesはスレッドローカル変数とファイバーローカル変数のどちらかを設定によって使い分けるようになっていますが、これがファイバーストレージに変わる未来もあるのかも?と思っています。しかし上記で示した課題があるので、安易に移行するのも難しい。なにかしらうまい解決方法はないかなあ…となっている今日このごろです。

*1:ここから別PRで振る舞いが修正されているので、コードに興味ある人は最新版を参照した方が良いです

*2:もともとRequestStoreが先にあって、Rails公式機能として後に導入されたのがActiveSupport::CurrentAttributesなはず(要出典)

ci_loggerのv0.9.0をリリースしました

Release v0.9.0 · willnet/ci_logger

次の二点の変更が入っています。

  • Rails6.0以上のサポートを終了して、Ruby3.3のサポートを追加しました
  • Rails7.1で追加されたActiveSupport::BroadcastLoggerの対応を入れました

ActiveSupport::BroadcastLoggerとは?

Add a public API for broadcasting logs by Edouard-chin · Pull Request #48615 · rails/rails で入った、Railsで複数のLoggerを扱いやすくするための仕組みです。

Rails7.0までのRails.loggerのデフォルトはActiveSupport::Loggerでした。このLoggerには一つのLoggerに書き込んだときに複数の出力先を設定できる機能があります。例えば端末でrails sすると端末とlog/development.rbの両方に同じ内容が出力されるはずです。これはこの機能を利用して実現されていました。

しかし、複数の出力を設定するためには次のようにActiveSupport::Logger.broadcastで作成したModuleをextendする必要がありました。これはだいぶおまじない感が強いですね…。しかもこれはprivate APIでした。

console = ActiveSupport::Logger.new(STDERR)
Rails.logger.extend ActiveSupport::Logger.broadcast console

これをもっとスッキリした形に変更してpublicにしたのがActiveSupport::BroadcastLoggerです。次のような書き方で使います。ActiveSupport::BroadcastLoggerが出力先のLoggerを持つという、だいぶ直感的な構造になっています。

stdout_logger = Logger.new(STDOUT)
file_logger = Logger.new("development.log")
broadcast = ActiveSupport::BroadcastLogger.new(stdout_logger, file_logger)

broadcast.info("Hello!") # The "Hello!" message is written on STDOUT and in the log file.

ci_loggerの対応

ci_loggerはLoggerをラップして、テストに失敗したときだけ出力するLoggerを作るライブラリです。Loggerをラップしているという点ではActiveSupport::BroadcastLoggerと同じですね😃。ActiveSupport::BroadcastLoggerをCiLoggerでラップしても普通に動くのですが、今回ActiveSupport::BroadcastLoggerが持つLoggerを個別にラップする方式に変更しました。

これにより、成功したログも失敗したログも全部出力しつつ、別途失敗したログだけを出力するぞこともできるようになります。

# config/environments/test.rb

config.after_initialize do
  all_logger = Logger.new("all.log")
  Rails.logger.broadcast_to(all_logger)
end

また、特定のgemの型検査でRails.loggerがActiveSupport::BroadcastLoggerのインスタンスであることを期待しているものがあって*1CiLoggerを導入する障害になっていたのも解決しています。

どうぞご利用ください。

committee-rails v0.8.0をリリースしました

Release v0.8.0 · willnet/committee-rails

Railsでinteragent/committee: A collection of Rack middleware to support JSON Schema.のテスト用ヘルパを簡単に使うためのgemとしてcommittee-railsというのを作りメンテしています。てっきりこのブログでも紹介していると思い込んでいたのですが、ググっても見つからなかったので初出のようです。

v0.8.0では@ydahさんがcommittee v5.1.0向けのPRをいくつか出してくれたので、それを取り込んでいます。どうぞご利用ください。