i18n gemを触ってみる

internationalizationは国際化という意味

i18nは数略語で、iとnの間の文字が18文字あったので、18が入っている。
ソフトウェアの国際化とは、どんな国や地域のプラットフォーム上でも、ソフトウェアの同様な動作・機能を保障することを指しします。例えば、米国で開発されたソフトウェアを日本語で使う場合だけではなく、日本で開発されたソフトウェアを中国で同様の動きを実現することも国際化と言います。ソフトウェアの国際化の範囲は幅広く、ロケール単位でのリソース情報を外部ファイル化することや、ユニコードやネイティブキャラクタセットなど異なるコードセットでのデータ表示、日付、時間、通貨などのフォーマット、各言語による並び替えや検索、フォントの取り扱い、データベースなどへのデータの格納方法、異なるキャラクタセットのコード変換、などなど多岐に渡ります。アプリケーション開発におけるソフトウェアの国際化の例をいくつかピックアップして、次回にご紹介したいと思います。
ソフトウェアの国際化とは、どんな国でもソフトウェアの同様な動作を保証すること。
この言い方が一番わかりやすいな。
そもそもソフトウェアをグローバルに展開したい、もしくはいろんな国の人に提供したいって、目的があるから、国際化対応する必要がある。
かつては、ソフトウェアの地域化や多言語化のために必要に応じて機能的な変更や拡張が行われてきた。しかし、このようなやり方ではソフトウェアの規模拡大や対応する言語が多くなるに従い、開発や保守に多くの時間と費用がかかるため対応が難しくなる。1990年代にプログラミング言語やオペレーティングシステムの国際化対応が標準化されると、ソフトウェアを予め国際化することにより、地域固有データの追加や最小限の変更で地域化が行われるようになった。
ソフトウェアをあらかじめ国際化する前提で作っておいて、地域特有のデータを管理して、地域ごとに表示を変えるやり方を採用した方が、今後国際化する上で楽になるから圧倒的に良い。

ソフトウェアの既存コードを一切変えずに、言語ごとの設定加えたら、その言語に合うように表示を変化(= ソフトウェアの振る舞いを変化)できるなら、そっちの方が圧倒的に楽。

Rails 国際化(I18n)API
RubyのI18n(国際化・多言語化: internationalizationの略)gemはRuby on Rails 2.2以降からRailsに同梱されています。このgemは、アプリケーションの文言を英語以外の別の1つの言語に翻訳する機能や、多言語サポート機能を簡単かつ拡張可能な方式で導入するためのフレームワークを提供します。
アプリケーションの「国際化」プロセスといえば、使われるすべての文言やロケール固有の要素(日付や通貨フォーマットなど)の抽象化までの作業を指すのが普通です。一方、「ローカライズ(localization)」とは、具体的な翻訳方法を提供したり、そのためのフォーマットを提供したりすることを指します。
Rubyの国際化と地域化(i18n)ソリューション。
現在、@radarによってメンテナンスされています。
i18n gemはwebアプリケーションの国際化と地域化に関する機能を提供しているgem
Railsアプリケーションを国際化するプロセスでは、以下を行う必要があります。
- i18nを確実にサポートすること。
- ロケール辞書の置き場所をRailsに指示すること。
- ロケールの設定・保存・切替方法をRailsに指示すること。
Railsアプリケーションをローカライズするプロセスでは、おそらく以下の3つの作業が必要となるでしょう。
- Railsのデフォルトロケールの差し替えまたはロケールの追加。日付や時刻のフォーマット、月の呼称、Active Recordモデル名などが対象。
- アプリケーションで使われる文字列を抽象化し、キーで検索できる辞書に保存する。フラッシュメッセージやビュー内の固定テキストなどが対象。
- 作成された辞書を別の場所に保存する。

Railsアプリケーションを国際化するには、
Railsアプリケーションに国際化の設定を追加して、あとは地域ごとのローカライズ設定を作れば良い。

lはlocalizeの略でtはtranslateの略だったのか

config/locales以下にあるすべての.rbファイルと.ymlファイルは、自動的に訳文読み込みパスに追加されます。
そういえばconfigにlocalesディレクトリってあったな
訳文読み込みパスってなんだ

en:
hello: "Hello world"
上の例は、「:enというロケールでは、"hello"というキーは"Hello world"という文字列に対応付けられる」という意味です。Rails内部の文字列はすべてこのように国際化されています
わかりやすい

I18nライブラリでは、Englishをデフォルトのロケールとして扱います。デフォルトのロケールに他の言語を指定しなかった場合は、訳文の検索に:enが使われます。
そうだったのか

