Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

週刊Railsウォッチ: Action Mailerプレビューで全メールヘッダーを表示可能に、rubocop-graphqlほか(20230307前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

また引き離されてしまいましたのでピッチを上げます。

🔗 Action ControllerのInstrumentation呼び出しでfiltered_pathを使うよう変更

機密性の高いクエリパラメータをフィルタするためにActionController::Instrumentationを変更した。イベントペイロードでfullpathではなくfiltered_pathを渡す。

get "/posts?password=test"
request.full_path         # => "/posts?password=test"
response.filtered_path    # => "/posts?password=[FILTERED]"

Ritikesh G
同Changelogより

参考: §3.1 Action Controller -- Active Support の Instrumentation 機能 - Railsガイド


つっつきボイス:「Instrumentationイベント内で#full_pathが使われていたことで、GET渡しのクエリパラメータなどが適切にフィルタされないままイベントが送信されていたのをフィルタするようにしたんですね」「filtered_pathはログでパスワードとかを[FILTERED]でマスクした文字列を取り出すメソッドですね」「こういったイベントデータのカスタマイズは自分でInstrumentation呼び出しのコードを書けばできますが、InstrumentationからのイベントはAPM(アプリケーションパフォーマンス監視)サービスのデフォルト設定で利用されることも多いので、知らない間に重要な情報がAPMサービスに流れていたということにならないようにデフォルトでフィルタ有効化されている方が安心ですね👍」

参考: §3.2.21 config.filter_parameters -- Rails アプリケーションを設定する - Railsガイド

🔗 token_listヘルパーでStimulusのdata-actionを余分なエスケープから保護する

動機/背景
このコミット以前は、token_listヘルパー呼び出しにdata-action属性値を渡すとHTMLエスケープが1個多すぎてしまう可能性がある。以後呼び出しを繰り返すと問題がさらに積み上がってしまう。
たとえば、以下の呼び出しをパースするとHTMLエスケープが多すぎてディスクリプタが無効になる可能性がある。

first   = "click->controller#action1"
second  = "click->controller#action2"
third   = "click->controller#action3"
fourth  = "click->controller#action4"

value = token_list(first, token_list(second, token_list(third)))

CGI.unescape_html value.to_s
 # => "click->controller#action1 click->controller#action2 click->controller#action3 click->controller#action4"

詳細
CGI.unescape_htmlを用いることで、token_list(値を再エスケープする)に渡される前の各String値をロスなしに連結処理し、HTML安全性も維持できるようになる。

このコミット後、上の例は以下のように期待通り動作する。

first   = "click->controller#action1"
second  = "click->controller#action2"
third   = "click->controller#action3"
fourth  = "click->controller#action4"

value = token_list(first, token_list(second, token_list(third)))

CGI.unescape_html value.to_s
 # => "click->controller#action1 click->controller#action2 click->controller#action3 click->controller#action4"

同PRより


つっつきボイス:「Stimulusサポートの修正です」「token_listヘルパーでHTMLの>->>みたいなエスケープが重複発生していたのね」「RubyのCGI.unescape_htmlで修正されている」

# actionview/lib/action_view/helpers/tag_helper.rb#L365
      def token_list(*args)
-       tokens = build_tag_values(*args).flat_map { |value| value.to_s.split(/\s+/) }.uniq
+       tokens = build_tag_values(*args).flat_map { |value| CGI.unescape_html(value.to_s).split(/\s+/) }.uniq

        safe_join(tokens, " ")
      end

参考: Rails API token_list -- ActionView::Helpers::TagHelper
参考: CGI.unescapeHTML (Ruby 3.2 リファレンスマニュアル)

🔗 メーラーのプレビューで全ヘッダーを表示する機能が追加

動機/背景
メールに追加したカスタムヘッダーをプレビューする手軽な方法がなかった。メールスレッド用のMessage-IDヘッダーや、メールサービスプロバイダ固有の分析用タグやアカウントメタデータといったヘッダーはproductionのRailsアプリケーションでしばしば用いられる。

mailcatcherやmailhogが提供しているような「フェイク受信トレイ」でメールの全ヘッダーを手軽に調べられるようにする。

詳細
このプルリクは、メーラーのプレビューテンプレートに展開可能なセクションを追加する。このセクションを展開すると、そのメーラーで全ヘッダーの表が表示される。


同PRより


つっつきボイス:「お、Action Mailerの標準機能でメールヘッダーを全部表示できるのは便利👍」「これはありがたい🙏」「開発でメールヘッダーを全部見たいことは普通にありますね」

参考: § 2.6 メールのプレビュー -- Action Mailer の基礎 - Railsガイド

🔗 Action Cableのログ出力でパラメータをフィルタするようにした


つっつきボイス:「上の#47296はActionController内でのInstrumentationイベント内文字列をフィルタするようにしたけれど、こちらはAction Cableのログが対象か」「今回はフィルタ関連の改修が続きますね」「config.filter_parametersでの設定がAction Cableにも効くようになるのね」

# actioncable/test/channel/base_test.rb#L108
+ test "does not log filtered parameters" do
+   @connection.server.config.filter_parameters << :password
+   data = { password: "password", foo: "foo" }
+
+   assert_logged(':password=>"[FILTERED]"') do
+     @channel.perform_action data
+   end
+ end

参考: §3.2.21 config.filter_parameters -- Rails アプリケーションを設定する - Railsガイド

🔗 Associations::Preloaderで複合外部キーの関連付けをプリロード可能になった

このプルリクは、Associations::Preloaderで複合外部キーをquery_constraintsによって関連付けられている関連付けをプリロードできるようにし、暗黙でincludes()リレーションをサポートするようにする。
複数テナントのレコードをバルクでクエリする場合の基本的な要件は、カラム名と値のセット/タプルを指定可能であること。最も効率の良いSQL句では以下のように行コンストラクタを利用する。

select * from comments where (blog_id, blog_post_id) IN ((1,2), (1,3))

しかし現在のRailsでは行コンストラクタ句をサポートしていないので、以下のようなOR句を連続で使うことになる。

select * from comments where (blog_id=1 and blog_post_id=2) OR (blog_id=1 and blog_post_id=3)

この方法は、クエリに含まれる値の個数に応じてクエリが急速に肥大化するため効率があまりよくないが、有効な方法ではある。

テナントベースのアプリケーションでは、ほとんどのクエリが単一テナントを対象として実行されることを想定しているが(上述のコード行)、Rails(あるいはArel?)は上のクエリを以下のように賢く変換してくれる。

select * from comments where blog_id=1 AND (blog_post_id=2 OR blog_post_id=3)

最適化の可能性

このプルリクで最適化を行うのは適切ではないと考えるが、上のクエリはさらに以下のようにSQL句をできるだけ短くする形での最適化が見込まれる。

select * from comments where blog_id=1 AND blog_post_id IN (2,3)

このオプションについてまだ調べてはいないが、可能ならload_records_for_keysレベルまたはArel内部で実装されることを期待したい。

さらなるステップ

理想としては、Railsが最終的に行コンストラクタをサポートし、load_records_for_keysを変更せずにresulting_scope.where(association_key_name => keys)association_key_nameは単一カラムまたはカラムセットを参照する)のようにシンプルにできるようにするのが望ましい。
同PRより

