View on GitHub

JLibs

Common Utilities for Java

Internationalization made easier

In traditional approach, we create properties file;
But with JLibs you create an interface and annotate it with @ResourceBundle

Dependencies

<dependency>
    <groupId>in.jlibs</groupId>
    <artifactId>jlibs-i18n</artifactId>
    <version>2.2.1</version>
</dependency> 

<dependency>
    <groupId>in.jlibs</groupId>
    <artifactId>jlibs-i18n-apt</artifactId>
    <version>2.2.1</version>
    <optional>true</optional>
</dependency> 

jlibs-i18n-apt contains annotation processor and is required only at compile time

Eclipse

Eclipse does not do automatic annotation processing from classpath currently. See Bug 280542

So you need to manually configure this as below:

Sample Code

import jlibs.core.util.i18n.I18N;
import jlibs.core.util.i18n.Message;
import jlibs.core.util.i18n.ResourceBundle;

@ResourceBundle
public interface DBBundle{
    public static final DBBundle DB_BUNDLE = I18N.getImplementation(DBBundle.class);
    
    @Message("SQL Execution completed in {0} seconds with {1} errors")
    public String executionFinished(long seconds, int errorCount);

    @Message(key="SQLExecutionException", value="Encountered an exception while executing the following statement:\n{0}")
    public String executionException(String query);

    @Message("executing {0}")
    public String executing(String query);
}

let us walk through code:

@ResourceBundle
public interface DBBundle{

@ResourceBundle says that this interface is used for I18N purpose. This annotation can be applied only on interface.

all methods in this interface should be annotated with @Message.
For each message you want, you will add a method in this interface

@Message("SQL Execution completed in {0} seconds with {1} errors")
public String executionFinished(long seconds, int errorCount);

here the key of message is the name of the method. i.e, executionFinished
and the value of message is SQL Execution completed in {0} seconds with {1} errors

@Message(key="SQLExecutionException", value="Encountered an exception while executing the following statement:\n{0}")
public String executionException(String query);

here we are explicitly specifying key as SQLExecutionException

When you compile this interface with jlibs-core.jar in classpath, it will generate:

public static final DBBundle DB_BUNDLE = I18N.getImplementation(DBBundle.class);

I18N.getImplementation(DBBundle.class) returns an instance of _Bundle class that is generated.

You can have more than one interface with @ResourceBundle in a package. In such case:

i.e it would be easier to group messages based on the context they are used. Let us say I have UIBundle interface in same package, which contains messages used by UI:

@ResourceBundle
public interface UIBundle{
    public static final UIBundle UI_BUNDLE = I18N.getImplementation(UIBundle.class);

    @Message("Execute")
    public String executeButton();
    
