Consider a simplified example. Let's say our problem involves aggregating error messages during request processing then delivering the collection of messages to the client, perhaps translated into the user's preferred language. Each anomaly encountered in the request prompts a call to our API that carries sufficient information to characterize the problem. In what form should information be passed?
For larger applications, a String-based API is a bad idea. The semantics are ambiguous, and to prove that all calls to the API throughout the code base are correct would be unnecessarily difficult. We'll examine this more closely in what follows; first take a look at a couple of ways in which this might be implemented:
// API method: String argument
public void addMessage(String s) {
...
}
// Example (1) client code: String argument is actual message
if ((name == null) || (name.length() == 0))
msg = ResourceBundle.getBundle(...).getMessage(NAME_REQUIRED);
errors.addMessage(msg);
}
// Example (2) client code: String argument is key to message
if ((name == null) || (name.length() == 0))
errors.addMessage(NAME_REQUIRED);
}
Example (2) is only slightly better. In this case the API requires that clients supply a key indicating which message to use; it also hides the critical implementation details. All of this represents improvement but ambiguities remain. The compiler cannot distinguish misspelled keys, for example. And to prove that the code is correct still depends essentially on careful inspection.
A substantial solution involves more specific types. The approach presented here will take advantage of existing APIs when possible, and will behave much like a typesafe constant. In the following code snippets, we use Message instances; in practice one would use types that have more specific meaning. For example, ErrorMessage, Warning, or FieldLabel might make sense.
// API method
public void addMessage(Message msg) { ... }
// client code
if ((name == null) || (name.length() == 0)) {
errors.addMessage(Message.NAME REQUIRED);
}
There are certainly several reasonable ways in which one might parameterize Message instances with localized text. I'll offer one such approach here. Message text in our hypothetical application will be externalized in properties files named according to the conventions specified by the ResourceBundle API. ResourceBundle will also be used to initially retrieve the text. In addition, we will require that names of Message instances be identical to the keys specified in the locale-specific properties files. Our design will enforce this simple, but obviously quite useful constraint. (For more detail, download the code and have a look. Do look at the unit tests as well.)
Examine first the source for Message. Developers who adopt this approach would create classes like this one, with meaningful names to suit their needs. It looks like an ordinary typesafe constant — but with a twist. As one might expect, the constructor is private; the only instances of Message are the static final members. There is a static initializer that calls init, defined in the base class, which handles the heavier work of retrieving text from its external location and parameterizing subclasses. As input it requires a class to parameterize, and a bundle name.
public class Message extends Externalized
{
public static Message NAME_REQUIRED = new Message();
public static Message INVALID_DATE = new Message();
private Message() {}
static {
init(Message.class, "Messages");
}
}
public abstract class Externalized
{
/**
* Initialize specified class using the named bundle.
* Equivalent to init(clazz, bundleName, null).
*/
protected static final void init(Class clazz, String bundleName) {
init(clazz, bundleName, null);
}
/**
* Initialize specified class using the named bundle and locales.
*/
protected static final void init(
Class clazz,
String bundleName,
Locale[] supportedLocales) {
try {
Field[] fields = clazz.getFields();
ResourceBundle bundle = ResourceBundle.getBundle(bundleName);
populate(fields, Locale.getDefault(), bundle);
if (supportedLocales == null) return;
for (Locale locale : supportedLocales) {
bundle = ResourceBundle.getBundle(bundleName, locale);
populate(fields, locale, bundle);
}
} catch (Throwable ex) {
ex.printStackTrace();
String msg = ex.getMessage();
throw new IllegalStateException(msg);
}
}
private static void populate(
Field[] fields,
Locale locale,
ResourceBundle bundle)
throws IllegalAccessException
{
for (Field f : fields) {
if (Externalized.class.isAssignableFrom(f.getType())) {
Externalized ext = (Externalized) f.get(null);
ext.localeValues.put(locale, bundle.getString(f.getName()));
}
}
}
/**
* Collection of messages, one for each supported Locale.
* The default locale is always represented.
*/
protected Map localeValues = new HashMap();
protected Externalized() {}
/**
* Return value for default Locale.
*/
public final String toString() {
return this.localeValues.get(Locale.getDefault());
}
/**
* Return value for specified Locale.
*/
public final String toString(Locale locale) {
String msg = this.localeValues.get(locale);
return (msg != null) ? msg : toString();
}
}