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

週刊Railsウォッチ(20200817前編)お盆も続くRails改修、Rails 6.1にManyモナドが入る?rails-auth gemでクライアント認証ほか

こんにちは、hachi8833です。皆さま熱中症にはお互い気をつけましょう。

参考: 熱中症を防ぐためには(環境庁PDF)


つっつきボイス:「昨日急に体調つらくなって、自分でもびっくりするぐらい丸一日寝てたんですけど、もう一日の記憶がありませんし😇」「これだけ暑いと冷房の効いた部屋にいても体調悪くなりそうですよね…」「いやホントお大事に💊」「ウォッチも熱中症対策ということでエントリを減らし目にしました」

私も猛暑になるととりあえず梅干ししゃぶってクエン酸補給してます。

「そうそう、『室温28℃はエアコンの設定温度ではありません』ってよく注意喚起されてますよね」「自分はとりあえずエアコン27℃にしてますけど」「私も」「結局28℃は目安でしかなくて、西日が差すとか部屋の立地や構造などの条件でいくらでも変わってきますし」(以下エアコン工事や百葉箱の話題など延々)

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

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

以下のコミットリストのChangelogを中心に見繕いました。お盆休みのせいか少なめです。


つっつきボイス:「お、6.1.0のマイルストーンですか」「少し前からできてたんですが、その中から1つをこの後ピックアップしました🍡」


なおつっつきの時点では6.1.0マイルストーンに余分なフィルタをかけてしまって残り2件だと思いこんでました😅。本日確認したところ、まだ25件がオープンです。

joinスコープのthrough関連付けが重複するとeager loadingの結果が正しくなくなる問題を修正

キリ番ゲットでした。


つっつきボイス:「ううめんどくさそう…」「こういう感じの現象前にも見た気がする」

# 同PRより
class Author < ActiveRecord::Base
  has_many :general_categorizations, -> { joins(:category).where("categories.name": "General") }, class_name: "Categorization"
  has_many :general_posts, through: :general_categorizations, source: :post
end

authors = Author.eager_load(:general_categorizations, :general_posts).to_a

「この辺がややこしくなってしまうのは、ActiveRecordのコードから導出されるSQLが、期待しているSQLと一致しているかどうかを考えるのが難しいからというのもありますね」「両方を一致させるのってえらく大変そう…」「お互い違う言語ですから」

「へ〜、general_categorizations_authors_joinっていう名前になってる↓」

-- 同PRより
SELECT "authors"."id" AS t0_r0, ... FROM "authors"

-- `has_many :general_categorizations, -> { joins(:category).where("categories.name": "General") }`
LEFT OUTER JOIN "categorizations" ON "categorizations"."author_id" = "authors"."id"
INNER JOIN "categories" ON "categories"."id" = "categorizations"."category_id" AND "categories"."name" = ?

-- `has_many :general_posts, through: :general_categorizations, source: :post`
---- duplicated `through: :general_categorizations` part
LEFT OUTER JOIN "categorizations" "general_categorizations_authors_join" ON "general_categorizations_authors_join"."author_id" = "authors"."id"
INNER JOIN "categories" "categories_categorizations" ON "categories_categorizations"."id" = "general_categorizations_authors_join"."category_id" AND "categories"."name" = ? -- <-- filtering `"categories"."name" = ?` won't work
---- `source: :post` part
LEFT OUTER JOIN "posts" ON "posts"."id" = "general_categorizations_authors_join"."post_id"

「上のクエリの1つ目のLEFT OUTER JOINにあるcategorizationsには別名が付いてなくて、その直後のINNER JOINにあるcategoriesにも別名が付いてないんですけど、その次のLEFT OUTER JOINにはgeneral_categorizations_authors_joinという別名が付いている: ポイントは、この別名とJOIN句がどうやって導出されたのか?という点です」「な、長い😅」

「導出方法がわからないと、JOINしたときにcategorizationsがどう紐付けられるかを予測できないんですよ」「う〜む」「categories_categorizationsっていう名前も自動生成なんですね」「なんという名前😆」

「というふうに、ActiveRecordで書かれたコードから生成されるSQLを読み解くのって難しいんですよ」「難しい〜😭」「何だか空中ブランコ見ているみたいで大丈夫なの?って気がしてきます」「動くと言われても不安になってきますよね…」

