Hacking A Maven Dependency with Javassist to Fix It

Have you ever wondered what to do when needing "just a small change" to a third-part library your project depended on? This post describes how to use Maven and Javassist to take a dependency of your project, instrument it to modify its behavior, re-pack it, and release it as an artifact with a different name (so that you me depend on my-customized-lib instead of on lib).

The process is as follows:
  1. Phase process-sources - maven-dependency-plugin unpacks the dependency to classes/
  2. Phase compile (implicit) - compile the bytecode manipulation code
  3. Phase process-classes - exec-maven-plugin executes the compiled Javassist instrumenter to modify the unpacked classes
  4. Phase test - run tests on the instrumented code
  5. Phase package - let maven-jar re-package the instrumented classes, excluding the instrumenter itself

Why: The Case Introduction

Modifying binaries of third-party libraries is certainly an ugly thing but sometimes it's the most feasible way to satisfy a need. So it was with my JSF EL Validator that reuses existing EL implementations with plugged-in custom variable and property resolvers (returning fake values of the expected types because real objects aren't available at the validation time). The problem was that the EL specification requires short-circuit evaluation of ?: branches and of /boolean expressions while to be able to validate the expressions I needed all parts of them to be evaluated. The only feasible solution proved to be the modification of the EL implementations to evaluate all children of a boolean/choice node.

How: Javassist, Dependency and Exec Plugins

I've used common Maven plugins to fetch and unpack the dependency, to execute the instrumentation code, and to pack the classes again and release them as an artifact with a different name. The instrumentation is implemented with a little of Java code leveraging Javassist to perform the actual modification on the unpacked class files.

The explanation will follow the Maven lifecycle phases the individual operations are bound to.

0. Set-up a Project to Do the Instrumentation

First we create a new project (or more likely a Maven module of the main project) to fetch, modify, pack, and release the dependency we need to tweak. The important thing in the project is only the POM that binds all the plugins and phases together and the Javassist code that performs the instrumentation.

An example is the jasper-el-customized-jsf12, which modifies org.apache.tomcat:jasper-el and releases it as jasper-el-customized-jsf12. Notice that I have added the jasper-el as an explicit dependency to the project - this is to make my IDE aware of it and to make it posssible for Maven to compile my instrumentation helper class (which accesses jasper-el classes). In theory it shouldn't be necessary as the classes will be made available in the process-sources phase but it would require some tweaking of the compiler's and IDE's classpaths though it would be a cleaner approach (for I would avoid having the dependency twice on the classpath: once explicitly as a .jar artifact and once as unpacked classes).

1. Phase process-sources - maven-dependency-plugin unpacks the dependency

First we need to fetch and unpack the dependency so that Javassist can operate on it. The easiest thing is to unpack it into target/classes/ so that the tests and the JAR plugin have access to it without any configuration of their classpath.

(Notice that the instrumentation code could be compiled without dependening on the artifact being modified as the code to insert/modified can be represented just as String.)

This is how to do it:


<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-dependency-plugin</artifactId>
  <version>2.3</version>
  <executions>
    <execution>
      <id>unpack</id>
      <goals>
        <goal>unpack</goal>
      </goals>
      <configuration>
        <artifactItems>
          <artifactItem>
            <groupId>org.apache.tomcat</groupId>
            <artifactId>tomcat-jasper-el</artifactId>
            <version>${jasper.jsf20.version}</version>
          </artifactItem>
        </artifactItems>
        <excludes>META-INF/**</excludes>
        <outputDirectory>${project.build.outputDirectory}</outputDirectory>
        <overWriteReleases>true</overWriteReleases>
      </configuration>
    </execution>
  </executions>
</plugin>

2. Phase compile (implicit) - compile the bytecode manipulation code

No configuration necessary here, mvn compile will compile the Javassist instrumentation code as it would do with any other Java code.

First you of course need to write the instrumentation code. I've decided to use Javassist because it doesn't introduce any new runtime dependencies to the modified artifact. (Read my recent introduction into Javassist if you feel the need.)

See JavassistTransformer.java for details of the implementation, the code is rather simple and straightforward. Few commnets:
  • JavassistTransformer has a main() so it can be run by the exec plugin
  • Javassist's ClassPool is instructed to search for the classes to instrument in the target/classes folder
  • We tell Javassist what classes it should find and which of their methods it should modify (via insertBefore) and then we save the modified classes.
  • I've minimized the code to inject in the transformer where it can only be represented as a string, and just insert a delegation of the processing to a helper class, GetValueFix.java, which is a regular Java code using the dependency's classes and compiled by Javac and added to the modified jar.

3. Phase process-classes - exec-maven-plugin executes the compiled Javassist instrumenter to modify the unpacked classes

The compiled instrumentation code has to be executed - that's why it has a main method - to actually modify the unpacked classes. That's easily achieved with the exec plugin:


<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>exec-maven-plugin</artifactId>
  <version>1.2.1</version>
  <executions>
    <execution>
      <goals>
        <goal>java</goal>
      </goals>
      <phase>process-classes</phase>
    </execution>
  </executions>
  <configuration>
    <mainClass>net.jakubholy.jeeutils.jsfelcheck.jasperelcustomizer.instrumenter.JavassistTransformer</mainClass>
    <arguments>
      <argument>${project.build.outputDirectory}</argument>
    </arguments>
    <includePluginDependencies>true</includePluginDependencies>
  </configuration>
  <dependencies>
    <dependency>
      <groupId>org.javassist</groupId>
      <artifactId>javassist</artifactId>
      <version>${javassist.version}</version>
    </dependency>
  </dependencies>
</plugin>


(I have to specify Javassist as an explicit dependency because its scope as a project dependency is "provided" and thus the exec plugin ignores it.)

4. Phase test - run tests on the instrumented code

You wouldn't expect me not to test my code, right?

Testing doesn't require any special configuration as the instrumented classes to test are already in target/classes. If we wanted to have them somewhere else, we would just provide the surefire plugin's configuration with an additionalClasspathElements/additionalClasspathElement.

(You will notice that I have actually done that in the POM even though it is unnecessary given my direct usage of target/classes.)

BTW, if you wonder how the tests written in Groovy get compiled and executed, notice that I've configured that in the parent POM. (Which is the only piece of configuration there with any impact on this module.)

5. Phase package - let maven-jar re-package the instrumented classes

Again we could leave the JAR plugin with its default configuration as everything necessary is under target/classes but I prefer to specify the classesDirectory explicitly and to exclude the instrumentation code (while including the GetValueFix helper).

The POM

You may want to check the complete pom.xml at GitHub.

Alternative: Renaming the Package

If you wanted to change the root package of the modified dependency you could do so with one of the package renamers for Maven, f.ex. the Uberize plugin but then you would need to hook into the processing it does to actually perform the instrumentation, e.g. by implementing a custom Uberize Transformer (which would likely need to be distributed as an independent artifcat of its own for the plugin to be able to use it).

Summary

I've shown an approach for configuring a set of Maven plugins to unpack, instrument, and re-pack a dependency and the code to perform the actual instrumentation using Javassist. The approach works but it certainly could be improved, for example I would prefer to unpack the classes of the dependency into a folder of their own rather than into target/classes and I'd also prefer not to need to specify the dependency explicitly in the dependencies section as this creates a duplication of its original classes (from the dependency's artifact jar) and modified (locally unpacked) classes on the classpath.

Tags: java library tool


Copyright © 2024 Jakub Holý
Powered by Cryogen
Theme by KingMob