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

週刊Railsウォッチ(20210419前編)RailsのN+1クエリを定番以外の方法で修正する、GitLabのセキュリティ修正リリースほか

こんにちは、hachi8833です。RailsConf 2021をすっかり見逃してました😇。


つっつきボイス:「RailsConf 2021、そういえば開催時期だったか」「つっつき中の今日(04/15)がラス日でした🙇」「キーノートのページにコミットでよく見かける顔が並んでますね」

「RailsConf 2021のfaqを見た感じでは有料イベントのようですね」「あ、そうでしたか」「参加者は後で動画を見られるそうですが、一般公開するとは書かれてない: たしかに有料でしかもリモート開催のイベントの動画を後で無料公開したら有料で登録する人がほとんどいなくなってしまいますよね」「それもそうか…」「スライドだけでも見られればと思ったけどしょうがないですね」


以下はつっつき後に見つけたツイートです。

週刊Railsウォッチについて

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

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

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

今回は以下のコミットリストのChangelogを中心に見繕いました。

🔗 エラーページのCSSとアクセシビリティを改善


つっつきボイス:「developmentモードで表示されるエラーページのスタイルをインライン(style=による直書き)ではなくちゃんとCSSのクラスで書き直したのか↓」「開発中によく見かける赤いエラーページですね」

# actionpack/lib/action_dispatch/middleware/templates/rescues/_message_and_suggestions.html.erb#L13
    <% corrections.each do |correction| %>
-     <li style="list-style-type: none"><%= h correction %></li>
+     <li class="correction"><%= h correction %></li>
    <% end %>

<div>タグも、意味を表せるHTML5の<main>タグに置き換えてる↓」「考えてみれば、developmentモードのエラー画面をスクリーンリーダーで読む人がいる可能性もありそう」「だからアクセシビリティ改善なんですね」

# actionpack/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb#L4
-<div id="container">
+<main role="main" id="container">
  <h2>To allow requests to <%= @host %>, add the following to your environment configuration:</h2>
  <pre>config.hosts << "<%= @host %>"</pre>
-</div>
+</main>

参考: HTML: アクセシビリティの基礎 - ウェブ開発を学ぶ | MDN


「ところで、accessibilityをa11yって略さなくてもいいんじゃないかしら」「i18n(internationalization)やm17n(multilingualization)ぐらい長ければ略すのもやむなしな気もしますけど」「Kubernetesも大して長くないけどK8sと略されたりしますね」

🔗 ActiveSupport::Duration#partsのハッシュをfreeze


つっつきボイス:「partsが返すハッシュをfreezeして返すことで高速化したらしい」「ベンチマークでも速くなってますね」「ライター系メソッドも削除したのか」「freezeしたんだからライター系メソッドはあったらマズいでしょうね」「あ、たしかに」

# activesupport/lib/active_support/duration.rb#L212
-   def initialize(value, parts) #:nodoc:
+   def initialize(value, parts, variable = nil) #:nodoc:
      @value, @parts = value, parts
      @parts.reject! { |k, v| v.zero? } unless value == 0
+     @parts.freeze
+     @variable = variable
+
+     if @variable.nil?
+       @variable = @parts.any? { |part, _| VARIABLE_PARTS.include?(part) }
+     end
+   end
+
+   # Returns a copy of the parts hash that defines the duration
+   def parts
+     @parts.dup
    end

「Active SupportのDurationは処理によっては利用頻度が高そうなので、高速化されるのはありがたい🙏」


「DateやTimeの差からDurationを得られるのは便利ですよね」「他の言語だといったんmsecに変換するとかいろいろ面倒になりがちですが、ActiveSupport::Duration.build(time1 - time2)のようにするとDurationが得られますね」

「もっとも、たとえばActiveSupport::Duration.build(time1.beginning_of_day - time2.beginning_of_day)がサマータイム切り替わりをはさんだときに常に日単位になってくれるかどうかは解釈によっては難しいですよね: 1日が23時間や25時間でも1日と解釈するのか、それともあくまで1日=24時間として解釈するのか、とか」「う、そうですね😅」「日付は難しい…」

