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

週刊Railsウォッチ: Rack 3アップグレードガイド、Stimpack gemほか(20220926前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

上より新しい公式情報を先回りしてチェックしたものもあります。

🔗 Active Storageのダウンロード中断・再開でRangeヘッダーが不正な場合のエラーを'416 Range Not Satisfiable'に変更

再現手順

require 'bundler/inline'

gemfile(true) do
  source 'https://rubygems.org'

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem 'rails', '~> 7.0.0'
  gem 'sqlite3'
end

require 'active_record/railtie'
require 'active_storage/engine'
require 'tmpdir'

class TestApp < Rails::Application
  config.root = __dir__
  config.eager_load = false
  config.session_store :cookie_store, key: 'cookie_store_key'
  secrets.secret_key_base = 'secret_key_base'
  config.hosts << 'www.example.com'

  config.logger = Logger.new($stdout)
  Rails.logger  = config.logger

  config.active_storage.service = :local
  config.active_storage.service_configurations = {
    local: {
      root: Dir.tmpdir,
      service: 'Disk'
    }
  }
end

ENV['DATABASE_URL'] = 'sqlite3::memory:'

Rails.application.initialize!

require ActiveStorage::Engine.root.join('db/migrate/20170806125915_create_active_storage_tables.rb').to_s

ActiveRecord::Schema.define do
  CreateActiveStorageTables.new.change

  create_table :users, force: true
end

class User < ActiveRecord::Base
  has_one_attached :pdf
end

require 'minitest/autorun'

# Download a file with incorrect partial byte range
class PartialDownloadTest < ActionDispatch::IntegrationTest
  test 'Partial download' do
    user = User.create!(
      pdf: {
        content_type: 'application/pdf',
        filename: 'dummy.pdf',
        io: ::StringIO.new(SecureRandom.random_bytes(1000))
      }
    )
    # Get URL for download
    get "/#{Rails.application.routes.url_helpers.rails_blob_path(user.pdf, only_path: true)}"
    assert_response :redirect
    # Download with non-existing range
    get(response.location, headers: { 'Range' => 'bytes=10000-10000' })
    # Should respond with status 416 Range not satisfiable, but throws a RoutingError and responds with 404 not found
    assert_response 416
  end
end

期待される振る舞い
存在しないファイルの一部をブラウザがリクエストしたとき(Safariで起きがち)のレスポンスは416 Range not satisfiableになるべき。
実際の振る舞い
レスポンスが404 Not foundになり、ActionController::RoutingErrorが発生する。
システム環境
Rails: 7.0.3.1
Ruby: 3.0.2p107
issue #45890より

参考: §5.6 X-Sendfileヘッダー -- アセットパイプライン - Railsガイド


つっつきボイス:「X-Cascadeは、Rackが追加して解釈するヘッダーなのか↓」

# activestorage/app/controllers/concerns/active_storage/file_server.rb#L3
+require "active_support/core_ext/hash/except"
+
module ActiveStorage::FileServer # :nodoc:
  private
    def serve_file(path, content_type:, disposition:)
      Rack::File.new(nil).serving(request, path).tap do |(status, headers, body)|
        self.status = status
        self.response_body = body
        headers.each do |name, value|
          response.headers[name] = value
        end

+       response.headers.except!("X-Cascade", "x-cascade") if status == 416
        response.headers["Content-Type"] = content_type || DEFAULT_SEND_FILE_TYPE
        response.headers["Content-Disposition"] = disposition || DEFAULT_SEND_FILE_DISPOSITION
      end
    end

参考: ruby - what is X-Cascade header - Stack Overflow

「修正の対象はRangeヘッダーの方ですね↓」「Rangeヘッダーの値が無効な場合のエラーとして404ではなく416を返す、なるほど」「HTTPの仕様なら直すべきですね👍」

# activestorage/test/controllers/disk_controller_test.rb#35
  test "showing blob with invalid range" do
    blob = create_blob
    get blob.url, headers: { "Range" => "bytes=1000-1000" }
    assert_response :range_not_satisfiable
  end

参考: 416 Range Not Satisfiable - HTTP | MDN
参考: 404 Not Found - HTTP | MDN


「ところで、HTTP 1.1で追加されたRangeヘッダーは偉大な機能だなと改めて思いますね: このヘッダーのおかげで、ブラウザでダウンロードを中断しても後から再開できるようになった」「回線が遅い時代は特に助かりましたね」「Rangeヘッダーは、ここでやっているようにダウンロードを中断・再開するときに使われます」

参考: Range - HTTP | MDN


「そういえばファイルストリームをNginxにパススルーするときに昔はX-Accel-Redirectヘッダーを使いましたね↓」「そうそう、セキュリティに気を遣うし、Nginx側にファイルを置かないとできなかったけど」「現代はS3のようなオブジェクトストレージサービスが全盛なので、X-Accel-Redirectヘッダーはだんだん使われなくなっているかもしれませんね」

参考: S3のファイルをX-Accel-Redirectで配信する | Money Forward Money Forward Engineers' Blog

「X-Accel-Redirectヘッダーを使うとしたらAWSを使えないプロジェクトとかかな」「そういう場合はしょうがないですね」「そういうプロジェクトで、X-Accel-Redirectヘッダーを付けないとiOSで動画をダウンロードできなかったことがあったんですが、Rangeヘッダーに対応してなかったのが原因でした」

🔗 ActiveRecord::QueryMethods#selectにハッシュを渡せるようになった

#selectメソッドにカラムやエイリアス名をハッシュで渡せる機能を追加した。

    Post
      .joins(:comments)
      .select(
        posts: { id: :post_id, title: :post_title },
        comments: { id: :comment_id, body: :comment_body}
      )

    # instead of:

    Post
      .joins(:comments)
      .select(
        "posts.id as post_id, posts.title as post_title,
        comments.id as comment_id, comments.body as comment_body"
      )

概要
#select引数に渡したhash引数を文字列に変換して#select_valuesがディープに変更されるのを回避する処理を追加。
その他情報
#whereにもとても似たインターフェイスがある。たとえば#joinsと以下のように組み合わせられる。

Post.joins(:comments).where(comments: { author_id: 1 })

ならばこの機能も明確で理解しやすいだろう。
同PRより


つっつきボイス:「お、selectposts: { id: :post_id, title: :post_title }のようなハッシュを渡せるようになるのか↓」「これは嬉しい🎉」「この書き方はできてもいいですね」「言われてみれば、whereでできる書き方がselectでできなかったのが不思議なくらい」

    Post
      .joins(:comments)
      .select(
        posts: { id: :post_id, title: :post_title },
        comments: { id: :comment_id, body: :comment_body}
      )

参考: Rails API select -- ActiveRecord::QueryMethods

「この書き方はSQLのASのシンタックスシュガーっぽいのかな」「でしょうね: 今まではASを使おうとすると以下のように生SQLを書くかArelでやるしかなかったけど、上のようにRubyらしく書けるのはいいですね👍」「これはもう使うしかないヤツ」「ちなみにposts: [:id, :title, :created_at]のように配列を渡せばASは使われないのね」

    # instead of:

    Post
      .joins(:comments)
      .select(
        "posts.id as post_id, posts.title as post_title,
        comments.id as comment_id, comments.body as comment_body"
      )

「余談ですけど、2番目のコードの生SQLが小文字で書かれていて最初SQLってわからなかった😅」「SQL予約語は、大文字で書く人も小文字で書く人もいますよね」「自分は大文字で書きたいかな」「自分はどっちでも」「SQL関数の視認性が高まるので揃えるなら大文字かな」

🔗 ActiveSupport::Cache::Store#fetchのブロックでoptionsアクセサを渡せるようになった

これによってキャッシュオプションをオーバーライドできるようになる。

Rails.cache.fetch("3rd-party-token") do |name, options|
  token = fetch_token_from_remote
  # キャッシュのTTLをトークンのTTLに合わせる
  options.expires_in = token.expires_in
 token
end

Andrii Gladkyi, Jean Boussier
同Changelogより


これは、寿命の短い認証トークンを扱うAPIで非常によくあるユースケース。
この実装は@argによるものとほとんど同じだが、オプションのハッシュを渡す代わりに、制限されたインターフェイスを持つオブジェクトをyieldする点が異なる。
すべてのオブジェクトが読み込みと書き込みを切り替えられるとは限らないので、ユーザーを驚かせないようインターフェイスを制限している。
共著: @arg
同PRより


つっつきボイス:「fetchにブロックを渡したときにブロック引数にoptionsを渡せるようになったんですね」「この書き方ができるとprocなどをoptionsに渡してブロック内で使いやすくなる👍」「こういう書き方好き❤️」

Rails.cache.fetch("3rd-party-token") do |name, options|
  token = fetch_token_from_remote
  # キャッシュのTTLをトークンのTTLに合わせる
  options.expires_in = token.expires_in
 token
end

「他の言語ではあまり見かけないRubyならではの文化というか書き方ですよね」「コードにもあるように、yieldを使うのがRuby独特↓」「他の言語から来た人はyieldで最初戸惑うかもしれないけど、そこから先はだいたい同じにやれるかなと思います」「JavaだとRunnableで似たようなことができそうだけど、引数を渡せなかったりいろいろ面倒そう」「わかります」

# activesupport/lib/active_support/cache.rb#L858
        def save_block_result_to_cache(name, options)
          result = instrument(:generate, name, options) do
-           yield(name)
+           yield(name, WriteOptions.new(options))
          end

          write(name, result, options) unless result.nil? && options[:skip_nil]
          result
        end

参考: Runnable (Java Platform SE 8 )

🔗 rails-ujsコードをES2015モジュールに変換

概要
このプルリクは多数の細かいコミットに分けてある(Git履歴を読みやすくするのと、手動変更と自動変更を区別するため)。

  • .coffeeファイルを移動およびリネーム(履歴用)
  • decaffeinateメソッドで.coffeeを.jsに変換(自動)
  • decaffeinateのwarningをクリーンアップ(手動)
  • bladeとsprocketsによるビルドをrollupに置き換え(手動)

その他情報
bladeを完全に削除するには今後もう少し作業が必要(ujsテストで使われているため)だが、このプルリクでは既に十分な規模で削除してある。
関連PR: #34177
修正issue: #45407


つっつきボイス:「CoffeeScriptのコードがとうとう削除されたんですね」「.coffeeファイルが軒並み置き換わってる」

「今思うとCoffeeScriptって何だったんでしょうね」「現代でCoffeeScriptを新たに使うことはないと思いますけど、CoffeeScriptが登場したことがきっかけでAltJSがいろいろ誕生して、そこから現代の書きやすい素のJavaScriptにつながったという側面もあると思いますよ」「それもそうですね」「昔のJavaScriptは人間が書くときにあまりに間違えやすくて、そこをカバーするためにAltJSが生まれましたけど、その中でもCoffeeScriptはRailsに取り入れられたこともあってproductionで実際に使われるようになって、それによってAltJSが認知されて広まったという功績はあったと思います」「たしかに今のJavaScriptは昔とは比べ物にならないぐらい良くなりましたよね」

参考: AltJS -- JavaScript - Wikipedia

🔗 ドキュメント更新2件


つっつきボイス:「1つ目の更新される前のupdate_allのドキュメント、:allを渡す書き方はたしかに古い↓」「Rails 2あたりの時代かな?」

-User.update(:all, max_login_attempts: 3, must_change_password: true)
+User.update_all max_login_attempts: 3, must_change_password: true

「2つ目はError Reporterのガイドが新たに追加されていました」「どこかで見たような気がしたけど、以前ウォッチでErrorReporterが取り上げていた(ウォッチ20211129)」「Rails 7のみが対象で、Rails.errorで以下のようにhandlerecordとかが書けるんですね」

Rails.error.record do
  do_something!
end

参考: Rails API ActiveSupport::ErrorReporter

🔗Rails

🔗 Rack 3へのアップグレードガイド(Rails公式ニュースより)


つっつきボイス:「先週取り上げたRack 3(ウォッチ20220920)のアップグレードガイドです」「work in progressとあるからまだ作業中ですね」

「変更内容を見てみると、Rack 2と3で結構変わっている部分あるな〜」「レスポンスの配列やheadersがfrozenにできなくなって、statusが100以下の整数値のみになってる」「特に長く使われているアプリだとRackのメジャーアップデートはそれなりに工数がかかりそう...」

🔗 stimpack: packwerkをさらに使いやすく

rubyatscale/stimpack - GitHub


つっつきボイス:「先週取り上げたpackwerk(ウォッチ20220920)のツイート↑で言及されていたstimpackを取り上げてみました」「stimpackというとFalloutのこれを思い出す↓」「元々はSF映画とかに登場する回復用携帯注射器を指すんですね」

参考: Fallout シリーズ - Wikipedia
参考: Urban Dictionary: stimpak

「このstimpackはpackwerkの上に乗っけて使うそうです」「あの後packwerkのドキュメントを見てみたけどなかなかのボリュームなんですよ」「そうそう、今packwerkのドキュメントを翻訳中なんですが結構長いですね」「stimpackを乗せるとpackwerkのコンフィグ周りとかが使いやすくなるならチェックしてみてもいいかも」「packwerkやstimpackは大規模なアプリケーションを想定しているので、評価するにはそういうプロジェクトで実際に使ってみる必要があるでしょうね」

🔗 Howitzer: Rubyで書かれた受け入れテストフレームワーク(Ruby Weeklyより)

strongqa/howitzer - GitHub


つっつきボイス:「Howitzerという受け入れテストフレームワークで、Rubyに限らずWebアプリケーション一般で使えるようです」「ドイツ語っぽい名前」「Howitzerは軍事用語だと榴弾砲だそうです」

参考: 榴弾砲 - Wikipedia

「公式サイトを見るのが早いかも↓」

「なるほど、受け入れテストを以下のように書けるんですね: Capybaraだとvisit '/'みたいに書くところだけど、それをHomePage.openとかProductPage.open(id: 1)のように書ける↓」「というと?」「visit '/'はテストをベタ書きする点はいいんだけど、それだけだと意味がわかりにくい: HowitzerはSearchPage.openのように1段階抽象化した形で意味がわかるように書ける」「なるほど」「自分はCapybaraでベタに書く方が好きですけどね」

# 同ガイドより
HomePage.open #=> visits /
ProductPage.open(id: 1) #=> visits /products/1
SearchPage.open #=> visits /search
SearchPage.open(query: {text: :foo}) #=> visits /search?text=foo

「バリデーションのような細かい部分はクラス定義で事前に隠蔽しておくという発想のようですね↓」

# 同ガイドより
# Example 1:
class HomePage < Howitzer::Web::Page
  path '/'
  validate :url, /\A(?:.*?:\/\/)?[^\/]*\/?\z/
end

# Example 2:
class LoginPage < Howitzer::Web::Page
  path '/users/sign_in'
  validate :title, /Sign In\z/
end

「最終的にこう書けるようにする、なるほど↓: CucomberやTurnipのように自然言語で受け入れテストを記述するのがつらい人にとっては、こういうふうにRubyのメソッド的に受け入れテストを書きたいかもしれませんね」「その気持すっごくわかります」「Rubyっぽく書けるなら日本語の問題を心配をせずに済みますね」

# 同ガイドより
When /^I navigate to article on article list page$/ do
  ArticleListPage.open
  ArticleListPage.on { open_article(out(:@article).title) }
end

「個人的には、テストコードがこれ以上複雑になると"テストコードのテスト"が必要になりそうなのが気になるかな」「そうなったら本末転倒ですよね」「こんなふうにテストコードにメソッドを定義するのは避けたい↓」

# 同ガイドより
class HomePage < Howitzer::Web::Page
  path '/'
  validate :url, /\A(?:.*?:\/\/)?[^\/]*\/?\z/
  def method1
    #some logic here
    self
  end
  def method2
    #some logic here
    self
  end    
end

「"これはクラスの形をしているけどコンフィグなんだ"と思って、メソッド定義やモジュール追加は禁止して使うならいいかも」「でもプログラマーはclassが使えるならついdefでメソッド定義したくなるんですよ😆」

「自分はたぶん使わないと思いますが、受け入れテストのために自然言語を大量に書きたくない場合はHowitzerが向いていることもありそうですね」「たしかに」

🔗 その他Rails


つっつきボイス:「N予備校頑張ってますね」「TechRachoの記事が引用されていたので気づきました」

「"letではなくlet!を使う"ルールがありますね」「let!は上から順に実行されるけど、letは遅延評価なのでデバッグがつらくなりがちなヤツ」「そうなんですよ」「letlet!はまったく違いますよね」

「ただ、自分ならlet!を使うよりもbeforeブロックでインスタンス変数とかに入れるでしょうね」「たとえば初心者向けの学習用ルールだとletはデバッグ大変すぎるのでlet!で書くことにする、ということなら理解できますけど、業務でテスト用のデータを初期化するならlet!をたくさん書くよりもbeforeブロックでまとめて初期化する方が一般的かなと自分も思います」


前編は以上です。

バックナンバー(2022年度第3四半期)

週刊Railsウォッチ: Ruby 3.2.0 Preview 2とRack 3.0リリース、packwerkでアプリコードの境界を強制ほか(20220920)

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

Rails公式ニュース

Ruby Weekly

Publickey

publickey_banner_captured


CONTACT

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