Java annotations + AspectJ5 version of the command line parser


Java annotations + AspectJ5 version of the command line parser

Dion Almaer had an interesting comment where he asked about marrying AspectJ5 to this to get rid of the ugly reflection stuff. I was hesitant at first because there isn’t really good support in AspectJ to do stuff like this but I eventually figured out a way to do it.

The essential problem is that there wasn’t anything to pointcut in the original version and thus it was difficult to get a handle on the fields we need to populate. I did a few experiments and found that if you always set a default for a field, even if its the system default, you will get a set() operation on the field at construction time. That’s enough to get hooked in. So here is what the example would look like:

package example;  
import com.sampullara.cli.Argument;
import com.sampullara.cli.aspects.ArgumentParser;
import java.util.List;
public class InputOutput {
@Argument(value = "input", description = "This is the input file", required = true)
private String inputFilename = null;
@Argument(value = "output", description = "This is the output file", required = true)
private String outputFilename = null;
@Argument(description = "This flag can optionally be set")
private boolean someflag = false;
public static void main(String[] args) {
InputOutput io = new InputOutput();
io.doit(ArgumentParser.getArguments());
}
public void doit(List extra) {
System.out.println("Input: " + inputFilename);
System.out.println("Output: " + outputFilename);
System.out.println("Someflag: " + someflag);
System.out.println("Extra: " + extra);
}
}

The biggest difference being that we no longer need to call "parse()" but we now have to initialize all the @Argument fields with some value. Additionally, there is an API to get the remaining arguments when you have instantiated all the objects that are going to use information from the command-line. This is a slight improvement to the old version. In addition to making those changes you also must compile your classes using the AspectJ compiler rather than javac and include the aspects with it. It's all pretty straight-forward and included in the build file in the attached jar.

Now let's take a look at the code required to make the above code work. The Argument annotation stays the same but now we have this ArgumentParser aspect (using the @AspectJ notation rather the original .aj syntax):

package com.sampullara.cli.aspects;
import com.sampullara.cli.Argument;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
@Aspect public class ArgumentParser {
public static List getArguments() {
return arguments;
}
private static List arguments = null;
@Pointcut("set(@com.sampullara.cli.Argument * *.*) && args(value)")
void anyArgumentSet(Object value) {}
@Pointcut("execution(public static void *.main(java.lang.String[]))")
void mainMethod() {}
@Pointcut("mainMethod() && !cflowbelow(mainMethod()) && args(args)")
void entryMainMethod(String[] args) {}
@Before("entryMainMethod(args)")
public void beforeEntryMainMethod(String[] args) {
if (arguments != null) {
throw new IllegalArgumentException("Too many main() methods somehow");
}
arguments = new ArrayList();
arguments.addAll(Arrays.asList(args));
}
@Around("anyArgumentSet(value)")
public void afterAnyArgumentSet(ProceedingJoinPoint jp, Object value) throws Throwable {
if (arguments == null) return;
Field field = jp.getSignature().getDeclaringType().getDeclaredField(jp.getSignature().getName());
Object target = jp.getThis();
Argument argument = field.getAnnotation(Argument.class);
boolean set = false;
for (Iterator i = arguments.iterator(); i.hasNext();) {
String arg = i.next();
if (arg.startsWith("-")) {
String name = argument.value();
if (name.equals("")) {
name = field.getName();
}
if (arg.substring(1).equals(name)) {
i.remove();
Class type = field.getType();
if (type == Boolean.TYPE || type == Boolean.class) {
value = true;
} else {
if (i.hasNext()) {
value = i.next();
i.remove();
} else {
throw new IllegalArgumentException("Must have a value for non-boolean argument " + argument.value());
}
}
jp.proceed(new Object[] { target, value });
set = true;
break;
}
}
}
if (!set) {
if (argument.required()) {
throw new IllegalArgumentException("You must set argument " + argument.value());
}
jp.proceed(new Object[] { target, value });
}
}
}

Some might wonder what the heck all that does. Well, lets start with the Pointcuts. The anyArgumentSet() pointcut matches every field set where the field has an Argument annotation. That picks out all the field sets in the constructor of the object. The entryMainMethod() ensures that the arguments are grabbed once and only once upon execution of the main method for the first time. Any further executions of main methods will not be matched unless they happen to be in a new thread :(. One of the problems with aspects is getting them exactly right, this one is close but there are still problems like that one. Unfortunately for Dion, we still don't get away from using some reflection... In fact the worst part about using this version is that there isn't really a possibility of doing a usage message. Sometimes reflection is the right tool for the job.

Another annoyance that I discovered when creating this version is that @AspectJ is not a pure runtime annotation based system. You still must compile the .java sources with the iajc compiler even though all the code is standard Java. I'm not sure how I feel about that but I'm going to talk to some of the guys over there and see if there is anything that can be done about that.

In the end it felt like an interesting exercise, but the original version is a lot more practical.

Download the complete project: cli2.jar