A Java internationalization framework that:
ResourceBundle API with JSON and XML resource bundles to serve complex, typed objects without code or compilationToo often, software is built for a local market first, then reworked to support internationalization, then localized for additional markets. That middle step is rework, and rework often produces a lower quality result at higher cost than getting it right the first time. An internationalization framework should make it natural to build global software from the start, so that internationalization is part of the design rather than a retrofit.
Designers need a vocabulary for expressing what is locale-sensitive and what is not. The Localizable and Resourceful interfaces provide that vocabulary. Marking a class as Localizable declares that it has locale-dependent behavior and can serve as a source of localized resources. Marking an object as Resourceful declares that it depends on an external source for its localized content. These distinctions are visible in the design, not buried in implementation details.
A single English word may require different translations depending on context. The word "cancel" on a button that dismisses a dialog has a different meaning than "cancel" on a radio button that voids a financial instrument. Translators need context to choose the right term, and the class hierarchy provides that context naturally.
Polymorphic properties allow resource bundles to follow the same inheritance rules as the classes they serve. A ResourceBundle for class Foo extends Bar first searches Foo's resources, then falls back to Bar's resources, mirroring method resolution. This enables appropriate reuse: shared translations are defined once in a base class, while subclasses override only what their specific context requires.
Reusing localized resources saves time and money, but reusing them out of context results in poor translations. The polymorphic property mechanism strikes the right balance. Resources are inherited by default, so common translations are never duplicated. But each class can override any resource to provide a translation that fits its specific context. If two classes send the same message, that may be an opportunity for further abstraction in the design.
PropertyResourceBundle supports only strings. ListResourceBundle supports arbitrary objects but requires Java code and a compilation step for every locale. JSON and XML resource bundles eliminate this trade-off: they can represent complex typed objects (images, structured settings, grouped properties) while remaining editable by translators and localizers without a development environment. Adding a new locale or changing a tooltip is an edit to a resource file, not a code change.
Applications, particularly multilingual web applications, may need to change locale at runtime. The Localizable interface includes a LocaleEvent mechanism so that when one object changes its locale, all interested listeners can update themselves automatically.
Deployment of ResourceBundles in modular applications requires the use of the ResourceBundleProvider service API. This can cause rework when changing the deployment model. The framework supports use of ResourceBundles in either deployment model, so long as the additional module declarations and service configuration are provided for module deployment.
<dependency>
<groupId>dev.javai18n</groupId>
<artifactId>i18n-core</artifactId>
<version>1.4.0</version>
</dependency>
module my.module
{
requires dev.javai18n.core;
}
public class MyComponent extends LocalizableImpl
{
static
{
// Required once per module — registers a callback
// so the library can load resource bundles from
// this module's context.
// The callback argument must specify a module singleton
// implementation of the GetResourceBundleCallback
// interface.
GetResourceBundleRegistrar
.registerGetResourceBundleCallback(callback);
}
public String getGreeting()
{
return getResourceBundle().getString("greeting");
}
}
By convention, the library appends Bundle to the class name when searching for resources. For com.example.MyComponent, it searches for com/example/MyComponentBundle in these formats (in order):
.json).xml).properties)MyComponentBundle.properties:
greeting=Hello!
MyComponentBundle_fr.properties:
greeting=Bonjour!
MyComponent comp = new MyComponent();
comp.setBundleLocale(Locale.ENGLISH);
comp.getGreeting(); // "Hello!"
comp.setBundleLocale(Locale.FRENCH);
comp.getGreeting(); // "Bonjour!"
Standard Java .properties files with string key-value pairs.
JSON resource bundles support strings, numbers, booleans, arrays, and custom typed objects:
{
"greeting": "Hello!",
"count": 42,
"colors": ["red", "green", "blue"],
"settings":
{
"type": "com.example.AppSettings",
"theme": "dark",
"fontSize": 14
}
}
XML resource bundles use a superset of the standard Java properties DTD:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties PUBLIC "-//dev.javai18n//DTD Properties//EN" "dev/javai18n/core/properties.dtd">
<properties>
<entry key="greeting">Hello!</entry>
<entry key="colors">
<array>
<item>red</item>
<item>green</item>
<item>blue</item>
</array>
</entry>
<entry key="settings">
<object type="com.example.AppSettings">
<entry key="theme">dark</entry>
<entry key="fontSize">14</entry>
</object>
</entry>
</properties>
To use typed objects in JSON or XML bundles, implement the AttributeCollection interface and register the package:
public class AppSettings
implements AttributeCollection
{
private String theme;
private int fontSize;
// public no-arg constructor required
public AppSettings() {}
@Override
public void setAttribute(
String name, Object value)
{
switch (name)
{
case "theme" ->
this.theme = (String) value;
case "fontSize" ->
this.fontSize = (Integer) value;
}
}
public String getTheme() { return theme; }
public int getFontSize() { return fontSize; }
}
// Register before loading any resource bundles
// that reference this package
AttributeCollectionResourceBundle.registerAttributeCollectionPackage("com.example");
The Localizable interface provides getBundleLocale(), setBundleLocale(), getResourceBundle(), and fires LocaleEvents when the object's locale changes. Use LocalizableImpl or LocalizationDelegate as base implementations.
The Resourceful interface marks objects that receive their localized resources from an external Resource. A Resource encapsulates a Localizable source and a string key, providing getString(), getObject(), and getStringArray() accessors. This is useful for reusable components like buttons and labels that appear in many contexts and cannot provide their own localized resources.
Some objects may be both Localizable and Resourceful. For example, a menu that manages its own locale but also needs external resources for its label.
A NestedResourceBundle wraps a standard ResourceBundle (the delegate) and links to an optional superBundle representing the next level up in a nesting hierarchy. When a key is looked up:
This is a general-purpose nesting mechanism. It does not dictate what the hierarchy represents; it simply provides a multi-level fallback search across linked ResourceBundles.
LocalizationDelegate uses NestedResourceBundle to build a resource hierarchy that mirrors the class hierarchy. When getNestedResourceBundle() is called, it walks from the concrete class up through each superclass that implements Localizable, loads a ResourceBundle for each class (via the registered GetResourceBundleCallback), and links them together as nested levels. The most derived class is at the top; the base class is at the bottom.
For class Foo extends Bar, the resulting structure looks like this:
FooBundle_fr_CA -> FooBundle_fr -> FooBundle
| |
BarBundle_fr_CA -> BarBundle_fr -> BarBundle
Horizontal arrows represent locale fallback (the standard ResourceBundle parent chain). Vertical arrows represent the superBundle link between nesting levels. A key lookup starts at FooBundle_fr_CA, walks across to FooBundle, then drops down to BarBundle_fr_CA and walks across to BarBundle.
This means subclasses inherit all parent resources by default and can override any of them simply by defining the same key in their own bundle.
public class MyListener implements LocaleEventListener
{
public void processLocaleEvent(LocaleEvent event)
{
// Update UI, refresh data, etc.
}
}
MyListener listener = new MyListener();
myLocalizable.addLocaleEventListener(listener);
// fires event and invokes MyListener.processLocaleEvent()
myLocalizable.setBundleLocale(Locale.JAPANESE);
For non-modular applications, the AssociativeResourceBundleControl class provides support for loading ResourceBundles from JSON and XML files (as well as java class and .properties files). The AssociativeResourceBundleControlProvider class implements the ResourceBundleControlProvider interface and is referenced in the META-INF/services/java.util.spi.ResourceBundleControlProvider file so that this behavior is the default for non-modular applications.
JPMS applications will use the AssociativeResourceBundleProvider class, which extends the JDK's AbstractResourceBundleProvider class. The following steps are required in order to make a ResourceBundle (say org/example/MyClassBundle.json - a ResourceBundle associated with the org.example.MyClass class) available in a JPMS application:
1. Declare that the module uses and provides an spi interface for the bundle:
In module-info.java for the JPMS application:
uses org.example.spi.MyClassProvider;
provides org.example.spi.MyClassProvider with dev.javai18n.core.test.spi.ModuleProviderImpl;
2. Provide an interface for the provider spi:
In org/example/spi/MyClassProvider.java:
import java.util.spi.ResourceBundleProvider;
/**
* The service provider interface for the MyClass bundle.
*/
public interface MyClassProvider extends ResourceBundleProvider {}
3. Provide an implementation for the provider spi, extending AssociativeResourceBundleProvider:
In org/example/spi/ModuleProviderImpl.java:
import dev.javai18n.core.AssociativeResourceBundleProvider;
/**
* An AssociativeResourceBundleProvider that implements the ResourceBundleProvider interfaces defined in this module.
*/
public class ModuleProviderImpl extends AssociativeResourceBundleProvider
implements MyClassProvider {}
NOTE: Additional spi implementations in the package can be added to the 'implements' clause, rather than creating an implementation for each interface.
The AssociativeResourceBundleProvider and AssociativeResourceBundleControl both make use of the AssociativeResourceBundleLocator class to locate and load ResourceBundles.
In a standard JPMS application, ResourceBundle lookups are restricted to the resources deployed with the module. Since this framework supports polymorphic resource inheritance and classes may extend classes that are defined in a different module, each module must register a GetResourceBundleCallback so the library can load bundles from the correct module context:
module my.app
{
requires dev.javai18n.core;
}
// In a static initializer or module bootstrap:
GetResourceBundleRegistrar.registerGetResourceBundleCallback(callback);
Where callback implements the GetResourceBundleCallback interface. Note that only one callback object may be defined per module.
Editorial comment: The ResourceBundleProvider spi doesn't strike one as elegant design, given the deployment steps required to use it...
| Class / Interface | Description |
|---|---|
Localizable | Interface for locale-aware objects |
Localizable.LocaleEvent | Event fired when a Localizable object changes its locale |
Localizable.LocaleEventListener | Listener interface for LocaleEvents |
LocalizableImpl | Base class implementing Localizable |
LocalizableLogger | A System.Logger that is Localizable |
LocalizationDelegate | Delegation helper for bundles and polymorphic inheritance support |
Resourceful | Interface for objects with a Resource |
Resource | Encapsulates source and key for lookup |
ResourcefulDelegate | Delegation helper for Resourceful behavior |
NestedResourceBundle | ResourceBundle hierarchy support |
JsonResourceBundle | Bundle loaded from JSON |
XMLResourceBundle | Bundle loaded from XML |
AttributeCollection | Interface for typed objects from JSON/XML entries |
AttributeCollectionResourceBundle | Base for JSON/XML bundles |
AssociativeResourceBundleLocator | Multi-format bundle locator |
AssociativeResourceBundleControl | ResourceBundle.Control using AssociativeResourceBundleLocator |
AssociativeResourceBundleControlProvider | ResourceBundleControlProvider using AssociativeResourceBundleControl |
AssociativeResourceBundleProvider | A ResourceBundleProvider implementation for modular environments |
GetResourceBundleCallback | Interface for cross-module bundle loading |
GetResourceBundleRegistrar | Registry for module callbacks |
ModuleResourceBundleCallback | Default GetResourceBundleCallback implementation |
ResourceStreamLoader | Helper for loading resources via Modules or ClassLoaders |
NoCallbackRegisteredForModuleException | An exception generated when no ResourceBundle.getBundle() callback has been registered for a module |
mvn clean package
To build with sources JAR, javadoc JAR, and GPG signing for release:
mvn -Prelease clean package
To execute unit tests under JPMS:
mvn clean test -Ptest-modulepath
To execute unit test on the classpath:
mvn clean test
You will see some messages on std out that confirm whether the tests are running under JPMS or the classpath:
[INFO] [exec] === MODULE DEBUG INFO ===
[INFO] [exec] Module isNamed: true
[INFO] [exec] Module name: dev.javai18n.core.test
[INFO] [exec] Module descriptor: module ...
[INFO] [exec] =========================
[INFO] [exec] jdk.module.path:...
[INFO] [exec] java.class.path:
[INFO] [exec] =========================
[INFO] [exec] Running in MODULE mode
[INFO] [exec] Module name: dev.javai18n.core.test
This project is licensed under the Apache License, Version 2.0.