「修正内容もどんなふうに修正されたのか、コードをちょっと見ただけだとわからない…」「とりあえず修正後を見るとJOINするテーブル数が減ってるから↓、プルリクに書いてあるとおりthrough関連付けをdeduplicateして再利用したということなんでしょう」「デデュプリケート…」「さっきのgeneral_categorizations_authors_joinが消えてる🎉」「ホントだ」「これはたしかにグレートワークカミポ」「余人が手出しできなさそう…」

# 同PRより
SELECT "authors"."id" AS t0_r0, ... FROM "authors"

-- `has_many :general_categorizations, -> { joins(:category).where("categories.name": "General") }`
LEFT OUTER JOIN "categorizations" ON "categorizations"."author_id" = "authors"."id"
INNER JOIN "categories" ON "categories"."id" = "categorizations"."category_id" AND "categories"."name" = ?

-- `has_many :general_posts, through: :general_categorizations, source: :post`
---- `through: :general_categorizations` part is deduplicated / re-used
LEFT OUTER JOIN "posts" ON "posts"."id" = "categorizations"."post_id"

「たしかにActive Recordでスコープを使っていると無意識にこういうの書いちゃうことありますけど」「joinedスコープ的な書き方では気をつけないといけないでしょうね」「これに似た問題は過去にもちょくちょくあった気がします: joinのときとか、mergeのときとか」


長い名前というと、ついメリー・ポピンズを思い出します。

参考: スーパーカリフラジリスティックエクスピアリドーシャス - Wikipedia

assert _recognizesがマウントしたルーティングでも使えるように修正

ルーティングのassert_recognizesアサーションをマウントしたrootルーティングでも使えるようにする。4a9d4c8と似ているが、テストのアサーションにあるパスを認識する点が異なる。
現状のルーティングのrecognizeでは、ActionDispatch::Journey::Patch::Pattern::MatchData#post_matchを用いてrootルーティング(/)を/\A\//という正規表現でチェックすると空文字列のPATH_INFO ("")が生成される。これではマウントしたエンジンのルーティングのforward先が空文字になるのでルーティングを見つけられない。
同PRより大意

# actionpack/lib/action_dispatch/journey/router.rb#L65
      def recognize(rails_req)
        find_routes(rails_req).each do |match, parameters, route|
          unless route.path.anchored
            rails_req.script_name = match.to_s
-           rails_req.path_info   = match.post_match.sub(/^([^\/])/, '/\1')
+           rails_req.path_info   = match.post_match
+           rails_req.path_info   = "/" + rails_req.path_info unless rails_req.path_info.start_with? "/"
          end

          parameters = route.defaults.merge parameters
          yield(route, parameters)
        end
      end

つっつきボイス:「assert_recognizesって使ったことないな〜」「Railsガイドにもあった↓」

「ガイドのassert_generatesアサーションはそのまんまでわかりやすい、つかこれがあればいい気がする」「でassert_recognizesアサーションはその逆をテストするのね」「assert_routingは使ったことあったかも」

# Railsガイドより
assert_recognizes({ controller: 'photos', action: 'show', id: '1' }, '/photos/1')

「ルーティング難しいから、複雑なルーティングを書いたときにはこういうアサーションを使うとよさそう」「普通のresoucesルーティングならわざわざテストは書かないかな〜」


「ところで今ググって出てきたrailsdoc.comって初めて見たんですけど何でしょう?」「はて?」「知らない〜」

後で調べました↓。昨年のツイートですが、今は動いています(運営主体が同じかどうかはわかりません)。参照するときは公式ではない点に留意しておこうと思います。

@controllerというインスタンス変数が初期化されない場合があったのを修正

#39937で指摘されたように、@controllerというインスタンス変数がルーティングのテストで定義されないことがある。

rails/actionpack/lib/action_dispatch/testing/assertions/routing.rb#202
 request = ActionController::TestRequest.create @controller.class 

上が一部のインスタンスでinstance variable @controller not initialized warningを出すことがある。
インスタンス変数にアクセスする前にdefined?(controller)をチェックするといいだろう。

controller = @controller if defined?(@controller)
request = ActionController::TestRequest.create controller&.class

@ioquatixのレポートと解決に感謝🙂。
同PRより大意


つっつきボイス:「@controllerという変数名にちょっとドキッとした」「@controllerが初期化されないことがあったら結構困るし」「コントローラを直接参照したいことがどのぐらいあるかですけど、まあテストだし、本来はこのパスで触れないはずのものでも状態のテストはしたいこともあるでしょうし」