訳文読み込みパス(I18n.load_path)はファイルへのパスの配列であり、自動的に読み込まれます。このパスを設定することで、訳文のディレクトリ構造やファイル命名スキームをカスタマイズできます。
irb(main):002:0> I18n.load_path
=>
["/usr/local/bundle/gems/activesupport-6.1.7.8/lib/active_support/locale/en.yml",
"/usr/local/bundle/gems/activesupport-6.1.7.8/lib/active_support/locale/en.rb",
"/usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/locale/en.yml",
"/usr/local/bundle/gems/activerecord-6.1.7.8/lib/active_record/locale/en.yml",
"/usr/local/bundle/gems/actionview-6.1.7.8/lib/action_view/locale/en.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/ar.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/bg.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/ca-CAT.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/ca.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/da-DK.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/de-AT.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/de-CH.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/de.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/ee.yml",
......
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/tr.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/uk.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/vi.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/zh-CN/bank.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/zh-CN.yml",
"/usr/local/bundle/gems/faker-3.3.1/lib/locales/zh-TW.yml",
"/var/app/config/locales/en.yml",
"/usr/local/bundle/gems/web-console-4.2.1/lib/web_console/locales/en.yml"]
irb(main):003:0>
思ってたよりめちゃ訳文読み込みパス出てきたな。

I18のバックエンドは、訳文が初めて参照されるときに遅延読み込みを行います。これにより、訳文を既に公開した後でもバックエンドを他のものに差し替えることができます。
遅延読み込みなのか

1.1 ライブラリのアーキテクチャ概観
以上のことから、RubyのI18nのgemは以下の2つに分けられます。
- I18nフレームワークのpublic API : ライブラリの動作を定義するpublicメソッドを持つRubyモジュール
- 上記メソッドを実装する、デフォルトのシンプルな(あえてこう呼びます)バックエンド
I18nのユーザーとしては、I18nモジュールのパブリックなメソッドにだけアクセスするのが筋ですが、バックエンドの機能についても知っておくと何かと便利です。
アーキテクチャとしては、public APIの提供とメソッドの実装って感じか。

2.2 リクエスト間でロケールを管理する
ローカライズされたアプリケーションは、将来複数ロケールのサポートが必要になるかもしれません。これを行うためには、各リクエストの冒頭にロケールを設定して、リクエストが持続する間はすべての文字列が指定のロケールで翻訳されるようにしておくべきです。
I18n.locale=やI18n.with_localeを使わない場合、すべての訳文でデフォルトのロケールが使われます。
I18n.localeの設定がすべてのコントローラで一貫していないと、同じスレッドやプロセスによって処理される今後のリクエストにI18n.localeが漏出する可能性があります。たとえば、あるPOSTリクエストでI18n.locale = :esを実行すると、ロケールを設定していないコントローラで以後のすべてのリクエストに効いてしまいます。こうした理由から、I18n.locale =の代わりに、漏出が発生しないI18n.with_localeを利用することもできます。
ロケールはApplicationControllerのaround_actionで設定できます。
I18n.localeだと、一度I18n.localeを実行すると、そのプロセスにおける以降のリクエストはそのlocaleで処理されてしまうのか。それの対策としてI18n.with_localeを使うと良い。

見せ方、つまりプレゼンテーションを変えるために、要は18n gemはあるのか

swith_localeして見せ方変えるには、どんな言語に変えるかって情報が必要だから、
言語のパラメータをフロントから送るか、ユーザーまたは会社に表示言語を紐付ける必要ありそう。

開発者は、選択したロケールをセッションやcookieに保存したくなる誘惑にかられるかもしれません。しかしこれは行ってはいけません。ロケールは透過的にすべきであり、かつURLの一部に含めるべきです。そうすることで、ユーザーがWeb自体に対して抱く基本的な前提を崩さずに済みます。あなたがそのURLを知人に送れば、あなたが見ているのとまったく同じページとコンテンツを知人も見ることができます。この前提を表す重要な言い回しが「RESTfulである」ということです。RESTfulアプローチについて詳しくは、Stefan Tilkovの記事を参照してください。RESTfulというルールから外れる場合もありますが、それについては後述します。
なるほど
確かに、クッキーとかセッションにロケール保存してたら、全く同じページを見れないよね。

Railsはt(translate)ヘルパーメソッドを自動的にビューに追加するので、I18n.tのように書かずに済みます。さらに、このヘルパーは訳文が見つからない場合にエラーメッセージを<span class="translation_missing">でラップして表示してくれます。
あー、ビューだけヘルパーメソッド提供してたから、tでかけるのか。だからコントローラとかモデルはI18nが必要だったんだ。