参考: MySQL :: MySQL 8.0 リファレンスマニュアル :: 8.2.1.22 行コンストラクタ式の最適化


つっつきボイス:「Railsで複合主キーが使えるようになったのかな?この改修は複合外部キーが対象だけど、複合主キーへの対応も順調だといいな」「複合外部キーは#47230がmainブランチにマージされていたんですね」「複合主キーといえばcomposite_primary_keys gemが定番ですね↓(ウォッチ20201209): DB設計をしていれば複合主キーは普通に使いたい」「サロゲートキーにしたくないこともありますよね」

composite-primary-keys/composite_primary_keys - GitHub


参考: Rails API create_table -- ActiveRecord::ConnectionAdapters::SchemaStatements

:primary_key
主キーが自動的に追加される場合の主キーの名前。デフォルトはid。このオプションは:idをfalseにすると無視される。
配列を渡すと複合主キーが作成される。
Active Recordモデルはこれらの主キーを自動検出することに注意。これはモデルでself.primary_key=を用いて明示的に主キーを定義することで回避できる。
APIドキュメント create_table -- ActiveRecord::ConnectionAdapters::SchemaStatementsより

つっつき後にRedditの書き込みをきっかけに調べてみると、APIドキュメントには上のように書かれていますが、#29135を見ると上のAPIドキュメントにある複合主キーの作成はマイグレーションレベルの話のようで、複合主キーはActive Recordではまだ公式にサポートされていないようです↓。

# https://github.com/rails/rails/blob/09f5d816a2b7a7828eca58c0c94682ae63d294bf/activerecord/lib/active_record/attribute_methods/primary_key.rb#L137
          private
            def suppress_composite_primary_key(pk)
              return pk unless pk.is_a?(Array)

              warn <<~WARNING
                WARNING: Active Record does not support composite primary key.
                #{table_name} has composite primary key. Composite primary key is ignored.
              WARNING
            end

rails/primary_key.rb at main · rails/railsより

🔗 SchemaCache#init_withで重複解消をスキップ可能になった

これはSchemaCacheのシリアライザを使っている人向けの非常に高度なAPI。