    @Message("File {0} already exists.  Do you really want to replace it?")
    public String confirmReplace(File file);
}

DBBundle contains all messages used in database interaction
UIBundle contains all messages used by UI classes

let us see sample code using these bundles:

import static i18n.DBBundle.DB_BUNDLE;
import static i18n.UIBundle.UI_BUNDLE;

executeButton.setText(UI_BUNDLE.executeButton());

try{
    System.out.println(DB_BUNDLE.executing(query));
    // execute query
    System.out.println(DB_BUNDLE.executionFinished(5, 0));
}catch(SQLException ex){
    System.out.println(DB_BUNDLE.executionException(query));
}

You can see that, the code looks clean without any hardcoded message keys.

Tip: If you replace I18N.getImplementation(UIBundle.class) with _Bundle.INSTANCE, then you dont even need to add jlibs-i18n dependency. i.e, your project has no runtime dependencies on jlibs.

Documentation

@Message("SQL Execution completed in {0} seconds with {1} errors")
public String executionFinished(long seconds, int errorCount);

the message generated in Bundle.properties will be:

# {0} seconds
# {1} errorCount
executionFinished=SQL Execution completed in {0} seconds with {1} errors

the generated message tells what {0} and {1}` are referring to.
This makes the job of translator (who is translating to some other language) easier, because he/she now understand the message better.

/**
  * thrown when failed to load application
  * because of network failure
  *
  * @param application   UID of application
  * @param version       version of the application
*/
@Message(key = "cannotKillApplication", value="failed to kill application {0} with version {1}")
public String cannotKillApplication(String application, String version);

the message generated in Bundle.properties will be:

# thrown when failed to load application
# because of network failure
# {0} application ==> UID of application
# {1} version ==> version of the application
cannotKillApplication=failed to kill application {0} with version {1}

i.e, any additional javadoc specified is also made available in generated Bundle.properties.
This makes the job of translator more comfortable.

Bundle.properties generated for DBBundle, UIBundle will look as below:

# DON'T EDIT THIS FILE. THIS IS GENERATED BY JLIBS
# @author Santhosh Kumar T

#-------------------------------------------------[ DBBundle ]---------------------------------------------------

# {0} query
executing=executing {0}

# {0} query
SQLExecutionException=Encountered an exception while executing the following statement:{0}

# {0} seconds
# {1} errorCount
executionFinished=SQL Execution completed in {0} seconds with {1} errors

#-------------------------------------------------[ UIBundle ]---------------------------------------------------

executeButton=Execute

# {0} file
confirmReplace=File {0} already exists.  Do you really want to replace it?

You can see that the messages from each interface are clearly separated in genrated properties file

Developer/IDE Friendly

import static i18n.DBBundle.DB_BUNDLE;
import static i18n.UIBundle.UI_BUNDLE;

executeButton.setText(UI_BUNDLE.executeButton());

try{
    System.out.println(DB_BUNDLE.executing(query));
    // execute query
    System.out.println(DB_BUNDLE.executionFinished(5, 0));
}catch(SQLException ex){
    System.out.println(DB_BUNDLE.executionException(query));
}

the code using I18N messages is no longer cluttered with hardcoded strings. you never need to fear of:

i.e you get complete compile-time safety, and IDE help, because messages are now java methods rather than hard-coded Strings

Invalid Messge Formats

@Message("your lass successfull login is on {0, timee}")
public String lastSucussfullLogin(Date date);<br>

here we misspelled the format time as timee
this will give following compile time error:

[javac] /jlibsuser/src/i18n/UIBundle.java:23: Invalid Message Format: unknown format type at 
[javac]     @Message("your lass successfull login is on {0, timee}")
[javac]     ^

i.e any invalid message formats are caught at compile time

Argument Count Mismatch

@Message("SQL Execution completed in {0} seconds with {1} errors")
public String executionFinished(long seconds);

here the message requires two arguments {0} and {1}. but the java method is taking only one argument.
this will give following compile time error:

[javac] /jlibsuser/src/i18n/DBBundle.java:15: no of args in message format doesn't match with the number of parameters this method accepts
[javac]     public String executionFinished(long seconds);
[javac]                   ^

Missing Argument

@Message("SQL Execution completed in {0} seconds with {2} errors and {2} warnings")
public String executionFinished(long seconds, int errorCount, int warningCount);

here we misspelled {1} errros as {2} errors.
this will give following compile time error:

[javac] /jlibsuser/src/i18n/DBBundle.java:14: {1} is missing in message
[javac]     @Message("SQL Execution completed in {0} seconds with {2} errors and {2} warnings")
[javac]     ^

Duplicate Key

@Message(key="JLIBS015", value="SQL Execution completed in {0} seconds with {1} errors and {2} warnings")
public String executionFinished(long seconds, int errorCount, int warningCount);

@Message(key="JLIBS015", value="Encountered an exception while executing the following statement:\n{0}")
public String executionException(String query);

here we accidently used same key JLIBS015 for both methods.
this will give following compile time error:

[javac] /jlibsuser/src/i18n/DBBundle.java:18: key 'JLIBS015' is already used by "java.lang.String executionFinished(long, int, int)" in i18n.DBBundle interface
[javac]     public String executionException(String query);
[javac]                   ^

Method Signature Clash

public interface DBBundle{
    ...
    @Message(key="EXECUTING", value="executing {0}")
    public String executing(String query);
    ...
}

public interface UIBundle{
    ...
    @Message(key="EXECUTING_QUERY", value="executing {0}")
    public String executing(String query);
    ...
}

here both DBBundle and UIBundle are in same package and has methods with identical signature.
The generated _Bundle class implements both the interfaces DBBundle and UIBundle,
so it can’t decide whether to use key EXECUTING or EXECUTING_QUERY
thus this will give following compile time error:

[javac] /jlibsuser/src/i18n/UIBundle.java:27: clashes with similar method in i18n.DBBundle interface
[javac]     public String executing(String query);
[javac]                   ^

Optimization

All annotations have source level retention policy. So there is no reflection used at runtime.

Only one _Bundle.java class is generated per package, and this class will implement all interfaces with @ResourceBundle annatation in that package

there is only one instance of _Bundle created by I18N.getImplementation(clazz)
i.e both DBBundle.DB_BUNDLE and UIBundle.UI_BUNDLE are referring to same instanceof _Bundle.

the _Bundle class caches the ResourceBundle loaded.

Customization

You can change the name of the properties file generated by passing -AResourceBundle.basename=MyBundle to javac.
this will create MyBundle.properties

ErrorCodes

If you application uses error codes, as below:

package com.foo.myapp;

public class UncheckedException extends RuntimeException{
    private String errorCode;

    public UncheckedException(String errorCode, String message){
        super(message);
        this.errorCode = errorCode;
    }
    
    public String getErrorCode(){
        return errorCode;
    }

    @Override
    public String toString(){
        String s = getClass().getName()+": "+errorCode;
        String message = getLocalizedMessage();
        return (message != null) ? (s + ": " + message) : s;
    }
}

and in you application you always throw UncheckedException with different errorcode.

In such case, you can internationalize errorcodes as below:

package com.foo.myapp.controllers;

public interface ErrorCodes{
    public static final ErrorCodes INSTANCE = I18N.getImplementation(ErrorCodes.class);
    
    @Message("Database connection to host {0} is lost")
    public UncheckedException connectionLost(String host);
    
    @Message("No book found titled {0} from author {1}")
    public UncheckedException noSuchBookFound(String title, String author);
}

now you can throw UncheckedException as follows:

throw ErrorCodes.INSTANCE.connectionList(host);
throw ErrorCodes.INSTANCE.noSuchBookFound(title, author);

the exception class returned in ErrorCodes should have a constructor taking errorCode and message as arguments. The errorCode generated for connectionLost will be myapp.controllers.ConnectionList. i.e, the package and method name are joined. the top two packages are ignored and the first letter of method name is changed to uppercase.

you can configure the number of top pakcages to be ignored by passing following option to annotation processor from javac:
-AResourceBundle.ignorePackageCount=3

The default value is 2.
If you want complete package name, then use value of 0.
If you do not want package name in errorcode, then use value of -1

Internationalizing Domain Objects

Rather than internationalizing GUI, I would recomment internationalizing your domain objects.

let us say your domain object is Employee:

import jlibs.core.util.i18n.*;

public class Employee{<
    public static final String PROP_NAME = "name";
    public static final String PROP_AGE  = "age";
    
    @Bundle({
        @Entry(hint=Hint.DISPLAY_NAME, rhs="User Name"),
        @Entry(hint=Hint.DESCRIPTION, rhs="Full Name of Employee")
    })
    private String name;

    @Bundle({
        @Entry(hint=Hint.DISPLAY_NAME, rhs="Age"),
        @Entry(hint=Hint.DESCRIPTION, rhs="Current Age of Employee")
    })
    private int age;
    
    // getter and setter methods<br>
}

here we are internationalizing each property of employee using @Bundle annotation.

@Bundle({
    @Entry(hint=Hint.DISPLAY_NAME, rhs="User Name"),
    @Entry(hint=Hint.DESCRIPTION, rhs="Full Name of Employee")
})
private String name;

this will create following in Bundle.properties:

Employee.name.displayName=User Name
Employee.name.descritpion=Full Name of Employee

i.e each property generated is qualified by the class and field.

now in Employee registration form, you can do:

import jlibs.core.util.i18n.*;

JLabel nameLabel = ...;
JTextField nameField = ...;
nameLabel.setText(Hint.DISPLAY_NAME.stringValue(Employee.class, Employee.PROP_NAME));
nameField.setTooltipText(Hint.DESCRIPTION.stringValue(Employee.class, Employee.PROP_NAME));

let us say you have a JTable listing all employees, then you can do:

import jlibs.core.util.i18n.*;

Vector columnNames = new Vector();
columnNames.add(Hint.DISPLAY_NAME.stringValue(Employee.class, Employee.PROP_NAME));
columnNames.add(Hint.DISPLAY_NAME.stringValue(Employee.class, Employee.PROP_AGE));
JTable table = new JTable(employees, columnNames);

Here we used same properties in two GUI Panes. We used same properties in both GUI to internationalize it.

Moving internationalization from GUI to Domain Objects, allows:

Hint is an enum which contains few frequently used hints like DISPLAY_NAME, DESCRIPTION etc

Let us say if you want to have how own hint.
For example, all values user specified are trimmed and then set into domain object.
but you don’t want password field to be trimmed. then you can do:

public static final String PROP_PASSWD = "passwd";
@Bundle({
    @Entry(hint=Hint.DISPLAY_NAME, rhs="Password"),
    @Entry(hintName="doNotTrim", rhs="true")
})
private String passwd;
String value = passwdField.getText();
if(Boolean.parseBoolean(I18n.getHint(Employee.class, Employee.PROP_PASSWD, "doNotTrim")))
    value = value.trim();
employee.setPasswd(value);

NOTE: currently Hint enum has very few hints, if you have any useful hints in your mind, then simply raise an issue, I will add them.

One Time used Property

Let us say in Employee registration gui form, you ask user to enter his/her password twice.

public class EmployeeForm extends JDialog{
    ...
    @Bundle(@Entry(lhs="PASSWORD_MISMATCH", rhs="Paswords specified doesn't math"))
    private void onOK(){
        ...
        String passwd1 = password1.getText();
        String passwd2 = password2.getText();
        if(!passwd1.equals(passwd2)){
            JOptionPane.showMessageDialog(this, I18N.getMessage(EmployeeForm.class, "PASSWORD_MISMATCH");
            return;
        }
        ...
    }
    ...
}

The advantage of this is when you delete this method, the property is also deleted
i.e, you no longer need to worry about having unused properties.

you can also add comments to properties as below:

@Bundle({
    @Entry(" {0} applicationName ==> Name of Application"),
    @Entry(" {1} applicationVersion ==> Version of Application"),
    @Entry(lhs="APP_NOT_FOUND", rhs="cannot find application {0} of version {1}")
})
public void launchApplication(String appName, int version){
    ...
    throw new RuntimeException(I18N.getMessage(Launcher.class, "APP_NOT_FOUND", appName, version));
}