DurationはValue Objectであり、ミューテーションされるべきではない。
この変更によって、Durationが変数であるかどうかにかかわらずキャッシュできるようになり、ActiveSupport::Duration+メソッドや-メソッドのパフォーマンスが改善される。さらにany?やインラインのarray定義を避けられるので、arrayが2個アロケーションされるのも回避できる。アプリ全体のパフォーマンスが著しく改善されるとまではいかないが、duration_of_variable_length?メソッドは2.5倍速くなる。

Warming up --------------------------------------
             current   325.663k i/100ms
                 new   828.177k i/100ms
Calculating -------------------------------------
             current      3.274M (± 2.9%) i/s -     16.609M in   5.076744s
                 new      8.337M (± 1.9%) i/s -     42.237M in   5.067887s

Comparison:
                 new:  8337279.2 i/s
             current:  3274297.7 i/s - 2.55x  (± 0.00) slower

parts=メソッドとvalue=メソッドはDurationで決して使うべきではないので、削除した。

さらにpartsがfrozen hashのコピーを返すようにし、内部でdupを使わずにpartsにアクセスできるよう_partsリーダーも追加した。
同PRより大意

🔗 ActiveSupport::TimeZone#utc_to_localを修正

utc_to_local_returns_utc_offset_timesがfalseでtimeインスタンスに小数の秒がある場合、Time.utc コンストラクタが小数秒の値ではなくusec(マイクロ秒)の値を取るため、新しいUTC timeインスタンスが1,000,000のファクターでずれていた。
Changelogより大意


つっつきボイス:「usecって何だったかなと思ったらマイクロ秒(μsec)の簡易表記なんですね」「そうそう」

参考: ミリ秒とは - IT用語辞典 e-Words

「ローカルのシステムはマイクロ秒も扱えたと思うので、手元のWSLのコンソール(Ubuntu 20)でdateコマンドやってみよう」(しばし操作)「dateにナノ秒の書式(%N)があったのでナノ秒まで出せた↓(マイクロ秒の書式は見当たらず)」

$ date +"%Y-%m-%d %H:%M:%S.%N"
2021-04-15 20:20:15.172406400

「で、従来はTime.utcで小数点以下の値が小数秒として扱われていなかったので* 1_000_000を追加して修正したようですね↓」「このバグよく見つけたな〜」

# activesupport/lib/active_support/values/time_zone.rb#L513
    def utc_to_local(time)
      tzinfo.utc_to_local(time).yield_self do |t|
        ActiveSupport.utc_to_local_returns_utc_offset_times ?
-         t : Time.utc(t.year, t.month, t.day, t.hour, t.min, t.sec, t.sec_fraction)
+         t : Time.utc(t.year, t.month, t.day, t.hour, t.min, t.sec, t.sec_fraction * 1_000_000)
      end
    end

🔗 Rack::Runtimeミドルウェアが非推奨化

  • Rack::RuntimeFakeRuntimeに置き換えられる。FakeRuntimeはリクエストを渡すだけのダミーのミドルウェアで、ミドルウェア操作では利用できない。
  • ミドルウェア操作(相対的なinsertmoveなど)でRack::Runtimeを使うとFakeRuntimeを使うようdeprecation warningを表示する。
  • アプリケーションが(useunshiftなどで)Rack::Runtimeを明示的に追加する場合はdeprecation warningは表示されず、FakeRuntimeは無視される。
  • RailsガイドはRack::Runtimeを参照しないよう更新される。
    同PRより大意
# actionpack/lib/action_dispatch/middleware/stack.rb#L6
module ActionDispatch
  class MiddlewareStack
+   class FakeRuntime
+     def initialize(app)
+       @app = app
+     end
+
+     def call(env)
+       @app.call(env)
+     end
+   end
+

つっつきボイス:「Rack::Runtimeって何をするんだろう?」「このissue↓を見ると、HTTPのX-RUNTIMEヘッダーを追加するミドルウェアみたいですね」「X-RUNTIMEでググると結構古い記事が出てきた」「元々デバッグ用なのかも?」

