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