mirror of
https://github.com/apache/struts.git
synced 2026-08-31 19:35:40 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user