Skip to content

Commit fb7095a

Browse files
authored
Merge pull request #612 from Shopify/pm/lazy-loadable-backend
LazyLoadable Backend
2 parents 00fc810 + 422959a commit fb7095a

File tree

8 files changed

+479
-7
lines changed

8 files changed

+479
-7
lines changed

lib/i18n/backend.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
module I18n
44
module Backend
55
autoload :Base, 'i18n/backend/base'
6-
autoload :InterpolationCompiler, 'i18n/backend/interpolation_compiler'
76
autoload :Cache, 'i18n/backend/cache'
87
autoload :CacheFile, 'i18n/backend/cache_file'
98
autoload :Cascade, 'i18n/backend/cascade'
109
autoload :Chain, 'i18n/backend/chain'
1110
autoload :Fallbacks, 'i18n/backend/fallbacks'
1211
autoload :Flatten, 'i18n/backend/flatten'
1312
autoload :Gettext, 'i18n/backend/gettext'
13+
autoload :InterpolationCompiler, 'i18n/backend/interpolation_compiler'
1414
autoload :KeyValue, 'i18n/backend/key_value'
15+
autoload :LazyLoadable, 'i18n/backend/lazy_loadable'
1516
autoload :Memoize, 'i18n/backend/memoize'
1617
autoload :Metadata, 'i18n/backend/metadata'
1718
autoload :Pluralization, 'i18n/backend/pluralization'

lib/i18n/backend/base.rb

+6-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ module Base
1313
# for details.
1414
def load_translations(*filenames)
1515
filenames = I18n.load_path if filenames.empty?
16-
filenames.flatten.each { |filename| load_file(filename) }
16+
filenames.flatten.each do |filename|
17+
loaded_translations = load_file(filename)
18+
yield filename, loaded_translations if block_given?
19+
end
1720
end
1821

1922
# This method receives a locale, a data hash and options for storing translations.
@@ -226,6 +229,8 @@ def load_file(filename)
226229
raise InvalidLocaleData.new(filename, 'expects it to return a hash, but does not')
227230
end
228231
data.each { |locale, d| store_translations(locale, d || {}, skip_symbolize_keys: keys_symbolized) }
232+
233+
data
229234
end
230235

231236
# Loads a plain Ruby translations file. eval'ing the file must yield

lib/i18n/backend/lazy_loadable.rb

