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

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

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をいくつか出してくれたので、それを取り込んでいます。どうぞご利用ください。

gimeiに型をつけた話

僕はRailsアプリケーション開発者としてはなるべく型は書きたくない派閥に属しています。でもライブラリ作者としては型をつけておくと利用者が嬉しいだろう、という気持ちがあります。

そんな折gimeiにPRがきたので、重い腰を上げて型を導入したときのメモを残しておきます。

関連エントリ: gimeiのv1.3.0をリリースしました - おもしろwebサービス開発日記

型はどうやって学ぶか

執筆時点では日本語におけるまとまったいい感じの記事は少なめな印象です。文法については pockeさんの記事が詳しく、読むと基本的な文法について把握することができます。

あとは公式のドキュメントでをひたすら読むのがよさそう。

gimeiで定義した型が正しいかチェックする

ドキュメントを読むとなんとなく型をつけていくことはできます。僕はgimeiの作者なのでgimeiの持っている各メソッドが期待する型について他の人よりも詳しいですが、作り始めてから12年ほど経過しているので忘れていることも多々あります。

遠い記憶を掘り起こすよりもツールを使って機械的に定義した型が正しいかをチェックしたほうが楽なので、lib/ 配下をsteep checkでチェックするようにしてみました。するといくつかのチェックが失敗します。以下はそのうち困った点について書いています。

困った点1. 依存ライブラリの型がない

gimeiにはふりがなをローマ字で表示するための機能があります。

gimei = Gimei.name
gimei.kanji          #=> "斎藤 陽菜"
gimei.hiragana       #=> "さいとう はるな"
gimei.katakana       #=> "サイトウ ハルナ"
gimei.romaji         #=> "Haruna Saitou"

ふりがなやフリガナに関してはリポジトリ中にデータとして保持しているのですが、ローマ字はromaji を利用してふりがなからローマ字に変換したものを使っています。

このromajiの型定義がなくて型チェックに失敗します。執筆時点ではromajiのリポジトリにも gem_rbs_collection にも型情報はありません。

一般的な振る舞いとしてはromajiもしくはgem_rbs_collectionにPRを出すのが良さそうですが、gimei内でromajiの型を追加してひとまず解決としました。

この件に関してだけでいうと、gimeiの依存ライブラリからromajiを削除する、というのも有力な選択肢です。

困った点2. 特異クラスでdefine_methodしたときの結果がおかしい

gimeiには次のようにdefine_methodを使って動的にメソッド生成している箇所がありました。これは必ずしもdefine_methodを使わなくてもよかったのですが、同じ内容のメソッドを大量に書くのが煩わしくてこうなっています。

class Gimei
  class << self
    %i[kanji hiragana katakana romaji first last family given].each do |method_name|
      define_method(method_name) do |gender = nil|
        name(gender).public_send(method_name)
      end

ここが次のように怒られます。

lib/gimei.rb:44:13: [error] Unexpected positional argument
│ Diagnostic ID: Ruby::UnexpectedPositionalArgument
│
└         name(gender).public_send(method_name)
               ~~~~~~

エラーメッセージはGimei.nameに期待しない位置引数が渡されている、と読めます。しかしGimei.name(gender)は別の場所で定義済み。

ちゃんとコードを追いかけたわけではないのですが、steepが特異クラスのdefine_methodのコンテキストを取り違えていそうだなと推測しています。同じ内容のIssueもたっていました。

上でも書いたように、define_methodである必要性はなかったので実装をclass_evalに変更して対応しました*1

困った点3. 特異クラスでextendできない

次のようにdef_delegatorsを使ってGimei.maleとしたらGimei::Name.maleに委譲しているところでsteepのチェックが失敗します。

class Gimei  
  class << self  
    extend Forwardable    
    def_delegators 'Gimei::Name', :male, :female

RBSでは、次のようにクラスにextendしていることを型を表すことはできるけど、特異クラスに対してextendしていることを表す方法は現時点でなさそうでした。

class Gimei
  extend Forwardable

ruby-jpで相談したところ次のようにしてスキップする方法を教わりました。

__skip__ = begin
  extend Forwardable
  def_delegators ...
end

Allow skipping type checking by soutaro · Pull Request #73 · soutaro/steep に説明があるように、__skip__に代入する式全体がsteepの型チェックのスキップ対象となるようです。

また、# steep:ignore 無視したいエラー内容のようにして回避する手段があります。が、対応時点ではまだマージされていなかったので採用しませんでした。

Ignore diagnostics by steep:ignore comment by soutaro · Pull Request #1034 · soutaro/steep

最終的に次のコマンドでsteep_expectations.ymlを生成し

bundle exec steep check --save-expectations

それを--with-expectaitonsオプションで参照しつつsteep checkするようにしました。このようにすると、現状の型エラーは予想されたものとして扱われ、steep checkはステータスコード0で終了します。

bundle exec steep check --with-expectations

ひとまず 💚

しかしこれで型が過不足なく定義できたか、というと不安が残ります*2。現状ではlib/しか見てないので、ライブラリの一番外側のインタフェースはチェックされていないはず。spec配下を見れば外側のインタフェースを利用しているコードがあるので不安はだいぶ減りますが、これを実現するにはテストフレームワーク(minitest)の型も必要です。また、minitestでも何かしらのメタプロが駆使されているので、採用には苦難が伴いそう。

gem_rbs_collectionはどうやっているのかな、と思ってみると次のようにひたすらインタフェースのコードを書いて、それをsteep checkしていました。例としてgimeiのテストコードを上げていますが、ほかのgemに対するテストも同じような感じでした。

