Madogiwa Blog

主に技術系の学習メモに使っていきます。

SQL(PostgreSQL): EXISTSとINとINNER JOIN、どれが一番パフォーマンスが良いのか🤔

SQLでなんかしらの条件でレコードを絞り込むときのやり方には、複数のやり方がありますが、どれが一番効率が良いのか調べてみました🤔

前提条件

今回はpostgresqlで調べていますバージョン等の情報は下記の通りです。

$ psql --version
psql (PostgreSQL) 12.1

また対象となるERは下記のようなものです。

f:id:madogiwa0124:20200102234749p:plain

テーブルの細かい名前は置いといて、feedsというテーブルとfeedsとboardsの中間テーブルです。(feedsとboard_feedsの関係は1:Nです)
ここからboardsが紐づくfeedsのみを取得する際にINEXISTSINNER JOINどれを使うのがパフォーマンスが良さそうなのか、実行計画を見ながら考えてみます。

取得結果のサンプルは下記のような感じです。

 id | title
 70 | MadogiwaBlog
 73 | Technology of DeNA
 74 | Engineering The GitHub Blog
 78 | さくらのナレッジ

実行計画を見てみる

IN

まずはINを見てみます、実行計画を見てみると下記のような流れで実行されているようです👀

  1. Seq Scan on board_feeds -> board_feedsを全件読み込む
  2. HashAggregate Group Key: board_feeds.feed_id -> board_feeds.feed_idで結果セットをGroup化
  3. Sort Sort Key: board_feeds.feed_id -> 結果セットをboard_feeds.feed_idでソート
  4. Seq Scan on feeds -> feedsを全件読み込む
  5. Hash Join Hash Cond: (feeds.id = board_feeds.feed_id) -> feedsboard_feedsをHash結合
  6. Sort Sort Key: feeds.id -> feeds.idで最終的な結果セットをソート

オプティマイザが導き出した最終的なコストは3.10..3.13でした。

-- IN
 Sort  (cost=3.10..3.13 rows=11 width=23)
   Sort Key: feeds.id
   ->  Hash Join  (cost=1.60..2.91 rows=11 width=23)
         Hash Cond: (feeds.id = board_feeds.feed_id)
         ->  Seq Scan on feeds  (cost=0.00..1.15 rows=15 width=23)
         ->  Hash  (cost=1.46..1.46 rows=11 width=8)
               ->  HashAggregate  (cost=1.35..1.46 rows=11 width=8)
                     Group Key: board_feeds.feed_id
                     ->  Seq Scan on board_feeds  (cost=0.00..1.28 rows=28 width=8)

EXISTS

次にEXISTSを見てみます、実行計画を見てみると下記のようなINと同じ流れで実行されているようです👀

  1. Seq Scan on board_feeds -> board_feedsを全件読み込む
  2. HashAggregate Group Key: board_feeds.feed_id -> board_feeds.feed_idで結果セットをGroup化
  3. Sort Sort Key: board_feeds.feed_id -> 結果セットをboard_feeds.feed_idでソート
  4. Seq Scan on feeds -> feedsを全件読み込む
  5. Hash Join Hash Cond: (feeds.id = board_feeds.feed_id) -> feedsboard_feedsをHash結合
  6. Sort Sort Key: feeds.id -> feeds.idで最終的な結果セットをソート

オプティマイザが導き出した最終的なコストは当然ですがINと同じ3.10..3.13でした。

-- EXISTS
$ EXPLAIN SELECT feeds.id,feeds.title FROM feeds WHERE id IN  (SELECT feed_id FROM board_feeds) ORDER BY id;
 Sort  (cost=3.10..3.13 rows=11 width=23)
   Sort Key: feeds.id
   ->  Hash Join  (cost=1.60..2.91 rows=11 width=23)
         Hash Cond: (feeds.id = board_feeds.feed_id)
         ->  Seq Scan on feeds  (cost=0.00..1.15 rows=15 width=23)
         ->  Hash  (cost=1.46..1.46 rows=11 width=8)
               ->  HashAggregate  (cost=1.35..1.46 rows=11 width=8)
                     Group Key: board_feeds.feed_id
                     ->  Seq Scan on board_feeds  (cost=0.00..1.28 rows=28 width=8)

EXISTSとINだと実行計画が一緒なんですね、EXISTSの半結合(Semi Join)になって早くなると思ってたのですが、どうやらIN句とパフォーマンスは特に変わらないようですね🤔

INNER JOIN

最後にINNER JOINを見てみます、実行計画を見てみると下記のような流れで実行されているようです。

  1. Seq Scan on feeds -> feedsを全件読み込む
  2. Hash -> feedsの全件のハッシュテーブルを作成
  3. Seq Scan on board_feeds -> board_feedsを全件読み込む
  4. Hash Join Hash Cond -> feedsboard_feedsをHash結合
  5. HashAggregate Group Key: feeds.id -> feeds.idで結果セットをグループ化
  6. Sort Sort Key: feeds.id -> feeds.idで最終的な結果セットをソート
-- INNER JOIN
$ EXPLAIN SELECT feeds.id, feeds.title FROM feeds INNER JOIN board_feeds ON feeds.id = board_feeds.feed_id GROUP BY feeds.id, feeds.title ORDER BY id;
 Sort  (cost=3.23..3.26 rows=15 width=23)
   Sort Key: feeds.id
   ->  HashAggregate  (cost=2.78..2.93 rows=15 width=23)
         Group Key: feeds.id
         ->  Hash Join  (cost=1.34..2.71 rows=28 width=23)
               Hash Cond: (board_feeds.feed_id = feeds.id)
               ->  Seq Scan on board_feeds  (cost=0.00..1.28 rows=28 width=8)
               ->  Hash  (cost=1.15..1.15 rows=15 width=23)
                     ->  Seq Scan on feeds  (cost=0.00..1.15 rows=15 width=23)

