This article describes how to get the best out of Java Annotations using Reflection. See how Reflection and Annotations can be used to add declarative programming to Java.
Java is a programming language, program execution environment and API from SUN. Java is a compile once, run everywhere environment giving programmers portable object files.
Annotations are a Java language feature for meta data introduced with Java 1.5. Annotations can be used to add tags with data to program elements like types, variables or methods. The program element that's annotated is called annotation target. The meta data can be available at compile time, in the class file or even during runtime; the availability policy of meta data is called retention policy.
Reflection is a Java API feature that allows a program to analyze and use itself during runtime. Java basically is a compiler environment which means that the classic way of analysis and use would be compile time.
According to one definition, a program is "declarative" if it describes what something is like, rather than how to create it.
Wikipedia
Declarative programming means that you rather declare what you want instead of coding the steps to achieve it imperatively. The most well-known variants of declarative programming are the Horn-clauses in Prolog or the XML processing template rules in XSLT.
We want to develop IRC software, e.g. an IRC client or an IRC bot (which basically also is an IRC client). IRC is a text based protocol with text commands. We are interested in interpreting the text commands that the server sends to our client because our client must react accordingly.
To keep it simple, we will only process the PRIVMSG and the PING command. Of course IRC knows much more commands than just these two, but they are enough to demonstrate the way of programming.
The classic approach would be to parse the line in a programmed for-loop and use a huge if-else-block construct to invoke the corresponding processing methods, like this:
public void mainLoop() {
final BufferedReader in = getIn();
final Pattern p1 = Pattern.compile("^:([^!]+)!([^ ]+) PRIVMSG ([^ ]+) :(.*)$");
final Pattern p2 = Pattern.compile("^PING :(.*)$");
for (String line; (line = in.readLine()) != null;) {
final Matcher m1 = p1.matcher(line);
if (m1.matches()) {
processPRIVMSG(m1.group(1), m2.group(2), m1.group(3), m1.group(4));
}
final Matcher m2 = p2.matcher(line);
if (m2.matches()) {
processPING(m2.group());
}
}
}
public void processPRIVMSG(final String actor, final String identity, final String channel, final String msg) {
// ...
}
public void processPING(final String server) {
final PrintWriter out = getOut();
out.println("PONG " + server);
out.flush();
}This looks usual and straight forward at first sight. But when taking a closer look this has some disadvantage. Every new command that is added will significantly increase the for-loop. Of course the core of the loop could be extracted into a separate method. But that method would still grow. The single match steps could of course also be put into separate methods, but that would lead to another additional method per IRC command.
Finally, the problem can be tracked down to a very simple thing: The core of the loop processes tuples: A Pattern, a Matcher and the corresponding method that's invoked always belong together.
The loop approach uses the knowledge that we're dealing with tuples and uses a Map.
private Map<Pattern, Method> processMap = initProcessMap();
private Map<Pattern, Method> initProcessMap() {
final Map<Pattern, Method> processMap = new HashMap<Pattern, Method>();
processMap.put(Pattern.compile("^:([^!]+)!([^ ]+) PRIVMSG ([^ ]+) :(.*)$"),
getClass().getMethod("processPRIVMSG", String.class, String.class, String.class, String.class));
processMap.put(Pattern.compile("^PING :(.*)$"), getClass().getMethod("processPING", String.class));
return processMap;
}
public void mainLoop() {
final BufferedReader in = getIn();
for (String line; (line = in.readLine()) != null;) {
for (final Map.Entry<Pattern, Method> entry : processMap) {
final Matcher m = entry.getKey().matcher(line);
if (m.matches()) {
final Object[] args = new Object[m.groupCount()];
for (int i = 0; i < args.length; i++) {
args[i] = m.group(i + 1);
}
entry.getValue().invoke(this, args);
}
}
}
}
public void processPRIVMSG(final String actor, final String identity, final String channel, final String msg) {
// ...
}
public void processPING(final String server) {
final PrintWriter out = getOut();
out.println("PONG " + server);
out.flush();
} As you see, the size of mainLoop() now is constant. It does no longer depend on the number of methods that you want to invoke. Also, the growth of initProcessMap() is very limited, only one additional line per method.
But this can be further improved. The number of lines will not be reduced by this approach, but the maintainability will grow because the regular expression that matches a method will now be with the method.
We will declare an annotation type that we may use along with these methods. I will name the annotation type Match, but this of course is just an example. Because we will look for this annotation during runtime, it is important to declare the Retention to be RetentionPolicy.RUNTIME.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Match {
String value();
}Now we can use this in reflection for automatically finding all methods and for declaring the regular expression along with the method instead of declaring it separately.
private Map<Pattern, Method> processMap = initProcessMap();
private Map<Pattern, Method> initProcessMap() {
final Map<Pattern, Method> processMap = new HashMap<Pattern, Method>();
for (final Method method : getClass().getMethods()) {
final Match match = method.getAnnotation(Match.class);
if (match != null) {
processMap.put(Pattern.compile(match.value()), method);
}
}
}
public void mainLoop() {
final BufferedReader in = getIn();
for (String line; (line = in.readLine()) != null;) {
for (final Map.Entry<Pattern, Method> entry : processMap) {
final Matcher m = entry.getKey().matcher(line);
if (m.matches()) {
final Object[] args = new Object[m.groupCount()];
for (int i = 0; i < args.length; i++) {
args[i] = m.group(i + 1);
}
entry.getValue().invoke(this, args);
}
}
}
}
@Match("^:([^!]+)!([^ ]+) PRIVMSG ([^ ]+) :(.*)$")
public void processPRIVMSG(final String actor, final String identity, final String channel, final String msg) {
// ...
}
@Match("^PING :(.*)$")
public void processPING(final String server) {
final PrintWriter out = getOut();
out.println("PONG " + server);
out.flush();
} Now if we want to implement another IRC command, all we need to do to support it is add another @Match-annotated method.
In the original approach, nearly all checks were performed by the compiler. The only check the compiler did not do was that the programmed number of regular expression groups actually matches the number of groups present in the regular expression string. While normally adding reflection reduces the number of possible compile time checks, in this case no additional drawbacks are added. All that happens is that the original problem is slightly shifted. The number of groups now must match the number of method parameters. Usually such bugs are easy to detect using a proper unit test suite.
As you can clearly see, there is some overhead involved. So for just two methods it's probably not worth doing it unless you extract parts of the code into a separate library. But I'm sure you can imagine that the declarative approach will be the most maintainably approach once you're dealing with 10 or more commands.
IRC is of course not the only possible application. So we want some reusability. And for one method we might want to have more than just one regular expression. So we also change the annotation.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Patterns {
String[] value();
}public class PatternDelegator {
private final Object target;
private final Map<Pattern, Method> targetMethods = new HashMap<Pattern, Method>();
public PatternDelegator(final Object target) {
this.target = target;
final Method[] methods = target.getClass().getMethods();
for (final Method method : methods) {
final Patterns patterns = method.getAnnotation(Patterns.class);
if (patterns != null) {
for (final String pattern : patterns.value()) {
targetMethods.put(Pattern.compile(pattern), method);
}
}
}
}
public int process(final String string) throws IllegalAccessException, InvocationTargetException {
int matchesFound = 0;
for (final Map.Entry<Pattern, Method> entry : targetMethods.entrySet()) {
final Pattern pattern = entry.getKey();
final Matcher matcher = pattern.matcher(string);
if (matcher.matches()) {
matchesFound++;
final String[] groups = new String[matcher.groupCount()];
for (int i = 0; i < groups.length; i++) {
groups[i] = matcher.group(i + 1);
}
final Method method = entry.getValue();
method.invoke(target, (Object[]) args);
}
}
return matchesFound;
}
} public void mainLoop() {
final BufferedReader in = getIn();
final PatternDelegator delegator = new PatternDelegator(this);
for (String line; (line = in.readLine()) != null;) {
delegator.process(line);
}
}
@Patterns("^:([^!]+)!([^ ]+) PRIVMSG ([^ ]+) :(.*)$")
public void processPRIVMSG(final String actor, final String identity, final String channel, final String msg) {
// ...
}
@Patterns("^PING :(.*)$")
public void processPING(final String server) {
final PrintWriter out = getOut();
out.println("PONG " + server);
out.flush();
}Now after extracting the non-IRC specific parts for reuse, the final program using declarative programming definitely is much smaller and more maintainable than the original version. That's it :)
You can find this code in action in ↗CherBot.