小ネタ : 開発環境でRailsアプリの日付を変更する

はじめに

開発環境で特定の日付に移動したい時ってありませんか?

私は チーム開発1のプラクティスを行っていて、「祝日の場合ダッシュボードの表示を変更したい」というIssueに取り組みました。

実装を行って祝日の表示確認を行う際、アプリ内の日付を祝日に変更する必要に迫られたのです。

以下の方法で開発環境の日付を一時的に変更することが可能ですので、今回ご紹介します。

変更方法

config/environments/development.rbに以下を追加して、アプリを再起動する。

require "active_support/core_ext/integer/time" # 追加
require "active_support/testing/time_helpers"  # 追加
include ActiveSupport::Testing::TimeHelpers    # 追加
 
Rails.application.configure do
  config.after_initialize do
    travel_to Time.zone.parse('移動したい日付') # 追加
  end
end

試しに今日の日付を表示する簡単なアプリを作成して、アプリ内の日付が変更されているか確認します。

祝日であるかどうかの確認のために、holiday-jpというGemを利用しています。

(app/views/home/index.slim)

h2 今日の日付

p 
  | 今日の日付は"#{l(Date.current, format: :long)}"です。

p 
  | #{HolidayJp.holiday?(Date.current) ? '今日は祝日です!🥳' : '今日は祝日ではありません🫠'}

h2 現在時刻

p
  | 現在時刻は"#{l(Time.current)}"です。

普通にアプリを起動した場合(日付は9/18日)。

アプリ内の日付を変更した場合(日付を9/25に変更)。

日付が変わっていることが分かります!

何を行っているの?

日付の変更を行うには、travel_toメソッドを利用します。 通常travel_toはテスト内でしか使用することができないため、travel_toを使う下準備として次のことを行います。

  1. travel_toが定義してあるファイル(active_support/testing/time_helpers)をrequireする
  2. モジュール(ActiveSupport::Testing::TimeHelpers)をincludeする

そして、config.after_initializeブロック内でtravel_toを実行し、変更したい日付を渡します。 config.after_initializeブロック内に記述されたコードは、Railsによってアプリケーションの初期化が完了した後に実行されます。

Rails アプリケーションを設定する - Railsガイド

travel_toは、Time.now Date.today DateTime.nowを引数で与えられた時間または日付を返すようにスタブ化することで、現在の時刻を特定の時刻に変更することができます。

スタブはテスト終了時に削除されます。

ActiveSupport::Testing::TimeHelpers

Time.currentとDate.currentについて

RailsAPIのtravel_toの頁に、次のような記述がありました。

Dates are taken as their timestamp at the beginning of the day in the application time zone. Time.current returns said timestamp, and Time.now its equivalent in the system time zone. Similarly, Date.current returns a date equal to the argument, and Date.today the date according to Time.now, which may be different. (Note that you rarely want to deal with Time.now, or Date.today, in order to honor the application time zone please always use Time.current and Date.current.)

簡単にまとめると、「Time.currentDate.currentはアプリケーションのタイムゾーンに応じた時刻と日付を返し、Time.nowDate,todayはシステムのタイムゾーンに応じた時刻と日付を返すため、アプリ内のタイムゾーンを尊重したい場合はTime.currentDate.currentを使いましょう」ということが書かれています。

では、このTime.currentDate.currentという2つのメソッドはどのように現在時刻や日付を返しているのでしょうか? 実装を見てみようと思います。

Time.current

Time.currentは次のようになっています。

    def current
      ::Time.zone ? ::Time.zone.now : ::Time.now
    end

https://github.com/rails/rails/blob/fc734f28e65ef8829a1a939ee6702c1f349a1d5a/activesupport/lib/active_support/core_ext/time/calculations.rb#L39

Time.zoneにはタイムゾーン情報が格納されており、これが存在する場合はTime.zone.nowを、存在しない場合はTime.nowを返しています。

config.time_zoneタイムゾーンを設定すると、Time.zoneは次のようになります。

irb(main):001:0> Time.zone
=> #<ActiveSupport::TimeZone:0x000000010eab9618 @name="Tokyo", @utc_offset=nil, @tzinfo=#<TZInfo::DataTimezone: Asia/Tokyo>>

では、Time.zone.nowを確認しましょう。

    def now
      time_now.utc.in_time_zone(self)
    end

https://github.com/rails/rails/blob/fc734f28e65ef8829a1a939ee6702c1f349a1d5a/activesupport/lib/active_support/values/time_zone.rb#L507

最初にtime_nowメソッドが呼ばれています。 このメソッドはただTime.nowを読んでいるだけです。

      def time_now
        Time.now
      end

https://github.com/rails/rails/blob/fc734f28e65ef8829a1a939ee6702c1f349a1d5a/activesupport/lib/active_support/values/time_zone.rb#L597C1-L599C10

次のutcメソッドですが、おそらくRubyの組み込みライブラリであるTimeクラスのメソッドが呼ばれているのではないかと思います。

タイムゾーン協定世界時に設定します。 このメソッドを呼び出した後は時刻変換を協定世界時として行ないます。

https://docs.ruby-lang.org/ja/latest/method/Time/i/gmtime.html

最後にin_time_zoneメソッドが呼ばれています。 タイムゾーンが存在する場合はActiveSupport::TimeWithZoneインスタンスを作成していることが分かります。

    def in_time_zone(zone = ::Time.zone)
      time_zone = ::Time.find_zone! zone
      time = acts_like?(:time) ? self : nil

      if time_zone
        time_with_zone(time, time_zone)
      else
        time || to_time
      end
    end

    private
      def time_with_zone(time, zone)
        if time
          ActiveSupport::TimeWithZone.new(time.utc? ? time : time.getutc, zone)
        else
          ActiveSupport::TimeWithZone.new(nil, zone, to_time(:utc))
        end
      end

