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

週刊Railsウォッチ(20200629前編)RSpecをメンテしやすくする9つのコツ、application.jsのrequireをimportに置き換え、HTTP 308 Permanent Redirectとはほか

こんにちは、hachi8833です。ニュースウォッチ9で富嶽のニュース見ました。


つっつきボイス:「富嶽、特別価格で買えるらしいっすよ↓」「マジで?」「おひとつ包んでくださいな、みたいに?」

参考: 【やじうまPC Watch】世界一の「富岳」と同じA64FX環境をお手元に! 4,155,300円で - PC Watch

「ご自宅に2ノード単位で置けるそうです」「いちじゅうひゃくせん...400万とか書いてますけど😆」「いやいや、スパコンのモジュールが税別とはいえ400万って、むしろお安いでしょう!」「自動車一台分で買えるということか」「400万なら手の届かない値段じゃないでしょうし」

「構成はそんなにリッチじゃないでしょうけど」「2Uラックマウントで48コアCPUか」「でもこれも最近流行りのARMですし💪」「富嶽ってARMなんですか!」「今週はWWDCもあったし、ARMの話題がやたら多い」(以下延々)

  • 各記事冒頭には⚓でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄

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

以下のコミットリストより見繕いました。ドキュメントの小さな更新が増えているようです。

follow_redirect!テストヘルパーを修正

follow_redirect!が308リダイレクトのときに同じHTTP verbでリダイレクトをフォローするよう修正。
同PRより


つっつきボイス:「HTTP 308ってありましたっけ?」「そんな上の方の番号使ったことない😆」「Mozillaのドキュメントにはparmanent redirectとありますね↓」

The HyperText Transfer Protocol (HTTP) 308 Permanent Redirect リダイレクトステータスコードは、リクエストされたリソースが Location ヘッダーで示された URL へ完全に移動したことを示します。ブラウザーはこのページにリダイレクトし、検索エンジンはリソースへのリンクを更新します (「SEO 用語」では、「リンクジュース」が新しい URL に送られたと言われます)。
308 Permanent Redirect: developer.mozilla.orgより

「お〜、308は2014年頃できたみたいですね↓」「308にすると検索エンジンがリソースへのリンクを更新しちゃうのか〜」「301のMoved ParmanentlyじゃなくてRedirectということか」「どういう文脈で使うんでしょうね?」「何だろう?検索エンジン向けとかならわからなくもないですけど🤔」

参考: webdbg.com/test/308/ -- ブラウザが308をサポートしているかどうかをテストできるサイト
参考: 新たなHTTPステータスコード「308」とは? - GIGAZINE

「でも301 Moved Parmanentlyもリソースへのリンクを更新する点では同じなんですよね...」「どう違うんでしょう?」「お、308を使うとPOSTメソッドをリダイレクトできるのか!↓」「お〜なるほど理解できた!」

参考: 301 Moved Permanently - HTTP | MDN

リダイレクトが行われるとき、仕様書ではメソッド (と本文) を変更しないよう要求していますが、すべてのユーザーエージェントが従っている訳ではありません。 - まだこの種のバグが発生するソフトウェアが見つかるでしょう。従って、 301 のコードは GET または HEAD メソッドのみに使用し、このステータスでは明確にメソッドの変更が禁止されているので、 POST メソッドでは代わりに 308 Permanent Redirect を使用することが推奨されています
301 Moved Parmanently: developer.mozilla.orgより(強調は編集部)

「そしてこれか↓、301はたしかにGETに変更される可能性がある: POSTに対して301を返すとGETでリクエストし直されちゃうのでPOSTされたものを引き継げないんですけど、なるほど308なら引き継げるのね、へぇ〜」「POSTしても308なら引き継いでもらえるんですか?」「ブラウザがそういうふうに動いてくれるというか、そう動かなければならないということでしょうね」「ははぁ」

301 の場合は不正に GET メソッドに変更される可能性があるのに対し、このコードの場合はリクエストメソッドと本文が変更されません。
308 Permanent Redirect: developer.mozilla.orgより

308がなかった頃はPOSTされる可能性のあるURLを変更するとどうしようもなかったんですけど、308が使えるとPOSTされるURLもリダイレクトできるようになるのね」「これは知らなかった〜」「でも、使います?😆」「😆」「まあAPIなんかで使う可能性はあるでしょうけど」「それ思いました!APIだと欲しいヤツです」

「昔301GETで再リクエストされる問題で悩んだことがあったんですけど、当時はもうどうしようもないという結論に達して、ブラウザ側でリロードして元のページに戻すみたいな処理にしたことがありましたよ」「HTTP、深いですね...」


