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

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

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:もし調べてみたけど内容がよくわからない、とかそもそもやり方がわからないなどあったらお近くの技術顧問にお声がけください

wkhtmltopdf_binary_gemのdebian12対応をしました

wkhtmltopdfの次どうするか問題 の余談でwkhtmltopdf_binary_gemにM1 mac対応のPR出しましたという内容を書きましたが、現時点でまだマージされる気配はありません。

そんな中、debian12(bookworm)がリリースされてDocker Hubのruby公式イメージでも使われるようになりました。PRを出した行きがかり上*1、debian12にも対応したほうがいいよな〜と思ったのでエイヤで対応してみました。もしdebian12でwkhtmltopdf_binary_gemを利用している人がいたら次のようにすると幸せになれると思います。

gem 'wkhtmltopdf-binary', github: 'willnet/wkhtmltopdf_binary_gem', branch: 'arm'

どうぞご利用ください。

2024/05/10追記

PRがマージされて、0.12.6.7としてリリースされました!

rubygems.org

*1:個人的には使っていないgemなのでやる気を振り絞りました

Kaigi on Rails2023で例外について発表してきました

Kaigi on Rails初のオフライン開催であるKaigi on Rails 2023で登壇する機会をいただけました。

例外は遅い

資料と動画は こちら から辿れます。

ちょっと間が空いてしまいましたが、以下登壇に関連してつらつら思いついたことを書いています。

なんでこの題材で話をしたんですか

これまでのKaigi on Railsの発表内容から

  • 明日の業務から使えるRailsの知見
  • Railsアプリケーション開発の実例

に関連したキャッチーなテーマが採択されやすんだろうな〜、とあたりをつけていました。僕は前者のネタはたくさんストックがある*1ので、その中で

  • みんな興味がありそう
  • 既存の書籍や記事ではあまり取り上げられていない題材

として、例外がよさそうだとなったのでした。発表でも言及した

  • destroyじゃなくてdestroy!を使いましょう
  • rescue_fromだけでエラーハンドリングするのは不十分なんですよ
  • なるべく静的なエラーページにしておいたほうが楽ですよ

といった内容はお手伝い先で幾度となく指摘していたので、この機会に「この資料を見てください」ですむようにDRYにするという狙いがありました。

さらに「いつどのように例外を扱うと良いのか」の指針を言語化するいい機会だなと感じていました。Clean Test Code Revisedのときもそうだったのですが、これまでの経験の蓄積で「なんとなくこうした方がいいと思う」で判断する状態から、ちゃんと言語化して人に説明できる状態に持っていくきっかけとして登壇はかなり有効な手段です。僕は技術顧問という仕事柄いろんなことを言語化して人に説明する必要があるので、このような登壇は今後も継続して行きたいところです。

まだ改善ポイントがあるぞ

発表を見てくれた色んな人から「わかりやすかった」とフィードバックをもらえて嬉しいのですが、「いつどのように例外を扱うと良いのか」の部分は(大筋はいいとしても)改善の余地がまだあるな、と感じています。これもClean Test Codeと同じように、2, 3年後あたりにまた改善させた発表ができるといいな。

発表に関して、なにかしら意見や指摘などあったらこちらに書いてくれるととても嬉しいです!

オフラインだと直接お礼が言えるぞ

僕は大多数のRubyistと同様にシャイかつ話すのがうまくないので自分から話しかける、ということはあまりしません。しかし普段の活動を褒められると嬉しいので、自分からもお礼言えるタイミングがあったら言っておこう、と思い今回実践していました。

たとえばpockeさんはruby-jpで毎週RubyKaigiの動画を観る会をやってくれていてめっちゃ助かっている*2ので「めっちゃ助かっています!」と直接伝えました。お礼できる内容があると「なにか話したいけど話題が思いつかない」問題が解決するのでべんりですね。

アルコールなしの懇親会もありですね

1日目の懇親会はSTORES CAFE〜Kaigi on Rails 2023出張版に参加しました。発表準備がギリギリで寝不足だったし、2日め朝にジョギングできたらいいな、と思っていたのでノンアルコールの懇親会は助かりました*3

実際参加してみるとノンアルコールでも普通に楽しく懇親できたので、毎日飲まなくても問題ないな〜という感想を持ちました。おかげで次の日にちゃんとジョギングができました。

運営の皆さんありがとうございました

運営、めっちゃ大変だと思うので頭が上がりません。slackの発表者向けチャンネルで質問できて、まとまった情報はesaにまとまっている、というのはめっちゃ体験が良かったです。

来年も登壇したいぞ

登壇にあたっては「資料を準備する時間を確保できるか」が一番の壁*4なのですが、出来得る限り万難排して来年も登壇できるように頑張ります(\( ⁰⊖⁰)/)

*1:後者に関しては各お手伝い先のエンジニア各位におまかせしたい

*2:技術関連の動画、なにかしらのキッカケがないと観れないですよね…

*3:アルコールありの懇親会でも飲むのをを我慢すればいいじゃん、という意見はごもっともだけど我慢できるはずがない

*4:子供の相手をすると時間が無限に溶ける

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

Release v0.7.0 · willnet/ci_logger

これまではRails.loggerだけが対象だったのですが、v0.7.0からは次のように、任意のloggerをラップできるようになりました。

your_logger = CiLogger.new(your_logger)
your_logger.debug('debug!') # これはテストが失敗したときだけ出力される

個人的にはferrumのログをラップして、テスト失敗時にferrumがどのようにChrome DevTools Protocolを叩いていたかを見るのに活用しています。

どうぞご利用ください。