Fluent API
The library provides a fluent API to load and format messages that is built on top of Java standard libraries. In addition to simplifying the overall setup, it brings strong typing and compile time checks to the mix when used with the annotation processor.
Use L10nMessages.builder()
to create instances. Ideally, use the annotation processor to make the
initialization simpler using the generated enum. Customize
"failure handling" to add logging or to change default behaviors. Consider
using caching when performance is sensitive. The builder can also be fully
configured manually if the annotation processor is not used.
L10nMessages
instance provides only one function format()
with different overloads for ease of
use. format()
takes care of loading the localized message from the
resource bundle for the given locale. When required, it
formats the message using the arguments passed to the function and the
base arguments.
Create an L10nMessages
instance
With the annotation processor
Create the L10nMessages
instance from the generated enum
using the builder()
. It will
automatically load the resource bundle baseName
and the type of MessageFormatAdapterProvider
that were defined in the @L10nProperties
annotation.
The first argument of format()
function will now be
one of the enum values. This
provides the strong typing of the key and ensures that messages are present at build time.
@L10nProperties(baseName = "com.pinterest.l10nmessages.example.Messages")
class Example {
{
L10nMessages<Messages> m = L10nMessages.builder(Messages.class).build();
System.out.println(m.format(welcome));
// Welcome!
}
}
Without the annotation processor
Without the enum
generated by the annotation processor, the resource bundle baseName
must be
provided manually with resourceBundleName()
. The L10nMessages
instance is manually typed.
String
is the most common type and is equivalent to the default Java support with a plain resource
bundle.
class Example {
{
L10nMessages<String> m =
L10nMessages.builder(String.class)
.resourceBundleName("com.pinterest.l10nmessages.example.Messages").build();
System.out.println(m.format("welcome"));
}
}
But the type can be any type that implements toString()
, for example a user defined enum:
class Example {
enum MyEnum {
welcome,
welcome_user
}
{
L10nMessages<MyEnum> m =
L10nMessages.builder(MyEnum.class)
.resourceBundleName("com.pinterest.l10nmessages.example.Messages").build();
System.out.println(m.format(MyEnum.welcome));
}
}
Specify a locale
The builder is created with the system locale: Locale.getDefault()
as default locale. The locale
can then be overridden using the locale()
. Changing the system locale after the builder has been
created has no impact on the creation of L10nMessages
instance (since it was set when the builder
was created).
It is not possible to change the locale of the L10nMessasges
instance later, so it should be done
on on the builder.
To specify the locale:
class Example {
{
L10nMessages<Messages> m = L10nMessages.builder(Messages.class)
.locale(Locale.forLanguageTag("fr-FR")).build();
System.out.println(m.format(welcome));
// Bienvenue!
}
}
Formatting
Formatting capabilities depends on which MessageFormat
implementation is used, see
more details here. This section covers the basic usages of
format()
.
format()
The first argument of format()
is the key of the message to load and optionally format. When using
the enum, the key is typed accordingly.
formatUntyped
can be used to look up the message using an untyped String as the key.
L10nMessages
always use named arguments (even when working with plain JDK and with messages that
have numbered arguments). All arguments are provided as map entries. The format()
function has
overloads to pass map entries or a full map.
welcome_user=Welcome {username}!
welcome_user_numbered=Welcome {0}!
class Example {
{
L10nMessages<Messages> m = L10nMessages.builder(Messages.class).build();
// format with map entries
System.out.println(m.format(welcome_user, "username", "Bob"));
// format with a map of arguments
System.out.println(m.format(welcome_user, ImmutableMap.of("username", "Bob")));
// Numbered arguments are referenced by name as any other arguments
System.out.println(m.format(welcome_user_numbered, "0", "Bob"));
// It is possible to load a message with an untyped key
System.out.println(m.formatUntyped("welcome_user", ImmutableMap.of("username", "Bob")));
}
}
Check ICU4J for advance formatting.
Argument names typing
Use enumType = WITH_ARGUMENT_BUILDERS
to generate additional "argument builders" / FormatContext
in the enum
. These provide strong typing on the argument names yet keeping a compact syntax.
Use the function Message.welcome_user()
to obtain the builder and then call the argument setter
username("Mary")
.
@L10nProperties(
baseName = "com.pinterest.l10nmessages.example.Messages",
enumType = EnumType.WITH_ARGUMENT_BUILDERS)
public class Example {
{
// Format using an "argument builder" / FormatContext
m.format(welcome_user().username("Mary"));
}
}
The enum value: Message.welcome_user
references the key, while the method:
Message.welcome_user()
creates an argument builder / FormatContext
.
Both style of format()
functions can be used at the same time for a same message.
No builder will be generated for messages with no arguments. Only the key
in the enum
will be
generated.
Base arguments
If multiple messages have the same arguments, having to provide them through the format()
every
single time can be cumbersome. Instead, "base" arguments can be defined on the L10nMessages
instance. Arguments provided to the format()
override the base arguments.
class Example {
{
L10nMessages<Messages> m = L10nMessages.builder(Messages.class)
.baseArguments(ImmutableMap.of("username", "Mary")).build();
System.out.println(m.format(welcome_user));
// Welcome Mary!
System.out.println(m.format(bye_user));
// Bye Mary...
System.out.println(m.format(bye_user, "username", "Bob"));
// Bye Bob...
}
}
Failure handling
In plain Java, following cases will throw an exception and, most likely cause a hard failure at the application level:
- trying to get a resource bundle with no matching properties file
- looking up for a message using a missing key in a resource bundle
- trying to create a
MessageFormat
with an invalid message - formatting a message with invalid arguments
L10nMessages
will instead, by default, gracefully fail by showing the broken message or the
missing key. Use OnFormatErrors.RETHROW
to propagate exceptions if wanted.
On the other hand, in plain Java, missing arguments when formatting a message are not causing an exception, and the rendered string just contains the original placeholder untouched.
L10nMessages
keeps that behavior as default to be consistent with other formatting issues. It is
possible to raise an exception instead using OnMissingArguments.THROW
if hard failure are wanted.
Note that when using the annotation processor's default configuration with properties backed resource bundle, the invalid messages will be identified at compile time. Missing keys should be avoided by using the enum.
Customize formatting error handling
The behavior can be customized by providing a customized OnFormatError
implementation. This
example adds logging to the default behavior that fallbacks to the key or the message.
class Example {
{
L10nMessages<Messages> m = L10nMessages.builder(Messages.class)
.messageFormatAdapterProvider(JDK)
.onFormatError(
(throwable, baseName, key, message) -> {
logger.severe(
String.format(
"Can't format message for baseName: `%1$s`, key: `%2$s` and message: `%3$s`",
baseName,
key,
message));
return OnFormatErrors.MESSAGE_FALLBACK.apply(throwable, baseName, key, message);
}).build();
String format = m.formatUntyped("missing_key", ImmutableMap.of());
// SEVERE: Can't format message for baseName: `com.pinterest.l10nmessages.example.Messages`, key: `missing_key` and message: `null`
System.out.println(format);
// missing_key
String invalidMessage = m.format(welcome, ImmutableMap.of("username", "Mary"));
// SEVERE: Can't format message for baseName: `com.pinterest.l10nmessages.example.Messages`, key: `welcome` and message: `Welcome {username}!`
System.out.println(invalidMessage);
// Welcome {username}!
}
}
Customize missing arguments handling
The behavior can be customized by providing a customized onMissingArgument
implementation. This
example adds logging to the default behavior. It is possible to replace the argument by returning an
Optional
with the replacement value.
class Example {
{
L10nMessages<Messages> m =
L10nMessages.builder(Messages.class)
.onMissingArgument((baseName, key, argumentName) -> {
logger.severe(
String.format("Argument: `%3$s` missing for baseName: `%1$s` and key: `%2$s`",
baseName, key, argumentName));
return OnMissingArguments.NOOP.apply(baseName, key, argumentName);
}).build();
System.out.println(m.format(connected_to, "username", "Mary"));
// SEVERE: Argument: `username2` missing for baseName: `com.pinterest.l10nmessages.example.Messages` and key: `connected_to`
// Mary is connected to {username2}
}
}
ICU4J, JDK or JDK with named arguments
By default, the fluent API (as the annotation processor) will use ICU4J if it is available. If not, it will use the JDK extended with named arguments support.
When initialized from the enum
, L10nMessages
will use the same messageFormatAdapterProvider
that was used by the annotation processor.
To provide a custom implementation:
class Example {
{
L10nMessages<Messages> m =
L10nMessages.builder(Messages.class)
.messageFormatAdapterProvider(
(message, locale) -> {
logger.info("Using named argument with JDK");
return JDK_NAMED_ARGS.get(message, locale);
}).build();
System.out.println(m.format(welcome_user, "username", "Mary"));
// INFO: Using named argument with JDK...
// Welcome Mary!
}
}
Named vs. Numbered arguments
Plain JDK MessageFormat
only support numbered arguments.
welcome_user=Welcome {0}!
ICU4J on ther other hand supports both numbered arguments and named arguments
welcome_user=Welcome {username}!
Named arguments with JDK MessageFormat
L10nMessages
provides a lightweight extension to support named arguments on top JDK
MessageFormat
s.
If you can't or don't wish to integrate with ICU4J because of its size or dependencies but still want named arguments, this is an interesting trade off.
By default, the library will use JDK with named argument
if ICU4J
is not used. To use plain
JDK
usually set the messageFormatAdapterProviders
at the annotation level to get the proper
message format validation.
@L10nProperties(
baseName = "com.pinterest.l10nmessages.example.Messages",
messageFormatAdapterProviders = MessageFormatAdapterProviders.JDK)
class Example {
{
L10nMessages<MessagesNamed> m = L10nMessages.builder(Messages.class).build();
System.out.println(m.format(MessagesNamed.welcome_user, "0", "Mary"));
}
}
Or just set it up on the builder
class Example {
{
L10nMessages<String> m = L10nMessages.<String>builder()
.resourceBundleName("com.pinterest.l10nmessages.example.Messages")
.messageFormatAdapterProvider(MessageFormatAdapterProviders.JDK)
.build();
System.out.println(m.format("welcome_user", "0", "Mary"));
}
}
arguments in Java?
The formatter can be used directly and since the library is lightweight and has no dependencies that can be a good option.
class Example {
{
MessageFormatAdapter mf =
MessageFormatAdapterProviders.JDK_NAMED_ARGS.get("Welcome {username}", Locale.ROOT);
System.out.println(mf.format(ImmutableMap.of("username", "Mary")));
}
}