「でfollow_redirect!は、今まで同じHTTP verbを使ってなかったのを修正したと↓」「そうしないといけない仕様ですもんね」「307や308の場合は今のリクエストメソッドをそのまま踏襲しないといけなくて、301の場合はGETで取り直している」「なるほど〜」

# actionpack/lib/action_dispatch/testing/integration.rb#L61
      def follow_redirect!(**args)
        raise "not a redirect! #{status} #{status_message}" unless redirect?

-       method = response.status == 307 ? request.method.downcase : :get
-       public_send(method, response.location, **args)
+       method =
+         if [307, 308].include?(response.status)
+           request.method.downcase
+         else
+           :get
+         end

+       public_send(method, response.location, **args)
        status
      end

follow_redirect!はよくみるとテストヘルパーみたいなのでテストだけ修正したということかな?」「あ、ほんとだ」「Action Dispatchのtesting/にあるから、実際にはリクエストを投げずにルーティングだけ回すみたいな、よくあるコントローラのテストヘルパーなんでしょうね」「ということは、システムテストは今までもちゃんと動いていたけどfollow_redirect!ヘルパーはちゃんと動いてなかったという流れなんでしょうね」「こんな感じでテストに書けると↓」「なるほど!」

# actionpack/test/controller/integration_test.rb#L375
+ def test_308_redirect_uses_the_same_http_verb
+   with_test_route_set do
+     post "/redirect_308"
+     assert_equal 308, status
+     follow_redirect!
+     assert_equal "POST", request.method
+   end
+ end

application.jsテンプレートのrequireをESモジュールのimportに置き換えた


つっつきボイス:「これはいいね👍が割と付いてますね」「requireimportに書き換わってる」「たしかにJSらしい書き方になった🎉」

# railties/lib/rails/generators/rails/app/templates/app/javascript/packs/application.js.tt#L6
-require("@rails/ujs").start()
+import Rails from "@rails/ujs"
<%- unless options[:skip_turbolinks] -%>
-require("turbolinks").start()
+import Turbolinks from "turbolinks"
<%- end -%>
<%- unless skip_active_storage? -%>
-require("@rails/activestorage").start()
+import * as ActiveStorage from "@rails/activestorage"
<%- end -%>
<%- unless options[:skip_action_cable] -%>
-require("channels")
+import "channels"
<%- end -%>

+Rails.start()
+<%- unless options[:skip_turbolinks] -%>
+Turbolinks.start()
+<%- end -%>
+<%- unless skip_active_storage? -%>
+ActiveStorage.start()
+<%- end -%>

「修正はrails/ujs周りのコードか」「ActiveStorageやTurbolinksも書き換わってるから、そういうのはJSのimportでやろうということになったんでしょうね」

概要

今回の変更は、Webpackerのapplication.js packテンプレートファイルやドキュメントのサンプルコードにあるCommonJSのrequire()構文をESモジュールのimport構文に差し替える。

その他

今回の変更の主な目的は、新しいRailsアプリを即使えるようにインクリメンタルに改良を進めること。以下のようなメリットが得られる。

  • 連続性を今よりも広くフロントエンドコミュニティに提供できる: Webpackerを採用する魅力のひとつは、BabelインテグレーションによってESモジュール構文をサポートできることだろう。Rails開発者がWebpackやWebpackerに抱く第一印象は、Webpacker入りの新しいRailsをインストールしてapplication.jsファイルの中を見たときに決まる。昨今、Rails開発者がネットで見つけるドキュメントやコード例はほぼESモジュール構文を元にしている。

  • 混乱を軽減できる: 開発者は自分のapplication.js packにESのimportを追加するのが普通だ(ネットのコード例に沿って作業する場合が典型的)が、今のままではrequire()importが1つのファイルで混在することになる。これでは混乱の元になるし、require()importで無用な軋轢も生じる。

  • ブラウザサポートに優しい: ESモジュール構文は将来を見据えてブラウザでサポートされる前提だが、require()構文は設計が同期的で、サーバーサイドJSとしてのNode.jsで元々採用されていたCommonJSと違ってブラウザでサポートされない。Webpackがrequire()をサポートしているのは利便性のためでしかない。

  • 最適化周りのベストプラクティスが促進される: WebpackはESモジュールやを統計的に分析して"tree-shake"する(特定の条件が満たされると最終ビルドから不要なexportを除去するが、package.jsonの指名がおかしくなる副作用もある)。

新機能: RubyネイティブのHash#exceptも使えるようになった

