Introduce LazyLoadable backend
This commit is contained in:
parent
eb9dbf485f
commit
422959a573
|
@ -12,6 +12,7 @@ module I18n
|
|||
autoload :Gettext, 'i18n/backend/gettext'
|
||||
autoload :InterpolationCompiler, 'i18n/backend/interpolation_compiler'
|
||||
autoload :KeyValue, 'i18n/backend/key_value'
|
||||
autoload :LazyLoadable, 'i18n/backend/lazy_loadable'
|
||||
autoload :Memoize, 'i18n/backend/memoize'
|
||||
autoload :Metadata, 'i18n/backend/metadata'
|
||||
autoload :Pluralization, 'i18n/backend/pluralization'
|
||||
|
|
|
@ -0,0 +1,184 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module I18n
|
||||
module Backend
|
||||
# Backend that lazy loads translations based on the current locale. This
|
||||
# implementation avoids loading all translations up front. Instead, it only
|
||||
# loads the translations that belong to the current locale. This offers a
|
||||
# performance incentive in local development and test environments for
|
||||
# applications with many translations for many different locales. It's
|
||||
# particularly useful when the application only refers to a single locales'
|
||||
# translations at a time (ex. A Rails workload). The implementation
|
||||
# identifies which translation files from the load path belong to the
|
||||
# current locale by pattern matching against their path name.
|
||||
#
|
||||
# Specifically, a translation file is considered to belong to a locale if:
|
||||
# a) the filename is in the I18n load path
|
||||
# b) the filename ends in a supported extension (ie. .yml, .json, .po, .rb)
|
||||
# c) the filename starts with the locale identifier
|
||||
# d) the locale identifier and optional proceeding text is separated by an underscore, ie. "_".
|
||||
#
|
||||
# Examples:
|
||||
# Valid files that will be selected by this backend:
|
||||
#
|
||||
# "files/locales/en_translation.yml" (Selected for locale "en")
|
||||
# "files/locales/fr.po" (Selected for locale "fr")
|
||||
#
|
||||
# Invalid files that won't be selected by this backend:
|
||||
#
|
||||
# "files/locales/translation-file"
|
||||
# "files/locales/en-translation.unsupported"
|
||||
# "files/locales/french/translation.yml"
|
||||
# "files/locales/fr/translation.yml"
|
||||
#
|
||||
# The implementation uses this assumption to defer the loading of
|
||||
# translation files until the current locale actually requires them.
|
||||
#
|
||||
# The backend has two working modes: lazy_load and eager_load.
|
||||
#
|
||||
# Note: This backend should only be enabled in test environments!
|
||||
# When the mode is set to false, the backend behaves exactly like the
|
||||
# Simple backend, with an additional check that the paths being loaded
|
||||
# abide by the format. If paths can't be matched to the format, an error is raised.
|
||||
#
|
||||
# You can configure lazy loaded backends through the initializer or backends
|
||||
# accessor:
|
||||
#
|
||||
# # In test environments
|
||||
#
|
||||
# I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: true)
|
||||
#
|
||||
# # In other environments, such as production and CI
|
||||
#
|
||||
# I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: false) # default
|
||||
#
|
||||
class LocaleExtractor
|
||||
class << self
|
||||
def locale_from_path(path)
|
||||
name = File.basename(path, ".*")
|
||||
locale = name.split("_").first
|
||||
locale.to_sym unless locale.nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class LazyLoadable < Simple
|
||||
def initialize(lazy_load: false)
|
||||
@lazy_load = lazy_load
|
||||
end
|
||||
|
||||
# Returns whether the current locale is initialized.
|
||||
def initialized?
|
||||
if lazy_load?
|
||||
initialized_locales[I18n.locale]
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
# Clean up translations and uninitialize all locales.
|
||||
def reload!
|
||||
if lazy_load?
|
||||
@initialized_locales = nil
|
||||
@translations = nil
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
# Eager loading is not supported in the lazy context.
|
||||
def eager_load!
|
||||
if lazy_load?
|
||||
raise UnsupportedMethod.new(__method__, self.class, "Cannot eager load translations because backend was configured with lazy_load: true.")
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
# Parse the load path and extract all locales.
|
||||
def available_locales
|
||||
if lazy_load?
|
||||
I18n.load_path.map { |path| LocaleExtractor.locale_from_path(path) }
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def lookup(locale, key, scope = [], options = EMPTY_HASH)
|
||||
if lazy_load?
|
||||
I18n.with_locale(locale) do
|
||||
super
|
||||
end
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
|
||||
# Load translations from files that belong to the current locale.
|
||||
def init_translations
|
||||
file_errors = if lazy_load?
|
||||
initialized_locales[I18n.locale] = true
|
||||
load_translations_and_collect_file_errors(filenames_for_current_locale)
|
||||
else
|
||||
@initialized = true
|
||||
load_translations_and_collect_file_errors(I18n.load_path)
|
||||
end
|
||||
|
||||
raise InvalidFilenames.new(file_errors) unless file_errors.empty?
|
||||
end
|
||||
|
||||
def initialized_locales
|
||||
@initialized_locales ||= Hash.new(false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def lazy_load?
|
||||
@lazy_load
|
||||
end
|
||||
|
||||
class FilenameIncorrect < StandardError
|
||||
def initialize(file, expected_locale, unexpected_locales)
|
||||
super "#{file} can only load translations for \"#{expected_locale}\". Found translations for: #{unexpected_locales}."
|
||||
end
|
||||
end
|
||||
|
||||
# Loads each file supplied and asserts that the file only loads
|
||||
# translations as expected by the name. The method returns a list of
|
||||
# errors corresponding to offending files.
|
||||
def load_translations_and_collect_file_errors(files)
|
||||
errors = []
|
||||
|
||||
load_translations(files) do |file, loaded_translations|
|
||||
assert_file_named_correctly!(file, loaded_translations)
|
||||
rescue FilenameIncorrect => e
|
||||
errors << e
|
||||
end
|
||||
|
||||
errors
|
||||
end
|
||||
|
||||
# Select all files from I18n load path that belong to current locale.
|
||||
# These files must start with the locale identifier (ie. "en", "pt-BR"),
|
||||
# followed by an "_" demarcation to separate proceeding text.
|
||||
def filenames_for_current_locale
|
||||
I18n.load_path.flatten.select do |path|
|
||||
LocaleExtractor.locale_from_path(path) == I18n.locale
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a filename is named in correspondence to the translations it loaded.
|
||||
# The locale extracted from the path must be the single locale loaded in the translations.
|
||||
def assert_file_named_correctly!(file, translations)
|
||||
loaded_locales = translations.keys.map(&:to_sym)
|
||||
expected_locale = LocaleExtractor.locale_from_path(file)
|
||||
unexpected_locales = loaded_locales.reject { |locale| locale == expected_locale }
|
||||
|
||||
raise FilenameIncorrect.new(file, expected_locale, unexpected_locales) unless unexpected_locales.empty?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -110,4 +110,38 @@ module I18n
|
|||
super "can not load translations from #{filename}, the file type #{type} is not known"
|
||||
end
|
||||
end
|
||||
|
||||
class UnsupportedMethod < ArgumentError
|
||||
attr_reader :method, :backend_klass, :msg
|
||||
def initialize(method, backend_klass, msg)
|
||||
@method = method
|
||||
@backend_klass = backend_klass
|
||||
@msg = msg
|
||||
super "#{backend_klass} does not support the ##{method} method. #{msg}"
|
||||
end
|
||||
end
|
||||
|
||||
class InvalidFilenames < ArgumentError
|
||||
NUMBER_OF_ERRORS_SHOWN = 20
|
||||
def initialize(file_errors)
|
||||
super <<~MSG
|
||||
Found #{file_errors.count} error(s).
|
||||
The first #{[file_errors.count, NUMBER_OF_ERRORS_SHOWN].min} error(s):
|
||||
#{file_errors.map(&:message).first(NUMBER_OF_ERRORS_SHOWN).join("\n")}
|
||||
|
||||
To use the LazyLoadable backend:
|
||||
1. Filenames must start with the locale.
|
||||
2. An underscore must separate the locale with any optional text that follows.
|
||||
3. The file must only contain translation data for the single locale.
|
||||
|
||||
Example:
|
||||
"/config/locales/fr.yml" which contains:
|
||||
```yml
|
||||
fr:
|
||||
dog:
|
||||
chien
|
||||
```
|
||||
MSG
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
require 'test_helper'
|
||||
|
||||
class I18nLazyLoadableBackendApiTest < I18n::TestCase
|
||||
def setup
|
||||
I18n.backend = I18n::Backend::LazyLoadable.new
|
||||
super
|
||||
end
|
||||
|
||||
include I18n::Tests::Basics
|
||||
include I18n::Tests::Defaults
|
||||
include I18n::Tests::Interpolation
|
||||
include I18n::Tests::Link
|
||||
include I18n::Tests::Lookup
|
||||
include I18n::Tests::Pluralization
|
||||
include I18n::Tests::Procs
|
||||
include I18n::Tests::Localization::Date
|
||||
include I18n::Tests::Localization::DateTime
|
||||
include I18n::Tests::Localization::Time
|
||||
include I18n::Tests::Localization::Procs
|
||||
|
||||
test "make sure we use the LazyLoadable backend" do
|
||||
assert_equal I18n::Backend::LazyLoadable, I18n.backend.class
|
||||
end
|
||||
end
|
|
@ -0,0 +1,223 @@
|
|||
require 'test_helper'
|
||||
|
||||
class I18nBackendLazyLoadableTest < I18n::TestCase
|
||||
def setup
|
||||
super
|
||||
|
||||
@lazy_mode_backend = I18n::Backend::LazyLoadable.new(lazy_load: true)
|
||||
@eager_mode_backend = I18n::Backend::LazyLoadable.new(lazy_load: false)
|
||||
|
||||
I18n.load_path = [File.join(locales_dir, '/en.yml'), File.join(locales_dir, '/fr.yml')]
|
||||
end
|
||||
|
||||
test "lazy mode: only loads translations for current locale" do
|
||||
with_lazy_mode do
|
||||
@backend.reload!
|
||||
|
||||
assert_nil translations
|
||||
|
||||
I18n.with_locale(:en) { I18n.t("foo.bar") }
|
||||
assert_equal({ en: { foo: { bar: "baz" }}}, translations)
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: merges translations for current locale with translations already existing in memory" do
|
||||
with_lazy_mode do
|
||||
@backend.reload!
|
||||
|
||||
I18n.with_locale(:en) { I18n.t("foo.bar") }
|
||||
assert_equal({ en: { foo: { bar: "baz" }}}, translations)
|
||||
|
||||
I18n.with_locale(:fr) { I18n.t("animal.dog") }
|
||||
assert_equal({ en: { foo: { bar: "baz" } }, fr: { animal: { dog: "chien" } } }, translations)
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: #initialized? responds based on whether current locale is initialized" do
|
||||
with_lazy_mode do
|
||||
@backend.reload!
|
||||
|
||||
I18n.with_locale(:en) do
|
||||
refute_predicate @backend, :initialized?
|
||||
I18n.t("foo.bar")
|
||||
assert_predicate @backend, :initialized?
|
||||
end
|
||||
|
||||
I18n.with_locale(:fr) do
|
||||
refute_predicate @backend, :initialized?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: reload! uninitializes all locales" do
|
||||
with_lazy_mode do
|
||||
I18n.with_locale(:en) { I18n.t("foo.bar") }
|
||||
I18n.with_locale(:fr) { I18n.t("animal.dog") }
|
||||
|
||||
@backend.reload!
|
||||
|
||||
I18n.with_locale(:en) do
|
||||
refute_predicate @backend, :initialized?
|
||||
end
|
||||
|
||||
I18n.with_locale(:fr) do
|
||||
refute_predicate @backend, :initialized?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: eager_load! raises UnsupportedMethod exception" do
|
||||
with_lazy_mode do
|
||||
exception = assert_raises(I18n::UnsupportedMethod) { @backend.eager_load! }
|
||||
expected_msg = "I18n::Backend::LazyLoadable does not support the #eager_load! method. Cannot eager load translations because backend was configured with lazy_load: true."
|
||||
assert_equal expected_msg, exception.message
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: loads translations from files that start with current locale identifier" do
|
||||
with_lazy_mode do
|
||||
file_contents = { en: { alice: "bob" } }.to_yaml
|
||||
|
||||
invalid_files = [
|
||||
{ filename: ['translation', '.yml'] }, # No locale identifier
|
||||
{ filename: ['translation', '.unsupported'] }, # No locale identifier and unsupported extension
|
||||
]
|
||||
|
||||
invalid_files.each do |file|
|
||||
with_translation_file_in_load_path(file[:filename], file[:dir], file_contents) do
|
||||
I18n.with_locale(:en) { I18n.t("foo.bar") }
|
||||
assert_equal({ en: { foo: { bar: "baz" }}}, translations)
|
||||
end
|
||||
end
|
||||
|
||||
valid_files = [
|
||||
{ filename: ['en_translation', '.yml'] }, # Contains locale identifier with correct demarcation, and supported extension
|
||||
{ filename: ['en_', '.yml'] }, # Path component matches locale identifier exactly
|
||||
]
|
||||
|
||||
valid_files.each do |file|
|
||||
with_translation_file_in_load_path(file[:filename], file[:dir], file_contents) do
|
||||
I18n.with_locale(:en) { I18n.t("foo.bar") }
|
||||
assert_equal({ en: { foo: { bar: "baz" }, alice: "bob" }}, translations)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: files with unsupported extensions raise UnknownFileType error" do
|
||||
with_lazy_mode do
|
||||
file_contents = { en: { alice: "bob" } }.to_yaml
|
||||
filename = ['en_translation', '.unsupported'] # Correct locale identifier, but unsupported extension
|
||||
|
||||
with_translation_file_in_load_path(filename, nil, file_contents) do
|
||||
assert_raises(I18n::UnknownFileType) { I18n.t("foo.bar") }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: #available_locales returns all locales available from load path irrespective of current locale" do
|
||||
with_lazy_mode do
|
||||
I18n.with_locale(:en) { assert_equal [:en, :fr], @backend.available_locales }
|
||||
I18n.with_locale(:fr) { assert_equal [:en, :fr], @backend.available_locales }
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: raises error if translations loaded don't correspond to locale extracted from filename" do
|
||||
filename = ["en_", ".yml"]
|
||||
file_contents = { fr: { dog: "chien" } }.to_yaml
|
||||
|
||||
with_lazy_mode do
|
||||
with_translation_file_in_load_path(filename, nil, file_contents) do |file_path|
|
||||
exception = assert_raises(I18n::InvalidFilenames) { I18n.t("foo.bar") }
|
||||
|
||||
expected_message = /#{Regexp.escape(file_path)} can only load translations for "en"\. Found translations for: \[\:fr\]/
|
||||
assert_match expected_message, exception.message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: raises error if translations for more than one locale are loaded from a single file" do
|
||||
filename = ["en_", ".yml"]
|
||||
file_contents = { en: { alice: "bob" }, fr: { dog: "chien" }, de: { cat: 'katze' } }.to_yaml
|
||||
|
||||
with_lazy_mode do
|
||||
with_translation_file_in_load_path(filename, nil, file_contents) do |file_path|
|
||||
exception = assert_raises(I18n::InvalidFilenames) { I18n.t("foo.bar") }
|
||||
|
||||
expected_message = /#{Regexp.escape(file_path)} can only load translations for "en"\. Found translations for: \[\:fr\, \:de\]/
|
||||
assert_match expected_message, exception.message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "lazy mode: #lookup lazy loads translations for supplied locale" do
|
||||
with_lazy_mode do
|
||||
@backend.reload!
|
||||
assert_nil translations
|
||||
|
||||
I18n.with_locale(:en) do
|
||||
assert_equal "chien", @backend.lookup(:fr, "animal.dog")
|
||||
end
|
||||
|
||||
assert_equal({ fr: { animal: { dog: "chien" } } }, translations)
|
||||
end
|
||||
end
|
||||
|
||||
test "eager mode: load all translations, irrespective of locale" do
|
||||
with_eager_mode do
|
||||
@backend.reload!
|
||||
|
||||
assert_nil translations
|
||||
|
||||
I18n.with_locale(:en) { I18n.t("foo.bar") }
|
||||
assert_equal({ en: { foo: { bar: "baz" } }, fr: { animal: { dog: "chien" } } }, translations)
|
||||
end
|
||||
end
|
||||
|
||||
test "eager mode: raises error if locales loaded cannot be extracted from load path names" do
|
||||
with_eager_mode do
|
||||
@backend.reload!
|
||||
|
||||
contents = { de: { cat: 'katze' } }.to_yaml
|
||||
|
||||
with_translation_file_in_load_path(['fr_translation', '.yml'], nil, contents) do |file_path|
|
||||
exception = assert_raises(I18n::InvalidFilenames) { I18n.t("foo.bar") }
|
||||
|
||||
expected_message = /#{Regexp.escape(file_path)} can only load translations for "fr"\. Found translations for: \[\:de\]/
|
||||
assert_match expected_message, exception.message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_lazy_mode
|
||||
@backend = I18n.backend = @lazy_mode_backend
|
||||
|
||||
yield
|
||||
end
|
||||
|
||||
def with_eager_mode
|
||||
@backend = I18n.backend = @eager_mode_backend
|
||||
|
||||
yield
|
||||
end
|
||||
|
||||
|
||||
def with_translation_file_in_load_path(name, tmpdir, file_contents)
|
||||
@backend.reload!
|
||||
|
||||
path_to_dir = FileUtils.mkdir_p(File.join(Dir.tmpdir, tmpdir)).first if tmpdir
|
||||
locale_file = Tempfile.new(name, path_to_dir)
|
||||
|
||||
locale_file.write(file_contents)
|
||||
locale_file.rewind
|
||||
|
||||
I18n.load_path << locale_file.path
|
||||
|
||||
yield(locale_file.path)
|
||||
|
||||
I18n.load_path.delete(locale_file.path)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fr:
|
||||
animal:
|
||||
dog: chien
|
Loading…
Reference in New Issue