Introduce LazyLoadable backend

This commit is contained in:
Paarth Madan 2022-02-02 19:47:41 -05:00
parent eb9dbf485f
commit 422959a573
6 changed files with 469 additions and 0 deletions

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
fr:
animal:
dog: chien