A Java internationalization framework that:
- Enables developers to designate Locale-sensitive objects via the Localizable and Resourceful interfaces
- Extends the standard
ResourceBundleAPI with JSON and XML resource bundles to serve complex, typed objects without code or compilation - Supports polymorphic resource inheritance to enable re-use of localized content
- Provides locale change events for dynamic Locale selection
- Supports ResourceBundle deployment in either classpath or module mode
Too 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.
- Java 17 or later
- Jackson Databind (provided as a transitive dependency)
<dependency>
<groupId>dev.javai18n</groupId>
<artifactId>i18n-core</artifactId>
<version>1.4.1</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):
- Java class
- JSON (
.json) - XML (
.xml) - Properties (
.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:
- The delegate is searched first.
- If not found, the locale fallback parent chain is walked, searching each parent's delegate.
- If still not found, the lookup continues into the superBundle, repeating from step 1 at the next level.
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 packageTo build with sources JAR, javadoc JAR, and GPG signing for release:
mvn -Prelease clean packageTo execute unit tests under JPMS:
mvn clean test -Ptest-modulepathTo execute unit test on the classpath:
mvn clean testYou 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.testThis project is licensed under the Apache License, Version 2.0.