https://github.com/rails/rails/blob/a967d355c6fee9ad9b8bd115d43bc8b0fc207e7e/activesupport/lib/active_support/core_ext/date_and_time/zones.rb#L20

コンソール上で確認すると、確かにActiveSupport::TimeWithZoneインスタンスが返っていますね。 time_zone属性にタイムゾーンの情報も格納されていることもわかります。

irb(main):004:0> Time.current
=> Mon, 18 Sep 2023 14:41:00.422901000 JST +09:00

irb(main):005:0> Time.current.class
=> ActiveSupport::TimeWithZone

irb(main):006:0> Time.current.time_zone
=> #<ActiveSupport::TimeZone:0x000000010e90e908 @name="Tokyo", @utc_offset=nil, @tzinfo=#<TZInfo::DataTimezone: Asia/Tokyo>>

Time.currentの実装を簡単ですが追ってみました。

途中でTime.nowメソッドを読んでおり、travel_toTime.nowをスタブ化するのでTime.currentの時間も変わるということが分かりましたね。

Date.current

ではDate.currentの方も見てみましょう。

Date.currentTime.currentと同じく、タイムゾーンが設定されていればTime.zone.todayを、そうでなければDate.todayを返します。

    def current
      ::Time.zone ? ::Time.zone.today : ::Date.today
    end

https://github.com/rails/rails/blob/fc734f28e65ef8829a1a939ee6702c1f349a1d5a/activesupport/lib/active_support/core_ext/date/calculations.rb#L48C1-L50C8

では、Time.zone.todayはどうなっているでしょうか。

    def today
      tzinfo.now.to_date
    end

https://github.com/rails/rails/blob/fc734f28e65ef8829a1a939ee6702c1f349a1d5a/activesupport/lib/active_support/values/time_zone.rb#L512

tzinfoが呼ばれていますね。

ActiveSupport::TimeZoneが初期化される時に@tzinfoに値が格納されます。

    attr_reader :name
    attr_reader :tzinfo

    def initialize(name, utc_offset = nil, tzinfo = nil)
      @name = name
      @utc_offset = utc_offset
      @tzinfo = tzinfo || TimeZone.find_tzinfo(name)
    end

https://github.com/rails/rails/blob/fc734f28e65ef8829a1a939ee6702c1f349a1d5a/activesupport/lib/active_support/values/time_zone.rb#L294-L305

TimeZone.find_tzinfo内ではTZInfo::Timezone.getが呼ばれています。

    def find_tzinfo(name)
      TZInfo::Timezone.get(MAPPING[name] || name)
    end

https://github.com/rails/rails/blob/fc734f28e65ef8829a1a939ee6702c1f349a1d5a/activesupport/lib/active_support/values/time_zone.rb#L205

TZInfo::Timezone.gettzinfoというGemにより提供されるもので、個別のタイムゾーンを表すTZInfo::Timezoneインスタンスを得ることができます。

A TZInfo::Timezone instance representing an individual time zone can be obtained with TZInfo::Timezone.get

https://github.com/tzinfo/tzinfo#example-usage

Time.zone.today内で呼ばれるtzinfoは、ActiveSupport::TimeZone初期化時に設定されたTZInfo::Timezoneインスタンスであることが分かりました。

tzinfo.nowは次のようになっています。 Time.nowを渡してto_localメソッドを呼び出していますね。

    def now
      to_local(Time.now)
    end

    def to_local(time)
      raise ArgumentError, 'time must be specified' unless time

      Timestamp.for(time) do |ts|
        TimestampWithOffset.set_timezone_offset(ts, period_for(ts).offset)
      end
    end

https://github.com/tzinfo/tzinfo/blob/9953fc092424d55deaea2dcdf6279943f3495724/lib/tzinfo/timezone.rb#L992

https://github.com/tzinfo/tzinfo/blob/9953fc092424d55deaea2dcdf6279943f3495724/lib/tzinfo/timezone.rb#L548

最後にto_dateメソッドを呼び出し、日付の文字列が返ってくるようになっています。

次のサンプルコードを実行してわかる通り、タイムゾーンに合わせた現在日時を取得していることが分かりますね。

irb(main):001:0> tz = TZInfo::Timezone.get('America/New_York')
=> #<TZInfo::DataTimezone: America/New_York>
irb(main):002:0> tz.now
=> 2023-09-18 02:25:04.614519 -0400
irb(main):003:0> tz.now.to_date
=> Mon, 18 Sep 2023

irb(main):004:0> tz_japan = TZInfo::Timezone.get('Asia/Tokyo')
=> #<TZInfo::DataTimezone: Asia/Tokyo>
irb(main):005:0> tz_japan.now
=> 2023-09-18 15:24:17.173699 +0900
irb(main):006:0> tz_japan.now.to_date
=> Mon, 18 Sep 2023

Date.currentの実装も簡単ですが追ってみました。 Date.currentも途中でTime.nowを読んでおり、travel_toの影響を受けることが分かりました。

終わりに

Railsアプリケーションの開発環境で日付を変更する方法と、Time.currentDate.currentについて書きました。

Rails内のコードを読むのは難しく完璧に理解はできませんでしたが、いい勉強になりました!

また気になるコードがあれば、コードリーディングに挑戦してみたいと思います💪

参考文献

RubyとRailsにおけるTime, Date, DateTime, TimeWithZoneの違い - Qiita


  1. フィヨルドブートキャンプの終盤にあるプラクティスで、フィヨルドブートキャンプアプリ内で振られたIssueの開発を行う、ということを行います。