• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

TAKETODAY / today-infrastructure / 18154768944

01 Oct 2025 07:26AM UTC coverage: 81.882% (-0.005%) from 81.887%
18154768944

push

github

web-flow
Merge pull request #290 from TAKETODAY/dev/jspecify

jspecify

59788 of 78013 branches covered (76.64%)

Branch coverage included in aggregate %.

141239 of 167496 relevant lines covered (84.32%)

3.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.48
today-context/src/main/java/infra/context/support/ResourceBundleMessageSource.java
1
/*
2
 * Copyright 2017 - 2025 the original author or authors.
3
 *
4
 * This program is free software: you can redistribute it and/or modify
5
 * it under the terms of the GNU General Public License as published by
6
 * the Free Software Foundation, either version 3 of the License, or
7
 * (at your option) any later version.
8
 *
9
 * This program is distributed in the hope that it will be useful,
10
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
 * GNU General Public License for more details.
13
 *
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see [https://www.gnu.org/licenses/]
16
 */
17

18
package infra.context.support;
19

20
import org.jspecify.annotations.Nullable;
21

22
import java.io.IOException;
23
import java.io.InputStream;
24
import java.io.InputStreamReader;
25
import java.io.Reader;
26
import java.net.URL;
27
import java.net.URLConnection;
28
import java.text.MessageFormat;
29
import java.util.Locale;
30
import java.util.Map;
31
import java.util.MissingResourceException;
32
import java.util.PropertyResourceBundle;
33
import java.util.ResourceBundle;
34
import java.util.Set;
35
import java.util.concurrent.ConcurrentHashMap;
36

37
import infra.beans.factory.BeanClassLoaderAware;
38
import infra.lang.Assert;
39
import infra.util.ClassUtils;
40

41
/**
42
 * {@link infra.context.MessageSource} implementation that
43
 * accesses resource bundles using specified basenames. This class relies
44
 * on the underlying JDK's {@link java.util.ResourceBundle} implementation,
45
 * in combination with the JDK's standard message parsing provided by
46
 * {@link java.text.MessageFormat}.
47
 *
48
 * <p>This MessageSource caches both the accessed ResourceBundle instances and
49
 * the generated MessageFormats for each message. It also implements rendering of
50
 * no-arg messages without MessageFormat, as supported by the AbstractMessageSource
51
 * base class. The caching provided by this MessageSource is significantly faster
52
 * than the built-in caching of the {@code java.util.ResourceBundle} class.
53
 *
54
 * <p>The basenames follow {@link java.util.ResourceBundle} conventions: essentially,
55
 * a fully-qualified classpath location. If it doesn't contain a package qualifier
56
 * (such as {@code org.mypackage}), it will be resolved from the classpath root.
57
 * Note that the JDK's standard ResourceBundle treats dots as package separators:
58
 * This means that "test.theme" is effectively equivalent to "test/theme".
59
 *
60
 * <p>On the classpath, bundle resources will be read with the locally configured
61
 * {@link #setDefaultEncoding encoding}: by default, ISO-8859-1; consider switching
62
 * this to UTF-8, or to {@code null} for the platform default encoding. On the JDK 9+
63
 * module path where locally provided {@code ResourceBundle.Control} handles are not
64
 * supported, this MessageSource always falls back to {@link ResourceBundle#getBundle}
65
 * retrieval with the platform default encoding: UTF-8 with a ISO-8859-1 fallback on
66
 * JDK 9+ (configurable through the "java.util.PropertyResourceBundle.encoding" system
67
 * property). Note that {@link #loadBundle(Reader)}/{@link #loadBundle(InputStream)}
68
 * won't be called in this case either, effectively ignoring overrides in subclasses.
69
 * Consider implementing a JDK 9 {@code java.util.spi.ResourceBundleProvider} instead.
70
 *
71
 * @author Rod Johnson
72
 * @author Juergen Hoeller
73
 * @author Qimiao Chen
74
 * @author <a href="https://github.com/TAKETODAY">Harry Yang</a>
75
 * @see #setBasenames
76
 * @see ReloadableResourceBundleMessageSource
77
 * @see java.util.ResourceBundle
78
 * @see java.text.MessageFormat
79
 */
