001 /* 002 * JBoss DNA (http://www.jboss.org/dna) 003 * See the COPYRIGHT.txt file distributed with this work for information 004 * regarding copyright ownership. Some portions may be licensed 005 * to Red Hat, Inc. under one or more contributor license agreements. 006 * See the AUTHORS.txt file in the distribution for a full listing of 007 * individual contributors. 008 * 009 * JBoss DNA is free software. Unless otherwise indicated, all code in JBoss DNA 010 * is licensed to you under the terms of the GNU Lesser General Public License as 011 * published by the Free Software Foundation; either version 2.1 of 012 * the License, or (at your option) any later version. 013 * 014 * JBoss DNA is distributed in the hope that it will be useful, 015 * but WITHOUT ANY WARRANTY; without even the implied warranty of 016 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 017 * Lesser General Public License for more details. 018 * 019 * You should have received a copy of the GNU Lesser General Public 020 * License along with this software; if not, write to the Free 021 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 022 * 02110-1301 USA, or see the FSF site: http://www.fsf.org. 023 */ 024 package org.jboss.dna.common.i18n; 025 026 import java.io.IOException; 027 import java.io.InputStream; 028 import java.lang.reflect.Field; 029 import java.lang.reflect.Modifier; 030 import java.net.URL; 031 import java.util.Collections; 032 import java.util.HashSet; 033 import java.util.Locale; 034 import java.util.Map; 035 import java.util.Properties; 036 import java.util.Set; 037 import java.util.Map.Entry; 038 import java.util.concurrent.ConcurrentHashMap; 039 import java.util.concurrent.ConcurrentMap; 040 import java.util.concurrent.CopyOnWriteArraySet; 041 import net.jcip.annotations.ThreadSafe; 042 import org.jboss.dna.common.CommonI18n; 043 import org.jboss.dna.common.SystemFailureException; 044 import org.jboss.dna.common.util.CheckArg; 045 import org.jboss.dna.common.util.ClassUtil; 046 import org.jboss.dna.common.util.StringUtil; 047 048 /** 049 * Manages the initialization of internationalization (i18n) files, substitution of values within i18n message placeholders, and 050 * dynamically reading properties from i18n property files. 051 * 052 * @author John Verhaeg 053 * @author Randall Hauch 054 */ 055 @ThreadSafe 056 public final class I18n { 057 058 private static final LocalizationRepository DEFAULT_LOCALIZATION_REPOSITORY = new ClasspathLocalizationRepository(); 059 060 /** 061 * The first level of this map indicates whether an i18n class has been localized to a particular locale. The second level 062 * contains any problems encountered during localization. 063 */ 064 static final ConcurrentMap<Locale, Map<Class<?>, Set<String>>> LOCALE_TO_CLASS_TO_PROBLEMS_MAP = new ConcurrentHashMap<Locale, Map<Class<?>, Set<String>>>(); 065 066 private static LocalizationRepository localizationRepository = DEFAULT_LOCALIZATION_REPOSITORY; 067 068 /** 069 * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class. 070 * 071 * @param i18nClass The internalization class for which localization problem locales should be returned. 072 * @return The locales for which localization problems were encountered while localizing the supplied internationalization 073 * class; never <code>null</code>. 074 */ 075 public static Set<Locale> getLocalizationProblemLocales( Class<?> i18nClass ) { 076 CheckArg.isNotNull(i18nClass, "i18nClass"); 077 Set<Locale> locales = new HashSet<Locale>(LOCALE_TO_CLASS_TO_PROBLEMS_MAP.size()); 078 for (Entry<Locale, Map<Class<?>, Set<String>>> localeEntry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) { 079 for (Entry<Class<?>, Set<String>> classEntry : localeEntry.getValue().entrySet()) { 080 if (!classEntry.getValue().isEmpty()) { 081 locales.add(localeEntry.getKey()); 082 break; 083 } 084 } 085 } 086 return locales; 087 } 088 089 /** 090 * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class. 091 * 092 * @param i18nClass The internalization class for which localization problems should be returned. 093 * @return The localization problems encountered while localizing the supplied internationalization class to the default 094 * locale; never <code>null</code>. 095 */ 096 public static Set<String> getLocalizationProblems( Class<?> i18nClass ) { 097 return getLocalizationProblems(i18nClass, null); 098 } 099 100 /** 101 * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class. 102 * 103 * @param i18nClass The internalization class for which localization problems should be returned. 104 * @param locale The locale for which localization problems should be returned. If <code>null</code>, the default locale 105 * will be used. 106 * @return The localization problems encountered while localizing the supplied internationalization class to the supplied 107 * locale; never <code>null</code>. 108 */ 109 public static Set<String> getLocalizationProblems( Class<?> i18nClass, 110 Locale locale ) { 111 CheckArg.isNotNull(i18nClass, "i18nClass"); 112 Map<Class<?>, Set<String>> classToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.get(locale == null ? Locale.getDefault() : locale); 113 if (classToProblemsMap == null) { 114 return Collections.emptySet(); 115 } 116 Set<String> problems = classToProblemsMap.get(i18nClass); 117 if (problems == null) { 118 return Collections.emptySet(); 119 } 120 return problems; 121 } 122 123 /** 124 * Get the repository of localized messages. By default, this instance uses a {@link ClasspathLocalizationRepository} that 125 * uses this class' classloader. 126 * 127 * @return localizationRepository 128 */ 129 public static LocalizationRepository getLocalizationRepository() { 130 return localizationRepository; 131 } 132 133 /** 134 * Set the repository of localized messages. If <code>null</code>, a {@link ClasspathLocalizationRepository} instance that 135 * uses this class loader will be used. 136 * 137 * @param localizationRepository the localization repository to use; may be <code>null</code> if the default repository 138 * should be used. 139 */ 140 public static void setLocalizationRepository( LocalizationRepository localizationRepository ) { 141 I18n.localizationRepository = localizationRepository != null ? localizationRepository : DEFAULT_LOCALIZATION_REPOSITORY; 142 } 143 144 /** 145 * Initializes the internationalization fields declared on the supplied class. Internationalization fields must be public, 146 * static, not final, and of type <code>I18n</code>. The supplied class must not be an interface (of course), but has no 147 * restrictions as to what class it may extend or what interfaces it must implement. 148 * 149 * @param i18nClass A class declaring one or more public, static, non-final fields of type <code>I18n</code>. 150 */ 151 public static void initialize( Class<?> i18nClass ) { 152 CheckArg.isNotNull(i18nClass, "i18nClass"); 153 if (i18nClass.isInterface()) { 154 throw new IllegalArgumentException(CommonI18n.i18nClassInterface.text(i18nClass.getName())); 155 } 156 157 synchronized (i18nClass) { 158 // Find all public static non-final String fields in the supplied class and instantiate an I18n object for each. 159 try { 160 for (Field fld : i18nClass.getDeclaredFields()) { 161 162 // Ensure field is of type I18n 163 if (fld.getType() == I18n.class) { 164 165 // Ensure field is public 166 if ((fld.getModifiers() & Modifier.PUBLIC) != Modifier.PUBLIC) { 167 throw new SystemFailureException(CommonI18n.i18nFieldNotPublic.text(fld.getName(), i18nClass)); 168 } 169 170 // Ensure field is static 171 if ((fld.getModifiers() & Modifier.STATIC) != Modifier.STATIC) { 172 throw new SystemFailureException(CommonI18n.i18nFieldNotStatic.text(fld.getName(), i18nClass)); 173 } 174 175 // Ensure field is not final 176 if ((fld.getModifiers() & Modifier.FINAL) == Modifier.FINAL) { 177 throw new SystemFailureException(CommonI18n.i18nFieldFinal.text(fld.getName(), i18nClass)); 178 } 179 180 // Ensure we can access field even if it's in a private class 181 ClassUtil.makeAccessible(fld); 182 183 // Initialize field. Do this every time the class is initialized (or re-initialized) 184 fld.set(null, new I18n(fld.getName(), i18nClass)); 185 } 186 } 187 188 // Remove all entries for the supplied i18n class to indicate it has not been localized. 189 for (Entry<Locale, Map<Class<?>, Set<String>>> entry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) { 190 entry.getValue().remove(i18nClass); 191 } 192 } catch (IllegalAccessException err) { 193 // If this happens, it will happen with the first field visited in the above loop 194 throw new IllegalArgumentException(CommonI18n.i18nClassNotPublic.text(i18nClass)); 195 } 196 } 197 } 198 199 /** 200 * Synchronized on the supplied internalization class. 201 * 202 * @param i18nClass The internalization class being localized 203 * @param locale The locale to which the supplied internationalization class should be localized. 204 */ 205 private static void localize( final Class<?> i18nClass, 206 final Locale locale ) { 207 assert i18nClass != null; 208 assert locale != null; 209 // Create a class-to-problem map for this locale if one doesn't exist, else get the existing one. 210 Map<Class<?>, Set<String>> classToProblemsMap = new ConcurrentHashMap<Class<?>, Set<String>>(); 211 Map<Class<?>, Set<String>> existingClassToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.putIfAbsent(locale, 212 classToProblemsMap); 213 if (existingClassToProblemsMap != null) { 214 classToProblemsMap = existingClassToProblemsMap; 215 } 216 // Check if already localized outside of synchronization block for 99% use-case 217 if (classToProblemsMap.get(i18nClass) != null) { 218 return; 219 } 220 synchronized (i18nClass) { 221 // Return if the supplied i18n class has already been localized to the supplied locale, despite the check outside of 222 // the synchronization block (1% use-case), else create a class-to-problems map for the class. 223 Set<String> problems = classToProblemsMap.get(i18nClass); 224 if (problems == null) { 225 problems = new CopyOnWriteArraySet<String>(); 226 classToProblemsMap.put(i18nClass, problems); 227 } else { 228 return; 229 } 230 // Get the URL to the localization properties file ... 231 final LocalizationRepository repos = getLocalizationRepository(); 232 final String localizationBaseName = i18nClass.getName(); 233 URL url = repos.getLocalizationBundle(localizationBaseName, locale); 234 if (url == null) { 235 // Nothing was found, so try the default locale 236 Locale defaultLocale = Locale.getDefault(); 237 if (!defaultLocale.equals(locale)) { 238 url = repos.getLocalizationBundle(localizationBaseName, defaultLocale); 239 } 240 // Return if no applicable localization file could be found 241 if (url == null) { 242 problems.add(CommonI18n.i18nLocalizationFileNotFound.text(localizationBaseName)); 243 return; 244 } 245 } 246 // Initialize i18n map 247 final URL finalUrl = url; 248 final Set<String> finalProblems = problems; 249 Properties props = new Properties() { 250 251 /** 252 */ 253 private static final long serialVersionUID = 3920620306881072843L; 254 255 @Override 256 public synchronized Object put( Object key, 257 Object value ) { 258 String id = (String)key; 259 String text = (String)value; 260 261 try { 262 Field fld = i18nClass.getDeclaredField(id); 263 if (fld.getType() != I18n.class) { 264 // Invalid field type 265 finalProblems.add(CommonI18n.i18nFieldInvalidType.text(id, finalUrl, getClass().getName())); 266 } else { 267 I18n i18n = (I18n)fld.get(null); 268 if (i18n.localeToTextMap.putIfAbsent(locale, text) != null) { 269 // Duplicate id encountered 270 String prevProblem = i18n.localeToProblemMap.putIfAbsent(locale, 271 CommonI18n.i18nPropertyDuplicate.text(id, 272 finalUrl)); 273 assert prevProblem == null; 274 } 275 } 276 } catch (NoSuchFieldException err) { 277 // No corresponding field exists 278 finalProblems.add(CommonI18n.i18nPropertyUnused.text(id, finalUrl)); 279 } catch (IllegalAccessException notPossible) { 280 // Would have already occurred in initialize method, but allowing for the impossible... 281 finalProblems.add(notPossible.getMessage()); 282 } 283 284 return null; 285 } 286 }; 287 288 try { 289 InputStream propStream = url.openStream(); 290 try { 291 props.load(propStream); 292 // Check for uninitialized fields 293 for (Field fld : i18nClass.getDeclaredFields()) { 294 if (fld.getType() == I18n.class) { 295 try { 296 I18n i18n = (I18n)fld.get(null); 297 if (i18n.localeToTextMap.get(locale) == null) { 298 i18n.localeToProblemMap.put(locale, CommonI18n.i18nPropertyMissing.text(fld.getName(), url)); 299 } 300 } catch (IllegalAccessException notPossible) { 301 // Would have already occurred in initialize method, but allowing for the impossible... 302 finalProblems.add(notPossible.getMessage()); 303 } 304 } 305 } 306 } finally { 307 propStream.close(); 308 } 309 } catch (IOException err) { 310 finalProblems.add(err.getMessage()); 311 } 312 } 313 } 314 315 private final String id; 316 private final Class<?> i18nClass; 317 final ConcurrentHashMap<Locale, String> localeToTextMap = new ConcurrentHashMap<Locale, String>(); 318 final ConcurrentHashMap<Locale, String> localeToProblemMap = new ConcurrentHashMap<Locale, String>(); 319 320 private I18n( String id, 321 Class<?> i18nClass ) { 322 this.id = id; 323 this.i18nClass = i18nClass; 324 } 325 326 /** 327 * @return This internationalization object's ID, which will match both the name of the relevant static field in the 328 * internationalization class and the relevant property name in the associated localization files. 329 */ 330 public String id() { 331 return id; 332 } 333 334 /** 335 * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the default 336 * locale. 337 */ 338 public boolean hasProblem() { 339 return (problem() != null); 340 } 341 342 /** 343 * @param locale The locale for which to check whether a problem was encountered. 344 * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the supplied 345 * locale. 346 */ 347 public boolean hasProblem( Locale locale ) { 348 return (problem(locale) != null); 349 } 350 351 /** 352 * @return The problem encountered while localizing this internationalization object to the default locale, or 353 * <code>null</code> if none was encountered. 354 */ 355 public String problem() { 356 return problem(null); 357 } 358 359 /** 360 * @param locale The locale for which to return the problem. 361 * @return The problem encountered while localizing this internationalization object to the supplied locale, or 362 * <code>null</code> if none was encountered. 363 */ 364 public String problem( Locale locale ) { 365 if (locale == null) { 366 locale = Locale.getDefault(); 367 } 368 localize(i18nClass, locale); 369 // Check for field/property error 370 String problem = localeToProblemMap.get(locale); 371 if (problem != null) { 372 return problem; 373 } 374 // Check if text exists 375 if (localeToTextMap.get(locale) != null) { 376 // If so, no problem exists 377 return null; 378 } 379 // If we get here, which will be at most once, there was at least one global localization error, so just return a message 380 // indicating to look them up. 381 problem = CommonI18n.i18nLocalizationProblems.text(i18nClass, locale); 382 localeToProblemMap.put(locale, problem); 383 return problem; 384 } 385 386 private String rawText( Locale locale ) { 387 assert locale != null; 388 localize(i18nClass, locale); 389 // Check if text exists 390 String text = localeToTextMap.get(locale); 391 if (text != null) { 392 return text; 393 } 394 // If not, there was a problem, so throw it within an exception so upstream callers can tell the difference between normal 395 // text and problem text. 396 throw new SystemFailureException(problem(locale)); 397 } 398 399 /** 400 * Get the localized text for the {@link Locale#getDefault() current (default) locale}, replacing the parameters in the text 401 * with those supplied. 402 * 403 * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty 404 * @return the localized text 405 */ 406 public String text( Object... arguments ) { 407 return text(null, arguments); 408 } 409 410 /** 411 * Get the localized text for the supplied locale, replacing the parameters in the text with those supplied. 412 * 413 * @param locale the locale, or <code>null</code> if the {@link Locale#getDefault() current (default) locale} should be used 414 * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty 415 * @return the localized text 416 */ 417 public String text( Locale locale, 418 Object... arguments ) { 419 try { 420 String rawText = rawText(locale == null ? Locale.getDefault() : locale); 421 return StringUtil.createString(rawText, arguments); 422 } catch (IllegalArgumentException err) { 423 throw new IllegalArgumentException(CommonI18n.i18nRequiredToSuppliedParameterMismatch.text(id, 424 i18nClass, 425 err.getMessage())); 426 } catch (SystemFailureException err) { 427 return '<' + err.getMessage() + '>'; 428 } 429 } 430 431 /** 432 * {@inheritDoc} 433 */ 434 @Override 435 public String toString() { 436 try { 437 return rawText(Locale.getDefault()); 438 } catch (SystemFailureException err) { 439 return '<' + err.getMessage() + '>'; 440 } 441 } 442 }