INNER JOINの場合はGROUP BY feeds.id, feeds.titleの処理(PHashAggregate Group Key: feeds.id`)が必要となるので、他よりもコストが高いようですね(・・;)

しかし、実際に対象レコードを絞り込むまで(Hash Join (cost=1.34..2.71 rows=28 width=23))までのコストは一番低いので、1:1のTBLの存在確認をしてレコードを絞り込む場合には一番効率が良さそう🤔

結果

絞り込み方 コスト メモ
IN 2.45..2.53
EXISTS 2.45..2.53 INと実行計画が同じ
INNER JOIN 3.23..3.26 一意の処理を除くとコストが一番低い(1.34..2.71 )

結果を見るとオプティマイザの評価としては、INとEXISTSにはパフォーマンス的に差がなくINNER JOINはコストが高いという結果でした👀

しかし、今回は1:NのTABLEの存在チェックだったので一意にする処理が必要なケースでしたが、一意にする必要がない1:1のTABLEの存在チェックの場合はINNER JOINが一番コストが低そうです!

※実行計画はDBMSによって違うので、今回の結果はMySQL等では違うかもしれません。

参考

www.postgresql.jp

tgk.hatenadiary.org

hamako9999.net

2019年振り返り

今年も一年が終わるということで今年も振り返ってみる。

今年の振り返り

アウトプット

BLOG

BLOGは、今年1年で56記事書いてました📝1週間に1.2記事ぐらい書いてるので、そこそこ書いてた🙌

ちょっと気になった記事をpickupして載せときます。

madogiwa0124.hatenablog.com

madogiwa0124.hatenablog.com

madogiwa0124.hatenablog.com

madogiwa0124.hatenablog.com

madogiwa0124.hatenablog.com

madogiwa0124.hatenablog.com

madogiwa0124.hatenablog.com

またPV数も5000は最低でも取れるようになってきていて、去年の1月ぐらいと比べると1000ぐらいは伸びてますね🎉

別にPVは気にしてないですが、ほぼトラフィックGoogle経由なので、Googleにコンテンツとして価値が高いと評価されているのは嬉しいことです。

f:id:madogiwa0124:20191231162401p:plain

Webサービスとかツール

今思い返すとgemとかwebサービスとか結構作って公開してた・・・!

ポートフォリオサイト(vue、typescript) madogiwa0124.github.io

タブが使えるMarkdownEditor(vue、electron) madogiwa0124.github.io

RSSをまとめられるWebサービス dogfeeds(rails、vue) github.com

オレオレWebフレームワーク makanai(ruby) github.com

Cron設定をパースするgem(ruby) github.com

embulkのサンプル環境(docker、embulk) github.com

railsとnuxtのdocker環境(docker、rails、nuxt.js) github.com

ブラウザのイベントチェックツール(javascript) github.com

結構玉石混交な感じなので、来年はサービス数は少なくてもいいので腰を据えてやっていく感じにしてもいいかなと思っている。

OSS活動

OSS活動としてはドキュメント変更ばかりですがPRを出しました、何個はマージしてもらえました🙏✨

github.com

github.com

github.com

github.com

来年はチェンジログに乗るようなPRを出したい・・・!

イベント登壇

今年は初めてイベントに登壇しました💎

madogiwa0124.hatenablog.com

書籍

Ruby on Rails 6 エンジニア養成読本のレビューに参加させていただきました🙏(私がやらせていただくのもおこがましかったのですが、とてもいい経験でした🙇‍♂️)

インプット

今年は12冊、本を読んでました📚

f:id:madogiwa0124:20191231165131p:plain

割と言語によらないDBやリファクタリング、セキュリティ的な本を多めに読んだのですが、地力が少し上がった感じがしてよかったです・・・!

あとはSwiftのチュートリアルをやりました!

github.com

去年の目標と結果

目標 結果 メモ
web design系の本を3冊は読む × デザイン系の本は結局読まなかった・・・!
アプリを作るだけじゃなくて、ちゃんと運用してみる × 自分のためのツールがメインでちゃんと運用してない・・・
rubyjavascript以外の言語に手を出す swiftのチュートリアルやった
勉強会で登壇する これは登壇したので!
Webサービスを作るだけじゃなくて、ちゃんと運用してみる BLOGにGA入れて数字見始めたので

いまいち・・・😅

今年の目標

目標 メモ
ChangeLogに乗るかbugfix的なドキュメント以外のPRを出してマージされる Changelogに自分の名前を残したいなと・・・!
DBスペシャリストを取る 逃げ恥みて、取りたくなった
プロポーザルがあるようなイベントで登壇してみる ちょっとチャレンジングですが!
なんかグロースハック系の本を読んで実践する ちゃんと数値貢献できるエンジニアになりたい。
フロントとバックエンドでリポジトリが別なサービスを作る openapiとか追えてないので、そのへんキャッチアップしたい・・・!

おわりに

去年は転職したり色々あったけど、今年は割と落ち着いていた気がする。来年もまたエンジニアとして成長していきたい。。。

今年もありがとうありがとうございました 、来年もよろしくおねがいします🙇‍♂️

Ruby: lamdaを使っていい感じにロジックを変数に入れてスッキリ書く

みなさんRubylamdaを使っていますか?lamdaを使うとロジックを変数にいれて抽出するようなことが出来ます。これを使うと複雑なロジックを変数に入れて名前をつけられたり、いろいろとスッキリかけるケースが多そうな気がしてきたのメモしておきます📝

👇lamdaの基本的な使い方下記を参照してください

docs.ruby-lang.org

例えば、下記のようなブログの一覧の取得取得したあとにお気に入りが多いものだけを取得するロジックがあったとします。(DBで取得するときに除外すればいいじゃんという話は置いといて)

Blogs.where(published: true).to_a.select { |blog| blog.favorites.length > 100 }

これでも良いと思うんですが、除外するロジックが増えてきた場合にどうでしょう👀

Blogs.where(published: true).to_a.select do |blog|
  blog.favorites.length > 100 && blog.comments.length > 0
end

selectの中身がごちゃごちゃしてきたのと、do..endが入って行数が増えきて、ちょっと辛くなってきましたね😅

こういうときにはlamda(proc)を使ってあげるとスッキリかけます💪

下記がlamdaを使って書き直した例です。

pickup = ->(blog) { blog.favorites.length > 100 && blog.comments.length > 0 }
Blogs.where(published: true).to_a.select(&pickup)

どうでしょうかselect内の条件判定部分のロジックが抽出出来て読みやすくなっていますね👍

またlamdaを使うとメソッドやクラスに切り出しやすいのでリファクタリングもしやすいです👩‍🏭

メソッドの場合はlamdaの中のロジックを Blog側のインスタンスメソッドに定義してあげるとインターフェースもほぼ変えずにリファクタリング出来ます。

Blogs.where(published: true).to_a.select(&:pickup?)

class Blog
  def pickup?(user)
     blog.favorites.length > 100 && blog.comments.length > 0
  end
end

クラスの場合はこんな感じ。

Blogs.where(published: true).to_a.select{ |blog| BlogPickuper.call(blog) }

class BlogPickuper
  def self.call(blog)
      blog.favorites.length > 100 && blog.comments.length > 0
  end
end

このようにメソッド切るほどじゃないけど少し複雑だったり名前をつけてあげたい場合にlamdaを使ってあげるとスッキリかけますし今後のリファクタリングもしやすくなりそうですね👍

DockerでNuxt.jsとRailsの環境を作った

昨今、フロントとバックエンドを別々のリポジトリにしてOpenApiでインターフェースを定義して進むプロジェクトが増えてきていると感じていますが、OpenApiとか全然付いていけてないので個人でNuxt.jsとRuby on Railsが別々のコンテナで立ち上がる環境を作ったのでメモしときます📝

👇Githubリポジトリを公開して使い方とか書いているので、中身がみたいだけの場合はこちらへどうぞ

github.com

環境の構成

DBはposgresqlで、あとはRailsとNuxtの環境が別々のコンテナで立ち上がります。

container name memo
db posgresql:latest
nuxt Nuxt.js from Node 12.13.1
rails Rails from Ruby 2.6.5

※今後OpenApiのコンテナも追加したい
 https://hub.docker.com/r/swaggerapi/swagger-editor/

使い方

実際に環境を立ち上げる手順を記載しておきます。

  1. リポジトリをクローンします。
$ git clone https://github.com/Madogiwa0124/nuxt_on_rails_on_docker.git
$ cd nuxt_on_rails_on_docker
  1. repos内にNuxt.jsとRailsのアプリケーションのリポジトリをクローンします。
$ cd repos
$ git clone nuxt-app-repository
$ git clone rails-app-repository
  1. docker-compose.yml内にリポジトリ名に依存した項目(volumesbuild args)を修正
nuxt:
  build:
    context: .
    dockerfile: ./docker/nuxt/Dockerfile
    args:
      - APP_NAME=nuxt-app-name
    volumes:
      - ./repos/nuxt-app-name:/nuxt-app-name
rails:
  build:
    context: .
    dockerfile: ./docker/rails/Dockerfile
    args:
      - APP_NAME=rails-app-name
    volumes:
      - ./repos/rails-app-name:/rails-app-name
  1. docker-compose up -dを実行してコンテナを立ち上げます。
$ docker-compose up -d
       Name                     Command               State            Ports
-------------------------------------------------------------------------------------
nuxt_rails_db_1      docker-entrypoint.sh postgres    Up      0.0.0.0:32839->5432/tcp
nuxt_rails_nuxt_1    docker-entrypoint.sh npm r ...   Up      0.0.0.0:8080->3000/tcp
nuxt_rails_rails_1   bin/rails s -b 0.0.0.0 -p 3000   Up      0.0.0.0:3000->3000/tcp

無事に立ち上がれば、localhost:3000にアクセスするとRailsが、localhost:8080にアクセスするとNuxt.jsの環境が立ち上がります🙌

おわりに

今回は、Nuxt.jsとRailsの環境が立ち上がるdocker環境を作ってみました。 私はモノリシックで標準的なRailsアプリケーションしかあまり扱ったことがないので、今回作った環境で色々勉強していきたいですね(・・;)

参考

qiita.com

qiita.com

Ruby: gem istallを実行してからgemがinstallされるまでの実装の流れを途中まで追ってみた

普段自分が何気なく実行しているgem installが実際にどのように行われれているのか気になったのでコードを読んで流れを追ってみました👀

※今回読んだのはrubygemsのstableなブランチを思われる3.0のコードです。

GitHub - rubygems/rubygems at 3.0

gem installの流れ

実行コマンドの実行

まずgem install hogeを実行するとbin/gemが実行されコマンドライン引数(ARGV)にinstallhogeが渡されます。 そしてGem::GemRunner#runコマンドライン引数を引数に実行されます。

gem install hoge
# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/bin/gem#L21
# => Gem::GemRunner.new.run ["install", "hoge"]

Gem::GemRunner#runが何をしているかというと引数をもとにconfigを実行時の引数を生成し、Gem::CommandManager#runを呼び出します。

 # https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/gem_runner.rb#L41
class Gem::GemRunner
  def initialize(options={})
    # 例外処理
    @command_manager_class = options[:command_manager] || Gem::CommandManager
    @config_file_class = options[:config_file] || Gem::ConfigFile
  end

  def run(args)
    # ...
    # 色々config周りの調整後にコマンド(install hoge)を実行
    # cmd.run (["install", "hoge"], [])
    cmd.run Gem.configuration.args, build_args
  end

Gem::CommandManager#runでは、process_argsを実行し引数から実行コマンドを特定しGem::Commands::InstallCommandのオブジェクトを取得してinvoke_with_build_argsで実行します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/command_manager.rb#L147

class Gem::CommandManager
  def run(args, build_args=nil)
    process_args(args, build_args) # process_args(["install", "hoge"], [])となる
    # 例外処理
  end

  def process_args(args, build_args=nil)
    # 例外処理
    case args.first # install
    when '-h', '--help' then
    # オプション周りの処理
    else
      cmd_name = args.shift.downcase # install
      cmd = find_command cmd_name # buildinコマンドからinstallがあるか探してGem::Commands::InstallCommandのオブジェクトを返す
      cmd.invoke_with_build_args args, build_args # Gem::Commands::InstallCommand#invoke_with_build_args("hoge", [])が実行される。
    end
  end

invoke_with_build_argsは、Gem::Commands::InstallCommandスーパークラスGem::Commandに定義されています。 やっていることはコマンドライン引数から実行時オプションまわりを設定や、コールバックの実行を行いGem::Commands::InstallCommand#executeを実行します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/command.rb#L306

class Gem::Command
  def invoke_with_build_args(args, build_args)
    handle_options args # options[:args]にargsを設定
    options[:build_args] = build_args
    # 省略
    if options[:help]
      show_help
    elsif @when_invoked
      @when_invoked.call options # callbackが設定されてたら実行(今回は関係なし)
    else
      execute # ここが実行される。
    end
  ensure
    # 例外発生時の処理
  end

Gem::Commands::InstallCommand#executeでは、install先やversion周りの指定値のチェック等の前処理を行ったあと、Gem::Commands::InstallCommand#install_gemを実行します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/commands/install_command.rb#L150

class Gem::Commands::InstallCommand < Gem::Command
  def execute
    # 省略
    check_install_dir # instal先のオプションのチェック
    check_version # 複数のgemをinstallしていてかつversionを指定していた場合に例外を上げる
    # 省略
    exit_code = install_gems # ここがgemのinstall処理
    show_installed
    terminate_interaction exit_code
  end

Gem::Commands::InstallCommand#install_gemsではコマンドライン引数で指定したgemの数だけGem::Commands::InstallCommand#install_gemを実行します。

例) gem install hoge fugaの場合install_gem "hoge"install gem "fuga"が実行される。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/commands/install_command.rb#L255

class Gem::Commands::InstallCommand < Gem::Command
  def install_gems # :nodoc:
    exit_code = 0
    # get_all_gem_names_and_versionsがgem install時に複数のgemやversionを指定した場合を考慮している
    # 今回は`hoge`だけ
    get_all_gem_names_and_versions.each do |gem_name, gem_version|
      # 省略

      begin
        # install処理の実態
        # install_gem("hoge", nil)が実行される
        install_gem gem_name, gem_version
        # 例外処理
      end
    end

    exit_code
  end

gemのインストール処理

Gem::Commands::InstallCommand#install_gemでは、大きくわけて下記の2つが実行されます。

  • Gem::DependencyInstaller#resolve_dependenciesによる依存gemの解決
  • Gem::RequestSet#installによるgemのinstall
# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/commands/install_command.rb#L191

class Gem::Commands::InstallCommand < Gem::Command
  def install_gem(name, version) # :nodoc:
    # 省略
    # 必要なバージョンを表すオブジェクトを取得
    req = Gem::Requirement.create(version)


    if options[:ignore_dependencies]
      install_gem_without_dependencies name, req
    else # optionを指定してないのでこっちの分岐
      inst = Gem::DependencyInstaller.new options
      # inst.resolve_dependencies("hoge", <Gem::Requirement:0x00007fb5648ccc08 @requirements=[[">=", #<Gem::Version "0">]]>)が実行される。
      request_set = inst.resolve_dependencies name, req

      if options[:explain]
        # 省略
      else # optionを指定していないのでこっちの分岐
        @installed_specs.concat request_set.install options # ここでinstall処理が実施される
      end

      show_install_errors inst.errors
    end
  end

Gem::DependencyInstaller#resolve_dependenciesによる依存gemの解決

Gem::DependencyInstaller#resolve_dependenciesでの依存gemの解決では、Gem::RequestSetGem::DependencyGem::Resolver::InstallerSetのオブジェクトを生成して、Gem::Resolver::InstallerSet#always_installでinstall対象の依存gemを取得し、最終的にGem::RequestSetのオブジェクトにまとめて返却しています。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/dependency_installer.rb#L436

class Gem::Commands::InstallCommand < Gem::Command
  def resolve_dependencies(dep_or_name, version) # :nodoc:
    request_set = Gem::RequestSet.new
    # 省略
    installer_set = Gem::Resolver::InstallerSet.new @domain
    # 省略
    # 依存関係を表すオブジェクトを生成
    dependency =
      if spec = installer_set.local?(dep_or_name)
        Gem::Dependency.new spec.name, version
      elsif String === dep_or_name
        Gem::Dependency.new dep_or_name, version
      else
        dep_or_name
      end
    dependency.prerelease = @prerelease
    request_set.import [dependency] #  install対象にgemを追加
    installer_set.add_always_install dependency
    request_set.always_install = installer_set.always_install
    # 省略
    request_set
  end

Gem::Resolver::InstallerSet#always_installでは、Gem::Resolver::DependencyRequestのオブジェクトを生成して、find_allを実行、そして@remote_set.find_allGem::Resolver::BestSet#find_allが実行される。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/installer_set.rb#L53

class Gem::Resolver::InstallerSet < Gem::Resolver::Set
  def initialize(domain)
    # 省略
    @remote_set          = Gem::Resolver::BestSet.new
    @specs               = {}
  end

  def add_always_install(dependency)
    request = Gem::Resolver::DependencyRequest.new dependency, nil
    found = find_all request
    #  省略
    newest = found.max_by do |s|
      [s.version, s.platform == Gem::Platform::RUBY ? -1 : 1]
    end

    @always_install << newest.spec
  end

 def find_all(req)
    res = []
    # 省略
    # @remote_setはGem::Resolver::BestSetのオブジェクト
    res.concat @remote_set.find_all req if consider_remote?

    res
  end

Gem::Resolver::BestSet#find_allでは、Gem::Resolver::BestSet#pick_setsが実行され、@sourcesの数だけGem::Source#dependency_resolver_setが実行される。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/best_set.rb#L28

class Gem::Resolver::BestSet < Gem::Resolver::ComposedSet
  def pick_sets # :nodoc:
    @sources.each_source do |source|
      @sets << source.dependency_resolver_set
    end
  end

  def find_all(req) # :nodoc:
    pick_sets if @remote and @sets.empty?

    super
  rescue Gem::RemoteFetcher::FetchError => e
    replace_failed_api_set e

    retry
  end

Gem::Sourcehttps://rubygems.org等のgemの取得元を表す。

  irb(main):001:0> Gem.sources
  => #<Gem::SourceList:0x00007fd3e3939f40 @sources=[#<Gem::Source:0x00007fd3e3939b80 @uri=#<URI::HTTPS https://rubygems.org/>>]>
  # デフォルトでは`https://rubygems.org`が入っているっぽい。

Gem::Source#dependency_resolver_setでは、Gem::RemoteFetcher#fetch_pathhttps://rubygems.org/api/v1/dependenciesにリクエストを投げてresponseを取得し、Gem::Resolver::APISetのオブジェクトを生成します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/source.rb#L80

class Gem::Source
  def dependency_resolver_set # :nodoc:
    return Gem::Resolver::IndexSet.new self if 'file' == uri.scheme

    bundler_api_uri = uri + './api/v1/dependencies'

    begin
      fetcher = Gem::RemoteFetcher.fetcher
      response = fetcher.fetch_path bundler_api_uri, nil, true
    rescue Gem::RemoteFetcher::FetchError
      Gem::Resolver::IndexSet.new self
    else
      if response.respond_to? :uri
        Gem::Resolver::APISet.new response.uri
      else
        Gem::Resolver::APISet.new bundler_api_uri
      end
    end
  end

Gem::Resolver::BestSet#pick_setsGem::Resolver::APISet.new@setsGem::Resolver::APISetのオブジェクトを設定したあと、 superを実行してGem::Resolver::ComposedSet#find_allを実行します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/best_set.rb#L28

class Gem::Resolver::BestSet < Gem::Resolver::ComposedSet
  def find_all(req) # :nodoc:
    pick_sets if @remote and @sets.empty?

    super
  rescue Gem::RemoteFetcher::FetchError => e
    replace_failed_api_set e

    retry
  end

Gem::Resolver::ComposedSet#find_allでは@setsの各要素に対してfind_allを呼び出すので、Gem::Resolver::APISet#find_allが実行されます。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/composed_set.rb#L53

class Gem::Resolver::ComposedSet < Gem::Resolver::Set
  def find_all(req)
    @sets.map do |s|
      s.find_all req
    end.flatten
  end

Gem::Resolver::APISet#find_allGem::Resolver::APISet#versionsを呼び出して、https://rubygems.org/api/v1/dependencies?gems=hogeにリクエストを送信し、結果から依存等を管理するGem::Resolver::APISpecificationのオブジェクトを生成します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/api_set.rb#L46

class Gem::Resolver::APISet < Gem::Resolver::Set
  def find_all(req)
    res = []
    # 省略
    versions(req.name).each do |ver|
      if req.dependency.match? req.name, ver[:number]
        res << Gem::Resolver::APISpecification.new(self, ver)
      end
    end

    res
  end

  def versions(name) # :nodoc:
    # 省略
    uri = @dep_uri + "?gems=#{name}"
    str = Gem::RemoteFetcher.fetcher.fetch_path uri

    Marshal.load(str).each do |ver|
      @data[ver[:name]] << ver
    end

    @data[name]
  end

実際にhttps://rubygems.org/api/v1/dependencies?gems=nameにアクセスしてみるとこんな感じにgemの情報を取得できます。

require 'net/http'
require 'uri'
url = URI.parse('https://rubygems.org/api/v1/dependencies?gems=makanai')
res = Net::HTTP.get(url)
Marshal.load(res)
#=> [{:name=>"makanai", :number=>"0.1.0", :platform=>"ruby", :dependencies=>[["sqlite3", "~> 1.4.1"], ["rack", "~> 2.0.7"], ["rake", "~> 10.0"]]}]

最初に戻ると最終的にはrequest_setalways_installにinstall対象のgemのGem::Resolver::APISpecificationオブジェクトが格納されます。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/dependency_installer.rb#L436

class Gem::Commands::InstallCommand < Gem::Command
  def resolve_dependencies(dep_or_name, version) # :nodoc:
    request_set = Gem::RequestSet.new
    # 省略
    installer_set = Gem::Resolver::InstallerSet.new @domain
    # 省略
    # 依存関係を表すオブジェクトを生成
    dependency =
      if spec = installer_set.local?(dep_or_name)
        Gem::Dependency.new spec.name, version
      elsif String === dep_or_name
        Gem::Dependency.new dep_or_name, version
      else
        dep_or_name
      end
    dependency.prerelease = @prerelease
    request_set.import [dependency] #  install対象にgemを追加
    installer_set.add_always_install dependency
    request_set.always_install = installer_set.always_install
    # 省略
    request_set
  end

Gem::RequestSet#installによるgemのinstall

Gem::RequestSet#installでは、マルチスレッドでの

  • Gemのダウンロード(req.spec.download)
  • Gemのインストール(req.spec.install)

が実行されます。

class Gem::RequestSet
 def install(options, &block) # :yields: request, installer
    # 省略
    # マルチスレッドでダウンロードするためのQueueに並び替え済みの中身を入れる
    sorted_requests.each do |req|
      download_queue << req
    end

    # configの設定値の数だけThreadを生成してgemをダウンロード
    threads = Gem.configuration.concurrent_downloads.times.map do
      download_queue << :stop
      Thread.new do
        while req = download_queue.pop
          break if req == :stop
          # ダウンロードを実施
          req.spec.download options unless req.installed?
        end
      end
    end

    # ダウンロードが終わるまで待つ
    threads.each(&:value)

    # Install requested gems after they have been downloaded
    sorted_requests.each do |req|
      if req.installed?
        # install済みだったらinstallしない
      end

      spec =
        begin
          # installを実施
          req.spec.install options do |installer|
            yield req, installer if block_given?
          end
        rescue Gem::RuntimeRequirementNotMetError => e
        # 省略
        end

      requests << spec
    end
    # 省略
    requests
  end

Gemのダウンロード(req.spec.download)

まずはreq.specで何が行われているかを確認します。まずreqGem::DependencyInstaller#resolve_dependenciesによる依存gemの解決で格納されたGem::Resolver::APISpecificationのオブジェクトです。

Gem::Resolver::APISpecification#specを見てみましょう。内部ではsource.fetch_specを実行してGem::Source#fetch_specを実行しています。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/api_specification.rb#L73
class Gem::Resolver::APISpecification < Gem::Resolver::Specification
  def spec # :nodoc:
    @spec ||=
      begin
        tuple = Gem::NameTuple.new @name, @version, @platform
        source.fetch_spec tuple
      rescue Gem::RemoteFetcher::FetchError
        raise if @original_platform == @platform

        tuple = Gem::NameTuple.new @name, @version, @original_platform
        source.fetch_spec tuple
      end
  end

Gem::Source#fetch_specでは、fetcher.fetch_path source_uriを実行してhttps://api.rubygems.org/quick/Marshal.4.8/makanai-0.1.0.gemspec.rzのようなURLにリクエストを投げて、(.rz)形式で取得してパースする、

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/source.rb#L129
class Gem::Source
  def fetch_spec(name_tuple)
    fetcher = Gem::RemoteFetcher.fetcher
    spec_file_name = name_tuple.spec_name
    # 省略
    source_uri = uri + "#{Gem::MARSHAL_SPEC_DIR}#{spec_file_name}"
    source_uri.path << '.rz'

    spec = fetcher.fetch_path source_uri
    spec = Gem::Util.inflate spec
    # 省略

    # TODO: Investigate setting Gem::Specification#loaded_from to a URI
    Marshal.load spec
  end

実際にMarshal.load specの箇所を再現すると、どんな値が設定されているかがわかる。

irb(main):007:0> pp Marshal.load(Gem::Util.inflate(File.read('/Users/user_name/Downloads/makanai-0.1.0.gemspec.rz')))
=> #<Gem::Specification:0x00007ff28f1f7410 @extension_dir=nil, @full_gem_path=nil, @gem_dir=nil, @ignored=nil, @bin_dir=nil, @cache_dir=nil, @cache_file=nil, @doc_dir=nil, @ri_dir=nil, @spec_dir=nil, @spec_file=nil, @gems_dir=nil, @base_dir=nil, @loaded=false, @activated=false, @loaded_from=nil, @original_platform="ruby", @installed_by_version=#<Gem::Version "0">, @autorequire=nil, @date=2019-10-22 00:00:00 UTC, @description="simple web application framework for learning.", @email=["madogiwa0124@gmail.com"], @homepage="https://github.com/Madogiwa0124/makanai", @name="makanai", @post_install_message=nil, @signing_key=nil, @summary="simple web application framework for learning.", @version=#<Gem::Version "0.1.0">, @authors=["Madogiwa"], @bindir="bin", @cert_chain=[], @dependencies=[<Gem::Dependency type=:runtime name="rake" requirements="~> 10.0">, <Gem::Dependency type=:runtime name="rack" requirements="~> 2.0.7">, <Gem::Dependency type=:runtime name="sqlite3" requirements="~> 1.4.1">, <Gem::Dependency type=:development name="rubocop" requirements="~> 0.74">, <Gem::Dependency type=:development name="bundler" requirements="~> 2.0">, <Gem::Dependency type=:development name="rspec" requirements="~> 3.0">], @executables=[], @extensions=[], @extra_rdoc_files=[], @files=[], @licenses=[], @metadata={"homepage_uri"=>"https://github.com/Madogiwa0124/makanai", "source_code_uri"=>"https://github.com/Madogiwa0124/makanai"}, @platform="ruby", @rdoc_options=[], @require_paths=["lib"], @required_ruby_version=#<Gem::Requirement:0x00007ff28f1e7e20 @requirements=[[">=", #<Gem::Version "0">]]>, @required_rubygems_version=#<Gem::Requirement:0x00007ff28f1e7448 @requirements=[[">=", #<Gem::Version "0">]]>, @requirements=[], @rubygems_version="3.0.3", @specification_version=4, @test_files=[], @new_platform="ruby", @has_rdoc=true, @license=["MIT"] makanai-0.1.0>

なので、パースしたSpecificationのオブジェクトに基づいてダウンロード処理が実行されるはず・・・。 ※このへんGem::Specificationdownloadメソッドが無くて、req.spec.downlaodが追えなくなってしまった。。。。

おそらくこの辺が呼ばれてgemの本体がダウンロードされている・・・?

rubygems/specification.rb at 9c142f5d9d7576012a440dceb8335c3139fcc16f · rubygems/rubygems · GitHub

rubygems/source.rb at 9c142f5d9d7576012a440dceb8335c3139fcc16f · rubygems/rubygems · GitHub

Gemのインストール(req.spec.install)

パースしたSpecificationのオブジェクトに基づいてインストール処理が実行されるはず・・・。 ※このへんGem::Specificationinstallメソッドが無くて、req.spec.installが追えなくなってしまった。。。。

おそらくこの辺が呼ばれてgemの本体がインストールされている・・・?

rubygems/specification.rb at 9c142f5d9d7576012a440dceb8335c3139fcc16f · rubygems/rubygems · GitHub

おわりに

今回はgem installを実行したときにrubygemsがどのようにgemをインストールしているかを実際の実装を読んで追ってみました。 ※ダウンロードやインストール周りの処理は最後まで終えませんでしたが、どうやってgemspecを取得して依存関係を探るか等は勉強になりました。

考えてみたら当たり前ですが、rubygems.orgにリクエストを投げて依存関係等を取得しているんですね👀 なのでgemspecに記載しているメタ情報を元にgemのインストールが行われるので、ちゃんとメンテナンスしておかないと使う人がgem installしたときに、依存gemがinstallされてなくて動かない、またちゃんとrubygemsにアップロードしないと修正が反映されない等が発生してしまうので注意ですね⚠

またrubygemsのコードは比較的pureなrubyで書かれていてtestを動かすのも簡単で処理は複雑でしたが読みやすかったです💎

次はbundlerのinstallの流れを追ってみようかなと思います。

参考

github.com

blog.freedom-man.com

Railsで確認画面を挟んで登録する場合どう実装するのがよさそうか考えてみた

はじめに

Railsで確認画面を実装すると毎回、ベストプラクティス的な解決方法が見つからず悩むので、どういう実装をするのが良いのか少し感がてみました。(私の個人的な見解です🙇‍♂️)

確認画面の難しさ

なぜ確認画面の実装で悩むのか自分で考えてみたところ、確認画面への遷移の際に前の画面の情報を引き継ぐ必要があるが、引き継ぐ方法が中々難しいというのがあるのかなと思います。

確認画面の挟む場合のリクエストは下記のような流れになると思うのですが、HTTPはステートレスなプロトコルなのでリクエスト単位にデータを毎回送信する必要があります。

作成--確認-->投稿
or
作成--確認-->作成

毎回データをリクエストに乗せて情報をやりとりしないといけないので手順が多く、実装及びデータの持ち回りも煩雑になりがちなのかなと思います。

よく見るやり方

確認画面を実装する場合によく目にするのがhiddenタグを使って送信する方法ですね👀 下記のような、よくあるブログを投稿するようなcontrollerがあったとします。

# controller
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end

  def new
    @post = Post.new
  end

  def confirm
    # editとnewに対応させるためfind_or_initializeする
    @post = Post.find_or_initialize_by(id: params[:id])
    @post.assign_attributes(post_params)
  end

  def create
    @post = Post.new(posts_params)
    if @post.save
      redirect_to posts_path
    else
      render :new, @post
    end
  end
end

formは普通なのですが、ポイントとしてはformのpost先をconfirmにして、confirmformでは入力値の表示と、hiddenで入力値を仕込むことによって次の投稿作成のリクエストでもパラメーターに入力画面で設定された値を引き継ぐことができるというものですね。

# form
<%= form_with(model: post, local: true, url: { action: :confirm }) do |form| %>
  <p>タイトル: <%= f.text_field :title %></p>
  <p>本文: <%= f.text_field :content %></p>
  <%= f.submit %>
<% end %>

# confirm
<%= form_with model: post, local: true do |f| %>
  <p>タイトル: <%= post.title %> </p>
  <p>本文: <%= post.content %></p>
  <%= f.hidden :title %>
  <%= f.hidden :content %>
  <%= f.submit %>
<% end %>

このやり方はRailsの機能を使ってシンプルに実装できて非常にわかりやすくて良いやり方だと思うのですが、実際に自分で書いてメンテナンスしていくと下記のような懸念事項が生まれ、他のやり方だと解決できるのかなと思い、今回ちょっと他の方法も考えてみました🤔

  • hiddenで送信する項目が多くなるをformが長くなり煩雑になる
  • hiddenの場合、確認画面上で値を編集され偽装される可能性があるので思わぬ項目が入力値として与えられる可能性がある
  • そもそも入力値を確認画面を表示するという要件でサーバー側にリクエストを送信する必要はあるのか

その他のやり方を検討してみる

セッションに入れる

ショッピングカート等しかり、画面をまたぐ情報を保存する先として先ず思いつくのがセッションだったので、セッションに登録するような実装を考えてみます。

確認画面でパラメーターとして取得した入力値をセッションにいれてあげます。

class PostsController < ApplicationController
  def confirm
    @post = Post.find_or_initialize_by(id: params[:id])
    session[:post] = post_params
    @post.assign_attributes(session[:post])
  end

  def create
    @post = Post.new(posts_params)
    if @post.save
      session.delete(:post)
      redirect_to posts_path
    else
      render :new, @post
    end
  end
end

セッションに入力値が保存されているので、下記のようにconfirmからhidden_tagを使って引き渡す必要がなくなりました。

# confirm
<%= form_with model: post, local: true do |f| %>
  <p>タイトル: <%= post.title %> </p>
  <p>本文: <%= post.content %></p>
  <%= f.submit %>
<% end %>

sessionによる実装は、下記のような感じですかね。

  • hiddenで送信する項目が多くなるをformが長くなり煩雑になる --- △
    • 割とシンプルに実装できるが、セッションの破棄等の管理コストが発生するため一長一短か
  • hiddenの場合、確認画面上で値を編集され偽装される可能性があるので思わぬ項目が入力値として与えられる可能性がある --- ○
    • hiddenで送らないので確認画面で入力値をいじるようなことは行えない
  • そもそも入力値を確認画面を表示するという要件でサーバー側にリクエストを送信する必要はあるのか --- ×
    • リクエストしてセッションに詰めるので、リクエストは必要

これだけみるとセッションのほうがシンプルに実装出来て良い感じがするんですが、セッションを使うと同一ユーザーが同時にリクエストを送信した場合等、セッション破棄のタイミング等によって不正なデータが出来てしまう可能性があるのがネックですね。。。

未公開状態でDBに保存する

これはconfirmに遷移したときにデータを非公開で保存してしまえ!という対応です。先程のセッションの例の保存先をDBにしたというものですね。

modelにpublisedというdefault: falseという項目を用意しておいて、confirmのときに未公開の状態で保存して、実際に確認画面でsubmitしたときに保存していた投稿をfindして公開状態にupdateするという内容です。

class PostsController < ApplicationController
  def confirm
    @post = Post.find_or_initialize_by(id: params[:id])
    @post.assign_attributes(post_params)
    target_render = @post.new_record? ? :new : :edit
    target_render = :confirm if @post.valid?
    @post.save
    render target_render
  end

  def update
    @post.published = true
    if @post.update(post_params)
      redirect_to @post, notice: 'Post was successfully updated.'
    else
      render :edit
    end
  end
end

DBに保存しているので下記のようにconfirmからhidden_tagを使って引き渡す必要がなくなりました。

# confirm
<%= form_with model: post, local: true do |f| %>
  <p>タイトル: <%= post.title %> </p>
  <p>本文: <%= post.content %></p>
  <%= f.submit %>
<% end %>

DBに未公開で保存する実装は、下記のような感じですかね。

  • hiddenで送信する項目が多くなるをformが長くなり煩雑になる --- ×
    • hiddenで引き継がなくて良くなった一方、controller側の実装はちょっと煩雑になりそう。
    • submitせずに離脱された場合にゴミデータが残る可能性がある。(仕様として組み込んだ場合には途中離脱しても続きから再開できるという副効用もあるかも)
  • hiddenの場合、確認画面上で値を編集され偽装される可能性があるので思わぬ項目が入力値として与えられる可能性がある --- ○
    • hiddenで送らないので確認画面で入力値をいじるようなことは行えない。
  • そもそも入力値を確認画面を表示するという要件でサーバー側にリクエストを送信する必要はあるのか --- ×
    • ここはリクエストしてDBに詰めるので、リクエストは必要

下書き機能とセットで考えるならありかもしれないですが、確認画面をはさみたいという要件だけでDBに保存するのは不要なDBアクセスも増えますし、実装も複雑になるのでイマイチな感じがしますね。。。 (私の実装が微妙な点も大いにありますが。。。)

JavaScriptでフォームを作る

ここは言わずもがな、なのであれですがJavaScript側でプレビュー機能付きのフォームを作ってあげるというものですね。

昨今のRails環境ではWebpackerが導入されモダンなフロントエンド環境も構築しやすくなっていると思うので普通に選択肢に入ってくるのかなと思います。

  • hiddenで送信する項目が多くなるをformが長くなり煩雑になる --- △
    • hiddenで引き継がなくて良い。
    • CSRF対策やValidation等、form_withを使っていればよしなにやってくれるところを対応しないといけないので一手間かかる。
  • hiddenの場合、確認画面上で値を編集され偽装される可能性があるので思わぬ項目が入力値として与えられる可能性がある --- ○
    • クライアントサイドでの実装ではあるがvalidation等を設定できるためhiddenより堅牢。(サーバーサイドでのチェックは必須)
  • そもそも入力値を確認画面を表示するという要件でサーバー側にリクエストを送信する必要はあるのか --- ○
    • クライアントサイドで行うためリクエストは不要。

リッチな投稿フォームにプレビュー機能が必要であればJavaScript側で実装してあげるのが良さそうですが、Rails側のform_withで十分な投稿フォームまでJavaScriptで書くのはイマイチな感じがしますね。。。

まとめ

色々考えてみて、私なりには一応下記で落ち着いたのですが、まだいいやり方もありそうで奥が深いですね。。。

  • 画面項目が少なく動きの少ない投稿フォームの場合
    • 項目が少なければhidddenタグが多く煩雑になるようなことはなさそうなのでhiddenに詰める。(hiddenタグの書き換えによるリスクは考慮する)
    • 同時実行の考慮が不要及び、modelのバリデーション等で防げない改竄がリスクとしてあればセッションに詰めるやり方も検討する。
  • 画面項目が多く動きの少ない投稿フォームの場合
    • 画面項目が多い場合にはユーザーが離脱した際の再入力コストも高いはずなので、下書き機能とセットでDBに保存方法を検討する。
  • 動きの多いインタラクティブな投稿フォームの場合

参考

qiita.com

eeedotweb.com

hacknote.jp

Rspec、Capybaraを使ったfeature specでJavaScript側でも指定した時間で固定できるHelperを作った

今回はタイトルどおり、Rspec、Capybaraを使ったfeature specでJavaScript側の時間を操作するhelperを作ってみたので考慮漏れとかあるかもですが書きます。

↓コードだけみたい人はこちら

rspecを使ったfeature specでjavascriptの時間を指定時間に固定するhelper · GitHub

作った背景

Railsで「公開して1時間以内の場合は記事にNEWのラベルを表示」する等の機能を検証するときにActiveSupport::Testing::TimeHelpers#travel_toを使って下記のような感じで時間を止めて検証すると思います。

require 'rails_helper'

RSpec.feature '記事一覧画面', type: :feature do
  describe '記事のリスト表示', js: true do
    before { create(:post, publish_at: Time.current.noon) }
    
    context '公開して1時間以内の場合' do
      bafore do
        trabel_to Time.current.noon
        visit posts_path
      end

    it 'NEWが表示されること' do
      expect(page).to have_content "NEW"
    end
  end
end

単純なRailsだけの世界であれば、上記テストは問題ないのですがJavaScript側で現在時間をもとにNEWのラベルを表示している場合に昼の12時~13時以外の時間にテストが走ると落ちてしまいます・・・!

それはなぜかというとActiveSupport::Testing::TimeHelpers#travel_toは、Railsの世界でDate.todayDateTime.nowTime.nowをスタブしているだけだからです。

rails/time_helpers.rb at b9ca94caea2ca6a6cc09abaffaad67b447134079 · rails/rails · GitHub

JavaScriptnew Date()Date.now()には何も影響を与えないので、travel_toしても意味が無いんですね。。。

こういうケースに対応したいというのが作った背景です。

どうやって使うか

下記のようにrequire 'helpers/travel_to_javascript'を実行後にtravel_to_javascriptが使用できるようになります。

require 'rails_helper'
require 'helpers/travel_to_javascript'

RSpec.feature '記事一覧画面', type: :feature do
  describe '記事のリスト表示', js: true do
    before { create(:post, publish_at: Time.current.noon) }
    
    context '公開して1時間以内の場合' do
      bafore do
        trabel_to Time.current.noon
        visit posts_path
      end

    it 'NEWが表示されること' do
      # travel_to_javascriptのブロック内は本日の12時で時間が固定される。
      travel_to_javascript(page, Time.current.noon) do
        page.execute_script("console.error('now1:', new Date())")
        page.execute_script("console.error('now2:', Date.now())")
        pp page.driver.browser.manage.logs.get(:browser).map(&:message)
        #=> ["console-api 0:32 \"now1:\" Thu Nov 11 2010 12:00:00 GMT+0900 (日本標準時)",
        #         "console-api 0:32 \"now2:\" 1289444400000"]
        expect(page).to have_content "NEW"
      end
    end
  end
end

travel_to_javascriptpageと固定する時間のオブジェクトを引数に渡して実行すると、ブロックの中はJavaScript側の時間が固定されます。

どうやって時間を固定しているか

travel_to_javascriptのコードは下記のとおりです。

module TravelToJavascript
  def travel_to_javascript(page, datetime)
    page.execute_script time_stop_javascript(datetime)
    yield
    page.execute_script time_undo_javsctipt
  end

やっていることは、下記の3つです。

  • execute_scriptを使ってDateDate.nowを引数で渡された日時を返すようにオーバーライド
  • ブロックで渡された処理を実行
  • オーバーライドを戻す

日時生成処理のオーバーライドでは下記のようなJavaScriptを実行しています。もともとの処理で引数を与えている箇所についてはtravel_to_javascriptの引数で指定した日時ではなくJavaScript側でもともと指定されていた日時を返却するようにしています。

originDate = Date;
Date = function (datetime) {
  if (datetime) {
    return new originDate(datetime);
  } else {
    return new originDate("2010-11-11T12:00:00+09:00");
  }
}
;
Date.now = function (datetime) {
  if (datetime) {
    return new originDate(datetime).getTime();
  } else {
    return new originDate("2010-11-11T12:00:00+09:00").getTime();
  }
}
;

originDate = Date;を実行しているのは、「オーバーライドを戻す」をする際に下記のJavaScriptを実行するためです。

'Date = originDate;'

詳しいコードは下記を見てみてください。

rspecを使ったfeature specでjavascriptの時間を指定時間に固定するhelper · GitHub

出来ないこと

JavaScriptのあらゆるケースで時間を操作することは現状できなさそうです。下記のようなケースには対応できなさそう。。。

  • travel_to_javascriptの中でvisit等をしてページ跨ぎで時間を止めるようなことはページがリロードされたタイミングでJavaScriptもリロードされてしまうので出来ません。
  • ページ読み込み時にJavaScript側で判定するような場合にはexecute_scriptの実行前に判定が行われる可能性があるので効かなそうです。

ページ読み込み時にDate等をオーバーライドするコードを一番先にロードするようなことができれば行けそうだけど、Capybaraのpageオブジェクト周りのコードを読むとどうにか出来たりするんだろうか。。。🤔

現状では。travel_to_javascriptに渡したブロック内でページのリロード等が行われなければ時間を固定するというだけのものです🙇‍♂️

おわりに

時間が関係するテストコードでRails以外が関わってくる場合に時間をどう扱うかって非常に難しいですよね・・・。 今回はJavaScript側の時間操作をサポートするようなヘルパーを作ってみましたが、CUIのツール等だと実行環境側の時間等、いろいろと考慮しないと他にもありそうです。

あとはRspecのhelperまわりの作り方とか、あんまりわかってなかったので勉強になりました。

今回作ったものが少しでも役に立てばなと思いますm( )m

参考

qiita.com

techracho.bpsinc.jp