# activesupport/lib/active_support/core_ext/hash/except.rb#L12
  def except(*keys)
    slice(*self.keys - keys)
- end
+ end unless {}.respond_to?(:except)

つっつきボイス:「Hash#exceptはまだRubyのmasterブランチに入ったところみたいです↓」「今度Rubyに入る新しい機能に対応したということですね」

参考: Feature #15822: Add Hash#except - Ruby master - Ruby Issue Tracking System

「RailsのActive Supportの機能がRubyにバックポートされることがちょくちょくありますけど、これも同じような流れなのでしょう」「Rubyネイティブの機能があるときはそれを使うようにすると」

ArelのIsDistinctFromクラスをEqualityクラスからBinaryクラスに移動


つっつきボイス:「Arelの中のクラスの置き場所が変だと思ったので移動したようです」「リファクタリングに近い感じ」

34451で追加されたIsDistinctFromNotEqualとほぼ同じ。
歴史的にはEqualityのサブクラスは特殊な機能を持つようになっていたがequality?メソッドに統合されたので、今後の新しいクラスでequalityが欲しい場合はEqualityを継承しない。少なくともIsDistinctFromはequalityノードに置くべきではない(実際のIsNotDistinctFromはほぼEqualityと同じだが、めったに使われないこのノードに特殊な機能を与えることについて興味が湧かない)。
同PRより大意

番外: スペースやハイフンを含むenumからスコープ名を生成できるようになった(取り消し)


つっつきボイス:「今までだとenumの値にハイフンを含んでいるとエラーになったけど、アンスコに置き換えるようにしたと」「たしかに.underscoreやってる↓」

# activerecord/lib/active_record/enum.rb#184
        _enum_methods_module.module_eval do
          pairs = values.respond_to?(:each_pair) ? values.each_pair : values.each_with_index
          pairs.each do |label, value|
            if enum_prefix == true
              prefix = "#{name}_"
            elsif enum_prefix
              prefix = "#{enum_prefix}_"
            end
            if enum_suffix == true
              suffix = "_#{name}"
            elsif enum_suffix
              suffix = "_#{enum_suffix}"
            end

-           value_method_name = "#{prefix}#{label}#{suffix}"
+           value_method_name = "#{prefix}#{label.to_s.parameterize.underscore}#{suffix}"
            enum_values[label] = value
            label = label.to_s
# activerecord/test/cases/enum_test.rb#L595
+ test "scopes are named like methods" do
+   klass = Class.new(ActiveRecord::Base) do
+     self.table_name = "cats"
+     enum breed: { "American Bobtail" => 0, "Balinese-Javanese" => 1 }
+   end
+
+   assert_respond_to klass, :american_bobtail
+   assert_respond_to klass, :balinese_javanese
+ end

「enumにハイフンやスペース入りの文字列を使っていいのかという点はさておき、対応するならこう変換するしかないでしょうね」「プルリク例みたいにpublic_sendする↓よりはいいかも😆」


Railsモデルでシンボルではなくstringキーを用いたのだが、その中にハイフンが入っているものがあると、欲しいスコープ名を得られないことに気づいた。

# MyModelが`enumパターンを含むとする: { "dot-fill" => 0 }`
>> puts MyModel.dot_fill.to_sql
Traceback (most recent call last):
        3: from /my_app/script/console:82:in `<main>'
        2: from (irb):1
        1: from /my_app/vendor/gems/2.6.6/ruby/2.6.0/gems/activerecord-6.0.3.2.5438067/lib/active_record/dynamic_matchers.rb:22:in `method_missing'