X-RUNTIMEはサーバーの処理にかかった時間を返すんですね」「今まではRack::RuntimeX-RUNTIMEを返してたけど、X-RUNTIMEが必要な人はほとんどいなさそうだし、セキュリティ上の懸念もあるので削除しようという流れっぽい」「こんなふうにRack::Runtimeに依存したコンフィグを書いてる↓人がいる可能性もあるので、いったんdeprecationをはさんでから削除するんでしょうね」

# #38412コメントより
config.middleware.insert_before(::Rack::Runtime, SomeCoolMiddleware.new)

参考: x-runtime は消すべきなのか - Qiita

「このプルリクを見なかったらRack::Runtimeを知ることは一生なかったかも😆」「X-RUNTIMEを使う人って相当マニアックな感じしますね」「New RelicのようなアプリケーションメトリクスツールならX-RUNTIMEヘッダーを使うことがあるかもしれませんね」「あ、ありそう!」

参考: New Relic | パフォーマンス分析プラットフォーム

🔗 番外: Webpackerがいつの間にかv6.0.0.betaに

rails/webpacker - GitHub


つっつきボイス:「Evil Martiansの以下の記事↓を翻訳しながらチェックしていたら、Webpacker 6の開発が思ったより進んでいることに気づいて、今記事を書きかけています(公開済み)」

参考: Set up Tailwind CSS JIT in a Rails project to compile styles 20x faster — Martian Chronicles, Evil Martians’ team blog

「Webpacker 5がリリースされたのは昨年ぐらいでしたっけ?」「リリースタグを見ると2020年3月だから1年前ぐらいですね」「早いな〜」「Webpacker 5から6への移行がそんなに手間でなければ大丈夫かなと思いますけど」「でも現時点の6.0アップグレードガイド↓を見ると割と変更点あるようです」「あ〜、source_pathとかも変わるのか」「コンフィグ洗い替えが必要っぽいですね」「Webpacker 5を頑張って導入してカスタマイズしていたら大変そう…」「容赦なき変更」


つっつき後、以下の記事に現状をメモしました↓。

Webpacker v6.0.0.beta.6の現時点の変更点について

🔗Rails

🔗 マネーフォワードのマイクロサービス化インタビュー記事


つっつきボイス:「マネーフォワードの中の人へのインタビューが本当に生々しかったので取り上げてみました」「なるほど、現在はリクエストの途中にRailsをはさんでいるのを将来Goで直接処理しようとしてるんですね」

中出: まず前提として先ほど申し上げたように、まずRuby on Railsでできた巨大なSaaSがありまして、それを今マイクロサービスに置き換えている最中です。現在のアーキテクチャとしては、ユーザーのリクエストがRailsに届き、それがGoでできたマイクロサービスを呼び出すという形になっています。将来的には、Railsを通さずに直接Goのマイクロサービスを呼び出す形に移行していくことを考えています。
同記事より

「マイクロサービス化はまだ始まったばかりだそうです」「マネーフォーワードのようにお金に関連するサービスだと大変そう」「マネーフォワードのサービス内容を考えると、周辺機能はともかくコア機能はマイクロサービスにしにくそうな気がしますね: 機能を横断しないと取れないものはマイクロサービス化が難しくなる」「そうですよね…」


「ところで、ひと頃のマイクロサービス化しようぜみたいな話も最近だいぶ落ち着いてきた感じはありますね」「言われてみれば前より静かになったかも」「表に出ていない部分も含めて、マイクロサービス化に挑戦して失敗したところは結構あるんじゃないかな」

「もしかすると本当に欲しかったのはマイクロサービスじゃなくて『多少モジュラライズされたモノリス』だったのかも」「気持ちわかります」「巨大モノリスのままだとつらいのは確かなので、モノリスにマイクロサービスのパラダイムを一部取り入れることで、モノリスを適切に分割するときの設計上の参考にするのはありだと思いますし、上の記事はそういうときに参考になりそうですね👍」