+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# frozen_string_literal: true
2+
3+
module I18n
4+
module Backend
5+
# Backend that lazy loads translations based on the current locale. This
6+
# implementation avoids loading all translations up front. Instead, it only
7+
# loads the translations that belong to the current locale. This offers a
8+
# performance incentive in local development and test environments for
9+
# applications with many translations for many different locales. It's
10+
# particularly useful when the application only refers to a single locales'
11+
# translations at a time (ex. A Rails workload). The implementation
12+
# identifies which translation files from the load path belong to the
13+
# current locale by pattern matching against their path name.
14+
#
15+
# Specifically, a translation file is considered to belong to a locale if:
16+
# a) the filename is in the I18n load path
17+
# b) the filename ends in a supported extension (ie. .yml, .json, .po, .rb)
18+
# c) the filename starts with the locale identifier
19+
# d) the locale identifier and optional proceeding text is separated by an underscore, ie. "_".
20+
#
21+
# Examples:
22+
# Valid files that will be selected by this backend:
23+
#
24+
# "files/locales/en_translation.yml" (Selected for locale "en")
25+
# "files/locales/fr.po" (Selected for locale "fr")
26+
#
27+
# Invalid files that won't be selected by this backend:
28+
#
29+
# "files/locales/translation-file"
30+
# "files/locales/en-translation.unsupported"
31+
# "files/locales/french/translation.yml"
32+
# "files/locales/fr/translation.yml"
33+
#
34+
# The implementation uses this assumption to defer the loading of
35+
# translation files until the current locale actually requires them.
36+
#
37+
# The backend has two working modes: lazy_load and eager_load.
38+
#
39+
# Note: This backend should only be enabled in test environments!
40+
# When the mode is set to false, the backend behaves exactly like the
41+
# Simple backend, with an additional check that the paths being loaded
42+
# abide by the format. If paths can't be matched to the format, an error is raised.
43+
#
44+
# You can configure lazy loaded backends through the initializer or backends
45+
# accessor:
46+
#
47+
# # In test environments
48+
#
49+
# I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: true)
50+
#
51+
# # In other environments, such as production and CI
52+
#
53+
# I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: false) # default
54+
#
55+
class LocaleExtractor
56+
class << self
57+
def locale_from_path(path)
58+
name = File.basename(path, ".*")
59+
locale = name.split("_").first
60+
locale.to_sym unless locale.nil?
61+
end
62+
end
63+
end
64+
65+
class LazyLoadable < Simple
66+
def initialize(lazy_load: false)
67+
@lazy_load = lazy_load
68+
end
69+
70+
# Returns whether the current locale is initialized.
71+
def initialized?
72+
if lazy_load?
73+
initialized_locales[I18n.locale]
74+
else
75+
super
76+
end
77+
end
78+
79+
# Clean up translations and uninitialize all locales.
80+
def reload!
81+
if lazy_load?
82+
@initialized_locales = nil
83+
@translations = nil
84+
else
85+
super
86+
end
87+
end
88+
89+
# Eager loading is not supported in the lazy context.
90+
def eager_load!
91+
if lazy_load?
92+
raise UnsupportedMethod.new(__method__, self.class, "Cannot eager load translations because backend was configured with lazy_load: true.")
93+
else
94+
super
95+
end
96+
end
97+
98+
# Parse the load path and extract all locales.
99+
def available_locales
100+
if lazy_load?
101+
I18n.load_path.map { |path| LocaleExtractor.locale_from_path(path) }
102+
else
103+
super
104+
end
105+
end
106+
107+
def lookup(locale, key, scope = [], options = EMPTY_HASH)
108+
if lazy_load?
109+
I18n.with_locale(locale) do
110+
super
111+
end
112+
else
113+
super
114+
end
115+
end
116+
117+
protected
118+
119+
120+
# Load translations from files that belong to the current locale.
121+
def init_translations
122+
file_errors = if lazy_load?
123+
initialized_locales[I18n.locale] = true
124+
load_translations_and_collect_file_errors(filenames_for_current_locale)
125+
else
126+
@initialized = true
127+
load_translations_and_collect_file_errors(I18n.load_path)
128+
end
129+
130+
raise InvalidFilenames.new(file_errors) unless file_errors.empty?
131+
end
132+
133+
def initialized_locales
134+
@initialized_locales ||= Hash.new(false)
135+
end
136+
137+
private
138+
139+
def lazy_load?
140+
@lazy_load
141+
end
142+
143+
class FilenameIncorrect < StandardError
144+
def initialize(file, expected_locale, unexpected_locales)
145+
super "#{file} can only load translations for \"#{expected_locale}\". Found translations for: #{unexpected_locales}."
146+
end
147+
end
148+
149+
# Loads each file supplied and asserts that the file only loads
150+
# translations as expected by the name. The method returns a list of
151+
# errors corresponding to offending files.
152+
def load_translations_and_collect_file_errors(files)
153+
errors = []
154+
155+
load_translations(files) do |file, loaded_translations|
156+
assert_file_named_correctly!(file, loaded_translations)
157+
rescue FilenameIncorrect => e
158+
errors << e
159+
end
160+
161+
errors
162+
end
163+
164+
# Select all files from I18n load path that belong to current locale.
165+
# These files must start with the locale identifier (ie. "en", "pt-BR"),
166+
# followed by an "_" demarcation to separate proceeding text.
167+
def filenames_for_current_locale
168+
I18n.load_path.flatten.select do |path|
169+
LocaleExtractor.locale_from_path(path) == I18n.locale
170+
end
171+
end
172+
173+
# Checks if a filename is named in correspondence to the translations it loaded.
174+
# The locale extracted from the path must be the single locale loaded in the translations.
175+
def assert_file_named_correctly!(file, translations)
176+
loaded_locales = translations.keys.map(&:to_sym)
177+
expected_locale = LocaleExtractor.locale_from_path(file)
178+
unexpected_locales = loaded_locales.reject { |locale| locale == expected_locale }
179+
180+
raise FilenameIncorrect.new(file, expected_locale, unexpected_locales) unless unexpected_locales.empty?
181+
end
182+
end
183+
end
184+
end

