Wednesday, October 21, 2009

Parsing command-line options - part 4

This is the last part of the story. This time we will search for a way to make our annotation work.
As we have not yet created any implementation to hold the values coming from the command line we will let Java do it dynamically by using java.lang.reflect.Proxy. Values will be stored in a Map<String,Object> accessed by a property name whenever the property getter is invoked by the caller. It is not the most efficient approach. Some of the arguments may be requested by the caller in a very frequent manner in which case dispatching of the call by name to a hash map obviously introduces a significant latency. The best approach (which I will not cover here) is generating implementation of the bean with a byte-code emitter (e.g. ASM). Alternatively the bean class can be generated with javax.tools package being a part of Java6 (more specifically with javax.tools.JavaCompiler). The latter solution might sometimes be preferable as user-readable Java source code is produced.
Using java.lang.reflect.Proxy implies we need to provide a java.lang.reflect.InvocationHandler implementation where all bean methods will be delegated to. I guess Surrogate will do just fine as a name for that class.
There is a requirement to provide default values for different types. There'll be a static map for this purpose.
Selecting of annotated bean properties is one more thing we need to take care of. For that purpose we create a predicate to feed it to the wonderful BeanUtils.forProperties() method:

    public static final IPredicate<PropertyDescriptor> IsOption =
       
new IPredicate<PropertyDescriptor>() {
           
public final boolean evaluate(PropertyDescriptor property) {
               
final Method getter = property.getReadMethod();
               
if (getter == null) {
                   
return false;
               
}
               
final Option option = getter.getAnnotation(Option.class);
               
if (option == null) {
                   
return false;
               
}
               
return true;
           
}
        }
;


We need an entry point for the application to get back the surrogate bean implementation populated with the values from the command line. A static method will do quite well.
And here is the most important part - the CommandLineSurrogate class with a couple of simple helper methods:

final class CommandLineSurrogate implements InvocationHandler
{
   
private final static Pattern OPTION_PATTERN =
        Pattern.compile
("-(?:(\\p{Alnum}\\b)|-{1}+(\\p{Alnum}[\\p{Alnum}-]*)(?:=(?:(?:\"([^\"]+)\")|(\\S+)))?)");

   
private final static Map<Class<?>, Object> DEFAULT_VALUES =
       
new IdentityHashMap<Class<?>, Object>();

   
static {
       
DEFAULT_VALUES.put(Boolean.TYPE, Boolean.FALSE);
        DEFAULT_VALUES.put
(Byte.TYPE, Byte.valueOf((byte) 0));
        DEFAULT_VALUES.put
(Character.TYPE,
            Character.valueOf
(Character.MIN_VALUE));
        DEFAULT_VALUES.put
(Short.TYPE, Short.valueOf((short) 0));
        DEFAULT_VALUES.put
(Integer.TYPE, Integer.valueOf(0));
        DEFAULT_VALUES.put
(Long.TYPE, Long.valueOf(0L));
        DEFAULT_VALUES.put
(Float.TYPE, Float.valueOf(Float.NaN));
        DEFAULT_VALUES.put
(Double.TYPE, Double.valueOf(Double.NaN));
   
}

   
private final Map<String, Object> _instanceData =
       
new HashMap<String, Object>();
   
private final String[] _userArguments;
   
private final Map<Character, PropertyDescriptor> _propertyByOneChar =
       
new HashMap<Character, PropertyDescriptor>();
   
private final Map<String, PropertyDescriptor> _propertyByLongTag =
       
new HashMap<String, PropertyDescriptor>();
   
private final Map<Method, PropertyDescriptor> _propertyByGetter =
       
new HashMap<Method, PropertyDescriptor>();

   
public CommandLineSurrogate(Class<?> type, String[] args)
       
throws Exception
   
{
       
Guard.isTrue(type.isInterface(), "cannot proxy a concrete class");
       
final Set<String> requiredProperties = new HashSet<String>();
        BeanUtils.forProperties
(type, IsOption,
           
new IAction<PropertyDescriptor>()
            {
               
public final void invoke(PropertyDescriptor property)
                   
throws Exception
               
{
                   
final Method getter = property.getReadMethod();
                    _propertyByGetter.put
(getter, property);
                   
final Option option =
                        getter.getAnnotation
(Option.class);
                   
final String propertyName = property.getName();
                   
final boolean isBoolean = isBoolean(property);
                   
if (isBoolean) {
                       
Guard.state(
                           
!hasArgument(option),
                           
"boolean property cannot have argumentTag set, property=" +
                                propertyName
);
                   
}
                   
else {
                       
Guard.state(
                           
hasArgument(option),
                           
"non-boolean property must have argumentTag set, property=" +
                                propertyName
);
                   
}
                   
final boolean hasLongTag = hasLongTag(option);
                    _propertyByOneChar.put
(option.opt(), property);
                   
if (hasLongTag) {
                       
_propertyByLongTag.put(option.longOpt(), property);
                   
}
                   
if (option.required()) {
                       
requiredProperties.add(propertyName);
                   
}
                   
else {
                       
final Object defaultValue =
                            getDefaultValue
(option, property);
                        _instanceData.put
(propertyName, defaultValue);
                   
}
                }
            })
;
       
final int nextPos = parse(args);
       
if (Option.IUserArgumentSupport.class.isAssignableFrom(type)) {
           
_userArguments =
                Arrays.copyOfRange
(args, nextPos, args.length);
       
}
       
else {
           
_userArguments = null;
       
}
       
requiredProperties.removeAll(_instanceData.keySet());
        Guard.state
(requiredProperties.isEmpty(),
           
"cannot set properties: " + requiredProperties.toString());
   
}

   
private int parse(String[] args)
    {
       
int pos = 0;
       
final Set<PropertyDescriptor> processed =
           
new HashSet<PropertyDescriptor>();
       
while (pos < args.length) {
           
PropertyDescriptor property = null;
           
if (!args[pos].startsWith("-")) {
               
break;
           
}
           
final ParsedOption parsed = new ParsedOption(args[pos]);
           
if (parsed.getOneChar() != null) {
               
final Character ch = parsed.getOneChar();
                property = _propertyByOneChar.get
(ch);
           
}
           
else {
               
final String longTag = parsed.getLongTag();
                property = _propertyByLongTag.get
(longTag);
           
}
           
Guard.isTrue(property != null, "unrecognized option '" +
                args
[pos] + "'");
            Guard.isTrue
(processed.add(property), "duplicate option '" +
                args
[pos] + "'");
           
final String name = property.getName();
           
int nextPos = pos + 1;
           
final boolean isBoolean = isBoolean(property);
           
if (isBoolean) {
               
Guard.isTrue(parsed.getArgumentValue() == null,
                   
"option '" + args[pos] +
                       
"' does not allow an argument");
               
final Object oldValue =
                    _instanceData.put
(name, Boolean.TRUE);
           
}
           
else {
               
String argumentValue = parsed.getArgumentValue();
               
if (argumentValue == null) {
                   
Guard.isTrue(pos < args.length - 1, "option '" +
                        args
[pos] + "' requires an argument ");
                    argumentValue = args
[pos + 1];
                    nextPos = pos +
2;
               
}
               
try {
                   
final Class<?> propertyType =
                        property.getPropertyType
();
                   
final Object value =
                        BeanUtils.convert
(argumentValue, propertyType);
                   
if (value == null) {
                       
throw new ParseException("invalid value for '" +
                            args
[pos] + "': " + argumentValue);
                   
}
                   
final Object oldValue = _instanceData.put(name, value);
               
}
               
catch (NumberFormatException nfe) {
                   
throw new ParseException("invalid value for '" +
                        args
[pos] + "': " + argumentValue);
               
}
            }
           
pos = nextPos;
       
}
       
return pos;
   
}

   
@Override
   
public String toString()
    {
       
final ToStringBuilder sb = new ToStringBuilder(this);
        sb.append
(_instanceData.toString());
       
return sb.toString();
   
}

   
private Object getDefaultValue(Option option,
        PropertyDescriptor property
) throws Exception
   
{
       
final String defaultValueStr = option.defaultValue();
       
final Class<?> propertyType = property.getPropertyType();
        Object defaultValue =
null;

       
if (!StringUtils.EMPTY.equals(defaultValueStr)) {
           
defaultValue =
                BeanUtils.convert
(defaultValueStr, propertyType);
           
if (defaultValue == null) {
               
throw new ParseException(
                   
"failed to parse default value for '" +
                        property.getName
() + "'");
           
}
        }
       
else {
           
// if an array - build an empty array of that type
           
final Class<?> componentType = propertyType.getComponentType();
           
if (componentType != null) {
               
defaultValue = Array.newInstance(componentType, 0);
           
}
           
else if (propertyType.isPrimitive()) {
               
defaultValue = DEFAULT_VALUES.get(propertyType);
           
}
        }
       
return defaultValue;
   
}

   
public Object invoke(Object proxy, Method method, Object[] args)
       
throws Throwable
   
{
       
final String methodName = method.getName();
       
if ("toString".equals(methodName)) {
           
return this.toString();
       
}
       
if ("getUserArguments".equals(methodName)) {
           
return _userArguments;
       
}
       
final PropertyDescriptor property = _propertyByGetter.get(method);
       
if (property != null) {
           
final String name = property.getName();
           
final Object value = _instanceData.get(name);
           
return value;
       
}
       
return method.invoke(proxy, args);
   
}

   
final class ParsedOption
   
{
       
private final Character _oneChar;
       
private final String _longTag;
       
private final String _argumentValue;

        ParsedOption
(String arg)
        {
           
final Matcher m = OPTION_PATTERN.matcher(arg);
            Guard.isTrue
(m.matches(), "invalid option " + arg);
            _oneChar = m.group
(1) != null ? m.group(1).charAt(0) : null;
            _longTag = m.group
(2);
           
if (_longTag != null) {
               
_argumentValue =
                    m.group
(3) != null ? m.group(3) : m.group(4);
           
}
           
else {
               
_argumentValue = null;
           
}
        }

       
public final Character getOneChar()
        {
           
return _oneChar;
       
}

       
public final String getLongTag()
        {
           
return _longTag;
       
}

       
public final String getArgumentValue()
        {
           
return _argumentValue;
       
}

       
@Override
       
public String toString()
        {
           
final ToStringBuilder sb = new ToStringBuilder();
            sb.append
("opt", _oneChar);
            sb.append
("long", _longTag);
            sb.append
("arg", _argumentValue);
           
return sb.toString();
       
}
    }
}


Some comments on the last snippet above:
Surrogate.invoke() gets called when the proxy is invoked with a method of our bean interface. To facilitate debugging we remember to override the toString() method so that it returns a readable representation of the surrogate. toString() requires special handling withing this method otherwise the internal Java proxy method gets called.
Our Surrogate class is capable of storing both option values and the rest of command-line arguments following the options. The storage for latter is allocated only in case the bean interface extends the IUserArgumentSupport interface.
Some constraints on property values of the Option annotation are deferred to the surrogate initialization where, for example, we assert that default value provided in the annotation is parseable into the corresponding type.
P.S. There is a alternative longOpt usage when instead of a space a 'equals' sign can be used so I had to adjust the class correspondingly. :-)
P.P.S. Finally I decided to replace custom parsing with regex.

No comments:

Post a Comment