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

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

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'

どうぞご利用ください。

*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を叩いていたかを見るのに活用しています。

どうぞご利用ください。

大阪Ruby会議03でflaky testについて話してきました

前回の大阪RubyKaigi02に続き、大阪RubyKaigi03でも登壇機会をいただけました。

スライドはこちら。

感想

自分含めみんなflaky testで疲弊しているので、それを仕組みで解決したいぞ、みんなで仕組みを作っていこうぜという発表でした。10分の発表は思っていたよりも短くて*1いろいろ省略してしまったのが残念ポイントです。

私と大阪

発表時間が足りなかったのもあって自己紹介は10秒で駆け抜けてしまったのですが、僕は昔ちょっとだけ大阪に住んでいたことがあります。発表の翌日は住んでいたあたりをひたすら散歩していました。当時の会社の寮が分譲マンションに変身していたり、通っていた店はほとんどなくなっていて悲しい😭。17年ほど経過しているのでそれはそうなんですが、ずっとそのままでいてほしいと思ってしまうのはなぜなんでしょうね。当時はコードも書いておらず、何を考えて生きていたかは覚えていないのですがなにかしら毎日頑張っていたような気がします。それを思い出せて良い散歩でした*2

写真は近くまで来たのでとりあえず来てみた大阪城です。

また次回あったら登壇できるように頑張ります💪

*1:スライド作る時間がないので短めにしておこう、と日和った結果が裏目に出た

*2:しかし歩きすぎて足が棒になった

Active Recordで関連先を保存するときに気をつけること

先日、仕事でRails(Active Record)の難しい仕様に遭遇したので共有するためにエントリをしたためました。似たようなケースに遭遇した人の手助けになれば幸いです(\( ⁰⊖⁰)/)

対応Railsバージョンと設定

  • Rails6.1以上
  • config.active_record.has_many_inversing = true(Rails6.1のデフォルト設定)である

問題1

まず次のコードを読んでみてください。

class User < ApplicationRecord
  has_many :posts
end

class Post < ApplicationRecord
  belongs_to :user, inverse_of: :posts # (1)
  before_update { puts 'before_update' }
end

user = User.new
post = Post.new(user:) # (2)
post.save # 'before_update'が出力される!

Post.newしてからsaveしているのでレコードは新規に作成されるはずなのですが、更新時のフックであるbefore_updateが実行されています。なぜでしょうか?

前提知識1

モデルの関連(has_many、has_one、belongs_to)にはそれぞれinverse_ofというオプションがあります。関連先のオブジェクトに関連元のオブジェクトをアサインするために使われる設定です。

例えばUserとAddressが1対1関連だったとして、次のコードを実行したとします。

address = user.address
address.user

このとき、address.userが指すuserは明らかなのでクエリを発行したくはないですよね?そこでRailsは予めuser.addressとした時点で戻り値のAddressインスタンスに対してuserをアサインしてくれます。

inverse_ofは、関連先に関連元をアサインするときの関連名を指定するためのオプションです。inverse_ofを明示的に設定しなくても、関連名を推測できるときは自動でアサインします。

余談ですが、執筆時点では自動でinverse_ofを類推する機能が複数形に対応していないため、問題1の(1)でinverse_ofを有効にするためには明示的なinverse_ofの指定が必要です。 過去に複数形対応のPRがマージされたのですがデグレがあり後にrevertされた、という状況です。だれかがコントリビュートしたら将来のRailsでは明示的なinverse_ofは不要になるかもしれません。

上記「対応Railsバージョンと設定」にあるconfig.active_record.has_many_inversing = trueは、belongs_toを宣言しているモデルにhas_manyを宣言しているモデルをアサインしたときやbuild_associationメソッドで関連先を生成したときに、関連先に対してinverse_ofによるモデルのアサインをするようにする設定です(Rails 6.1まではbelongs_to :user, inverse_of: :postsのようにinverse_ofを明示的に宣言していても意味がありませんでした)。

この設定が有効なとき、問題1の(2)のタイミングでuser.postsが[post]を返すようになります。

前提知識2