lib/i18n/exceptions.rb

+34
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,38 @@ def initialize(type, filename)
110110
super "can not load translations from #{filename}, the file type #{type} is not known"
111111
end
112112
end
113+
114+
class UnsupportedMethod < ArgumentError
115+
attr_reader :method, :backend_klass, :msg
116+
def initialize(method, backend_klass, msg)
117+
@method = method
118+
@backend_klass = backend_klass
119+
@msg = msg
120+
super "#{backend_klass} does not support the ##{method} method. #{msg}"
121+
end
122+
end
123+
124+
class InvalidFilenames < ArgumentError
125+
NUMBER_OF_ERRORS_SHOWN = 20
126+
def initialize(file_errors)
127+
super <<~MSG
128+
Found #{file_errors.count} error(s).
129+
The first #{[file_errors.count, NUMBER_OF_ERRORS_SHOWN].min} error(s):
130+
#{file_errors.map(&:message).first(NUMBER_OF_ERRORS_SHOWN).join("\n")}
131+
132+
To use the LazyLoadable backend:
133+
1. Filenames must start with the locale.
134+
2. An underscore must separate the locale with any optional text that follows.
135+
3. The file must only contain translation data for the single locale.
136+
137+
Example:
138+
"/config/locales/fr.yml" which contains:
139+
```yml
140+
fr:
141+
dog:
142+
chien
143+
```
144+
MSG
145+
end
146+
end
113147
end

lib/i18n/tests/basics.rb

+3-5
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ def teardown
55
I18n.available_locales = nil
66
end
77

8-
test "available_locales returns the locales stored to the backend by default" do
8+
test "available_locales returns the available_locales produced by the backend, by default" do
99
I18n.backend.store_translations('de', :foo => 'bar')
1010
I18n.backend.store_translations('en', :foo => 'foo')
1111

12-
assert I18n.available_locales.include?(:de)
13-
assert I18n.available_locales.include?(:en)
12+
assert_equal I18n.available_locales, I18n.backend.available_locales
1413
end
1514

1615
test "available_locales can be set to something else independently from the actual locale data" do
@@ -24,8 +23,7 @@ def teardown
2423
assert_equal [:foo, :bar], I18n.available_locales
2524

2625
I18n.available_locales = nil
27-
assert I18n.available_locales.include?(:de)
28-
assert I18n.available_locales.include?(:en)
26+
assert_equal I18n.available_locales, I18n.backend.available_locales
2927
end
3028

3129
test "available_locales memoizes when set explicitely" do

test/api/lazy_loadable_test.rb

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
require 'test_helper'
2+
3+
class I18nLazyLoadableBackendApiTest < I18n::TestCase
4+
def setup
5+
I18n.backend = I18n::Backend::LazyLoadable.new
6+
super
7+
end
8+
9+
include I18n::Tests::Basics
10+
include I18n::Tests::Defaults
11+
include I18n::Tests::Interpolation
12+
include I18n::Tests::Link
13+
include I18n::Tests::Lookup
14+
include I18n::Tests::Pluralization
15+
include I18n::Tests::Procs
16+
include I18n::Tests::Localization::Date
17+
include I18n::Tests::Localization::DateTime
18+
include I18n::Tests::Localization::Time
19+
include I18n::Tests::Localization::Procs
20+
21+
test "make sure we use the LazyLoadable backend" do
22+
assert_equal I18n::Backend::LazyLoadable, I18n.backend.class
23+
end
24+
end

0 commit comments

Comments
 (0)