つっつきの後で、以下の「シタデルアーキテクチャ」をやっと思い出しました↓。

Rails: AppSignalが採用する「シタデルアーキテクチャ」(翻訳)

🔗 rollout gemで「フィーチャーゲート」を実現する

fetlife/rollout - GitHub

fetlife/rollout-ui - GitHub


つっつきボイス:「Engine Yardの記事です」「フィーチャーゲートはいわゆるフィーチャーフラグ的な機能かな: この機能はいろんな呼び方があるんですよ」「あ、そうなんですね」

参考: フィーチャートグル - Wikipedia

「ちなみに、ちょうどこの構成によく似た案件をこれから始めるところです↓」「へ〜」「RailsのAPIをURLベースでカレントから新しいものに切り替える感じで、よく行われる作業ですね」


同記事より


「そういえば自分も今Rails 4の案件やってます」「Rails 4ぐらい古いものをアップグレードする場合、小さなアプリだったら頑張って少しずつRails 6までアップグレードするより、rails newしてそっちにコードを移す方が早いんじゃないかって思いますね」「そうそう、少しずつアップグレードするのって意外としんどいですよね」「その途中でテスト系のgemがつかえたりするとアプリ本来の挙動と関係ないところで力を使わないといけなくなってつらい」

「でもアプリが大きいとなかなかそうもいかないので、順当に少しずつアップグレードすることになりますけど」「手間はかかりますけど、ステップバイステップでアップグレードする方が明らかに安全ですよね」「進捗も出しやすいですし」「rails newしてコードをコピペする方式だと、完全に移し終わるまで全体をテストできないんですよね」「コードベースが大きいとリスクが高すぎる」「そういうことがありうるので巨大モノリスにするのは避けたい気持ちがあります」

🔗 .countでRailsアプリが落ちることがある(Ruby Weeklyより)


つっつきボイス:「.countでそんなに遅くなるものかな?」「あ、テーブルの行数がでかいのか」「30秒経過してもクエリが返ってこないのはつらそう」「クエリが返るまで数分かかるRedmineの対応したことならありますよ」「お〜」「なかなか最適化しがいのあるクエリでした😆」

「特定のクエリだけがものすごく遅い場合なら、頑張って最適化すればたいてい速くできますし、結果の整合性も取りやすいですね」「お〜」「逆に、複雑にテーブルをまたぐようなN+1クエリを手動で定数回に最適化する方がしんどい: 数百msecのクエリが数百件とか」「たしかに」「N+1の最適化は油断すると結果を変えてしまうことがあるんですよ」

「記事の結論は、.count > 0.count.positive?をもっと速いメソッドに置き換えましょうということか」「遅いクエリの最適化は頑張りが大事」「たしかActiveRecord::Relationempty?はレコードが1件あるかどうかがわかればいいので、COUNT()を使わずLIMIT 1を使った実装になっていて速かった覚えがありますね」「なるほど」

参考: 【Ruby on Rails】ActiveRecordのexists?の逆はempty?を使う - Qiita

🔗 N+1クエリを定番以外の方法で修正する


つっつきボイス:「ちょうどN+1クエリの記事です」「non standardなN+1クエリ修正ですか」

「ちなみに最初の見出し『N+1 queries 101』の101は、英語圏では『入門』の意味ですね」「そうそう、海外の大学のシラバスだと最初に履修する授業の番号が101になってることが多い」「へ〜、101ってそんな意味なんですね」

# 同記事より
# app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user

  def author_name
    user.name
  end
end

# app/models/user.rb

class User < ApplicationRecord
  has_many :posts
end

# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  def index
    @posts = Post.published
  end
end

「入門の次は、Scout APM↓を使ってメトリクスを取ってる」「APM(Application Performance Monitoring)ツールはN+1クエリの検出には有効ですね: N+1クエリが確実に発生していて、かつ実際に遅くなっていることを確かめてから対策する方がいい👍」「そうですね」「N+1クエリが発生していても実用上問題ない速度が出ていれば、結果が変わってしまう可能性のある更新を行わずに済むときもあります」