establish_connectionのパラメータをリネームしてキーワードに変更

# activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb#L1034
-     def establish_connection(config, pool_key = Base.default_pool_key, owner_name = Base.name)
+     def establish_connection(config, owner_name: Base.name, shard: Base.default_shard)
        owner_name = config.to_s if config.is_a?(Symbol)

        pool_config = resolve_pool_config(config, owner_name)
        db_config = pool_config.db_config
        # Protects the connection named `ActiveRecord::Base` from being removed
        # if the user calls `establish_connection :primary`.
        if owner_to_pool_manager.key?(pool_config.connection_specification_name)
-         remove_connection_pool(pool_config.connection_specification_name, pool_key)
+         remove_connection_pool(pool_config.connection_specification_name, shard: shard)
        end

        message_bus = ActiveSupport::Notifications.instrumenter
        payload = {}
        if pool_config
          payload[:spec_name] = pool_config.connection_specification_name
          payload[:config] = db_config.configuration_hash
        end

        owner_to_pool_manager[pool_config.connection_specification_name] ||= PoolManager.new
        pool_manager = get_pool_manager(pool_config.connection_specification_name)
-       pool_manager.set_pool_config(pool_key, pool_config)
+       pool_manager.set_pool_config(shard, pool_config)

        message_bus.instrument("!connection.active_record", payload) do
          pool_config.pool
        end
      end

つっつきボイス:「poolという名前が変わった」「ついでにキーワード引数に変わってる」「publicなestablish_connectionで使う名前としては、これがふさわしいという判断なんでしょうね」

  • current_pool_key -> current_shard
  • default_pool_key -> default_shard
  • pool_key -> shard

「poolという言葉だと自分はほぼコネクションプールを連想しますし、poolだと排他制御されてるように見えてしまうかもしれませんね: コネクションプールは自分だけが使えるリソースをもらって、終わったら返却する印象がありますけど、public云々と言っているのはもしかすると同時に使われる可能性も加味してるんじゃないかと推測してみました🤔」「ははぁ、なるほど」

参考: コネクションプーリング(コネクションプール)とは - IT用語辞典 e-Words

  • pool_keyからshardへのリネーム

もともとはshardpool_keyという名前にするつもりだった(内部のprivate APIを実装するときにシャーディングに使われるかどうかがわからなかったのと、public APIにせずに振る舞いを実装したかったので)。シャーディングのpublic APIができることになったので、private向けの名前にするより同じ名前をpublic APIで使う方がよい。今後のコード改修やバグ追跡がやりやすくなるはず。

このプルリクでdeprecationは不要(シャーディングAPIはまだリリースされておらず、内部コードはすべてpool_keyを使うようになっているので)。

  • 接続メソッドでキーワード引数を使うよう更新
    この変更では位置引数ではなくキーワード引数を接続用メソッドで使うようになる。この変更は一見必要ではなさそうに見えるが、今自分たちは接続管理をより頑丈かつ柔軟にするためにリファクタリング中なので、この時点でメソッドシグネチャを変更しておけば今後の変更がやりやすくなる。
    このコミットではリリース済みのpublic APIは変更されない(shardowner_name引数も6.1に追加されている)。キーワード引数にすることで柔軟性が高まり、内部動作を変更しても見苦しくならなくなる。このキーワード引数ではデフォルトで複数の「非位置引数」をサポートする。
    (@seejohnrunとの共作)
    同PRより大意

issue: Active SupportやActive RecordリレーションにManyモナドを導入したい

上のRails 6.1マイルストーンのissueの1つです。

@tomstuartのRubyでモナドを使う素敵なスピーチに刺激を受けたので、「Rails on Many monad」に足を踏み入れてみたいと思う。
Tomのmonads gemにある彼の成果を必要に応じてActive Supportにコピーすることについて快諾いただいた。このAPIの最もイケてる点は、以下のようにActive RecordリレーションでMany monadを使えるようになるという点だ。

Blogs.all.with_many.categories.posts.comments.body.split(/\s+/).values

同じことを以下のようにflat_mapでやるよりはるかにレベルアップできる。

Blogs.all.flat_map(:categories).flat_map(:posts).flat_map(:comments).flat_map(:body).split(/\s+/).values