NoMethodError (undefined method `dot_fill' for #<Class:0x0000000125f6ede0>)
>> puts MyModel.public_send(:"dot-fill").to_sql
SELECT `my_table`.* FROM `my_table` WHERE `my_table`.`pattern` = 0
=> nil

このブランチは、生成されたスコープ名をenum値用に変換し、スペースやハイフンをアンダースコアに置き換える。これで期待どおりMyModel.dot_fillと書けるようになる。
同PRより大意


「そして以下の続きのプルリクは@kamipoさんによるものなんですけど、上の変更はbreaking changesだと指摘していますね」「こちらは大文字を含むActiveSupportみたいな文字列を今までどおり使えるようにするために上をrevertするということか」「こちらもマージされました」「こっちのユースケースの方が多いと思うのでいいと思います👍」「そもそもこの制約を意識したことがなかった😆」

# activerecord/test/cases/enum_test.rb#L595
- test "scopes are named like methods" do
+ test "capital characters for enum names" do
    klass = Class.new(ActiveRecord::Base) do
-     self.table_name = "cats"
+     enum breed: { "American Bobtail" => 0, "Balinese-Javanese" => 1 }
+     self.table_name = "computers"
+     enum extendedWarranty: [:extendedSilver, :extendedGold]
    end

-   assert_respond_to klass, :american_bobtail
-   assert_respond_to klass, :balinese_javanese
+   computer = klass.extendedSilver.build
+   assert_predicate computer, :extendedSilver?
+   assert_not_predicate computer, :extendedGold?
  end

39673ではenum名の制約を変更して大文字で始まる単語を含むメソッド名の生成を禁止しているが、これはbreaking changeの懸念がある。
とりあえずブランチを2つ用意した。既存のアプリが動かなくなってもいいという意図がなければ今はrevertするべき(このブランチはrevert用)。
逆にこのbreaking changeが意図的(つまり既存のアプリは新しい制約に移行すべき)であれば、少なくとも既存の動作を削除する前に非推奨にしておくべき。
同PRより大意


ナイスフォロー感謝です!スペースやハイフンをアンダースコアに変換したかっただけなので、capitalをなくすつもりはなかった。スペースやハイフンを変換するときにcapitalを変えないためにシンプルなgsub正規表現でやる方がいいのかもしれない。
同PRコメントより大意

Rails

ソフトウェアパターンを闇雲に適用しないこと

短い記事です。


つっつきボイス:「よく言われることですけどピックアップしてみました」「英語でもblindly applyって言うのか」「銀の弾丸は英語でもsilver bulletなのか」「それはきっと英語の方が元でしょう😆」「あ、そうでしたか😅」「『狼男は銀の弾丸でないと倒せない』という伝説が元でしたっけ🐺」

参考: 銀の弾丸 - Wikipedia


記事目次より:

  • ソフトウェアパターンは「銀の弾丸」ではない
  • ソフトウェアパターンを闇雲に適用しないこと
  • ここではこう解決する方がいいのでは?

ルーティングヘルパーとRailsアーキテクチャのtips


つっつきボイス:「書かれていることはまあ普通ですね」「Railsを学び始めたばかりだと覚えることが大量にあるから、こういうtipsが必要なのもわかります😭」


記事要点

# 同記事より
resources :users do
  # 既存の外部ルーティング
  get :avatar
  get :new_avatar
  post :create_avatar
end
  • このルーティング↑だけのうちはいいが、コントローラのコードを書き始めるとUserモデル以外のリソースに対するコントローラの責務が増えがち
    • user_save_avatarみたいなやりすぎルーティングに誘惑される)
  • コントローラは小さくしよう(コントローラが小さいほどメンテやテストがやりやすくなる)
  • Railsの7つのCRUDアクションになるべく収めよう
# ネステッドルーティングで改善後
resources :users do
  resource :avatar, only: [:show, :new, :create]
end

Railsのルーティングを極める(前編)

Rails Architects Conference 2020がオンライン開催

こちらは一足先に記事を出しました↓。

Rails Architects Conference 2020が7/1より順次オンライン開催


つっつきボイス:「6月末〜7月頭に開催されるんですが、トピックがそそる感じだったので」「オンラインカンファレンスだけど1日1トピックというのがなかなか珍しそう」「このぐらいのボリューム感の方が何かしながら流し聞けていいかも😋」「時間帯UTCなんですけど😳」「そこなんですよ、日本時間だと殆どが夜半過ぎとか早朝で」「朝2時5時3時😆」「1つだけ夜20:00ありますけど」「極東住民にはなかなかハードな時間割ですね〜🕐」

「スピーチは英語でやってくれるのかしら?」「発表者の名前はいかにもポーランド系なんですけど、集客のためにもたぶん英語でやってくれるだろうと想像してます」「よかった〜☺️」「ネイティブじゃない分英語聞き取りやすいと思います」「ポーランド語とか無理😆」「名前見てポーランド系ってわかるんですか?」「はい、昔の仕事で目が慣れました☺️」

RSpecのメンテナンス性を改善する9つのコツ(RubyFlowより)


つっつきボイス:「RSpecのコツか〜」「6.のこれ↓は誰が見ても下の方がいいですし」

# 同記事より
# BAD
it 'has correct attributes' do
  expect(user.name).to eq 'john'
  expect(user.age).to eq 20
  expect(user.email).to eq 'john@ruby.com'
  expect(user.gender).to eq 'male'
  expect(user.country).to eq 'us'
end

# GOOD
it 'has correct attributes' do
  expect(user).to have_attributes(
    name: 'john',
    age: 20,
    email: 'john@ruby.com',
    gender: 'male',
    country: 'us',
  )
end

「7.の『RSpecのトランザクション』のあたりっていろいろ面倒」

「8.の『モックでexpect使うな』」「モックをうまく使うのも難しいですよね...」

「9.の、BADでやってる個別のテストは同じ処理をやってるからGOODみたいにDRYに書こう↓というのはワカル」「こんな書き方できるんですか?」「え、普通こう書きませんか?」「やったことなかった😅」「こう書かないとcaseを増やすのが面倒だと思いますけど」「不思議にレビューでも指摘されたことなかった...」

# 同記事より
# BAD
#
describe '.extract_extension' do
  subject { described_class.extract_extension(filename) }

  context 'when the filename is empty' do
    let(:filename) { '' }
    it { is_expected.to eq '' }
  end

  context 'when the filename is video123.mp4' do
    let(:filename) { 'video123.mp4' }
    it { is_expected.to eq 'mp4' }
  end

  context 'when the filename is video.edited.mp4' do
    let(:filename) { 'video.edited.mp4' }
    it { is_expected.to eq 'mp4' }
  end

  context 'when the filename is video-edited' do
    let(:filename) { 'video-edited' }
    it { is_expected.to eq '' }
  end

  context 'when the filename is .mp4' do
    let(:filename) { '.mp4' }
    it { is_expected.to eq '' }
  end
end


# GOOD
#
describe '.extract_extension' do
  subject { described_class.extract_extension(filename) }

  test_cases = [
    '' => '',
    'video123.mp4' => 'mp4'
    'video.edited.mp4' => 'mp4'
    'video-edited' => ''
    '.mp4' => ''
  ]

  test_cases.each do |test_filename, extension|
    context "when filename = #{test_filename}" do
      let(:filename) { test_filename }
      it { is_expected.to eq extension }
    end
  end
end

「ただこのDRYな書き方にすると、テストがfailしたときにどのパターンでコケたのかがちょっと探しにくくなるところが難点なんですよ」「たしかに!」

「DRYじゃない方の書き方なら、failしたときにそこの行番号を出してくれるから探しやすいんですけど、DRYな方はeachで回しているからテストデータが似通っていたりすると落ちた場所がすぐにわからなくて😢」「でもメンテしやすいのは明らかにDRYな方なんですよね...」「そこが悩ましい」「そういえばGo言語のテストにもまったく同じ問題あります」

「テストがコケたときにitの中身も出力してくれたらもう少し探しやすくなるかな🤔」「好みが分かれそうな部分かも」

RSpec vs minitest

「ところで最近Railsプロジェクトのテストはminitestでやってますヨ」「やや、ついにminitestですか?」「RSpec、好きというほどでもないので😆」「初めて聞きました😆」

「RSpecって、キレイに書かないとという思いに囚われて結局大変な気がするんですよ」「それわかります😆」「😆」「その代わりRSpecだとモデルのSpecはとてもキレイに書けると思います: subject形式とか」「それはありますね!」「個人の感想ですけど、RSpecはそれ以外のテストにはあんまり合ってない気がします😭」

「モデルのSpecだと、テストする主体がそのままsubjectでとても読みやすい👍」「たしかに」「でもコントローラのテストとかだとね...」「コントローラ、合う場合と合わない場合はあるかも🤔」

RSpecえかきうた

その他Rails


つっつきボイス:「AciveModel::Errorsは前回も取り上げたヤツかな?(ウォッチ20200622)」「New thingsのあたりのerrors.where()でフィルタするのは前回も出ましたね」「失礼しました😅」「errorsのハッシュ改変でdeprecation warningを出すというあたりは仕様変更っぽい👀」

book.errors.where(:name) # => name属性関連の全エラー
book.errors.where(:name, :too_short) # => name属性の全"too short"エラー
book.errors.where(:name, :too_short, minimum: 2) # => name属性の全"too short"エラー、2件以上

「以前はエラーを取り出すと全部ぐちゃらっと出てきてしまったので、errors.where()でフィルタする機能は欲しかったんですよ」「6.1早く出ないかな〜😋」「新機能も速度も楽しみですよね😋」


前編は以上です。

おたより発掘

バックナンバー(2020年度第2四半期)

週刊Railsウォッチ(20200623後編)Bootstrap 5 alphaリリース、Lambda FunctionsとEFS、DB設計で気をつけていることほか

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

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

Rails公式ニュース

RubyFlow

160928_1638_XvIP4h


CONTACT

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