`ActiveSupport::TimeFormats` / `DateFormats` によるカスタム日付フォーマット登録の刷新
カスタム日付フォーマットを to_fs に追加する際に Time::DATE_FORMATS や Date::DATE_FORMATS といったグローバルなHashを直接書き換える必要がなくなりました。新たに導入された ActiveSupport::TimeFormats.register / ActiveSupport::DateFormats.register を使うことで、ActiveSupport 管理下の名前空間に閉じた形でフォーマットを登録できます。
背景
この変更は、グローバル定数のフリーズ問題をめぐる一連の議論から生まれました。#57332 は Date::DATE_FORMATS に誤って freeze が適用されていた問題を修正したものですが、そもそも「ユーザーがグローバル定数を直接変更する」設計そのものへの懸念が @byroot によるコメントで指摘されました。続いて #57341 がミュータブルな定数の変更を期待するテストを追加し、この設計が変更されることへの地ならしを行っています。
Time::DATE_FORMATS や Date::DATE_FORMATS はグローバルなHashであるため、他のライブラリが同じキーを登録すると衝突が起きる可能性がありました。また、直接書き換えという操作自体がカプセル化を損なっており、テストでは ensure ブロックで後片付けが必要になるなど、副作用のある API 設計でした。
技術的な変更
ActiveSupport::TimeFormats と ActiveSupport::DateFormats という2つのモジュールが新たに追加されました。それぞれ activesupport/lib/active_support/time_formats.rb と activesupport/lib/active_support/date_formats.rb として実装され、active_support.rb の autoload 宣言に追加されています。
各モジュールの実装は共通のパターンに従っています。
module ActiveSupport
module TimeFormats
@list = Time::DATE_FORMATS.dup.freeze
@deprecated_list = Time::DATE_FORMATS
Time.deprecate_constant :DATE_FORMATS
# :nodoc:
def self.lookup(format)
@list[format] || @deprecated_list[format]
end
def self.register(name, format)
@list = @list.merge(name => format).freeze
end
end
end
モジュール初期化時に既存の Time::DATE_FORMATS を dup.freeze してコピーし、元の定数を deprecated_constant として宣言します。lookup メソッドは新しい @list を優先しつつ、旧定数への参照 @deprecated_list にもフォールバックすることで後方互換性を保っています。
register メソッドは @list.merge(name => format).freeze というイミュータブルな更新パターンを採用しています。毎回新しいfreezeされたHashを生成するため、登録後の @list は常にfreezeされた状態が保たれます。
Date#to_fs、Time#to_fs、DateTime#to_fs、ActiveSupport::TimeWithZone#to_fs のすべてで、ルックアップが DATE_FORMATS[format] から ActiveSupport::TimeFormats.lookup(format) / ActiveSupport::DateFormats.lookup(format) に切り替わっています。
# 変更前
def to_fs(format = :default)
if formatter = DATE_FORMATS[format]
formatter.respond_to?(:call) ? formatter.call(self).to_s : strftime(formatter)
else
to_s
end
end
# 変更後
def to_fs(format = :default)
if formatter = ::ActiveSupport::TimeFormats.lookup(format)
formatter.respond_to?(:call) ? formatter.call(self).to_s : strftime(formatter)
else
to_s
end
end
テストコードも新しい API に対応し、グローバルな定数を書き換えて ensure で戻す従来のパターンから、ActiveSupport::TimeFormats.stub(:lookup, ...) を使ったスタブパターンへと刷新されました。これにより、テストの副作用が完全に排除されています。
設計判断
後方互換性と段階的移行 を両立させる設計が採用されています。@deprecated_list として旧定数への参照を保持し続けることで、既存コードは Time::DATE_FORMATS[:custom] = ... という書き方を即座に壊されることなく、非推奨警告を経て次バージョンでの削除に向けた猶予期間が設けられています。
register 後の即時フリーズ という設計も重要な判断です。PR の説明によれば、register が一度でも呼ばれた時点で @list がフリーズされます。これは「新しい API にオプトインしたユーザーは、グローバル定数を直接変更する旧来の方法を混在させるべきでない」という意図の表明です。
ルックアップのロジックに @list[format] || @deprecated_list[format] というフォールバック順序が設けられている点も注目に値します。register で登録されたフォーマットが旧定数の同名エントリより優先されるため、移行期間中に両方のフォーマットが登録されていても新しい API が勝ちます。
まとめ
ActiveSupport::TimeFormats / DateFormats の導入により、カスタム日付フォーマットの登録がグローバルなHashの直接変更からカプセル化された API 呼び出しへと移行しました。旧定数の非推奨化と lookup のフォールバック設計によって段階的な移行を可能にしつつ、イミュータブルな @list 管理によりスレッドセーフ性も向上しています。