これはずっと前からやりたかったのだが、これを正しく説明する方法について考えてみていいだろう。目指すはモナド!
これを動かすために、まずActive SupportにMaybeモナドを入れ、続いてcore extensionでwith_manyというEnumerableを追加し、それからActive Recordリレーションでもうまく動くようにすることを提案する(たぶんさくっとやれるはず)。
同issueより大意

tomstuart/monads - GitHub


つっつきボイス:「ちょうど昨日モノイド記事↓を公開したので、DHHがあげたマイルストーンissueを貼ってみました」

「モノイド」マジックでRubyとRailsをパワーアップしよう(翻訳)

「モナドか〜、こういうwith_manyをやりたいということなのね↓」「あ、今気づいたんですけど、Manyが大文字なのはモナドの名前なのか😅: モナドってMaybeとかOptionみたいな名前付けられがち」

Blogs.all.with_many.categories.posts.comments.body.split(/\s+/).values

「まあ書き方が増える分にはいいんじゃないかな」「他のテストがコケたりしなければ😆」「まだ作り中みたいですけど、6.1.0のマイルストーンにこのissueが入っているということは入れるつもりなんでしょうね」

参考: Maybeモナド と Listモナド - Qiita

「モナドはまずActive Supportに入れるつもりみたい: Active Recordにいきなり入れるよりはいいかも」「モナド大好きなkazzさんに見せたいな〜」「kazzさん最近つっつきになかなか参加できないんですよね…」

Rails

RailsアプリをDocker化する(Ruby Weeklyより)


つっつきボイス:「Rails Docker化を割と基本的なところから説明してる感じでした」「RailsをDocker Compose化するのはそんなに大変じゃないんですけど、ECSやEKSに乗せようとするといろいろ難しくなりがち: コンテナオーケストレーションツールに乗せると条件も変わってくるし考えないといけないことも増えてくるので」


記事見出しより:

  • RailsアプリケーションをDocker化するメリット
    • development環境でののメリット
    • production環境でのメリット
  • チュートリアルの概要
  • 前提条件
  • Dockerの基本概念
    • イメージ
    • コンテナ
  • DockerとDocker Composeの違い
  • アプリのDocker化に関連するファイル
    • Dockerfile
    • docker-compose.yml
  • アプリをDocker化する
    • Dockerfile
    • docker-compose.yml
    • init.sql
    • config/database.yml
  • Docker化したアプリのビルドと実行

RailsのログをRSpecでテストする方法2種(Ruby Weeklyより)

# 同記事より
# expect
it "logs a message" do
  allow(Rails.logger).to receive(:info)
  expect(Rails.logger).to receive(:info).with("Someone visited the site!")

  visit root_path

  expect(page).to have_content "Welcome to my site!"
end

つっつきボイス:「Everyday Railブログです」「spyを使う方法もあるか、なるほど」「ログの中身をテストすることってあるんですね」「当然あります、重要なシステムでログが正常に出力されてなくて復旧できなくなったら洒落にならないので」「なるほど」「そうなったのを見たことならあります😆」

Rails tips: RSpecの「スパイ(spy)」の解説(翻訳)

rails-auth: authenticationとauthorizationをミドルウェアベースで実現(Ruby Weeklyより)

square/rails-auth - GitHub


つっつきボイス:「authentication(認証)とauthorization(認可)をどっちもやれるgemみたいです」

Rails::Authは、authentication(以下AuthN)やauthorization(以下AuthZ)でRackミドルウェアを用いるよう設計された柔軟なライブラリです。AuthNの手順とAuthZの手順を別々のミドルウェアクラスに分割し、最初にAuthNのミドルウェアでcredential(X.509証明書やcookieなど)を検証し、続いてcredential(アクセス制御リストなど)を使うリクエストを別のAuthZ用ミドルウェアで認可します。
Rails::Authによる認証や認可は、ブラウザcookieを用いてエンドユーザーに対して使うことも、X.509クライアント証明書を用いてサービス間リクエストに対して使うことも、適切な認証ミドルウェアを持つcredentialを用いてその他のクライアントに対して使うこともできます。
名前とはうらはらに、SinatraなどのRackベースのフレームワークでも使えます。
同READMEより大意

# 同リポジトリより
module MyApp
  class Application < Rails::Application
    [...]

    Rails::Auth::ConfigBuilder.application(config, matchers: { allow_x509_subject: Rails::Auth::X509::Matcher })
  end
end

「X.509マッチャーとかあるんだ、やるな〜」「あれ、X.509って何でしたっけ?」「OpenSSLの証明書とかでよく使われているヤツですね」