=> []
irb(main):005:0> I18n.locale
=> :en
irb(main):006:0>
irb(main):007:0> I18n.t("en.hello")
=> "Translation missing: en.en.hello"
irb(main):008:0> I18n.t("hello")
=> "Hello world"
irb(main):009:0> I18n.with_locale("ja")
/usr/local/bundle/gems/i18n-1.14.6/lib/i18n.rb:353:in `with_locale': no block given (yield) (LocalJumpError)
irb(main):010:0> I18n.locale = "ja"
=> "ja"
irb(main):011:0> I18n.t("hello")
=> "Translation missing: ja.hello"
irb(main):012:0> I18n.locale
=> :ja
irb(main):013:0>
言語ファイルがないとこうなる

config/locales/ja.ymlを追加
ja:
hello: "こんにちわ"

"/var/app/config/locales/en.yml",
"/var/app/config/locales/ja.yml",
I18n.load_pathで追加されていることを確認。

きた
irb(main):002:0> I18n.locale
=> :en
irb(main):004:0> I18n.locale = :ja
=> :ja
irb(main):005:0> I18n.t("hello")
=> "こんにちわ"
irb(main):006:0>

ロケールの訳が定義されてないとこうなる。
irb(main):008:0> I18n.t("message.coffee")
=> "Translation missing: ja.message.coffee"
irb(main):009:0>

新しく追加したロケールファイルは、サーバーを再起動するまで反映されません。
config/application.rbがrailsアプリケーション起動時にしか呼ばれないからが、理由な気がする

<!-- app/views/products/show.html.erb -->
<%= t('product_price', price: @product.price) %>
# config/locales/en.yml
en:
product_price: "$%{price}"
こんな感じで変数渡せるのか

日付・時刻のローカライズでlメソッドを使うのか。strftimeとかもあった気がする

アプリケーションで使われるすべての訳文をロケールごとに1つのファイルに保存すると、サイズが大きくなったときに管理が困難になる可能性があります。このため、訳文ファイルを階層化してわかりやすく保存できるようになっています。
確かに。
たとえば、config/localesディレクトリ以下を以下のように編成できます。
|-defaults
|---es.yml
|---en.yml
|-models
|---book
|-----es.yml
|-----en.yml
|-views
|---defaults
|-----es.yml
|-----en.yml
|---books
|-----es.yml
|-----en.yml
|---users
|-----es.yml
|-----en.yml
|---navigation
|-----es.yml
|-----en.yml

訳文を参照するキーには、シンボルと文字列のどちらでも使えます。したがって、以下の2つの呼び出しは等価です。
I18n.t :message
I18n.t "message"
どっちも行けたのか

irb(main):009:0> I18n.t :record_invalid, scope: [:activerecord, :errors, :messages]
=> "Translation missing: ja.activerecord.errors.messages.record_invalid"
irb(main):010:0>
スコープオプションでわざわざ.で繋げて描かなくても良くなるのか。
下とやってることは同じ。
I18n.translate "activerecord.errors.messages.record_invalid"
I18n.t "activerecord.errors.messages.record_invalid"
I18n.t "errors.messages.record_invalid", scope: :activerecord
I18n.t :record_invalid, scope: "activerecord.errors.messages"
I18n.t :record_invalid, scope: [:activerecord, :errors, :messages]

4.1.4 遅延探索
Railsには、ロケールを「ビュー」内部で参照するときに便利な方法が実装されています。以下のような辞書があるとします。
es:
books:
index:
title: "Título"
以下のように、app/views/books/index.html.erbビューテンプレート内部でbooks.index.title値にアクセスできます。ドットが使われていることにご注目ください。
<%= t '.title' %>
パーシャルによる自動訳文スコープは、translateビューヘルパーメソッドでのみ使えます。
ビューヘルパーでこういうこともできたのか。

config/application.rbに許可する言語の設定とデフォルトの言語追加して、
コントローラにリクエストごとの言語切り替える処理入れれば、あとはロケールファイル追加していけば、簡単にその地域に対応したアプリケーションを作れる。
ロケールファイル追加した後に、必ずI18n.load_pathを見て、ロケールファイルが読み込まれているか確認する。このロケールファイルが読み込まれていたら、i18n.localeでアプリケーションで使う言語切り替えて、tメソッドを使った時に、そのキーとロケールに該当するパスのロケールファイルが参照されるようになる。

モデルとエラーとメーラーだけ後でやる

4.5 Active Recordモデルで翻訳を行なう
Model.model_name.humanメソッドとModel.human_attribute_name(attribute)メソッドを使うことで、モデル名と属性名を透過的に参照できるようになります。
ディレクトリ構造が大事なのではなくて、ymlの構造が大事。
ローカルにyml定義することで、ymlの構造のキーをオーバーライドしている。
frozen_string_literal: true
module ActiveModel
# == Active \Model \Translation
#
# Provides integration between your object and the Rails internationalization
# (i18n) framework.
#
# A minimal implementation could be:
#
# class TranslatedPerson
# extend ActiveModel::Translation
# end
#
# TranslatedPerson.human_attribute_name('my_attribute')
# # => "My attribute"
#
# This also provides the required class methods for hooking into the
# Rails internationalization API, including being able to define a
# class based +i18n_scope+ and +lookup_ancestors+ to find translations in
# parent classes.
module Translation
include ActiveModel::Naming
# Returns the +i18n_scope+ for the class. Overwrite if you want custom lookup.
def i18n_scope
:activemodel
end
# When localizing a string, it goes through the lookup returned by this
# method, which is used in ActiveModel::Name#human,
# ActiveModel::Errors#full_messages and
# ActiveModel::Translation#human_attribute_name.
def lookup_ancestors
ancestors.select { |x| x.respond_to?(:model_name) }
end
# Transforms attribute names into a more human format, such as "First name"
# instead of "first_name".
#
# Person.human_attribute_name("first_name") # => "First name"
#
# Specify +options+ with additional translating options.
def human_attribute_name(attribute, options = {})
options = { count: 1 }.merge!(options)
parts = attribute.to_s.split(".")
attribute = parts.pop
namespace = parts.join("/") unless parts.empty?
attributes_scope = "#{i18n_scope}.attributes"
if namespace
defaults = lookup_ancestors.map do |klass|
:"#{attributes_scope}.#{klass.model_name.i18n_key}/#{namespace}.#{attribute}"
end
defaults << :"#{attributes_scope}.#{namespace}.#{attribute}"
else
defaults = lookup_ancestors.map do |klass|
:"#{attributes_scope}.#{klass.model_name.i18n_key}.#{attribute}"
end
end
debugger
defaults << :"attributes.#{attribute}"
defaults << options.delete(:default) if options[:default]
defaults << attribute.humanize
options[:default] = defaults
I18n.translate(defaults.shift, **options)
end
end
end

(byebug) defaults << attribute.humanize
[:"activerecord.attributes.user.last_name", :"attributes.last_name", "Last name"]

"activerecord.attributes.user.last_name"の構造を定義していれば、それが参照される。config/locatlesのディレクトリの構造は関係ない。それよりもymlの構造が大事。
それが参照する時のキーになるから。
config/locales/models/user/en.yml
en:
activerecord:
models:
user: "User"
attributes:
user:
first_name: "First First Name"
last_name: "Last Last Name"
email: "Email Address"
(byebug) { **options }
{:count=>1}
(byebug) I18n.translate(defaults.shift, **options)
"Translation missing: en.attributes.last_name"
(byebug) defaults.shift
"Last name"
(byebug) I18n.translate("activerecord.attributes.user.last_name", **options)
"Last Last Name"

config/locales/models/user/en.ymlを消してみると、default値が参照される。
irb(main):001:0> User.human_attribute_name(:last_name)
[58, 67] in /usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/translation.rb
58: :"#{attributes_scope}.#{klass.model_name.i18n_key}.#{attribute}"
59: end
60: end
61:
62: debugger
=> 63: defaults << :"attributes.#{attribute}"
64: defaults << options.delete(:default) if options[:default]
65: defaults << attribute.humanize
66:
67: options[:default] = defaults
(byebug) defaults << :"attributes.#{attribute}"
[:"activerecord.attributes.user.last_name", :"attributes.last_name"]
(byebug) defaults << options.delete(:default) if options[:default]
nil
(byebug) defaults << attribute.humanize
[:"activerecord.attributes.user.last_name", :"attributes.last_name", "Last name"]
(byebug) options[:default] = defaults
[:"activerecord.attributes.user.last_name", :"attributes.last_name", "Last name"]
(byebug) I18n.translate("activerecord.attributes.user.last_name", **options)
"Last name"
(byebug)
/usr/local/bundle/gems/activerecord-6.1.7.8/lib/active_record/locale/en.ymlにactiverecord.attributesは定義されているが、activerecord.attributes.user.last_nameは定義されてない。
en:
# Attributes names common to most models
#attributes:
#created_at: "Created at"
#updated_at: "Updated at"
# Default error messages
errors:
messages:
required: "must exist"
taken: "has already been taken"
# Active Record models configuration
activerecord:
errors:
messages:
record_invalid: "Validation failed: %{errors}"
#attributes:
# For example,

デフォルト値変えてみる
(byebug) I18n.translate("attributes.last_name")
"Translation missing: en.attributes.last_name"
(byebug) options
{:count=>1, :default=>[:"activerecord.attributes.user.last_name", :"attributes.last_name", "Last name"]}
(byebug) options[:default]
[:"activerecord.attributes.user.last_name", :"attributes.last_name", "Last name"]
(byebug) options[:default].pop
"Last name"
(byebug) options[:default]
[:"activerecord.attributes.user.last_name", :"attributes.last_name"]
(byebug) options[:default].push("yakunashi")
[:"activerecord.attributes.user.last_name", :"attributes.last_name", "yakunashi"]
(byebug) I18n.translate("attributes.last_name")
"Translation missing: en.attributes.last_name"
(byebug) I18n.translate("attributes.last_name", defaults)
*** ArgumentError Exception: wrong number of arguments (given 2, expected 0..1)
nil
(byebug) I18n.translate("attributes.last_name", **options)
"yakunashi"

:defaultオプションが与えられると、訳文が見つからない場合にここで指定した値が返されます。
I18n.t :missing, default: "【訳文なし】"
# => '【訳文なし】'
:defaultオプションに与えられる値がシンボルの場合、キーとして使われ、訳文に置き換えられます。複数の値をデフォルトとして指定できます。複数の場合、最初に返された値が返されます。
例: 以下では最初に:missingというキーを訳文に置き換えようとし、続いて:also_missingというキーを置き換えようとします。ここではどちらからも結果を得られないので、「【訳文なし】」という文字列が返されます。
I18n.t :missing, default: [:also_missing, "【訳文なし】"]
# => '【訳文なし】'
シンボルだとキーとして扱われる。

humanizeは、最初の単語を大文字にしてアンダーバーをスペースへ変換し末尾の_idを削除

instance method Object#respond_to?は、レシーバがそのメソッドを持つ時にtrueを返す。
def lookup_ancestors
ancestors.select { |x| x.respond_to?(:model_name) }
end
ancestorsメソッドを使えば、継承チェーンを見れる。

class Pathnameは、パス名をオブジェクトとして扱うクラス。

Dirの[]はワイルドカードの展開を行い、パターンにマッチするファイル名を文字列の配列として返す。
pathにある**は任意の階層を表している。
irb(main):002:0> Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}")]
=> ["/var/app/config/locales/en.yml", "/var/app/config/locales/ja.yml", "/var/app/config/locales/models/user/ja.yml"]

あるメソッドをデバッグして挙動をチェックしたい時は、まずmethodとsource_locationを使って、そのメソッドの定義場所を見つける。methodのレシーバーは引数のメソッドを持つオブジェクトを指定する。
irb(main):004:0> User.method(:human_attribute_name).source_location
=> ["/usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/translation.rb", 44]
上の場合、activemodel gemにメソッドが定義されているから、
vim "/usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/translation.rb"でそのパスを見て、あとはdebuggerを差し込めば良い。
debuggerを差し込んだあと、もう一度サーバーを立ち上げる必要がある。
bundle infoとかactivemodelがローカル(またはコンテナ)のどこにあるかを見れたりもする。
github上のどこにあるかと、チェンジログがどこにあるかもわかる。
bash-5.1# bundle info activemodel
* activemodel (6.1.7.8)
Summary: A toolkit for building modeling frameworks (part of Rails).
Homepage: https://rubyonrails.org
Documentation: https://api.rubyonrails.org/v6.1.7.8/
Source Code: https://github.com/rails/rails/tree/v6.1.7.8/activemodel
Changelog: https://github.com/rails/rails/blob/v6.1.7.8/activemodel/CHANGELOG.md
Bug Tracker: https://github.com/rails/rails/issues
Mailing List: https://discuss.rubyonrails.org/c/rubyonrails-talk
Path: /usr/local/bundle/gems/activemodel-6.1.7.8
Reverse Dependencies:
activerecord (6.1.7.8) depends on activemodel (= 6.1.7.8)
rails (6.1.7.8) depends on activemodel (= 6.1.7.8)
web-console (4.2.1) depends on activemodel (>= 6.0.0)
bash-5.1#

# == Schema Information
#
# Table name: users
#
# id :bigint not null, primary key
# crypted_password :string
# email :string not null
# first_name :string not null
# last_name :string not null
# salt :string
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_users_on_email (email) UNIQUE
#
class User < ApplicationRecord
# Userモデルにソーサリーを使用していることを知らせる
authenticates_with_sorcery!
has_many :products, dependent: :destroy
validates :password, length: { minimum: 3 }, if: -> { new_record? || changes[:crypted_password] }
# confirmation:このバリデーションは仮想の属性を作成します
# https://railsguides.jp/active_record_validations.html#confirmation
validates :password, confirmation: true, if: -> { new_record? || changes[:crypted_password] }
validates :password_confirmation, presence: true, if: -> { new_record? || changes[:crypted_password] }
validates :email, uniqueness: true
validates :email, presence: true
end
irb(main):001:0> User.new(password: "hoge", password_confirmation: "hoge")
irb(main):002:0*
=> #<User:0x00007f2992f50790 id: nil, email: nil, first_name: nil, last_name: nil, created_at: nil, updated_at: nil, crypted_password: nil, salt: nil>
irb(main):003:0> user = User.new(password: "hoge", password_confirmation: "hoge")
irb(main):004:0*
=> #<User:0x00007f298f91d8d0 id: nil, email: nil, first_name: nil, last_name: nil, created_at: nil, updated_at: nil, crypted_password: nil, salt: nil>
irb(main):005:0> user.valid?
User Exists? (1.1ms) SELECT 1 AS one FROM "users" WHERE "users"."email" IS NULL LIMIT $1 [["LIMIT", 1]]
=> false
irb(main):006:0> user.errors.full_messages
=> ["Email can't be blank"]
irb(main):001:0> user = User.new(password: "hoge", password_confirmation: "hoge")
=> #<User:0x00007f4a3fcb7448 id: nil, email: nil, first_name: nil, last_name: nil, created_at: nil, updated_at: nil, crypted_password: nil, salt: nil>
irb(main):002:0> user.valid?
User Exists? (0.9ms) SELECT 1 AS one FROM "users" WHERE "users"."email" IS NULL LIMIT $1 [["LIMIT", 1]]
=> false
irb(main):003:0> user.errors.full_messages
[474, 483] in /usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/errors.rb
474: # person = Person.create(address: '123 First St.')
475: # person.errors.full_messages
476: # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Email can't be blank"]
477: def full_messages
478: debugger
=> 479: @errors.map(&:full_message)
480: end
481: alias :to_a :full_messages
482:
483: # Returns all the full error messages for a given attribute in an array.
(byebug) @errors
[#<ActiveModel::Error attribute=email, type=blank, options={}>]
(byebug) @errors[0]
#<ActiveModel::Error attribute=email, type=blank, options={}>
(byebug) @errors[0].full_message
"Email can't be blank"
(byebug) @errors[0].method(:full_message).source_location
["/usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/error.rb", 158]
(byebug) exit
bash-5.1# vim v
bash-5.1# vim "/usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/error.rb"
bash-5.1# exit
exit
errorの方にデバッガー仕込まないといけなかったのか。
次それやる

irb(main):001:0> user = User.new(password: "hoge", password_confirmation: "hoge")
=> #<User:0x00007f62e45b50d8 id: nil, email: nil, first_name: nil, last_name: nil, created_at: nil, updated_at: nil, crypted_password: nil, salt: nil>
irb(main):002:0> user.valid?
User Exists? (0.8ms) SELECT 1 AS one FROM "users" WHERE "users"."email" IS NULL LIMIT $1 [["LIMIT", 1]]
=> false
irb(main):003:0> user.errors
=>
#<ActiveModel::Errors:0x00007f62e55b7f80
@base=
#<User:0x00007f62e45b50d8
id: nil,
email: nil,
first_name: nil,
last_name: nil,
created_at: nil,
updated_at: nil,
crypted_password: "$2a$10$fX2JPPXeuAH..k2PGH7OXOoZA7vo.stINjxjnzK6N/msPsw60Kgga",
salt: "B2TQEuXN4QZ2K6nh_zWY">,
@errors=[#<ActiveModel::Error attribute=email, type=blank, options={}>]>
irb(main):004:0> user.errors.full_messages
[156, 165] in /usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/error.rb
156: # error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
157: # error.full_message
158: # # => "Name is too short (minimum is 5 characters)"
159: def full_message
160: debugger
=> 161: self.class.full_message(attribute, message, @base)
162: end
163:
164: # See if error matches provided +attribute+, +type+ and +options+.
165: #
(byebug) self.class
ActiveModel::Error
(byebug) attribute
:email
(byebug) message
"can't be blank"
(byebug) @base
#<User id: nil, email: nil, first_name: nil, last_name: nil, created_at: nil, updated_at: nil, crypted_password: "$2a$10$fX2JPPXeuAH..k2PGH7OXOoZA7vo.stINjxjnzK6N/m...", salt: "B2TQEuXN4QZ2K6nh_zWY">
(byebug)

# frozen_string_literal: true
require "active_support/core_ext/class/attribute"
module ActiveModel
# == Active \Model \Error
#
# Represents one single error
class Error
CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict]
MESSAGE_OPTIONS = [:message]
class_attribute :i18n_customize_full_message, default: false
def self.full_message(attribute, message, base) # :nodoc:
return message if attribute == :base
base_class = base.class
attribute = attribute.to_s
if i18n_customize_full_message && base_class.respond_to?(:i18n_scope)
attribute = attribute.remove(/\[\d+\]/)
parts = attribute.split(".")
attribute_name = parts.pop
namespace = parts.join("/") unless parts.empty?
attributes_scope = "#{base_class.i18n_scope}.errors.models"
if namespace
defaults = base_class.lookup_ancestors.map do |klass|
[
:"#{attributes_scope}.#{klass.model_name.i18n_key}/#{namespace}.attributes.#{attribute_name}.format",
:"#{attributes_scope}.#{klass.model_name.i18n_key}/#{namespace}.format",
]
end
else
defaults = base_class.lookup_ancestors.map do |klass|
[
:"#{attributes_scope}.#{klass.model_name.i18n_key}.attributes.#{attribute_name}.format",
:"#{attributes_scope}.#{klass.model_name.i18n_key}.format",
]
end
end
defaults.flatten!
else
defaults = []
end
defaults << :"errors.format"
defaults << "%{attribute} %{message}"
attr_name = attribute.tr(".", "_").humanize
attr_name = base_class.human_attribute_name(attribute, {
default: attr_name,
base: base,
})
I18n.t(defaults.shift,
default: defaults,
attribute: attr_name,
message: message)
end
def self.generate_message(attribute, type, base, options) # :nodoc:
type = options.delete(:message) if options[:message].is_a?(Symbol)
value = (attribute != :base ? base.read_attribute_for_validation(attribute) : nil)
options = {
model: base.model_name.human,
attribute: base.class.human_attribute_name(attribute, { base: base }),
value: value,
object: base
}.merge!(options)
if base.class.respond_to?(:i18n_scope)
i18n_scope = base.class.i18n_scope.to_s
attribute = attribute.to_s.remove(/\[\d+\]/)
defaults = base.class.lookup_ancestors.flat_map do |klass|
[ :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}",
:"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.#{type}" ]
end
defaults << :"#{i18n_scope}.errors.messages.#{type}"
catch(:exception) do
translation = I18n.translate(defaults.first, **options.merge(default: defaults.drop(1), throw: true))
return translation unless translation.nil?
end unless options[:message]
else
defaults = []
end
defaults << :"errors.attributes.#{attribute}.#{type}"
defaults << :"errors.messages.#{type}"
key = defaults.shift
defaults = options.delete(:message) if options[:message]
options[:default] = defaults
I18n.translate(key, **options)
end
def initialize(base, attribute, type = :invalid, **options)
@base = base
@attribute = attribute
@raw_type = type
@type = type || :invalid
@options = options
end
def initialize_dup(other) # :nodoc:
@attribute = @attribute.dup
@raw_type = @raw_type.dup
@type = @type.dup
@options = @options.deep_dup
end
# The object which the error belongs to
attr_reader :base
# The attribute of +base+ which the error belongs to
attr_reader :attribute
# The type of error, defaults to +:invalid+ unless specified
attr_reader :type
# The raw value provided as the second parameter when calling +errors#add+
attr_reader :raw_type
# The options provided when calling +errors#add+
attr_reader :options
# Returns the error message.
#
# error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
# error.message
# # => "is too short (minimum is 5 characters)"
def message
case raw_type
when Symbol
self.class.generate_message(attribute, raw_type, @base, options.except(*CALLBACKS_OPTIONS))
else
raw_type
end
end
# Returns the error details.
#
# error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
# error.details
# # => { error: :too_short, count: 5 }
def details
{ error: raw_type }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS))
end
alias_method :detail, :details
# Returns the full error message.
#
# error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
# error.full_message
# # => "Name is too short (minimum is 5 characters)"
def full_message
self.class.full_message(attribute, message, @base)
end
# See if error matches provided +attribute+, +type+ and +options+.
#
# Omitted params are not checked for a match.
def match?(attribute, type = nil, **options)
if @attribute != attribute || (type && @type != type)
return false
end
options.each do |key, value|
if @options[key] != value
return false
end
end
true
end
# See if error matches provided +attribute+, +type+ and +options+ exactly.
#
# All params must be equal to Error's own attributes to be considered a
# strict match.
def strict_match?(attribute, type, **options)
return false unless match?(attribute, type)
options == @options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS)
end
def ==(other) # :nodoc:
other.is_a?(self.class) && attributes_for_hash == other.attributes_for_hash
end
alias eql? ==
def hash # :nodoc:
attributes_for_hash.hash
end
def inspect # :nodoc:
"#<#{self.class.name} attribute=#{@attribute}, type=#{@type}, options=#{@options.inspect}>"
end
protected
def attributes_for_hash
[@base, @attribute, @raw_type, @options.except(*CALLBACKS_OPTIONS)]
end
end
end

irb(main):001:0> user = User.new(password: "hoge", password_confirmation: "hoge")
=> #<User:0x00007fb68c77a480 id: nil, email: nil, first_name: nil, last_name: nil, created_at: nil, updated_at: nil, crypted_password: nil, salt: nil>
irb(main):002:0> user.valid?
User Exists? (0.8ms) SELECT 1 AS one FROM "users" WHERE "users"."email" IS NULL LIMIT $1 [["LIMIT", 1]]
=> false
irb(main):003:0> user.errors.full_messages
irb(main):003:0> user.errors.full_messages
[92, 101] in /usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/error.rb
92:
93: defaults << :"errors.attributes.#{attribute}.#{type}"
94: defaults << :"errors.messages.#{type}"
95:
96: debugger
=> 97: key = defaults.shift
98: defaults = options.delete(:message) if options[:message]
99: options[:default] = defaults
100:
101: I18n.translate(key, **options)
(byebug) defaults
[:"activerecord.errors.models.user.attributes.email.blank", :"activerecord.errors.models.user.blank", :"activerecord.errors.messages.blank", :"errors.attributes.email.blank", :"errors.messages.blank"]
(byebug) base.class
User(id: integer, email: string, first_name: string, last_name: string, created_at: datetime, updated_at: datetime, crypted_password: string, salt: string)
(byebug) User.i18n_scope
:activerecord
(byebug)
message呼び出す時に
generate_message呼び出しているが、そこでどのパスの候補出して参照してた。
この場合、エラーメッセージのキーは:blankになります。この例では、以下のキーを記載順に探索し、最初に見つかったものを結果として返します。
activerecord.errors.models.user.attributes.name.blank
activerecord.errors.models.user.blank
activerecord.errors.messages.blank
errors.attributes.name.blank
errors.messages.blank
これと同じ順番だった。

"/usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/locale/en.yml"
en:
errors:
# The default format to use in full error messages.
format: "%{attribute} %{message}"
# The values :model, :attribute and :value are always available for interpolation
# The value :count is available when applicable. Can be used for pluralization.
messages:
model_invalid: "Validation failed: %{errors}"
inclusion: "is not included in the list"
exclusion: "is reserved"
invalid: "is invalid"
confirmation: "doesn't match %{attribute}"
accepted: "must be accepted"
empty: "can't be empty"
blank: "can't be blank"
present: "must be blank"
too_long:
one: "is too long (maximum is 1 character)"
other: "is too long (maximum is %{count} characters)"
too_short:
one: "is too short (minimum is 1 character)"
other: "is too short (minimum is %{count} characters)"
wrong_length:
one: "is the wrong length (should be 1 character)"
other: "is the wrong length (should be %{count} characters)"
not_a_number: "is not a number"
not_an_integer: "must be an integer"
greater_than: "must be greater than %{count}"
greater_than_or_equal_to: "must be greater than or equal to %{count}"
equal_to: "must be equal to %{count}"
less_than: "must be less than %{count}"
less_than_or_equal_to: "must be less than or equal to %{count}"
other_than: "must be other than %{count}"
odd: "must be odd"
even: "must be even"
en.ymlに変数付きの訳文を定義できる。呼び出す時にattributeとnameを渡せばその変数の内容で翻訳できる。
I18n gemには「変数の式展開」機能が含まれており、訳文定義で変数を使えるようにし、翻訳メソッドでそれらに値を渡せるようにします。
<!-- app/views/products/show.html.erb -->
<%= t('product_price', price: @product.price) %>
# config/locales/en.yml
en:
product_price: "$%{price}"

irb(main):006:0> user.errors.full_messages
[54, 63] in /usr/local/bundle/gems/activemodel-6.1.7.8/lib/active_model/error.rb
54: default: attr_name,
55: base: base,
56: })
57:
58: debugger
=> 59: I18n.t(defaults.shift,
60: default: defaults,
61: attribute: attr_name,
62: message: message)
63: end
(byebug) defaults
[:"errors.format", "%{attribute} %{message}"]
(byebug) attr_name
"Email"
(byebug) message
"can't be blank"
(byebug) I18n.t(:"errors.format")
"%{attribute} %{message}"
(byebug) I18n.t(:"errors.format")
"%{attribute} %{message}"
(byebug) attribute
"email"
(byebug) message
これも要は上のファイルに定義されているerrors.formatを呼び出しているだけか。

irb(main):001:0> user = User.new(password: "hoge", password_confirmation: "hoge")
=> #<User:0x00007fdbd5cf9690 id: nil, email: nil, first_name: nil, last_name: nil, created_at: nil, updated_at: nil, crypted_password: nil, salt: nil>
irb(main):002:0> user.valid?
User Exists? (1.1ms) SELECT 1 AS one FROM "users" WHERE "users"."email" IS NULL LIMIT $1 [["LIMIT", 1]]
=> false
irb(main):003:0> user.errors.full_messages
=> ["Email can't be blank"]
irb(main):004:0>

理解できたのでクローズ

Railsで扱っているerrorsって、ActiveModel::Errorsのクラスだったのか。ちなみにActiveModel::Errorってクラスもある
ライブラリのコード群はgemのlib配下に置かれてる
パッケージのコードはlibディレクトリ内に配置されます。慣例として、gemと同じ名前のRubyファイルを1つ用意します。これはrequire "hola"が実行された際に読み込まれるためです。この1つのファイルが、gemのコードとAPIの設定を担当します。