Closed54

i18n gemを触ってみる

ハガユウキハガユウキ

i18nは数略語で、iとnの間の文字が18文字あったので、18が入っている。

ソフトウェアの国際化とは、どんな国や地域のプラットフォーム上でも、ソフトウェアの同様な動作・機能を保障することを指しします。例えば、米国で開発されたソフトウェアを日本語で使う場合だけではなく、日本で開発されたソフトウェアを中国で同様の動きを実現することも国際化と言います。ソフトウェアの国際化の範囲は幅広く、ロケール単位でのリソース情報を外部ファイル化することや、ユニコードやネイティブキャラクタセットなど異なるコードセットでのデータ表示、日付、時間、通貨などのフォーマット、各言語による並び替えや検索、フォントの取り扱い、データベースなどへのデータの格納方法、異なるキャラクタセットのコード変換、などなど多岐に渡ります。アプリケーション開発におけるソフトウェアの国際化の例をいくつかピックアップして、次回にご紹介したいと思います。

ソフトウェアの国際化とは、どんな国でもソフトウェアの同様な動作を保証すること。
この言い方が一番わかりやすいな。
https://www.liber.co.jp/it-career-laboratory/wa-002/

そもそもソフトウェアをグローバルに展開したい、もしくはいろんな国の人に提供したいって、目的があるから、国際化対応する必要がある。

かつては、ソフトウェアの地域化や多言語化のために必要に応じて機能的な変更や拡張が行われてきた。しかし、このようなやり方ではソフトウェアの規模拡大や対応する言語が多くなるに従い、開発や保守に多くの時間と費用がかかるため対応が難しくなる。1990年代にプログラミング言語やオペレーティングシステムの国際化対応が標準化されると、ソフトウェアを予め国際化することにより、地域固有データの追加や最小限の変更で地域化が行われるようになった。

ソフトウェアをあらかじめ国際化する前提で作っておいて、地域特有のデータを管理して、地域ごとに表示を変えるやり方を採用した方が、今後国際化する上で楽になるから圧倒的に良い。
https://ja.wikipedia.org/wiki/国際化と地域化

ハガユウキハガユウキ

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

ハガユウキハガユウキ

Rails 国際化(I18n)API
RubyのI18n(国際化・多言語化: internationalizationの略)gemはRuby on Rails 2.2以降からRailsに同梱されています。このgemは、アプリケーションの文言を英語以外の別の1つの言語に翻訳する機能や、多言語サポート機能を簡単かつ拡張可能な方式で導入するためのフレームワークを提供します。
アプリケーションの「国際化」プロセスといえば、使われるすべての文言やロケール固有の要素(日付や通貨フォーマットなど)の抽象化までの作業を指すのが普通です。一方、「ローカライズ(localization)」とは、具体的な翻訳方法を提供したり、そのためのフォーマットを提供したりすることを指します。

Rubyの国際化と地域化(i18n)ソリューション。
現在、@radarによってメンテナンスされています。

i18n gemはwebアプリケーションの国際化と地域化に関する機能を提供しているgem

https://github.com/ruby-i18n/i18n

Railsアプリケーションを国際化するプロセスでは、以下を行う必要があります。

  • i18nを確実にサポートすること。
  • ロケール辞書の置き場所をRailsに指示すること。
  • ロケールの設定・保存・切替方法をRailsに指示すること。

Railsアプリケーションをローカライズするプロセスでは、おそらく以下の3つの作業が必要となるでしょう。

  • Railsのデフォルトロケールの差し替えまたはロケールの追加。日付や時刻のフォーマット、月の呼称、Active Recordモデル名などが対象。
  • アプリケーションで使われる文字列を抽象化し、キーで検索できる辞書に保存する。フラッシュメッセージやビュー内の固定テキストなどが対象。
  • 作成された辞書を別の場所に保存する。

https://railsguides.jp/i18n.html

ハガユウキハガユウキ

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

ハガユウキハガユウキ

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を使うと良い。

ハガユウキハガユウキ
around_action :switch_locale

def switch_locale(&action)
  locale = params[:locale] || I18n.default_locale
  I18n.with_locale(locale, &action)
end
ハガユウキハガユウキ

見せ方、つまりプレゼンテーションを変えるために、要は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>

言語ファイルがないとこうなる

ハガユウキハガユウキ
 "/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>
ハガユウキハガユウキ
<!-- 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,  "【訳文なし】"]
# =>  '【訳文なし】'

シンボルだとキーとして扱われる。

ハガユウキハガユウキ

instance method Object#respond_to?は、レシーバがそのメソッドを持つ時にtrueを返す。

https://docs.ruby-lang.org/ja/latest/method/Object/i/respond_to=3f.html

    def lookup_ancestors
      ancestors.select { |x| x.respond_to?(:model_name) }
    end

https://github.com/rails/rails/blob/b555f4e1ef862400ade23d1dfbbfda149d3a7f7d/activemodel/lib/active_model/translation.rb#L36
ancestorsメソッドを使えば、継承チェーンを見れる。
https://zenn.dev/yukihaga/scraps/ac2053176f24b0

ハガユウキハガユウキ

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"]

https://pystyle.info/linux-glob-syntax/#index_id7
https://docs.ruby-lang.org/ja/latest/class/Dir.html#S_--5B--5D

ハガユウキハガユウキ

あるメソッドをデバッグして挙動をチェックしたい時は、まず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#

https://qiita.com/jnchito/items/fc8a61b421d026a23ffe
https://zenn.dev/hiroendore/articles/d25c528e57e6f2#デバッグして振る舞いを調べる

ハガユウキハガユウキ
# == 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配下に置かれてる
https://github.com/rails/rails/blob/main/activemodel/lib/active_model/error.rb#L8
https://github.com/rails/rails/blob/v6.1.7.8/activemodel/lib/active_model/error.rb

パッケージのコードはlibディレクトリ内に配置されます。慣例として、gemと同じ名前のRubyファイルを1つ用意します。これはrequire "hola"が実行された際に読み込まれるためです。この1つのファイルが、gemのコードとAPIの設定を担当します。

https://guides.rubygems.org/make-your-own-gem/

このスクラップは2日前にクローズされました