80
public class ResourceBundleMessageSource extends AbstractResourceBasedMessageSource implements BeanClassLoaderAware {
81

82
  @Nullable
83
  private ClassLoader bundleClassLoader;
84

85
  @Nullable
1✔
86
  private ClassLoader beanClassLoader = ClassUtils.getDefaultClassLoader();
2✔
87

88
  /**
89
   * Cache to hold loaded ResourceBundles.
90
   * This Map is keyed with the bundle basename, which holds a Map that is
91
   * keyed with the Locale and in turn holds the ResourceBundle instances.
92
   * This allows for very efficient hash lookups, significantly faster
93
   * than the ResourceBundle class's own cache.
94
   */
95
  private final ConcurrentHashMap<String, Map<Locale, ResourceBundle>> cachedResourceBundles =
5✔
96
          new ConcurrentHashMap<>();
97

98
  /**
99
   * Cache to hold already generated MessageFormats.
100
   * This Map is keyed with the ResourceBundle, which holds a Map that is
101
   * keyed with the message code, which in turn holds a Map that is keyed
102
   * with the Locale and holds the MessageFormat values. This allows for
103
   * very efficient hash lookups without concatenated keys.
104
   *
105
   * @see #getMessageFormat
106
   */
107
  private final ConcurrentHashMap<ResourceBundle, Map<String, Map<Locale, MessageFormat>>> cachedBundleMessageFormats =
5✔
108
          new ConcurrentHashMap<>();
109

110
  @Nullable
6✔
111
  private volatile MessageSourceControl control = new MessageSourceControl();
112

113
  public ResourceBundleMessageSource() {
2✔
114
    setDefaultEncoding("ISO-8859-1");
3✔
115
  }
1✔
116

117
  /**
118
   * Set the ClassLoader to load resource bundles with.
119
   * <p>Default is the containing BeanFactory's
120
   * {@link BeanClassLoaderAware bean ClassLoader},
121
   * or the default ClassLoader determined by
122
   * {@link ClassUtils#getDefaultClassLoader()}
123
   * if not running within a BeanFactory.
124
   */
125
  public void setBundleClassLoader(ClassLoader classLoader) {
126
    this.bundleClassLoader = classLoader;
×
127
  }
×
128

129
  /**
130
   * Return the ClassLoader to load resource bundles with.
131
   * <p>Default is the containing BeanFactory's bean ClassLoader.
132
   *
133
   * @see #setBundleClassLoader
134
   */
135
  @Nullable
136
  protected ClassLoader getBundleClassLoader() {
137
    return (this.bundleClassLoader != null ? this.bundleClassLoader : this.beanClassLoader);
6!
138
  }
139

140
  @Override
141
  public void setBeanClassLoader(ClassLoader classLoader) {
142
    this.beanClassLoader = classLoader;
3✔
143
  }
1✔
144

145
  /**
146
   * Resolves the given message code as key in the registered resource bundles,
147
   * returning the value found in the bundle as-is (without MessageFormat parsing).
148
   */
149
  @Override
150
  @Nullable
151
  protected String resolveCodeWithoutArguments(String code, Locale locale) {
152
    Set<String> basenames = getBasenameSet();
3✔
153
    for (String basename : basenames) {
10✔
154
      ResourceBundle bundle = getResourceBundle(basename, locale);
5✔
155
      if (bundle != null) {
2✔
156
        String result = getStringOrNull(bundle, code);
5✔
157
        if (result != null) {
2✔
158
          return result;
2✔
159
        }
160
      }
161
    }
1✔
162
    return null;
2✔
163
  }
164

165
  /**
166
   * Resolves the given message code as key in the registered resource bundles,
167
   * using a cached MessageFormat instance per message code.
168
   */
169
  @Override
170
  @Nullable
171
  protected MessageFormat resolveCode(String code, Locale locale) {
172
    Set<String> basenames = getBasenameSet();
3✔
173
    for (String basename : basenames) {
10✔
174
      ResourceBundle bundle = getResourceBundle(basename, locale);
5✔
175
      if (bundle != null) {
2!
176
        MessageFormat messageFormat = getMessageFormat(bundle, code, locale);
6✔
177
        if (messageFormat != null) {
2✔
178
          return messageFormat;
2✔
179
        }
180
      }
181
    }
1✔
182
    return null;
2✔
183
  }
184

185
  /**
186
   * Return a ResourceBundle for the given basename and Locale,
187
   * fetching already generated ResourceBundle from the cache.
188
   *
189
   * @param basename the basename of the ResourceBundle
190
   * @param locale the Locale to find the ResourceBundle for
191
   * @return the resulting ResourceBundle, or {@code null} if none
192
   * found for the given basename and Locale
193
   */
194
  @Nullable
195
  protected ResourceBundle getResourceBundle(String basename, Locale locale) {
196
    if (getCacheMillis() >= 0) {
5!
197
      // Fresh ResourceBundle.getBundle call in order to let ResourceBundle
198
      // do its native caching, at the expense of more extensive lookup steps.
199
      return doGetBundle(basename, locale);
×
200
    }
201
    else {
202
      // Cache forever: prefer locale cache over repeated getBundle calls.
203
      Map<Locale, ResourceBundle> localeMap = this.cachedResourceBundles.get(basename);
6✔
204
      if (localeMap != null) {
2✔
205
        ResourceBundle bundle = localeMap.get(locale);
5✔
206
        if (bundle != null) {
2✔
207
          return bundle;
2✔
208
        }
209
      }
210
      try {
211
        ResourceBundle bundle = doGetBundle(basename, locale);
5✔
212
        if (localeMap == null) {
2✔
213
          localeMap = this.cachedResourceBundles.computeIfAbsent(basename, bn -> new ConcurrentHashMap<>());
11✔
214
        }
215
        localeMap.put(locale, bundle);
5✔
216
        return bundle;
2✔
217
      }
218
      catch (MissingResourceException ex) {
1✔
219
        if (logger.isWarnEnabled()) {
4!
220
          logger.warn("ResourceBundle [{}] not found for MessageSource: {}", basename, ex.getMessage());
×
221
        }
222
        // Assume bundle not found
223
        // -> do NOT throw the exception to allow for checking parent message source.
224
        return null;
2✔
225
      }
226
    }
227
  }
228

229
  /**
230
   * Obtain the resource bundle for the given basename and Locale.
231
   *
232
   * @param basename the basename to look for
233
   * @param locale the Locale to look for
234
   * @return the corresponding ResourceBundle
235
   * @throws MissingResourceException if no matching bundle could be found
236
   * @see java.util.ResourceBundle#getBundle(String, Locale, ClassLoader)
237
   * @see #getBundleClassLoader()
238
   */
239
  protected ResourceBundle doGetBundle(String basename, Locale locale) throws MissingResourceException {
240
    ClassLoader classLoader = getBundleClassLoader();
3✔
241
    Assert.state(classLoader != null, "No bundle ClassLoader set");
6!
242

243
    MessageSourceControl control = this.control;
3✔
244
    if (control != null) {
2!
245
      try {
246
        return ResourceBundle.getBundle(basename, locale, classLoader, control);
6✔
247
      }
248
      catch (UnsupportedOperationException ex) {
×
249
        // Probably in a Jigsaw environment on JDK 9+
250
        this.control = null;
×
251
        String encoding = getDefaultEncoding();
×
252
        if (encoding != null && logger.isInfoEnabled()) {
×
253
          logger.info("""
×
254
                  ResourceBundleMessageSource is configured to read resources with encoding '%s' but ResourceBundle.Control not supported \
255
                  in current system environment: %s - falling back to plain ResourceBundle.getBundle retrieval with the platform default \
256
                  encoding. Consider setting the 'defaultEncoding' property to 'null' for participating in the platform default and \
257
                  therefore avoiding this log message.""".formatted(encoding, ex.getMessage()));
×
258
        }
259
      }
260
    }
261

262
    // Fallback: plain getBundle lookup without Control handle
263
    return ResourceBundle.getBundle(basename, locale, classLoader);
×
264
  }
265

266
  /**
267
   * Load a property-based resource bundle from the given reader.
268
   * <p>This will be called in case of a {@link #setDefaultEncoding "defaultEncoding"},
269
   * including {@link ResourceBundleMessageSource}'s default ISO-8859-1 encoding.
270
   * Note that this method can only be called with a {@code ResourceBundle.Control}:
271
   * When running on the JDK 9+ module path where such control handles are not
272
   * supported, any overrides in custom subclasses will effectively get ignored.
273
   * <p>The default implementation returns a {@link PropertyResourceBundle}.
274
   *
275
   * @param reader the reader for the target resource
276
   * @return the fully loaded bundle
277
   * @throws IOException in case of I/O failure
278
   * @see #loadBundle(InputStream)
279
   * @see PropertyResourceBundle#PropertyResourceBundle(Reader)
280
   */
281
  protected ResourceBundle loadBundle(Reader reader) throws IOException {
282
    return new PropertyResourceBundle(reader);
5✔
283
  }
284

285
  /**
286
   * Load a property-based resource bundle from the given input stream,
287
   * picking up the default properties encoding on JDK 9+.
288
   * <p>This will only be called with {@link #setDefaultEncoding "defaultEncoding"}
289
   * set to {@code null}, explicitly enforcing the platform default encoding
290
   * (which is UTF-8 with a ISO-8859-1 fallback on JDK 9+ but configurable
291
   * through the "java.util.PropertyResourceBundle.encoding" system property).
292
   * Note that this method can only be called with a {@code ResourceBundle.Control}:
293
   * When running on the JDK 9+ module path where such control handles are not
294
   * supported, any overrides in custom subclasses will effectively get ignored.
295
   * <p>The default implementation returns a {@link PropertyResourceBundle}.
296
   *
297
   * @param inputStream the input stream for the target resource
298
   * @return the fully loaded bundle
299
   * @throws IOException in case of I/O failure
300
   * @see #loadBundle(Reader)
301
   * @see PropertyResourceBundle#PropertyResourceBundle(InputStream)
302
   */
303
  protected ResourceBundle loadBundle(InputStream inputStream) throws IOException {
304
    return new PropertyResourceBundle(inputStream);
×
305
  }
306

307
  /**
308
   * Return a MessageFormat for the given bundle and code,
309
   * fetching already generated MessageFormats from the cache.
310
   *
311
   * @param bundle the ResourceBundle to work on
312
   * @param code the message code to retrieve
313
   * @param locale the Locale to use to build the MessageFormat
314
   * @return the resulting MessageFormat, or {@code null} if no message
315
   * defined for the given code
316
   * @throws MissingResourceException if thrown by the ResourceBundle
317
   */
318
  @Nullable
319
  protected MessageFormat getMessageFormat(ResourceBundle bundle, String code, Locale locale)
320
          throws MissingResourceException {
321

322
    Map<String, Map<Locale, MessageFormat>> codeMap = this.cachedBundleMessageFormats.get(bundle);
6✔
323
    Map<Locale, MessageFormat> localeMap = null;
2✔
324
    if (codeMap != null) {
2✔
325
      localeMap = codeMap.get(code);
5✔
326
      if (localeMap != null) {
2✔
327
        MessageFormat result = localeMap.get(locale);
5✔
328
        if (result != null) {
2!
329
          return result;
2✔
330
        }
331
      }
332
    }
333

334
    String msg = getStringOrNull(bundle, code);
5✔
335
    if (msg != null) {
2✔
336
      if (codeMap == null) {
2✔
337
        codeMap = this.cachedBundleMessageFormats.computeIfAbsent(bundle, b -> new ConcurrentHashMap<>());
11✔
338
      }
339
      if (localeMap == null) {
2!
340
        localeMap = codeMap.computeIfAbsent(code, c -> new ConcurrentHashMap<>());
10✔
341
      }
342
      MessageFormat result = createMessageFormat(msg, locale);
5✔
343
      localeMap.put(locale, result);
5✔
344
      return result;
2✔
345
    }
346

347
    return null;
2✔
348
  }
349

350
  /**
351
   * Efficiently retrieve the String value for the specified key,
352
   * or return {@code null} if not found.
353
   * <p>the default implementation checks {@code containsKey}
354
   * before it attempts to call {@code getString} (which would require
355
   * catching {@code MissingResourceException} for key not found).
356
   * <p>Can be overridden in subclasses.
357
   *
358
   * @param bundle the ResourceBundle to perform the lookup in
359
   * @param key the key to look up
360
   * @return the associated value, or {@code null} if none
361
   * @see ResourceBundle#getString(String)
362
   * @see ResourceBundle#containsKey(String)
363
   */
364
  @Nullable
365
  protected String getStringOrNull(ResourceBundle bundle, String key) {
366
    if (bundle.containsKey(key)) {
4✔
367
      try {
368
        return bundle.getString(key);
4✔
369
      }
370
      catch (MissingResourceException ex) {
×
371
        // Assume key not found for some other reason
372
        // -> do NOT throw the exception to allow for checking parent message source.
373
      }
374
    }
375
    return null;
2✔
376
  }
377

378
  /**
379
   * Show the configuration of this MessageSource.
380
   */
381
  @Override
382
  public String toString() {
383
    return getClass().getName() + ": basenames=" + getBasenameSet();
×
384
  }
385

386
  /**
387
   * Custom implementation of {@code ResourceBundle.Control}, adding support
388
   * for custom file encodings, deactivating the fallback to the system locale
389
   * and activating ResourceBundle's native cache, if desired.
390
   */
391
  private final class MessageSourceControl extends ResourceBundle.Control {
6✔
392

393
    @Override
394
    @Nullable
395
    public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload)
396
            throws IllegalAccessException, InstantiationException, IOException {
397

398
      // Special handling of default encoding
399
      if (format.equals("java.properties")) {
4✔
400
        String bundleName = toBundleName(baseName, locale);
5✔
401
        final String resourceName = toResourceName(bundleName, "properties");
5✔
402
        InputStream inputStream = null;
2✔
403
        if (reload) {
2!
404
          URL url = loader.getResource(resourceName);
×
405
          if (url != null) {
×
406
            URLConnection connection = url.openConnection();
×
407
            if (connection != null) {
×
408
              connection.setUseCaches(false);
×
409
              inputStream = connection.getInputStream();
×
410
            }
411
          }
412
        }
×
413
        else {
414
          inputStream = loader.getResourceAsStream(resourceName);
4✔
415
        }
416
        if (inputStream != null) {
2✔
417
          String encoding = getDefaultEncoding();
4✔
418
          if (encoding != null) {
2!
419
            try (InputStreamReader bundleReader = new InputStreamReader(inputStream, encoding)) {
6✔
420
              return loadBundle(bundleReader);
7✔
421
            }
422
          }
423
          else {
424
            try (InputStream bundleStream = inputStream) {
×
425
              return loadBundle(bundleStream);
×
426
            }
427
          }
428
        }
429
        else {
430
          return null;
2✔
431
        }
432
      }
433
      else {
434
        // Delegate handling of "java.class" format to standard Control
435
        return super.newBundle(baseName, locale, format, loader, reload);
8✔
436
      }
437
    }
438

439
    @Override
440
    @Nullable
441
    public Locale getFallbackLocale(String baseName, Locale locale) {
442
      Locale defaultLocale = getDefaultLocale();
4✔
443
      return defaultLocale != null && !defaultLocale.equals(locale) ? defaultLocale : null;
10✔
444
    }
445

446
    @Override
447
    public long getTimeToLive(String baseName, Locale locale) {
448
      long cacheMillis = getCacheMillis();
4✔
449
      return cacheMillis >= 0 ? cacheMillis : super.getTimeToLive(baseName, locale);
9!
450
    }
451

452
    @Override
453
    public boolean needsReload(String baseName, Locale locale, String format, ClassLoader loader, ResourceBundle bundle, long loadTime) {
454
      if (super.needsReload(baseName, locale, format, loader, bundle, loadTime)) {
×
455
        cachedBundleMessageFormats.remove(bundle);
×
456
        return true;
×
457
      }
458
      else {
459
        return false;
×
460
      }
461
    }
462
  }
463

464
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc