Mark Lorenz on Technology

Friday, March 03, 2006

Handling user messages on Java projects

In my past projects, messages to display to the end user were handled in different ways, most not optimal. Recently, I have started putting references to standard informational, verification, and error messages in a supplementary specification and referring to them in the project's use cases.

This post presents an arguably better way to handle user messages. The messages are reused across the system, which results in consistent wording, and since the messages are centralized they are easier to change (in one place). It also facilitates internationalization. The parts of this design are:
Note: MessageFormat was introduced in the Java 1.3.1 release
UserMessages.properties file
  • This is a textual file that is easily edited to define the format and content of the messages used by the system. The owners of this file are the project's SQEs.
  • The format of a message is key=value, where
    • key is a unique identifier for the message (e.g. ERR0001)
    • value is the text of the message, with indications of where to insert parameters (e.g. Delete {0} from the system?)
  • The only restriction in the text is that quotes must be preceded by a backslash (e.g. {0} named \"{1}\" is already defined.)

UserMessages.properties
//Note: If you want a quote in your message, you MUST precede it with a backslash.
//E.g.:
//SAMPLE0001=Patient named \"{0}\" not found.
//results in the message
//Patient named "Fred Smith" not found.
//
//See the User Messages specification in PVCS for more information.
//
//--- Informational Messages ---
INF0003={0} {1} was successful.
INF0004=No matches found. Please try again.
//
//--- Verification Messages ---
VER0001=Delete {0} from the system?
VER0005=Permanently inactivate customer account {0}?
//
//--- Error Messages ---
ERR0001={0} named \"{1}\" is already defined. Please choose another name.
ERR0005={0} is not a valid IP address. IP addresses are formatted as four integers separated by periods: \“N.N.N.N\”. Please try again.
ERR0007=The date ranges must occur in chronological order. Please try again.
ERR0010=Order {0} is in {1} state and cannot be canceled.
ERR0011=Account {0} already has an update with effective date {1}. Please choose another date.
ERR0015=Account {0}: Effective date must be current date or later. Please try again.


UserMessage.java file
  • This class creates MessageFormat objects for each message in the UserMessages.properties file.
  • The important method is the format( messageID, args ) method.

UserMessage.java
/**
*
*/
package com.yourcompany.util;

import java.io.IOException;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.PropertyResourceBundle;

/**
* An output formatted appropriately for an end user.
*
* @author lorenzm
*/
public class UserMessage {
//Constants MUST match UserMessages.properties file
//
// --- Informational Messages ---
public static final String INF0003 = "INF0003";
public static final String INF0004 = "INF0004";
//
// --- Verification Messages ---
public static final String VER0001 = "VER0001";
public static final String VER0005 = "VER0005";
//
// --- Error Messages ---
public static final String ERR0001 = "ERR0001";
public static final String ERR0005 = "ERR0005";
public static final String ERR0007 = "ERR0007";
public static final String ERR0010 = "ERR0010";
public static final String ERR0011 = "ERR0011";
public static final String ERR0015 = "ERR0015";

protected static Map userMessages;
protected static PropertyResourceBundle userMessageBundle;

/**
* Should use static methods
*/
private UserMessage() {
super();
}

/**
* read my file and initialize my MessageFormat objects
*/
protected static void initialize() throws IOException {
userMessageBundle = new PropertyResourceBundle(
ClassLoader.getSystemResourceAsStream("UserMessages.properties"));
userMessages = new HashMap();
// --- Informational Messages ---
userMessages.put( INF0003, new MessageFormat( userMessageBundle.getString(INF0003) ) );
userMessages.put( INF0004, new MessageFormat( userMessageBundle.getString(INF0004) ) );
// --- Verification Messages ---
userMessages.put( VER0001, new MessageFormat( userMessageBundle.getString(VER0001) ) );
userMessages.put( VER0005, new MessageFormat( userMessageBundle.getString(VER0005) ) );
// --- Error Messages ---
userMessages.put( ERR0001, new MessageFormat( userMessageBundle.getString(ERR0001) ) );
userMessages.put( ERR0005, new MessageFormat( userMessageBundle.getString(ERR0005) ) );
userMessages.put( ERR0007, new MessageFormat( userMessageBundle.getString(ERR0007) ) );
userMessages.put( ERR0010, new MessageFormat( userMessageBundle.getString(ERR0010) ) );
userMessages.put( ERR0011, new MessageFormat( userMessageBundle.getString(ERR0011) ) );
userMessages.put( ERR0015, new MessageFormat( userMessageBundle.getString(ERR0015) ) );
}

/**
* @param messageID Must be one of my constants and must be in my .properties file
* @param args Strings, Dates, ... to fill into message; can be null if no params needed
* @return A formatted message, else null (invalid messageID or args)
*/
public static String format( String messageID, List args ) {
if( userMessages == null ) {
try {
initialize();
} catch (IOException e) {
//File is missing!
e.printStackTrace();
return null;
}
}
MessageFormat userMessage = null;
try {
userMessage = getMessage( messageID );
} catch (IllegalArgumentException e) {
//No such message ID!
e.printStackTrace();
return null;
}
if( args == null || args.isEmpty() )
return userMessage.toPattern(); //nothing to fill in!
String formattedMessage = null;
try {
formattedMessage = userMessage.format( args.toArray(), new StringBuffer(), null ).toString();
} catch (IllegalArgumentException e) {
//Wrong types of params!
e.printStackTrace();
return null;
}
int unusedParamIndex = formattedMessage.indexOf("{");
if( unusedParamIndex > 0 ) { //found {n} - wasn't used
//Wrong number of params!
IllegalArgumentException e = new IllegalArgumentException(messageID + ": Too few parameters");
e.printStackTrace();
return null;
}
return formattedMessage;
}

/**
* @param messageID Must be one of my constant identifiers
* @return my MessageFormat identified by messageID
* @throws Exception
*/
protected static MessageFormat getMessage(String messageID) throws IllegalArgumentException {
if( userMessages.containsKey(messageID) )
return (MessageFormat)userMessages.get(messageID);
else {
throw new IllegalArgumentException( messageID + " not found" );
}
}

}

Client code
  • I've included my JUnit test file, which exercises multiple valid and invalid uses of UserMessage.format().
  • Clients only have to write String formattedMessage = UserMessage.format(messageID, args);. Assuming the arguments are valid, that's it (null is returned when invalid arguments are sent, which is really a bug in the client code).

UserMessageTest.java
package com.yourcompany.util;

import java.util.ArrayList;
import java.util.List;

import junit.framework.TestCase;

/**
* The class UserMessageTest contains tests for the class {@link
* UserMessage}
*
* @pattern JUnit Test Case
* @generatedBy CodePro
* @author lorenzm
* @version $Revision$
*/
public class UserMessageTest extends TestCase {

/**
* Construct new test instance
*
* @param name the test name
*/
public UserMessageTest(String name) {
super(name);
}

/**
* Launch the test.
*
* @param args String[]
*/
public static void main(String[] args) {
junit.textui.TestRunner.run(UserMessageTest.class);
}

/**
* Perform pre-test initialization
*
* @throws Exception
*
* @see TestCase#setUp()
*/
protected void setUp() throws Exception {
super.setUp();
}

/**
* Perform post-test clean up
*
* @throws Exception
*
* @see TestCase#tearDown()
*/
protected void tearDown() throws Exception {
super.tearDown();
}

/**
* Run the String format(String, List) method test
*/
public void testSuccessfulFormatERR0001() {
// add test code here
String messageID = UserMessage.ERR0001;
List args = new ArrayList(2);
args.add("Department");
args.add("Sales");
String formattedMessage = UserMessage.format(messageID, args);
assertEquals(formattedMessage, "Department named \"Sales\" is already defined. Please choose another name." );
}

public void testSuccessfulFormatINF0004() {
// add test code here
String messageID = UserMessage.INF0004;
String result = UserMessage.format(messageID, null);
assertEquals(result, "No matches found. Please try again." );
}

public void testInvalidID() {
String messageID = "ERR9999"; //bogus!
List args = new ArrayList(2);
args.add("Department");
args.add("Sales");
String formattedMessage = null;
formattedMessage = UserMessage.format(messageID, args);
assertNull("Invalid ID should return null. Instead got: ", formattedMessage);
}

public void testWrongNumParams() {
String messageID = UserMessage.ERR0001;
List args = new ArrayList(1);
args.add("Department");
//left off name!
String formattedMessage = UserMessage.format(messageID, args);
assertNull("Missing parameter should return null. Instead got: ", formattedMessage);
}
}



That's it! It's not perfect, but solves many of the previous problems I've encountered with user messaging. Let me know if you have a better way.


0 Comments:

Post a Comment

<< Home