001// Licensed under the Apache License, Version 2.0 (the "License"); 002// you may not use this file except in compliance with the License. 003// You may obtain a copy of the License at 004// 005// http://www.apache.org/licenses/LICENSE-2.0 006// 007// Unless required by applicable law or agreed to in writing, software 008// distributed under the License is distributed on an "AS IS" BASIS, 009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 010// See the License for the specific language governing permissions and 011// limitations under the License. 012 013package org.apache.tapestry5.internal.services; 014 015import java.util.Collections; 016import java.util.Iterator; 017import java.util.List; 018import java.util.Map; 019import java.util.Map.Entry; 020import java.util.Objects; 021import java.util.Set; 022import java.util.stream.Collectors; 023 024import org.apache.tapestry5.commons.Messages; 025import org.apache.tapestry5.commons.Resource; 026import org.apache.tapestry5.commons.util.CaseInsensitiveMap; 027import org.apache.tapestry5.commons.util.CollectionFactory; 028import org.apache.tapestry5.commons.util.MultiKey; 029import org.apache.tapestry5.func.F; 030import org.apache.tapestry5.internal.event.InvalidationEventHubImpl; 031import org.apache.tapestry5.ioc.internal.util.URLChangeTracker; 032import org.apache.tapestry5.services.ComponentClassResolver; 033import org.apache.tapestry5.services.messages.PropertiesFileParser; 034import org.apache.tapestry5.services.pageload.ComponentResourceLocator; 035import org.apache.tapestry5.services.pageload.ComponentResourceSelector; 036import org.slf4j.Logger; 037 038/** 039 * A utility class that encapsulates all the logic for reading properties files and assembling {@link Messages} from 040 * them, in accordance with extension rules and locale. This represents code that was refactored out of 041 * {@link ComponentMessagesSourceImpl}. This class can be used as a base class, though the existing code base uses it as 042 * a utility. Composition trumps inheritance! 043 * 044 * The message catalog for a component is the combination of all appropriate properties files for the component, plus 045 * any keys inherited form base components and, ultimately, the application global message catalog. At some point we 046 * should add support for per-library message catalogs. 047 * 048 * Message catalogs are read using the UTF-8 character set. This is tricky in JDK 1.5; we read the file into memory then 049 * feed that bytestream to Properties.load(). 050 */ 051public class MessagesSourceImpl extends InvalidationEventHubImpl implements MessagesSource 052{ 053 private final URLChangeTracker<MessagesTrackingInfo> tracker; 054 055 private final PropertiesFileParser propertiesFileParser; 056 057 private final ComponentResourceLocator resourceLocator; 058 059 private final ComponentClassResolver componentClassResolver; 060 061 private final boolean multipleClassLoaders; 062 063 private final Logger logger; 064 065 /** 066 * Keyed on bundle id and ComponentResourceSelector. 067 */ 068 private final Map<MultiKey, Messages> messagesByBundleIdAndSelector = CollectionFactory.newConcurrentMap(); 069 070 /** 071 * Keyed on bundle id and ComponentResourceSelector, the cooked properties include properties inherited from less 072 * locale-specific properties files, or inherited from parent bundles. 073 */ 074 private final Map<MultiKey, Map<String, String>> cookedProperties = CollectionFactory.newConcurrentMap(); 075 076 /** 077 * Raw properties represent just the properties read from a specific properties file, in isolation. 078 */ 079 private final Map<Resource, Map<String, String>> rawProperties = CollectionFactory.newConcurrentMap(); 080 081 private final Map<String, String> emptyMap = Collections.emptyMap(); 082 083 public MessagesSourceImpl(boolean productionMode, boolean multipleClassLoaders, URLChangeTracker tracker, 084 ComponentResourceLocator resourceLocator, PropertiesFileParser propertiesFileParser, 085 ComponentClassResolver componentClassResolver, 086 Logger logger) 087 { 088 super(productionMode, logger); 089 090 this.tracker = tracker; 091 this.propertiesFileParser = propertiesFileParser; 092 this.resourceLocator = resourceLocator; 093 this.logger = logger; 094 this.componentClassResolver = componentClassResolver; 095 this.multipleClassLoaders = multipleClassLoaders; 096 } 097 098 public void checkForUpdates() 099 { 100 if (tracker != null) 101 { 102 final Set<MessagesTrackingInfo> changedResources = tracker.getChangedResourcesInfo(); 103 if (!changedResources.isEmpty() && logger.isInfoEnabled()) 104 { 105 logger.info("Changed message file(s): {}", changedResources.stream() 106 .map(MessagesTrackingInfo::getResource) 107 .map(Resource::toString) 108 .collect(Collectors.joining(", "))); 109 } 110 111 boolean applicationLevelChange = false; 112 113 for (MessagesTrackingInfo info : changedResources) 114 { 115 116 final String className = info.getClassName(); 117 118 // An application-level file was changed, so we need to invalidate everything. 119 if (className == null || !multipleClassLoaders) 120 { 121 invalidate(); 122 applicationLevelChange = true; 123 break; 124 } 125 else 126 { 127 128 final Iterator<Entry<MultiKey, Messages>> messagesByBundleIdAndSelectorIterator = 129 messagesByBundleIdAndSelector.entrySet().iterator(); 130 131 while (messagesByBundleIdAndSelectorIterator.hasNext()) 132 { 133 final Entry<MultiKey, Messages> entry = messagesByBundleIdAndSelectorIterator.next(); 134 if (className.equals(entry.getKey().getValues()[0])) 135 { 136 messagesByBundleIdAndSelectorIterator.remove(); 137 } 138 } 139 140 final Iterator<Entry<MultiKey, Map<String, String>>> cookedPropertiesIterator = 141 cookedProperties.entrySet().iterator(); 142 143 while (cookedPropertiesIterator.hasNext()) 144 { 145 final Entry<MultiKey, Map<String, String>> entry = cookedPropertiesIterator.next(); 146 if (className.equals(entry.getKey().getValues()[0])) 147 { 148 cookedPropertiesIterator.remove(); 149 } 150 } 151 152 final String resourceFile = info.getResource().getFile(); 153 final Iterator<Entry<Resource, Map<String, String>>> rawPropertiesIterator = rawProperties.entrySet().iterator(); 154 while (rawPropertiesIterator.hasNext()) 155 { 156 final Entry<Resource, Map<String, String>> entry = rawPropertiesIterator.next(); 157 if (resourceFile.equals(entry.getKey().getFile())) 158 { 159 rawPropertiesIterator.remove(); 160 } 161 } 162 163 } 164 } 165 166 if (!changedResources.isEmpty() && !applicationLevelChange) 167 { 168 fireInvalidationEvent(changedResources.stream() 169 .filter(Objects::nonNull) 170 .map(ClassNameHolder::getClassName) 171 .filter(Objects::nonNull) 172 .collect(Collectors.toList())); 173 } 174 } 175 } 176 177 public void invalidate() 178 { 179 messagesByBundleIdAndSelector.clear(); 180 cookedProperties.clear(); 181 rawProperties.clear(); 182 183 tracker.clear(); 184 185 fireInvalidationEvent(); 186 } 187 188 public Messages getMessages(MessagesBundle bundle, ComponentResourceSelector selector) 189 { 190 MultiKey key = new MultiKey(bundle.getId(), selector); 191 192 Messages result = messagesByBundleIdAndSelector.get(key); 193 194 if (result == null) 195 { 196 result = buildMessages(bundle, selector); 197 messagesByBundleIdAndSelector.put(key, result); 198 } 199 200 return result; 201 } 202 203 private Messages buildMessages(MessagesBundle bundle, ComponentResourceSelector selector) 204 { 205 Map<String, String> properties = findBundleProperties(bundle, selector); 206 207 return new MapMessages(selector.locale, properties); 208 } 209 210 /** 211 * Assembles a set of properties appropriate for the bundle in question, and the desired locale. The properties 212 * reflect the properties of the bundles' parent (if any) for the locale, overalyed with any properties defined for 213 * this bundle and its locale. 214 */ 215 private Map<String, String> findBundleProperties(MessagesBundle bundle, ComponentResourceSelector selector) 216 { 217 if (bundle == null) 218 return emptyMap; 219 220 MultiKey key = new MultiKey(bundle.getId(), selector); 221 222 Map<String, String> existing = cookedProperties.get(key); 223 224 if (existing != null) 225 return existing; 226 227 // What would be cool is if we could maintain a cache of bundle id + locale --> 228 // Resource. That would optimize quite a bit of this; may need to use an alternative to 229 // LocalizedNameGenerator. 230 231 Resource propertiesResource = bundle.getBaseResource().withExtension("properties"); 232 233 List<Resource> localizations = resourceLocator.locateMessageCatalog(propertiesResource, selector); 234 235 // Localizations are now in least-specific to most-specific order. 236 237 Map<String, String> previous = findBundleProperties(bundle.getParent(), selector); 238 239 for (Resource localization : F.flow(localizations).reverse()) 240 { 241 Map<String, String> rawProperties = getRawProperties(localization, bundle); 242 243 // Would be nice to write into the cookedProperties cache here, 244 // but we can't because we don't know the selector part of the MultiKey. 245 246 previous = extend(previous, rawProperties); 247 } 248 249 cookedProperties.put(key, previous); 250 251 return previous; 252 } 253 254 /** 255 * Returns a new map consisting of all the properties in previous overlayed with all the properties in 256 * rawProperties. If rawProperties is empty, returns just the base map. 257 */ 258 private Map<String, String> extend(Map<String, String> base, Map<String, String> rawProperties) 259 { 260 if (rawProperties.isEmpty()) 261 return base; 262 263 // Make a copy of the base Map 264 265 Map<String, String> result = new CaseInsensitiveMap<String>(base); 266 267 // Add or overwrite properties to the copy 268 269 result.putAll(rawProperties); 270 271 return result; 272 } 273 274 private Map<String, String> getRawProperties(Resource localization, MessagesBundle bundle) 275 { 276 Map<String, String> result = rawProperties.get(localization); 277 278 if (result == null) 279 { 280 result = readProperties(localization, bundle); 281 282 rawProperties.put(localization, result); 283 } 284 285 return result; 286 } 287 288 /** 289 * Creates and returns a new map that contains properties read from the properties file. 290 * @param bundle 291 */ 292 private Map<String, String> readProperties(Resource resource, MessagesBundle bundle) 293 { 294 if (!resource.exists()) 295 return emptyMap; 296 297 if (tracker != null) 298 { 299 MessagesTrackingInfo info = new MessagesTrackingInfo( 300 resource, bundle != null ? bundle.getId() : bundle, getClassName(bundle)); 301 tracker.add(resource.toURL(), info); 302 } 303 304 try 305 { 306 return propertiesFileParser.parsePropertiesFile(resource); 307 } catch (Exception ex) 308 { 309 throw new RuntimeException(String.format("Unable to read message catalog from %s: %s", resource, ex), ex); 310 } 311 } 312 313 private String getClassName(MessagesBundle bundle) 314 { 315 String className = null; 316 if (bundle != null && bundle.getBaseResource().getPath() != null) 317 { 318 final String path = bundle.getBaseResource().getPath(); 319 if (path.endsWith(".class")) 320 { 321 className = path.replace('/', '.').replace(".class", ""); 322 if (!componentClassResolver.isPage(className)) 323 { 324 className = null; 325 } 326 } 327 } 328 return className; 329 } 330 331}