gem_rbs_collection/gems/gimei/1.1/_test/test.rb at main · ruby/gem_rbs_collection

require "gimei"

gimei = Gimei.name
gimei.kanji
gimei.hiragana
gimei.katakana
gimei.romaji
gimei.gender
gimei.male?
gimei.female?
# ...

このコードが実際にライブラリが提供しているインタフェースと一致しているかはテスト中では特に保証されていない模様です。ここの信頼性をあげるには、テストフレームワークの型付けを頑張ってやるか、RBS::Testで型のテストを書くしかないのかな?となっています。

所感

gimeiに型を導入してみて、型の導入に完璧を目指そうとすると大変だな、という所感を得ました。完璧を目指そうとせずエイヤで型を定義していき、うまく定義できないところはuntypedするくらいの大らかさで導入していくのが良さそう。

ベストエフォートな型の追加でもそれなりに恩恵はあるようなので、特にgemの作者はできる範囲でちょっとずつ型を足していくとみんなが幸せになると思います。やっていきましょう。

*1:本当はdefで地道にやったほうがいいと思います

*2:実際漏れがあってあとで型情報を追記しました…

gimeiのv1.3.0をリリースしました

Release v1.3.0 · willnet/gimei

特に機能面の追加はありません。

  • @greendropさんのPRにより型を導入した
  • Ruby3.3のサポート

この2つがメインの修正です。型の導入は思ったより大変でしたが、お陰でRBSによる型づけについての理解が深まりました*1

どうぞご利用ください。

追記

バグを直したり足りなかった型を足したりしたのを修正した v1.3.2 がリリースされています

*1:どこかで知見を書いたり発表したりするかも

Railsの設定をアップグレードしていく技術

このエントリは SmartHR Advent Calendar 2023の21日目の記事です。

Railsのバージョンを上げる作業は、単に新しいバージョンのgemをインストールするだけでは終わりません。Railsの新しいバージョンに沿った設定項目を確認し、適宜適用していく必要があります。もちろん必ずしもすべての設定を最新にしなければならないわけではありませんが、Railsの新しい設定というのは基本的にそうすることにメリットがあるから作られているわけで、特別な理由がなければ最新の状態にしておきたいものです。

みなさんのRailsアプリケーションのconfig/application.rb には次のような設定があるはずです。

config.load_defaults 6.1

このコードサンプルは引数が6.1なので、Rails6.1デフォルトの設定を適用していることを示しています。これを7.0にするには、まずRails自体を7.0にアップグレードした後に一つずつ必要な設定を足していき、すべての設定を追加した後config.load_defaults 7.0にするのが一番手堅いやり方だと思います*1

Railsのバージョンアップと設定変更は別々にやったほうが良い

このとき、rails gemのバージョンと一緒にconfig.load_defaultsの引数を変更するのは推奨しません。設定の中にはデプロイのロールバックを困難にするものがあるからです。

例えばRails7.0ではキャッシュの形式を変更するPRがマージされています。このコミットには互換性についての考慮があり、Rails7.0で作られた新しい形式のキャッシュとRails6.1以前に作られた古い形式のキャッシュ両方を読めるようになっています。しかしRails6.1にはこのコミットは含まれていないため、Rails7.0形式で作られたキャッシュをRails6.1のアプリケーションが読むことはできません。

このことから、Rails7.0のアップグレードとキャッシュの形式変更を同時に行った場合、もしデプロイ後しばらくしてから不具合が見つかりRails6.1にロールバックするとRails7.0の間に作られたキャッシュが失われることになってしまいます。キャッシュの形式は変えずにRails7.0のアプリケーションをデプロイし、問題ないことを確認してから設定を変更することで安全にアップグレードを進めることができます。

適用すべき設定一覧を調べる方法

load_defaultsの各バージョンの差分は、コードを見るのが手っ取り早いです。rails app:updateconfig/initializers/new_framework_defaults_7_0.rbのようなファイルを生成する、という方法もありますが個人的にはコードを直接見るほうが好きです。Railsのアップグレードしたいバージョンをcheckoutし、Rails::Application::Configuration#load_defaultsを見ます。

例えば6.1から7.0にアップグレードしたいときは次のコードが対象になります。

このとき、mainブランチの最新を見るのは推奨しません。例えばconfig.load_defauts 6.1に含まれる active_record.legacy_connection_handling=falseという設定はRails7.1ではデフォルトとなり設定からは削除されたので、mainブランチのコードを参照するとこのような設定を見逃してしまいます。

あとはひたすらやっていくのみ

ここからコードの各設定部分のコミットをgit blameで調べます。コミットコメントや紐づいているPRコメントとコードの差分を読み内容を理解した上で、アプリケーションの修正が必要かを判断していきます。修正が不要であればconfig/application.rbなどに該当の設定を記載してコミットするだけです。

一部の設定は Upgrading Ruby on Rails — Ruby on Rails Guides にも説明が記載されているのでこちらも読むと理解が捗ります。

先程のコードを眺めてみると、config.load_defaultsの引数を6.1から7.0にするためには最大で21個の設定変更が必要になります。プロジェクトによっては利用していないコンポーネントがありもう少し数が減る可能性はありますが、それでも多いことに変わりはありません。逐一設定を調べていくのは大変ですが、普段意識することの少ないRailsの内部仕様について詳しくなるよい機会だとも言えます。頑張ってやっていきましょう*2

*1:もちろん個人のアプリケーションや、影響範囲がないことが明らかな場合は除きます

*2:もし調べてみたけど内容がよくわからない、とかそもそもやり方がわからないなどあったらお近くの技術顧問にお声がけください