関連を定義すると、モデルをsaveする時のhookで関連先のsaveが実行される事があります。関連メソッドにautosave: trueオプションを付けていなければ、関連先のモデルが存在しているかつ関連先が永続化がされていない(new_record? #=> true)ことがsaveされる条件です。

belongs_to関連ならbefore_savehas_(one|many)ならafter_(create|update)のタイミングで関連先のsaveが実行されます*1

関連先のモデルにも関連が定義されている場合、関連先のsaveは連鎖的に実行されていきます。すると関連の定義の仕方によっては永久にsaveの連鎖が実行されそうですが、そうはなりません。起点となるsaveメソッドの中で、同じhookメソッドを2回以上呼ぶとその処理は無視する仕組みが入っています*2

問題1の解答 - なぜ新規保存なのにbefore_updateが実行されるのか

ようやく前提は終わりです。長かったですね。それでは実際に問題1のコードを実行したときに、どのような順番で関連先の保存が実行されるか見ていきましょう。

以下は ログ出力用に用意したスクリプト を利用して、saveと関連先の保存用メソッド、コールバックなどを時系列に出力したものです。メソッドの依存関係はインデントで表しています。

Post#save start
  Post#before_save start
    Post#autosave_associated_records_for_user start
      User#save start
        User#before_save start
        User#before_save end
  TRANSACTION (0.1ms)  begin transaction
  User Create (0.7ms)  INSERT INTO "users" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2023-08-07 01:58:17.018474"], ["updated_at", "2023-08-07 01:58:17.018474"]]
        User#after_create start
          User#autosave_associated_records_for_posts start
            Post#save start
              Post#before_save start
                Post#autosave_associated_records_for_user start
                Post#autosave_associated_records_for_user end
              Post#before_save end
  Post Create (0.2ms)  INSERT INTO "posts" ("user_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["user_id", 8], ["created_at", "2023-08-07 01:58:17.022856"], ["updated_at", "2023-08-07 01:58:17.022856"]]
              Post#after_create start
              Post#after_create end
            Post#save end
          User#autosave_associated_records_for_posts end
        User#after_create end
      User#save end
    Post#autosave_associated_records_for_user end
  Post#before_save end
before_update
  Post#after_update start
  Post#after_update end
Post#save end

Post#autosave_associated_records_for_userUser#autosave_associated_records_for_postsが関連を定義したときに自動で生成される、関連先を保存するためのメソッド名です。

このログを注意深く見ると、最初に実行したPostのbefore_saveの中でPostのsaveが実行されてしまっているのがわかるのではないでしょうか。postにはuserが関連先として紐付けられていますし、userにはinverse_ofによりpostが紐づいている、という状況なのでこのように連鎖的にsaveが実行されることになります。

Postのbefore_saveの中でPost#saveが実行された後に、before_saveの続きが実行されます。このときすでにpostは永続化されているのでこのsaveはupdateと同様の操作になり、結果としてbefore_updateコールバックが実行されるという流れになります(before_updateはbefore_saveより後に呼ばれます)。このあとの処理ではモデルの属性に変化がないのでクエリは発行されません。

問題2

すでになるほどわからん、となっていそうですがまだ話は終わりません。次のコードを見てください。

class Comment < ApplicationRecord
  belongs_to :user, inverse_of: :comments
  belongs_to :post, inverse_of: :comments
end

class User < ApplicationRecord
  has_many :posts
  has_many :comments
end

class Post < ApplicationRecord
  has_many :comments
  belongs_to :user, inverse_of: :posts
end

user = User.new
post = Post.new(user:)
comment = Comment.new(user:, post:)

comment.save #=> NOT NULL constraint failed: comments.user_id (SQLite3::ConstraintException)

一見なんの問題もないように見えますがsaveに失敗します。理由はエラーメッセージを見る限り`comments.user_id がnullでNOT NULL制約に引っかかってしまったからのようです。なぜこのような事が起こるのでしょうか?

問題2の解答 - なぜNOT NULL制約になるのか

これも同じように ログ出力用に用意したスクリプトを利用してログ出力してみます。

Comment#save start
  Comment#before_save start
    Comment#autosave_associated_records_for_user start
      User#save start
        User#before_save start
        User#before_save end
  TRANSACTION (0.2ms)  begin transaction
  User Create (0.8ms)  INSERT INTO "users" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2023-08-07 02:12:35.739117"], ["updated_at", "2023-08-07 02:12:35.739117"]]
        User#after_create start
          User#autosave_associated_records_for_posts start
            Post#save start
              Post#before_save start
                Post#autosave_associated_records_for_user start
                Post#autosave_associated_records_for_user end
              Post#before_save end
  Post Create (0.2ms)  INSERT INTO "posts" ("user_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["user_id", 1], ["created_at", "2023-08-07 02:12:35.744300"], ["updated_at", "2023-08-07 02:12:35.744300"]]
              Post#after_create start
                Post#autosave_associated_records_for_comments start
                  Comment#save start
                    Comment#before_save start
                      Comment#autosave_associated_records_for_user start
                      Comment#autosave_associated_records_for_user end
                      Comment#autosave_associated_records_for_post start
                      Comment#autosave_associated_records_for_post end
                    Comment#before_save end
  Comment Create (0.9ms)  INSERT INTO "comments" ("user_id", "post_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["user_id", nil], ["post_id", 1], ["created_at", "2023-08-07 02:12:35.746900"], ["updated_at", "2023-08-07 02:12:35.746900"]]
                  Comment#save end
                Post#autosave_associated_records_for_comments end
              Post#save end
            User#autosave_associated_records_for_posts end
          User#save end # (a)
        Comment#autosave_associated_records_for_user end # (b)
      Comment#save end 
  TRANSACTION (0.6ms)  rollback transaction
/Users/willnet/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sqlite3-1.6.3-arm64-darwin/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: NOT NULL constraint failed: comments.user_id (ActiveRecord::NotNullViolation)
/Users/willnet/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sqlite3-1.6.3-arm64-darwin/lib/sqlite3/statement.rb:108:in `step': NOT NULL constraint failed: comments.user_id (SQLite3::ConstraintException)

問題1のときと同じように、Commentのbefore_save中にComment#saveが実行されているのが見て取れます。エラーになっているのはこれが原因です。

関連先のsaveが終わらないと関連先の主キーが確定しないので、belongs_to関連の外部キーが設定されるタイミングは関連先のsaveが終わったタイミングになります。has_many関連のときは、関連先のsaveを実行する前に外部キーを設定します。

問題2のコードでは、関連先(User)の主キーをCommentの外部キーとして設定するのはUser#saveが終わった後のComment#autosave_associated_records_for_userの処理中(上記ログの(a)から(b)の間)です。

ですがこのコードでは、Comment->User->Post->Commentのように関連先の保存の連鎖が起きた結果、外部キーを設定するより前にINSERTクエリを発行しているのでNOT NULL制約に違反してエラーとなっています。Comment#saveが終わってからエラーの表示が出力されているので分かりづらいですが、実際にはComment Create (0.9ms) INSERT INTO ...のところでNOT NULL制約でエラーになっています。

問題の解決策

テストで利用するデータの作成にFactoryBotを使い、buildストラテジを利用してモデルと関連先を作成してからsaveすると今回のサンプルコードのようなケースになりやすい印象です。

複数の関連を一度のsaveで連鎖的に保存しようとしない限りは問題は起きないはず。もし遭遇した場合はもっと細かい単位でモデルをsaveするようにコードを修正すると良いでしょう。

*1:関連先が保存された後にafter_createやafter_updateを発火できるように、after_saveではなく、それよりも前に実行されるafter_createとafter_updateを利用しています

*2:この仕組みを実現するために、define_non_cyclic_method というメソッドが使われていますhttps://github.com/rails/rails/blob/db6159ce62b393d4422d4890f26b53c66db5c4f9/activerecord/lib/active_record/autosave_association.rb#L157-L174

TokyuRuby会議14でmrskについてLTしました

TokyuRuby会議14に参加してLTしてきました。

スライドはこちら。

発表について

mrskは個人的にかなり推しているプロダクトで、これの存在により仕事でも個人でも今より安いインフラを活用する選択肢を取りやすくなったと感じています。とりあえず僕が今運用しているサービスはmrskを活用してDigitalOceanあたりに寄せていけたらな、と思います。みなさんも使ってみると便利ですよ。オススメ。

感想

久しぶりにオフラインで5分のLTをしました。あと色んな人とビールを飲みながら雑に会話して、コロナ禍以前の地域コミュニティの感覚ってこんなだったなあ…と思っていたら6時間があっという間に過ぎていきましたね。次回のTokyuRuby会議も楽しみにしています。

次回発表予告

Osaka RubyKaigi 03のCFPが通っているので、9月の発表に向けて資料作りを頑張っていきます。当日大阪参加する人はよろしくお願いします(\( ⁰⊖⁰)/)