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

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

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

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:実際漏れがあってあとで型情報を追記しました…