カスタムシリアライザでは既にデータを重複解消(deduplicate)している可能性があり、その場合に再度重複解除するのは非常に無駄が多い。
同PRより

参考: Rails API ActiveRecord::ConnectionAdapters::SchemaCache


つっつきボイス:「deduplicateが不要な場合にスキップできるようにしたんですね: very advanced APIとあるので使う人は限られそう」「デデュプリケートって言いにくい...」

# activerecord/lib/active_record/connection_adapters/schema_cache.rb#L68
      def init_with(coder)
        @columns          = coder["columns"]
+       @columns_hash     = coder["columns_hash"]
        @primary_keys     = coder["primary_keys"]
        @data_sources     = coder["data_sources"]
        @indexes          = coder["indexes"] || {}
        @version          = coder["version"]
        @database_version = coder["database_version"]

-       derive_columns_hash_and_deduplicate_values
+       unless coder["deduplicated"]
+         derive_columns_hash_and_deduplicate_values
+       end
      end

🔗 Dockerfieのユーザー名をappuserからrailsに変更

些細ではあるが、他のDockerイメージと衝突する可能性のある一般的なappuserではなく、railsイメージの中にいることをコンテナユーザーが認識できるようになる。
同PRより

# railties/lib/rails/generators/rails/app/templates/Dockerfile.tt#L69
# add custom user
-RUN useradd appuser
+RUN useradd rails

-USER appuser:appuser
+USER rails:rails

# Copy built artifacts: gems, application
-COPY --from=build --chown=appuser:appuser /usr/local/bundle /usr/local/bundle
-COPY --from=build --chown=appuser:appuser /rails /rails
+COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
+COPY --from=build --chown=rails:rails /rails /rails

つっつきボイス:「Docker関連の改修も入りました」「ところで、Railsコンテナの名前に使うのはrails以外にappなんかも考えられるけど、少なくともappuserじゃない方がいいですね」「たしかに」

🔗Rails

🔗 Railsにモンキーパッチを当てるのは避けよう(Ruby Weeklyより)


つっつきボイス:「Shopifyの記事です」「モンキーパッチはRailsのアップグレードのときに問題になりやすいので極力当てたくないけど、実装上の都合でやむを得ず当てざるを得ないこともあるんですよ」「gemがモンキーパッチを当ててくることもごくまれにありましたね」「イニシャライザとかでモンキーパッチを当てた当時は覚えていても、すぐ忘れられがち」「Rubyのrefinementである程度は緩和できますけどね↓」

RubyのRefinement(翻訳: 公式ドキュメントより)

「Shopifyも以下の自社製gemでActive Recordのアダプタにモンキーパッチを当てていたけど、その後本家にプルリク#46690を投げてマージしてもらったそうです↓」「手元でモンキーパッチで回避するよりはアップストリームにマージしてもらう方がいいですよね」

Shopify/activerecord-pedantmysql2-adapter - GitHub

「Railsのmainブランチにしか入っていない機能や修正がどうしても必要なときとかに、やむを得ずモンキーパッチを当てることはあるかな」「やるとしたらそうかも」

「記事では、モンキーパッチより先にRailsをアップグレードする方を検討しようと書かれてますね」「たしかにそうなんですけどね...」「でも使っているgemによってはアップグレードが難しくなったりすることもあるんですよ」「う〜む」

🔗 RSpecではcontext間の違いを表現するときにのみletを使う


つっつきボイス:「onkさんが以下の翻訳の元記事に応答した記事です」

Rails: RSpecが好きでないことを思い出したテスト(翻訳)

「タイトルを見ただけで伝えたいことがよくわかる記事ですね: 特定のcontextの中でのみ切り替えたい変数がある場合に"だけ"letを使いたいというのはとても理解できる👍」「ですよね」「どんなletがよくないかは、letを使っていくうちに体得していく形になるかな」

参考: RSpecドキュメント let -- Module: RSpec::Core::MemoizedHelpers::ClassMethods — Documentation by YARD 0.9.28

letは乱用して欲しくないけど、特定のcontextの中でだけ変数を上書きするのはletじゃないと書きにくかったりする」「そうそう」「その書き方ならテストコードの近くにletが置かれるので、どの変数が上書きされているかが見えやすくなります」「なるほど」「そういえば元記事のリファクタリング後のテストコードではletが末尾に固められていましたね↓」

# https://techracho.bpsinc.jp/hachi8833/2023_02_13/126094より
  ...
  let(:user) { create(:user) }
  let(:teacher) { create(:user, :teacher) }
  let(:student) { create(:student, parent: parent) }
  let(:parent) { create(:parent) }
  let(:new_zip_code) { Faker::Address.zip_code }
  let(:parent_first_name) { Fake::Name.female_first_name }
end

🔗 Railsのログを最大限活用する


