WW-5540 Add caching to AbstractLocalizedTextProvider (#1808)

* WW-5540 docs: add caching design spec for AbstractLocalizedTextProvider

Design for caching the class/package hierarchy traversal result in
findText, keyed on (classloader, class name, textKey, locale). Caches
the raw resolved pattern (or a NOT_FOUND marker) only; translation and
formatting stay per-call. Wires invalidation into the existing
reloadBundles/clearBundle/clearMissingBundlesCache sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 docs: add implementation plan and refine spec

Add the 3-task TDD implementation plan and record the
formatWithNullDetection fall-through decision in the spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 docs: deprecate+delegate findMessage/getMessage in plan

Resolve pre-flight duplication/dead-code finding: old traversal helpers
delegate to the raw twins and are marked @Deprecated instead of being
duplicated. Add a direct characterization test for the findMessage delegator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 refactor(core): split raw message resolution from formatting

Add getRawMessage/formatMessage and a raw twin findMessageRaw. Re-express
getMessage via formatMessage and make findMessage delegate to
findMessageRaw + formatMessage; deprecate both as legacy extension points
superseded by the raw-resolution path. The deprecated findMessage triggers
the bundle reload on entry, preserving the reload side effect the old
getMessage-per-probe walk provided. Groundwork for the traversal caches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 docs: refine Task 1 plan (deprecate/delegate + reload-on-entry)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 perf(core): cache class-hierarchy text resolution

Cache the class/interface/superclass traversal in findText keyed on
(classloader, class name, key, locale), storing the raw pattern or a
NOT_FOUND marker. Formatting stays per call and falls through to the
next tier when a cached pattern formats to null. Invalidated on
reloadBundles/clearBundle/clearMissingBundlesCache; reload is hoisted
to the top of findText so caches are cleared before they are read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 docs: draft follow-up ticket for null-control-flow cleanup

Capture the deferred result-wrapper refactor (raised during WW-5540) as a
ready-to-file Jira draft; keep WW-5540 focused on caching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 perf(core): cache package-hierarchy text resolution

Cache the *.package traversal in findText the same way as the class
hierarchy, with the same keying, fall-through, and invalidation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 test(core): tighten localized-text cache tests

Assert single cache entry in the per-call-format tests (proves the raw
pattern is cached, not the formatted result), and mirror the package-cache
clearBundle/clearMissingBundlesCache invalidation test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 docs: note devMode null-valueStack eager-reload edge

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 docs: link follow-up doc to filed ticket WW-5655

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 chore(core): add ASF license header to CacheFixture.properties

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 chore(core): add since/forRemoval to @Deprecated annotations

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 docs: drop follow-up draft superseded by WW-5655

The ticket is filed; the draft's content now lives in WW-5655 itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 fix(core): address fresh-eyes review findings

- Document that the deprecated getMessage/findMessage are no longer
  invoked by findText, and name formatMessage as the override point
- Fall back to the ActionContext-based reloadBundles() when findText is
  called without a value stack, so the RELOADED flag is tracked and the
  caches can warm on that path in reload/devMode
- Narrow resolveClassHierarchyRaw/resolvePackageHierarchyRaw to
  package-private (the cache key omits indexedKey, which is safe only
  when derived from textKey as the internal call sites do)
- Suppress java:S2129 on the NOT_FOUND identity sentinel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 docs: strip stray NUL bytes from design spec

Two literal NUL bytes in the sentinel example made git/GitHub treat the
whole markdown file as binary and unreviewable in the PR UI; align the
example with the shipped sentinel name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 test(core): cover ModelDriven tier, per-locale keys, indexed keys

Close the review-noted coverage gaps: the ModelDriven tier resolves via
the shared class-hierarchy cache (action miss + model hit), each locale
gets its own cache entry backed by a new _de fixture bundle, and indexed
keys (name[N] -> name[*]) resolve and cache per full textKey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 fix(core): address Copilot review comments

- Partition the caches by System.identityHashCode of the context
  classloader so a custom ClassLoader overriding hashCode() cannot
  collide or collapse the per-loader partitions
- Derive the indexed key inside the resolvers (miss-only) instead of
  accepting it as a parameter, so the cache key trivially covers every
  input that influences the resolution result

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 fix(core): resolve SonarCloud quality-gate findings

- Suppress S4973 on isNotFound: the identity comparison against the
  non-interned NOT_FOUND sentinel is the design, not a bug
- Reduce findMessageRaw cognitive complexity (S3776) by extracting
  getRawMessageWithAlternate, reused by the package walk
- Add missing @Override annotations and suppress the deliberate
  deprecated-delegator call in the test helper (S1161, S5738)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 fix(core): make findMessageRaw cycle guard effective

The `checked` set tested `contains(clazz.getName())` but never added the
class, so the diamond-interface cycle guard was a no-op (a latent issue
inherited from the original findMessage). Add the class name after the
contains-check so repeated interface branches aren't re-traversed. Behavior
is unchanged (lookups are idempotent); this only avoids redundant recursion.
The deprecated findMessage delegates here, so it's fixed too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5540 docs(core): reference WW-5658 in deprecated method javadoc

Point the @deprecated javadoc of getMessage and findMessage at WW-5658,
the ticket tracking their removal in the next major release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lukasz Lenart
2026-07-26 09:52:08 +02:00
committed by GitHub
parent 833220346c
commit 12015d0bf5
8 changed files with 1685 additions and 75 deletions
@@ -56,6 +56,8 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
private static final String TOMCAT_WEBAPP_CLASSLOADER = "org.apache.catalina.loader.WebappClassLoader";
private static final String TOMCAT_WEBAPP_CLASSLOADER_BASE = "org.apache.catalina.loader.WebappClassLoaderBase";
private static final String RELOADED = "org.apache.struts2.util.LocalizedTextProvider.reloaded";
@SuppressWarnings("java:S2129") // deliberate: a non-interned instance is required for an identity (==) sentinel
private static final String NOT_FOUND = new String("__STRUTS_TEXT_NOT_FOUND__"); // unique identity sentinel; compared with ==
protected final ConcurrentMap<String, ResourceBundle> bundlesMap = new ConcurrentHashMap<>();
protected boolean devMode = false;
@@ -66,6 +68,8 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
private final ConcurrentMap<Integer, List<String>> classLoaderMap = new ConcurrentHashMap<>();
private final Set<String> missingBundles = ConcurrentHashMap.newKeySet();
private final ConcurrentMap<Integer, ClassLoader> delegatedClassLoaderMap = new ConcurrentHashMap<>();
private final ConcurrentMap<TextCacheKey, String> classHierarchyCache = new ConcurrentHashMap<>();
private final ConcurrentMap<TextCacheKey, String> packageHierarchyCache = new ConcurrentHashMap<>();
@Override
public void addDefaultResourceBundle(String bundleName) {
@@ -90,6 +94,22 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
return Thread.currentThread().getContextClassLoader();
}
private int currentLoaderHashCode() {
// Identity-based on purpose: a custom ClassLoader overriding hashCode() must not be able to
// collapse (or collide) the per-classloader cache partitions.
return System.identityHashCode(getCurrentThreadContextClassLoader());
}
/** Test-support accessor: current number of cached class-hierarchy resolutions. */
protected int classHierarchyCacheSize() {
return classHierarchyCache.size();
}
/** Test-support accessor: current number of cached package-hierarchy resolutions. */
protected int packageHierarchyCacheSize() {
return packageHierarchyCache.size();
}
@Inject(value = StrutsConstants.STRUTS_CUSTOM_I18N_RESOURCES, required = false)
public void setCustomI18NResources(String bundles) {
if (bundles == null || bundles.isEmpty()) {
@@ -187,6 +207,8 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
protected void clearBundle(final String bundleName, Locale locale) {
final String key = createMissesKey(String.valueOf(getCurrentThreadContextClassLoader().hashCode()), bundleName, locale);
final ResourceBundle removedBundle = bundlesMap.remove(key);
classHierarchyCache.clear();
packageHierarchyCache.clear();
LOG.debug("Clearing resource bundle [{}], locale [{}], result: [{}].", bundleName, locale, removedBundle != null);
}
@@ -204,6 +226,8 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
*/
protected void clearMissingBundlesCache() {
missingBundles.clear();
classHierarchyCache.clear();
packageHierarchyCache.clear();
LOG.debug("Cleared the missing bundles cache.");
}
@@ -222,6 +246,8 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
}
if (!reloaded) {
bundlesMap.clear();
classHierarchyCache.clear();
packageHierarchyCache.clear();
clearResourceBundleClassloaderCaches();
// now, for the true and utter hack, if we're running in tomcat, clear
@@ -508,8 +534,47 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
}
/**
* @return the message from the named resource bundle.
* Resolves the raw (untranslated, unformatted) message pattern for a key within a single bundle.
* Returns {@code null} when the bundle or key is absent. This is the cacheable unit relied upon by
* the hierarchy-resolution caches; translation and formatting are applied separately by
* {@link #formatMessage(String, Locale, ValueStack, Object[])}.
*/
private String getRawMessage(String bundleName, Locale locale, String key) {
ResourceBundle bundle = findResourceBundle(bundleName, locale);
if (bundle == null) {
return null;
}
try {
return bundle.getString(key);
} catch (MissingResourceException e) {
LOG.debug("Missing key [{}] in bundle [{}]!", key, bundleName);
return null;
}
}
/**
* Applies value stack variable translation (when a stack is available) and {@link MessageFormat}
* argument substitution to a raw message pattern. Mirrors the rendering previously performed inline
* by {@link #getMessage(String, Locale, String, ValueStack, Object[])}.
*/
protected String formatMessage(String rawPattern, Locale locale, ValueStack valueStack, Object[] args) {
String message = (valueStack != null)
? TextParseUtil.translateVariables(rawPattern, valueStack)
: rawPattern;
MessageFormat mf = buildMessageFormat(message, locale);
return formatWithNullDetection(mf, args);
}
/**
* @return the message from the named resource bundle.
* @deprecated since 7.3.0 — superseded by the internal raw-resolution + caching path
* ({@link #formatMessage(String, Locale, ValueStack, Object[])} over a raw lookup). Retained for
* backward compatibility with descendant classes that call it directly. <strong>No longer invoked
* by {@code findText}</strong>: overriding this method does not affect framework message lookup
* anymore; override {@link #formatMessage(String, Locale, ValueStack, Object[])} to customize
* rendering instead. Scheduled for removal in the next major release (see WW-5658).
*/
@Deprecated(since = "7.3.0", forRemoval = true)
protected String getMessage(String bundleName, Locale locale, String key, ValueStack valueStack, Object[] args) {
ResourceBundle bundle = findResourceBundle(bundleName, locale);
if (bundle == null) {
@@ -519,12 +584,8 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
reloadBundles(valueStack.getContext());
}
try {
String message = bundle.getString(key);
if (valueStack != null) {
message = TextParseUtil.translateVariables(bundle.getString(key), valueStack);
}
MessageFormat mf = buildMessageFormat(message, locale);
return formatWithNullDetection(mf, args);
String rawPattern = bundle.getString(key);
return formatMessage(rawPattern, locale, valueStack, args);
} catch (MissingResourceException e) {
LOG.debug("Missing key [{}] in bundle [{}]!", key, bundleName);
return null;
@@ -532,73 +593,158 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
}
/**
* Traverse up class hierarchy looking for message. Looks at class, then implemented interface,
* before going up hierarchy.
*
* @return the message
* Raw-pattern twin of {@link #findMessage}. Walks class, implemented interfaces, then up the
* hierarchy, returning the first raw message pattern found (via {@link #getRawMessage}) without
* translation or formatting. Used by the cached class-hierarchy resolver.
*/
protected String findMessage(Class<?> clazz, String key, String indexedKey, Locale locale, Object[] args, Set<String> checked,
ValueStack valueStack) {
private String findMessageRaw(Class<?> clazz, String key, String indexedKey, Locale locale, Set<String> checked) {
if (checked == null) {
checked = new TreeSet<>();
} else if (checked.contains(clazz.getName())) {
return null;
}
// Record this class so diamond-shaped interface hierarchies aren't re-traversed.
checked.add(clazz.getName());
// look in properties of this class
String msg = getMessage(clazz.getName(), locale, key, valueStack, args);
String msg = getRawMessageWithAlternate(clazz.getName(), locale, key, indexedKey);
if (msg != null) {
return msg;
}
if (indexedKey != null) {
msg = getMessage(clazz.getName(), locale, indexedKey, valueStack, args);
if (msg != null) {
return msg;
}
}
// look in properties of implemented interfaces
Class<?>[] interfaces = clazz.getInterfaces();
for (Class<?> anInterface : interfaces) {
msg = getMessage(anInterface.getName(), locale, key, valueStack, args);
for (Class<?> anInterface : clazz.getInterfaces()) {
msg = getRawMessageWithAlternate(anInterface.getName(), locale, key, indexedKey);
if (msg != null) {
return msg;
}
if (indexedKey != null) {
msg = getMessage(anInterface.getName(), locale, indexedKey, valueStack, args);
if (msg != null) {
return msg;
}
}
}
// traverse up hierarchy
if (clazz.isInterface()) {
interfaces = clazz.getInterfaces();
for (Class<?> anInterface : interfaces) {
msg = findMessage(anInterface, key, indexedKey, locale, args, checked, valueStack);
for (Class<?> anInterface : clazz.getInterfaces()) {
msg = findMessageRaw(anInterface, key, indexedKey, locale, checked);
if (msg != null) {
return msg;
}
}
} else {
if (!clazz.equals(Object.class) && !clazz.isPrimitive()) {
return findMessage(clazz.getSuperclass(), key, indexedKey, locale, args, checked, valueStack);
}
} else if (!clazz.equals(Object.class) && !clazz.isPrimitive()) {
return findMessageRaw(clazz.getSuperclass(), key, indexedKey, locale, checked);
}
return null;
}
/**
* Resolves the raw message pattern for a key within a single bundle, falling back to the
* indexed (general-form) key when the primary key is absent.
*/
private String getRawMessageWithAlternate(String bundleName, Locale locale, String key, String indexedKey) {
String msg = getRawMessage(bundleName, locale, key);
if (msg == null && indexedKey != null) {
msg = getRawMessage(bundleName, locale, indexedKey);
}
return msg;
}
/**
* Cached resolution of the class/interface/superclass hierarchy for a key. Returns the raw pattern
* found, or {@link #NOT_FOUND} when the key is absent from the entire hierarchy. Keyed on the
* context classloader hash + class name + key + locale, so no {@link Class} reference is retained.
* Uses get + putIfAbsent (never computeIfAbsent) because the child-property path recurses into findText.
*/
String resolveClassHierarchyRaw(Class<?> clazz, String textKey, Locale locale) {
TextCacheKey cacheKey = new TextCacheKey(currentLoaderHashCode(), clazz.getName(), textKey, locale);
String cached = classHierarchyCache.get(cacheKey);
if (cached != null) {
return cached;
}
// Derived here (miss-only) rather than accepted as a parameter, so the cache key trivially
// covers every input that influences the resolution result.
String raw = findMessageRaw(clazz, textKey, extractIndexedName(textKey), locale, null);
String toStore = (raw != null) ? raw : NOT_FOUND;
classHierarchyCache.putIfAbsent(cacheKey, toStore);
return toStore;
}
/** @return true when a cached raw-resolution result represents "not found". */
@SuppressWarnings("java:S4973") // deliberate identity comparison against the non-interned NOT_FOUND sentinel
protected boolean isNotFound(String cachedRawResult) {
return cachedRawResult == NOT_FOUND;
}
/**
* Raw-pattern walk of the {@code *.package} bundles up the class hierarchy of {@code startClazz}.
* Returns the first raw pattern found (via {@link #getRawMessage}) for the key or its indexed form,
* or {@code null} when none match.
*/
private String findPackageMessageRaw(Class<?> startClazz, String textKey, String indexedTextName, Locale locale) {
for (Class<?> clazz = startClazz;
(clazz != null) && !clazz.equals(Object.class);
clazz = clazz.getSuperclass()) {
String basePackageName = clazz.getName();
while (basePackageName.lastIndexOf('.') != -1) {
basePackageName = basePackageName.substring(0, basePackageName.lastIndexOf('.'));
String packageName = basePackageName + ".package";
String msg = getRawMessageWithAlternate(packageName, locale, textKey, indexedTextName);
if (msg != null) {
return msg;
}
}
}
return null;
}
/**
* Cached resolution of the {@code *.package} hierarchy for a key. Returns the raw pattern found, or
* {@link #NOT_FOUND} when absent. Same keying and get + putIfAbsent discipline as
* {@link #resolveClassHierarchyRaw}.
*/
String resolvePackageHierarchyRaw(Class<?> startClazz, String textKey, Locale locale) {
TextCacheKey cacheKey = new TextCacheKey(currentLoaderHashCode(), startClazz.getName(), textKey, locale);
String cached = packageHierarchyCache.get(cacheKey);
if (cached != null) {
return cached;
}
// Derived here (miss-only) rather than accepted as a parameter, so the cache key trivially
// covers every input that influences the resolution result.
String raw = findPackageMessageRaw(startClazz, textKey, extractIndexedName(textKey), locale);
String toStore = (raw != null) ? raw : NOT_FOUND;
packageHierarchyCache.putIfAbsent(cacheKey, toStore);
return toStore;
}
/**
* Traverse up class hierarchy looking for message. Looks at class, then implemented interface,
* before going up hierarchy.
*
* @return the message
* @deprecated since 7.3.0 — superseded by the internal raw-resolution + caching path
* ({@link #findMessageRaw} + {@link #formatMessage(String, Locale, ValueStack, Object[])}). Retained
* for backward compatibility with descendant classes that call it directly. <strong>No longer
* invoked by {@code findText}</strong>: overriding this method does not affect framework message
* lookup anymore; override {@link #formatMessage(String, Locale, ValueStack, Object[])} to
* customize rendering instead. Note: unlike the pre-7.3.0 implementation, a
* candidate whose formatted value is the literal {@code "null"} no longer causes the search to
* continue deeper in the same hierarchy; this affects only the pathological case of the same key
* redefined at multiple hierarchy levels with the shallow value formatting to {@code "null"}.
* The bundle-reload check is now triggered once on entry (when reload mode is enabled) rather than
* lazily per bundle probe, preserving the reload side effect that the previous getMessage-per-probe
* walk provided. Scheduled for removal in the next major release (see WW-5658).
*/
@Deprecated(since = "7.3.0", forRemoval = true)
protected String findMessage(Class<?> clazz, String key, String indexedKey, Locale locale, Object[] args, Set<String> checked,
ValueStack valueStack) {
if (valueStack != null) {
reloadBundles(valueStack.getContext());
} else {
reloadBundles();
}
String rawPattern = findMessageRaw(clazz, key, indexedKey, locale, checked);
return rawPattern != null ? formatMessage(rawPattern, locale, valueStack, args) : null;
}
protected String extractIndexedName(String textKey) {
String indexedTextName = null;
// calculate indexedTextName (collection[*]) if applicable
@@ -658,6 +804,40 @@ abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
}
}
static class TextCacheKey {
private final int classLoaderHash;
private final String className;
private final String textKey;
private final Locale locale;
TextCacheKey(int classLoaderHash, String className, String textKey, Locale locale) {
this.classLoaderHash = classLoaderHash;
this.className = className;
this.textKey = textKey;
this.locale = locale;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TextCacheKey that = (TextCacheKey) o;
return classLoaderHash == that.classLoaderHash
&& Objects.equals(className, that.className)
&& Objects.equals(textKey, that.textKey)
&& Objects.equals(locale, that.locale);
}
@Override
public int hashCode() {
int result = classLoaderHash;
result = 31 * result + (className != null ? className.hashCode() : 0);
result = 31 * result + (textKey != null ? textKey.hashCode() : 0);
result = 31 * result + (locale != null ? locale.hashCode() : 0);
return result;
}
}
static class GetDefaultMessageReturnArg {
String message;
boolean foundInBundle;
@@ -65,6 +65,17 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
LOG.debug("Key is null, short-circuit to default message");
return defaultMessage;
}
// Trigger bundle reload (and cache invalidation) once, before any cached hierarchy lookup,
// so that in reload/devMode the hierarchy caches are cleared before they are read. With no
// value stack, fall back to the ActionContext-based overload so the RELOADED flag is still
// tracked and the caches can warm on that path too.
if (valueStack != null) {
reloadBundles(valueStack.getContext());
} else {
reloadBundles();
}
String indexedTextName = extractIndexedName(textKey);
// Allow for and track an early lookup for the message in the default resource bundles first, before searching the class hierarchy.
@@ -81,11 +92,14 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
}
}
// search up class hierarchy
String msg = findMessage(startClazz, textKey, indexedTextName, locale, args, null, valueStack);
if (msg != null) {
return msg;
// search up class hierarchy (cached raw resolution; format per call)
String classHierarchyRaw = resolveClassHierarchyRaw(startClazz, textKey, locale);
String msg = null;
if (!isNotFound(classHierarchyRaw)) {
msg = formatMessage(classHierarchyRaw, locale, valueStack, args);
if (msg != null) {
return msg;
}
}
if (ModelDriven.class.isAssignableFrom(startClazz)) {
@@ -99,37 +113,24 @@ public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider {
if (action instanceof ModelDriven) {
Object model = ((ModelDriven<?>) action).getModel();
if (model != null) {
msg = findMessage(model.getClass(), textKey, indexedTextName, locale, args, null, valueStack);
if (msg != null) {
return msg;
String modelRaw = resolveClassHierarchyRaw(model.getClass(), textKey, locale);
if (!isNotFound(modelRaw)) {
msg = formatMessage(modelRaw, locale, valueStack, args);
if (msg != null) {
return msg;
}
}
}
}
}
}
// nothing still? alright, search the package hierarchy now
for (Class<?> clazz = startClazz;
(clazz != null) && !clazz.equals(Object.class);
clazz = clazz.getSuperclass()) {
String basePackageName = clazz.getName();
while (basePackageName.lastIndexOf('.') != -1) {
basePackageName = basePackageName.substring(0, basePackageName.lastIndexOf('.'));
String packageName = basePackageName + ".package";
msg = getMessage(packageName, locale, textKey, valueStack, args);
if (msg != null) {
return msg;
}
if (indexedTextName != null) {
msg = getMessage(packageName, locale, indexedTextName, valueStack, args);
if (msg != null) {
return msg;
}
}
// search the package hierarchy (cached raw resolution; format per call)
String packageRaw = resolvePackageHierarchyRaw(startClazz, textKey, locale);
if (!isNotFound(packageRaw)) {
msg = formatMessage(packageRaw, locale, valueStack, args);
if (msg != null) {
return msg;
}
}
@@ -0,0 +1,37 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.struts2.text;
/**
* Simple fixture whose class-associated bundle ({@code CacheFixture.properties}) backs the
* localized-text caching tests. The {@code name} property is exposed so OGNL expressions such as
* {@code ${name}} can be resolved against a value stack.
*/
public class CacheFixture {
private final String name;
public CacheFixture(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
@@ -547,6 +547,207 @@ public class StrutsLocalizedTextProviderTest extends XWorkTestCase {
assertEquals("Result of bean2.name lookup not as expected ?", "Okay! You found Me!", messageResult);
}
public void testClassHierarchyCacheReusesFoundPattern() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
assertEquals("Cache not empty before first lookup ?", 0, provider.classHierarchyCacheSize());
String first = provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, null, null, valueStack);
assertEquals("Static cached value", first);
assertEquals("Cache not populated after found lookup ?", 1, provider.classHierarchyCacheSize());
String second = provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, null, null, valueStack);
assertEquals("Second lookup differs from first ?", first, second);
assertEquals("Cache grew on repeated lookup ?", 1, provider.classHierarchyCacheSize());
}
public void testClassHierarchyCacheStoresMisses() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
String first = provider.findText(CacheFixture.class, "cache.missing", Locale.ENGLISH, "Fallback", null, valueStack);
assertEquals("Fallback", first);
assertEquals("Miss not cached ?", 1, provider.classHierarchyCacheSize());
String second = provider.findText(CacheFixture.class, "cache.missing", Locale.ENGLISH, "Fallback", null, valueStack);
assertEquals("Fallback", second);
assertEquals("Miss cache grew on repeat ?", 1, provider.classHierarchyCacheSize());
}
public void testFormattingIsPerCallNotCached() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
String x = provider.findText(CacheFixture.class, "cache.withparam", Locale.ENGLISH, null, new Object[]{"X"}, valueStack);
String y = provider.findText(CacheFixture.class, "cache.withparam", Locale.ENGLISH, null, new Object[]{"Y"}, valueStack);
assertEquals("Value with param X", x);
assertEquals("Value with param Y", y);
assertEquals("Raw pattern should be cached once, not per format ?", 1, provider.classHierarchyCacheSize());
}
public void testOgnlTranslationIsPerCall() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
valueStack.push(new CacheFixture("World"));
String world = provider.findText(CacheFixture.class, "cache.withognl", Locale.ENGLISH, null, null, valueStack);
valueStack.pop();
valueStack.push(new CacheFixture("Mars"));
String mars = provider.findText(CacheFixture.class, "cache.withognl", Locale.ENGLISH, null, null, valueStack);
valueStack.pop();
assertEquals("Hello World", world);
assertEquals("Hello Mars", mars);
assertEquals("Raw pattern should be cached once across value stacks ?", 1, provider.classHierarchyCacheSize());
}
public void testNullFormattingFallsThroughToDefault() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
// "{0}" with a null arg formats to the literal "null"; findText must fall through to the default.
String first = provider.findText(CacheFixture.class, "cache.nullformat", Locale.ENGLISH, "Fallback", new Object[]{null}, valueStack);
assertEquals("Fallback", first);
// Repeat after the pattern is cached — still falls through.
String second = provider.findText(CacheFixture.class, "cache.nullformat", Locale.ENGLISH, "Fallback", new Object[]{null}, valueStack);
assertEquals("Fallback", second);
}
public void testReloadClearsClassHierarchyCache() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, null, null, valueStack);
assertEquals("Cache not populated ?", 1, provider.classHierarchyCacheSize());
provider.callReloadBundlesForceReload();
assertEquals("Reload did not clear class hierarchy cache ?", 0, provider.classHierarchyCacheSize());
}
public void testClearBundleAndClearMissingCacheEmptyClassHierarchyCache() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, null, null, valueStack);
assertEquals("Cache not populated ?", 1, provider.classHierarchyCacheSize());
provider.callClearBundleWithLocale("org/apache/struts2/text/CacheFixture", Locale.ENGLISH);
assertEquals("clearBundle did not empty class hierarchy cache ?", 0, provider.classHierarchyCacheSize());
provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, null, null, valueStack);
assertEquals("Cache not repopulated ?", 1, provider.classHierarchyCacheSize());
provider.callClearMissingBundlesCache();
assertEquals("clearMissingBundlesCache did not empty class hierarchy cache ?", 0, provider.classHierarchyCacheSize());
}
public void testPackageHierarchyCacheReusesFoundPattern() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
// ModelDrivenAction2 lives in a package that provides "package.properties" = "It works!".
assertEquals("Package cache not empty before lookup ?", 0, provider.packageHierarchyCacheSize());
String first = provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, "package.properties", Locale.getDefault(), null, null, valueStack);
assertEquals("It works!", first);
assertEquals("Package cache not populated after found lookup ?", 1, provider.packageHierarchyCacheSize());
String second = provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, "package.properties", Locale.getDefault(), null, null, valueStack);
assertEquals("Second package lookup differs ?", first, second);
assertEquals("Package cache grew on repeat ?", 1, provider.packageHierarchyCacheSize());
}
public void testReloadClearsPackageHierarchyCache() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, "package.properties", Locale.getDefault(), null, null, valueStack);
assertEquals("Package cache not populated ?", 1, provider.packageHierarchyCacheSize());
provider.callReloadBundlesForceReload();
assertEquals("Reload did not clear package hierarchy cache ?", 0, provider.packageHierarchyCacheSize());
}
public void testClearBundleAndClearMissingCacheEmptyPackageHierarchyCache() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, "package.properties", Locale.getDefault(), null, null, valueStack);
assertEquals("Package cache not populated ?", 1, provider.packageHierarchyCacheSize());
provider.callClearBundleWithLocale("org/apache/struts2/test/package", Locale.getDefault());
assertEquals("clearBundle did not empty package hierarchy cache ?", 0, provider.packageHierarchyCacheSize());
provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, "package.properties", Locale.getDefault(), null, null, valueStack);
assertEquals("Package cache not repopulated ?", 1, provider.packageHierarchyCacheSize());
provider.callClearMissingBundlesCache();
assertEquals("clearMissingBundlesCache did not empty package hierarchy cache ?", 0, provider.packageHierarchyCacheSize());
}
public void testDeprecatedFindMessageStillDelegates() {
// findMessage leaves findText's hot path in this task; this locks the deprecated delegator.
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
assertEquals("Static cached value", provider.callFindMessage(CacheFixture.class, "cache.static", Locale.ENGLISH, valueStack));
assertNull(provider.callFindMessage(CacheFixture.class, "cache.missing", Locale.ENGLISH, valueStack));
}
public void testModelDrivenTierUsesClassHierarchyCache() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ModelDrivenAction2 action = new ModelDrivenAction2();
Mock mockActionInvocation = new Mock(ActionInvocation.class);
mockActionInvocation.matchAndReturn("getAction", action);
ActionContext.getContext().withActionInvocation((ActionInvocation) mockActionInvocation.proxy());
ValueStack valueStack = ActionContext.getContext().getValueStack();
// "invalid.count" resolves only via the model's hierarchy (TestBean2 -> TestBean.properties),
// not via the action class hierarchy, so it exercises the ModelDriven tier.
String first = provider.findText(ModelDrivenAction2.class, "invalid.count", Locale.ENGLISH, null, null, valueStack);
assertNotNull("Model-tier lookup found nothing ?", first);
assertTrue("Model-tier lookup did not resolve via the TestBean bundle ?", first.startsWith("TestBean model:"));
// Two entries: a miss for the action class hierarchy plus a hit for the model class hierarchy.
assertEquals("Class-hierarchy cache should hold action miss + model hit ?", 2, provider.classHierarchyCacheSize());
String second = provider.findText(ModelDrivenAction2.class, "invalid.count", Locale.ENGLISH, null, null, valueStack);
assertEquals("Warm model-tier lookup differs from cold ?", first, second);
assertEquals("Cache grew on repeated model-tier lookup ?", 2, provider.classHierarchyCacheSize());
}
public void testLocaleIsPartOfCacheKey() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
String english = provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, null, null, valueStack);
String german = provider.findText(CacheFixture.class, "cache.static", Locale.GERMAN, null, null, valueStack);
assertEquals("Static cached value", english);
assertEquals("Statischer Wert", german);
assertEquals("Each locale should have its own cache entry ?", 2, provider.classHierarchyCacheSize());
assertEquals("Warm English lookup differs ?", english,
provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, null, null, valueStack));
assertEquals("Warm German lookup differs ?", german,
provider.findText(CacheFixture.class, "cache.static", Locale.GERMAN, null, null, valueStack));
assertEquals("Cache grew on warm per-locale lookups ?", 2, provider.classHierarchyCacheSize());
}
public void testIndexedKeyResolvesThroughCache() {
TestStrutsLocalizedTextProvider provider = new TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();
// "cache.indexed[20]" falls back to the general form "cache.indexed[*]" during raw resolution.
String first = provider.findText(CacheFixture.class, "cache.indexed[20]", Locale.ENGLISH, null, null, valueStack);
assertEquals("Indexed cached value", first);
assertEquals("Indexed lookup not cached ?", 1, provider.classHierarchyCacheSize());
String second = provider.findText(CacheFixture.class, "cache.indexed[20]", Locale.ENGLISH, null, null, valueStack);
assertEquals("Warm indexed lookup differs from cold ?", first, second);
assertEquals("Cache grew on warm indexed lookup ?", 1, provider.classHierarchyCacheSize());
// A different index is a distinct cache key (the cache is keyed on the full textKey),
// resolving to the same general form.
String other = provider.findText(CacheFixture.class, "cache.indexed[7]", Locale.ENGLISH, null, null, valueStack);
assertEquals("Indexed cached value", other);
assertEquals("A different index should create its own cache entry ?", 2, provider.classHierarchyCacheSize());
}
@Override
protected void setUp() throws Exception {
super.setUp();
@@ -616,5 +817,20 @@ public class StrutsLocalizedTextProviderTest extends XWorkTestCase {
final Object reloadedObject = ActionContext.getContext().get(RELOADED);
return reloadedObject instanceof Boolean && (Boolean) reloadedObject;
}
@Override
public int classHierarchyCacheSize() {
return super.classHierarchyCacheSize();
}
@Override
public int packageHierarchyCacheSize() {
return super.packageHierarchyCacheSize();
}
@SuppressWarnings("removal") // deliberately exercises the deprecated delegator
public String callFindMessage(Class<?> clazz, String key, Locale locale, ValueStack valueStack) {
return super.findMessage(clazz, key, null, locale, null, null, valueStack);
}
}
}
@@ -0,0 +1,23 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
cache.static=Static cached value
cache.withparam=Value with param {0}
cache.withognl=Hello ${name}
cache.nullformat={0}
cache.indexed[*]=Indexed cached value
@@ -0,0 +1,19 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
cache.static=Statischer Wert