「方法その1は、Active Recordのキャッシュを作って手動でヒットさせる方式↓」「たしかにRailsだとnon standard」「この方法が有効なケースは、限定的ですがたしかにあります: SQLでJOINを駆使してもきれいに取れないケースがたまにあるんですが、そういうときはこのように展開済みの一時的なハッシュを作ったりしますね」「なるほど!」「これをRedisに入れられればリクエストをまたいで共有できるので便利です」

# 同記事より
class PostsController < ApplicationController
  def index
    @posts = Post.published.includes(:comments)
    @users = User.active
    @users_cache = @users.reduce({}) do |agg, user|
      agg[user.id] = user
      agg
    end
  end
end

「Rails標準の方法だとeager loadingするんですが、それが効かないケースにもこういう書き方をすることがありますね」


「方法2は、引数や戻り値をオブジェクトではなくプリミティブな値にする ↓: スコープでpostオブジェクトを渡すとpost.user.idの部分でクエリが発生するので、生のuser_idを受け取る方がいい、たしかに」「あ〜、なるほど」「効くかどうかは内部の処理にもよりますし、呼び出し方も変えないといけないので、効果を確かめてから使う方がいいと思います」

# 同記事より
class Post < ApplicationRecord
  scope :by_author, -> (user_id) {
    where(user_id: user_id)
  }
end

「方法3はリレーションシップのショートカットを作る」「この手法もときどき使いますね: リレーションを普通にたどるとuser.account.activity.last_active_atのように長くなりますけど、UserのidをActivityにも置くことで以下のようにactivity.last_active_atと書けるようにする↓」

# 同記事より
class Post < ApplicationRecord
  belongs_to :user
  belongs_to :activity

  def author_last_active_at
    activity.last_active_at
  end
end

「正規化するならuser.accountをたどればUserのidを取れるのでActivetyにUserのidを置く必要はないんですが、置くことで参照の距離を縮めるという発想ですね」「お〜、なるほど!」

「マルチテナントのアプリケーションなどでこの方法を使うことがあります: テナントのidは利用頻度が高いんですが、きれいに正規化すると距離が離れてしまってJOINを何度も行わないと取れなくなるので、テナントのidを途中のテーブルにも持たせたりします」「記事には『やりすぎ注意』とありますね」「そう、この方法だと正規化を崩すことになるので、データ不整合が発生したら悲惨なことになります😇」「たしかに」「あと、外部キーを付けすぎるとINSERTが重くなる可能性もあります: いずれにしろ十分検討してからにしましょう」


「方法4の『リレーションシップのデータ複製』もよく使いますね: マテリアライズドビューあたりがこれに近い方法かな」「なるほど」「テーブルパーティショニングもこの一種でしょうね: 直近1か月のデータだけ別テーブルに切り出してすぐ取り出せるようにするとか」「ログデータなんかもそうやったりしますね」

参考: マテリアライズドビュー(マテビュー)とは - IT用語辞典 e-Words
参考: 5.11. テーブルのパーティショニング


「記事タイトルはnon standard wayとあるけど、これはRailsの標準ではないというだけで、Railsに限らない一般的なN+1クエリの解決ではどれも普通に使われる方法だと思います」「あ、そういうことでしたか」「むしろRailsでN+1解決というとすぐeager loadingの話になる方が一般的でないんじゃないかな」「まあたしかに😅」「記事で紹介されている方法は少なくともバッドプラクティスではありませんね: いい記事👍」

🔗 GitLabのセキュリティ修正がリリース


つっつきボイス:「さっきHacklinesに流れてたので取り上げました」「あ、ログインユーザーが画像アップロードしてコード実行できるのは結構エグい!社内GitLabサーバーにも赤ランプ点灯してるし↓、週末に適用しなきゃ」

つっつき後の日曜日に無事アップデート完了しました。


前編は以上です。

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

週刊Railsウォッチ(20210413後編)RubyMineのRBSサポートとCode With Me、GitHub ActionとDockerレイヤキャッシュほか

今週の主なニュースソース

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース

Ruby Weekly


CONTACT

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