つっつきボイス:「AppSignalの記事です」「記事はログの出力レベルなどのRailsのログに関する定番の話かな: AppSignalはパフォーマンス監視サービスなどを手がけているのでRailsのログは同社の得意な分野ですね👍」

参考: §2.2 ログの出力レベル -- Rails アプリケーションのデバッグ - Railsガイド

「記事にもあるように、ログをいかに読みやすく出力するかは大事: 呼び出し元クラスのような情報がログにないと追いかけるのが大変」

# 同記事より
def call_external_api(user_id, payload)
  Rails.logger.debug('Calling external api')
  client = Client.new(user_id)
  response = client.request(payload)
  if response.ok?
    Rails.logger.info { "Request success, received #{response.body}" }
    return response
  else
    Rails.logger.warn { "Request returned #{response.code}, #{response.body}" }
    return response
  end
  rescue ClientError => e
    logger.error { "#{e}: #{e.message}" }
end

「ある程度以上読みやすくしようと思ったら、記事にもあるようにstructured logにすることになるでしょうね」「記事ではLogstashサービスやlograge gemが紹介されていますね」

# 同記事より
# lib/json_log_formatter.rb
class JsonLogFormatter < ::Logger::Formatter
  def call(severity, time, progname, msg)
    json = { time: time, progname: progname, severity: severity, message: msg2str(msg) }
      .compact_blank
      .to_json
    "#{json}\n"
  end
end

# application.rb
config.log_formatter = JsonLogFormatter.new

参考: Logstash:データの一元化、変換、保管 | Elastic

elastic/logstash - GitHub

roidrage/lograge - GitHub

記事では他にもougaiやmr-loga-logaというカスタムロガーも紹介されていました↓。

tilfin/ougai - GitHub
hschne/mr-loga-loga - GitHub

🔗 rubocop-graphql(RubyFlowより)

DmitryTsepelev/rubocop-graphql - GitHub


つっつきボイス:「rubocop-graphqlが出ていたのを初めて知りました」「これはgraphql-rubyを使ったRubyコードを対象とするcopですね」「最初のコミットは2020年5月か」「graphql-rubyはかなり使われているはずだけど、そこまで世間的な書き方のベストプラクティスが確立している感じでもないので、対応するcopが提供されるのはその辺りを考えるきっかけになってよさそう👍」

参考: Module: RuboCop::Cop::GraphQL — Documentation for rubocop-graphql (1.0.0)

# https://www.rubydoc.info/gems/rubocop-graphql/RuboCop/Cop/GraphQL/ArgumentDescription
# フィールドごとにdescriptionがあること
# good

class BanUser < BaseMutation
  argument :uuid, ID, required: true, description: "UUID of the user to ban"
end

# bad

class BanUser < BaseMutation
  argument :uuid, ID, required: true
end

🔗 Railsアプリでメモリ肥大化を事前に発見する(Ruby Weeklyより)


つっつきボイス:「メモリ肥大化を発見するのは事前にあたりを付けておかないと難しいでしょうね: Railsで割とあるのはActive Recordで全レコードをメモリに乗せてしまうとかかな」「Active Recordでmapを使ったりすると起きがち」「それそれ」

「記事ではio_monitor gemで調べる方法が紹介されていますね↓」「このコンフィグで思い出したけど、巨大なファイルをガバっと取得したりするとRubyのNet::HTTPでメモリ肥大化につながる可能性はありそう」「最近だとスマホのカメラの解像度が高くなっていて画像の容量がでかいので、アップロードサービスを追加するときは以前よりもメモリ肥大化に注意が必要でしょうね」「Redisも巨大なデータをインデックス化して一括で取り出したりすると起きやすそう」

# 同記事より
IoMonitor.configure do |config|
  config.publish = [:logs, :notifications, :prometheus] # defaults to :logs
  config.warn_threshold = 0.8 # defaults to 0
  config.adapters = [:active_record, :net_http, :redis] # defaults to [:active_record]
end

DmitryTsepelev/io_monitor - GitHub

参考: library net/http (Ruby 3.2 リファレンスマニュアル)

参考: Redis | redis.io

🔗 その他Rails

「お、Railsチュートリアルに型を付けてみたのね」「静的型チェッカーはSteepを使ってますね」「SteepのVSCode拡張も使ってる」

参考: Ruby on Rails チュートリアル:プロダクト開発の0→1を学ぼう

soutaro/steep - GitHub

参考: Steep - Visual Studio Marketplace


前編は以上です。

バックナンバー(2023年度第1四半期)

週刊Railsウォッチ: Ruby 3.2のData#initializeの設計、ruby-openai gemほか(20230222後編)

Rails公式ニュース

Ruby Weekly

RubyFlow

160928_1638_XvIP4h


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。