Wednesday, March 3, 2010

Maven Review

At work, I recently started working on a Java project and had to use Maven as it is the standard build tool for Java projects in the company. I have mostly worked on C/C++ projects for several years and recall that Ant was the de-facto standard Java build system. This post is a quick review of my experience trying Maven.

Idea

To get more familiar with why someone thought that Maven was necessary I read the following:
The objectives of Maven seem to be pretty reasonable. It standardizes some best practices and conventions to make all projects more consistent. Though my conventions are slightly different, I typically use a standard layout for projects with Ant or Make so that I just need to set some variables and import the common build file with all of the standard targets. Maven provides a POM file that details all of the project specific settings with more flexibility than my method and a standard that is used more broadly than just my personal projects. Part of the POM is a declarative specification of the dependencies needed for the project. The Maven tool can then retrieve the dependencies from a repository instead of having to package them with the project in version control. Internal repositories can be setup to provide additional safety and control. I like the idea of Maven and it is fairly obvious why it would be appealing to a company. The standardization and repositories are similar to internal infrastructure mandated for all projects at the company where I work. Maven provides that infrastructure without a lot of work for the company.

Implementation

What about the actual Maven tool? It sucks. The primary issue I have with the Maven tool is that it seems to take much longer to make simple changes and get them working. As an example, consider adding an option to tell the compiler that all warnings should be treated as errors. Create a basic Maven project:
shell$ mvn archetype:generate
...
Choose a number:  (1..41) 15: :
Define value for groupId: : test
Define value for artifactId: : test
Define value for version:  1.0-SNAPSHOT: :
Define value for package:  test: :
Confirm properties configuration:
groupId: test
artifactId: test
version: 1.0-SNAPSHOT
package: test
Y: :
...
[INFO] BUILD SUCCESSFUL
...
shell$ cd test
shell$ mvn test
...
[INFO] BUILD SUCCESSFUL
...
Now we have a basic project setup. The generated POM file is:
<project
 xmlns="http://maven.apache.org/POM/4.0.0"
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
 <modelVersion>4.0.0</modelVersion>
 <groupId>test</groupId>
 <artifactId>test</artifactId>
 <packaging>jar</packaging>
 <version>1.0-SNAPSHOT</version>
 <name>test</name>
 <url>http://maven.apache.org</url>
 <dependencies>
   <dependency>
     <groupId>junit</groupId>
     <artifactId>junit</artifactId>
     <version>3.8.1</version>
     <scope>test</scope>
   </dependency>
 </dependencies>
</project>
Now, I'll change the code to have a warning and try to get Maven to have the build fail. The new version of src/main/java/test/App.java looks like:
package test;

import java.util.ArrayList;

public class App {
   public static void main( String[] args ) {
       ArrayList list = new ArrayList<String>();
       for (String arg : args) {
           list.add(arg);
       }
       System.out.println(list);
   }
}
Trying to compile this using javac directly I get:
shell$ javac -Xlint:all,-path -Werror -d target/classes src/main/java/test/App.java
src/main/java/test/App.java:11: warning: [unchecked] unchecked call to add(E) as a member of the raw type java.util.ArrayList
           list.add(arg);
                   ^
1 warning
shell$ echo $?
1
Running mvn clean compile to see what the default is it complains that generics are not supported in -source 1.3. Fixing this is straightforward using the docs from the maven-compiler-plugin. New POM that specifies the source and target versions:
@@ -15,4 +15,17 @@
      <scope>test</scope>
    </dependency>
  </dependencies>
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <version>2.1</version>
+        <configuration>
+          <source>1.6</source>
+          <target>1.6</target>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
</project>
Now to pass the compiler arguments I want it looks simple at first:
@@ -24,6 +24,7 @@
        <configuration>
          <source>1.6</source>
          <target>1.6</target>
+          <compilerArgument>-Xlint:all,-path -Werror</compilerArgument>
        </configuration>
      </plugin>
    </plugins>