参考: X.509 - Wikipedia

「OpenSSLとかで証明書を表示するとこういうのがどひゃ〜っと出力されるんですよ↓」「あ〜これですか😳」「ちらっと見た感じでは、このgemはクライアント証明書も使えるという触れ込みなんでしょうね」

# Wikipediaより
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            10:e6:fc:62:b7:41:8a:d5:00:5e:45:b6
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=BE, O=GlobalSign nv-sa, CN=GlobalSign Organization Validation CA - SHA256 - G2
        Validity
            Not Before: Nov 21 08:00:00 2016 GMT
            Not After : Nov 22 07:59:59 2017 GMT
        Subject: C=US, ST=California, L=San Francisco, O=Wikimedia Foundation, Inc., CN=*.wikipedia.org
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
            pub: 
                    00:c9:22:69:31:8a:d6:6c:ea:da:c3:7f:2c:ac:a5:
                    af:c0:02:ea:81:cb:65:b9:fd:0c:6d:46:5b:c9:1e:
                    9d:3b:ef
                ASN1 OID: prime256v1
                NIST CURVE: P-256
        X509v3 extensions:
(省略)

「READMEにX.509クライアント証明書を使うって書いてあるところからして、このgemはもしかするとRailsにSSLというかTLSを解釈させるものなんだろうか?」

参考: クライアント証明書とは - IT用語辞典 e-Words

「コード覗いてみると↓、このgemはX.509証明書を受け取るようになってる」「証明書を直接扱ってるような感じがありますね」「でないとSubject Alternate Names(SANs)とか取り出せないし」

# lib/rails/auth/x509/certificate.rb#10
        def initialize(certificate)
          unless certificate.is_a?(OpenSSL::X509::Certificate)
            raise TypeError, "expecting OpenSSL::X509::Certificate, got #{certificate.class}"
          end

          @certificate = certificate.freeze
          @subject = {}

          @certificate.subject.to_a.each do |name, data, _type|
            @subject[name.freeze] = data.freeze
          end
          @subject_alt_names = SubjectAltNameExtension.new(certificate)
          @subject_alt_names.freeze
          @subject.freeze
        end

「Deviseとかとは使いみちが少し違う感じでしょうか?」「Wikiに書いてあるような、マイクロサービスでクライアント証明書を使うみたいな用途なら、接続先が限定されるし偽装がほぼ不可能になるという意味でわかるような気がするけど」

「gemspecを見ると↓、やっぱりこのgemはTLSをしゃべるのか」「opensslもrequireしてるし」「TLSをしゃべらせるのって大変そうだけど、よく作ったな〜」

lib = File.expand_path("lib", __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require "rails/auth/version"

Gem::Specification.new do |spec|
  spec.name          = "rails-auth"
  spec.version       = Rails::Auth::VERSION
  spec.authors       = ["Tony Arcieri"]
  spec.email         = ["tonyarcieri@squareup.com"]
  spec.homepage      = "https://github.com/square/rails-auth/"
  spec.licenses      = ["Apache-2.0"]

  spec.summary       = "Modular resource-oriented authentication and authorization for Rails/Rack"
  spec.description   = <<-DESCRIPTION.strip.gsub(/\s+/, " ")
    A plugin-based framework for supporting multiple authentication and
    authorization systems in Rails/Rack apps. Supports resource-oriented
    route-by-route access control lists with TLS authentication.
  DESCRIPTION
# (省略)

「どんなふうに使うのかがまだピンとこない」「READMEにもうちょっと情報があるといいんですけど」


「ところでこのリポジトリのSquareってあの決済システムのSquareなのかな?」「最初違うかなと思ったけど、アイコンも見覚えあるし、どうやらそうみたい」

「iPhoneのヘッドフォン端子に挿して使うSquareの決済システムがコミケとかでも使われてたりしますね」

参考: サークル参加者と語る、「同人サークルとクレジットカード決済」

「rails-authはもしかするとSquareが主に内部で使っているのかも?🤔」「あ〜そうかも」「クライアント認証をRubyで書きたかったのかも」「Rackミドルウェアでやってるみたいだからまあいいけど、もし名前のとおりにRailsでクライアント認証とかやってたら速度とかつらそうですけどね」


前編は以上です。

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

週刊Railsウォッチ(20200811山の日短縮版)RSpec Queueでパラレルテスト、カロリーメイトとRubyのコラボ、Rubyのcoercionほか

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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