When I try to do a clean compile now, I do get a warning but the build doesn't fail.
shell$ mvn clean compile
...
[WARNING] /tmp/test/src/main/java/test/App.java:[11,20] [unchecked] unchecked call to add(E) as a member of the raw type java.util.ArrayList

[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESSFUL
[INFO] ------------------------------------------------------------------------
...
shell$
There is also an option to supply a compiler arguments map, but I don't see how to encode the options I care about in that format. In particular how to map the -Xlint:all,-path option into the map since Xlint:all,-path is not an acceptable XML element name and javac will not accept a space in the argument. The warnings as error flag could probably be encoded as <Werror/>. To get more information I run with debugging logs turned on:
shell$ mvn --debug clean compile
...
[DEBUG] Configuring mojo 'org.apache.maven.plugins:maven-compiler-plugin:2.1:compile' -->
[DEBUG]   (f) basedir = /tmp/test
[DEBUG]   (f) buildDirectory = /tmp/test/target
[DEBUG]   (f) classpathElements = [/tmp/test/target/classes]
[DEBUG]   (f) compileSourceRoots = [/tmp/test/src/main/java]
[DEBUG]   (f) compilerArgument = -Xlint:all,-path -Werror
[DEBUG]   (f) compilerId = javac
[DEBUG]   (f) debug = true
[DEBUG]   (f) failOnError = true
[DEBUG]   (f) fork = false
[DEBUG]   (f) optimize = false
[DEBUG]   (f) outputDirectory = /tmp/test/target/classes
[DEBUG]   (f) outputFileName = test-1.0-SNAPSHOT
[DEBUG]   (f) projectArtifact = test:test:jar:1.0-SNAPSHOT
[DEBUG]   (f) session = org.apache.maven.execution.MavenSession@687ea9
[DEBUG]   (f) showDeprecation = false
[DEBUG]   (f) showWarnings = false
[DEBUG]   (f) source = 1.6
[DEBUG]   (f) staleMillis = 0
[DEBUG]   (f) target = 1.6
[DEBUG]   (f) verbose = false
[DEBUG] -- end configuration --
...
shell$
This dump shows that it did get the arguments I set, and it also shows some configuration options that I did not see mentioned in the documentation. The maven site for the compiler plugin does have a handy link to the plugin source so we can just look to see what it is doing. The comments in the source for AbstractCompilerMojo.java indicate that the arguments are only used if the compiler is forked. So lets fork the compiler:
@@ -25,6 +25,7 @@
          <source>1.6</source>
          <target>1.6</target>
          <compilerArgument>-Xlint:all,-path -Werror</compilerArgument>
+          <fork>true</fork>
        </configuration>
      </plugin>
    </plugins>
No luck. Debug output shows the following:
[DEBUG] Using compiler 'javac'.
[DEBUG] Source directories: [/tmp/test/src/main/java]
[DEBUG] Classpath: [/tmp/test/target/classes]
[DEBUG] Output directory: /tmp/test/target/classes
[DEBUG] Classpath:
[DEBUG]  /tmp/test/target/classes
[DEBUG] Source roots:
[DEBUG]  /tmp/test/src/main/java
[DEBUG] Command line options:
[DEBUG] -d /tmp/test/target/classes -classpath /tmp/test/target/classes: /tmp/test/src/main/java/test/App.java -g -nowarn -target 1.6 -source 1.6 -Xlint:all,-path -Werror
So it does appear to pass the option. My first guess is that when doing the exec the compilerArgument gets passed as a single string instead of tokenizing. A simple test with javac confirms this could be the case:
shell$ javac '-Xlint:all,-path -Werror' -d target/classes src/main/java/test/App.java
src/main/java/test/App.java:11: warning: [unchecked] unchecked call to add(E) as a member of the raw type java.util.ArrayList
           list.add(arg);
                   ^
1 warning
shell$ echo $?
0
Lines 393 to 419 of AbstractCompilerMojo.java show that based on the flags in the POM it will create a map of options with all of the compilerArguments flag just prepending a "-" to the key if one is not already present. The arguments specified in compilerArgument are added as a key to this map with no modifications. It does some other stuff and then on line 575 tries to compile passing in the compiler configuration that was built. The maven compiler plugin uses plexus to actually do the compile. Looking at JavacCompiler.java from plexus the buildCompilerArguments method loops through the custom compiler options and creates a flat list with each key value pair. So based on this I should be able to do:
@@ -24,7 +24,8 @@
        <configuration>
          <source>1.6</source>
          <target>1.6</target>
-          <compilerArgument>-Xlint:all,-path -Werror</compilerArgument>
+          <compilerArgument>-Xlint:all,-path</compilerArgument>
+          <compilerArguments><Werror/></compilerArguments>
          <fork>true</fork>
        </configuration>
      </plugin>
I use the compilerArgument to get the literal for -Xlint:all,-path and the compilerArguments map to put in a separate entry for -Werror. It still doesn't work. Looking at the compileOutOfProcess method I find that plexus ignores the return code if it received any messages by parsing the compiler output.

So at this point I want to just replace the plugin being used for the compile phase with something else, such as the maven-antrun-plugin. This way I will be able to move on and my build will be working the way I want it to until Maven gets fixed. However, changing the plugin used for the compile phase is not easy either. I would need to create a plugin that customizes the build lifecycle. I can setup something to run in one of the existing phases, but I can't override the default plugin and use something else to do the compile in the existing lifecycle.

One of the nice features of Ant or Make is that I can choose exactly what does the compile operation and I could just exec the compiler directly if the javac task can't do what I need. It may be slower, but for me correctness is more important. With Maven I have no way to fix this without writing a Maven plugin or fixing the maven compiler plugin or plexus. When trying to setup a build in Maven I run into cases like this far too often.

At work the workaround they are doing is to use the maven-antrun-plugin and just tolerate things like having ant compile the code and then the maven compiler runs and finds the classes are up to date. It works, but it isn't a very clean solution. Also, hacks like this lead to some build targets being much slower than they should be because things just get pigeon-holed into some existing lifecycle phase instead of having accurate target dependencies that only do what is needed.

Summary

Some of the ideas around Maven are nice and it seems to have led to some nice infrastructure, such as the maven repositories. I find the Maven implementation to be terrible. Looking at how it meets the stated objectives:
  • Making the build process easy: It is easy if the POM is already setup and working. Trying to get to that point is a real pain and in my opinion much more work than using Ant.
  • Providing a uniform build system: This has been true for all the Maven projects I have seen thus far.
  • Providing quality project information: Maven makes it relatively easy to generate a site based on various reporting plugins. The problem I ran into is that you need to worry about the version of Maven, the Maven plugin, and the actual tool being integrated for the report. Ant may be a little more hassle initially, but in my case once the tools were setup I can import and reuse that build file. So the cost for subsequent projects is not very high.
  • Providing guidelines for best practices development: Yes they have guidelines.
  • Allowing transparent migration to new features: I think this means you could leave out the versions for plugins in the POM and it would grab the latest version automatically. Yes you can do this, but I would never use this feature as I want to stay on a stable set of plugins until I have tested a new version. Maven would let me easily update by changing the version, but I wouldn't consider that transparent. Looking online you can find many complaints about maven builds being non-deterministic and unrepeatable. In my experience the builds with Maven always work the same way, but I explicitly specify versions and most of the time have been hitting an internal repository.
If someone else, such as the build team at my company, is willing to create the POM and work through all the issues to make it work, then Maven isn't too bad. For personal projects I will stick with Ant and make use of the Maven Ant Tasks for interacting with repositories. When I get time I may look into Ivy and buildr to see if they are more usable than Maven.

2 comments:

  1. Soooo freaking funny. You can fix this issue by reversing two lines of code and -Werror works properly:
    http://jira.codehaus.org/browse/MCOMPILER-120
    Its a freaking P1 issue on the bugtracker but I've posted a patch 3 weeks ago and there's no reply.

    ReplyDelete
  2. They finally, applied my patch. Issue is fixed in plexus-compiler-javac 1.8.2.

    ReplyDelete