Category: Class Loader

Scenario

In a spring-boot application that uses spring-boot-dependencies instead of spring-boot-starter-parent and a third party library that depends on elasticsearch version that is not comparable with version 6.x, elasticsearch dependency version get mixed up creating class loading issue.

Steps to reproduce

Create a spring-boot project and include spring-boot-dependencies in the dependency management section of the maven pom.xml

<properties>
  <spring.boot.version>2.1.6.RELEASE</spring.boot.version>
  <spring.cloud.version>Greenwich.RELEASE</spring.cloud.version>
</properties>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>${spring.boot.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>

    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>${spring.cloud.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Later, in the dependencies section include a third party library that depends on elastic search version 5.6.8

<dependnecies>
  <dependency>
    <groupId>com.netflix.conductor</groupId>
    <artifactId>conductor-es5-persistence</artifactId>
    <version>2.8.1</version>
  </dependency>
</dependnecies>

Note that conductor-es5-persistence depends on elasticsearch version 5.6.8

The try to run mvn dependency:tree on your project and observe the following:

[INFO] +- com.netflix.conductor:conductor-es5-persistence:jar:2.8.1:compile
[INFO] |  +- org.elasticsearch:elasticsearch:jar:6.4.3:compile
[INFO] |  |  +- org.elasticsearch:elasticsearch-core:jar:6.4.3:compile

Where 6.4.3 is coming from?

Diagnostic

Turns out mvn dependency:tree shows the issue, but it does not say where org.elasticsearch:elasticsearch:jar:6.4.3:compile is comming from. The best command for the job is:

mvn -Dverbose=true help:effective-pom

This command outputs effective POM that can be redirected to a file mvn -Dverbose=true help:effective-pom > ~/pom.xml and analysed. The effective pom will look something like this

...
<dependency>
  <groupId>org.elasticsearch</groupId>  <!-- org.springframework.boot:spring-boot-dependencies:2.1.6.RELEASE, line 1965 -->
  <artifactId>elasticsearch</artifactId>  <!-- org.springframework.boot:spring-boot-dependencies:2.1.6.RELEASE, line 1966 -->
  <version>6.4.3</version>  <!-- org.springframework.boot:spring-boot-dependencies:2.1.6.RELEASE, line 1967 -->
</dependency>
...

Magic! now we can see exactly where the problem is:

<!-- org.springframework.boot:spring-boot-dependencies:2.1.6.RELEASE, line 1967 -->

Solution

Lets look at this import dependency:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-dependencies</artifactId>
    <version>${spring.boot.version}</version>
    <type>pom</type>
    <scope>import</scope>
  </dependency>

spring-boot-dependencies is imported, so there is no easy way to exclude elasticsearch dependency that it pulls into our project. A simple maven <exclude> will not work here. The only way to solve the issue is to bring elasticsearch dependencies into your project dependency management block like this:

<properties>
  <spring.boot.version>2.1.6.RELEASE</spring.boot.version>
  <spring.cloud.version>Greenwich.RELEASE</spring.cloud.version>
  
  <elasticsearch.version>5.6.8</elasticsearch.version>
</properties>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>${spring.boot.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>

    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>${spring.cloud.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    
    <!-- Elasticsearch -->
    <dependency>
      <groupId>org.elasticsearch</groupId>
      <artifactId>elasticsearch</artifactId>
      <version>${elasticsearch.version}</version>
    </dependency>

    <dependency>
      <groupId>org.elasticsearch.client</groupId>
      <artifactId>transport</artifactId>
      <version>${elasticsearch.version}</version>
    </dependency>

    <dependency>
      <groupId>org.elasticsearch.distribution.integ-test-zip</groupId>
      <artifactId>elasticsearch</artifactId>
      <version>${elasticsearch.version}</version>
      <type>zip</type>
    </dependency>

    <dependency>
      <groupId>org.elasticsearch.plugin</groupId>
      <artifactId>transport-netty4-client</artifactId>
      <version>${elasticsearch.version}</version>
    </dependency>

  </dependencies>
</dependencyManagement>

Now run mvn -Dverbose=true help:effective-pom and confirm that the problem is fixed.

Vision Statement

This project was inspired by the requirement of loading Java dependencies at run time with the ability to dynamically switch dependency versions without application re-start. This can be useful for a Java library that can be downloaded and invoked while older versions of the library still present and can be used.

Use Case

Let’s imagine a front end application that invokes back end Java library for processing and needs to be able to switch between processor versions without application re-start. Moreover, this application will be able to download new versions of the processor library and make it available without any re-installer. In addition, you need iterating over its design or implement features that a group of your customers is interested in. Some of the features may affect results of the processing that are not desirable for the other group of customers. Your product can offer a solution that can satisfy both groups by providing in-process switch for the version of your processing engine. In addition, the other group of customers can try-out new version of the engine and have some time to adopt to new results.

Other Solutions

Although, I think that the itzap-proxy is great there are other solutions that can implement this requirement, or get close to it.

JBoss Modules

Jboss Modules http://jboss-modules.github.io/jboss-modules/manual/ can be used to implement this requirement. They provide an isolated class loader that can be used to load different versions of the processing engine Java back end.

Java CGI

Java CGI is originally intended for this kind of scenarios. https://www.javaworld.com/article/2076863/write-cgi-programs-in-java.html

Why itzap-proxy

The main advantage of the itzap-proxy is the ease of use. While JBoss modules can solve the problem of class loading isolation, they impose some requirement on how modules have to be defined. To effectively use JBoss Modules Java engine has to be originally designed with JBoss modules in mind which makes it impossible to retrofit older versions of the Java engine that customers may still want to use with your application. itzap-proxy does not impose any changes on the Java library that needs to be loaded at run time, which makes it a perfect solution in situations when older versions of the engine still need to be used, while new versions are being rolled out. Java CGI, on the other hand, is hard. There are frameworks that make it easier to use, but it makes the application more heavyweight.

Other Applications

Isolated Class Loader

itzap-proxy provides isolated class loader that can be used to load dependencies. If there are conflicting versions of libraries used by dependencies that cannot be reconciled by maven excludes itzap-proxy provides a way to load them using dedicated class loader. Most common examples are log4j or Apache POI used in the third party libraries.

Mixing Java 7 and Java 8

Suppose, your application provides functionality that targets different user groups, and one group cannot upgrade JVM to Java 8 while another group needs features of Java 8. Using itzap-proxy you can combine Java 7 application with Java 8 dependencies that are loaded at run time by the “Java 8 user group”. This way, you can still maintain single application with features supported by Java 7 and Java 8.

Usage

itzap-proxy

Load java jar files dynamically in isolated class loader to avoid dependency conflicts and enable modular updates. All jar files in the [main jar folder]/lib/myLib/2.0/*.jar will be loaded.

Visit my ITZap blog to read more about this project.

Code Examples

  1. Create Object from a JAR file located in the myLib/2.0 folder:
ProxyCallerInterface object = ObjectBuilder.builder()
       .setPackageName("org.my.package")
       .setClassName("MyClass")
       .setVersionInfo(newVersionInfo("myLib", "2.0"))
       .build();
object.call("objectMethod");
  1. Create object from factory method and pass "string param" and 1 to it:
ProxyCallerInterface object = ObjectBuilder.builder()
       .setPackageName("org.my.package")
       .setClassName("MyClass")
       .setVersionInfo(newVersionInfo("myLib", "2.0"))
       .setFactoryMethod("initMyClass")
       .setParams("string param", 1)
       .build();
  1. Object builder can implement proxy interface:
ProxyCallerInterface object = ObjectBuilder.builder()
   .setClassName("org.mypackage.myclass")
   .setVersionInfo(newVersionInfo("myLib", "2.0"))
   .build();

// Example with call back
object.call("addListener", ObjectBuilder.from(builder)
                            .setInterfaceName("org.mypackage.MyListener")
                            .setHandler(new InvocationHandler() {
                                @Override
                                public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                                   // handle call back
                                   return null;
                                })
                            .build()
  1. Instantiating object with constructor parameters
ProxyCallerInterface object = ObjectBuilder.builder()
       .setPackageName("org.my.package")
       .setClassName("MyClass")
       .setVersionInfo(newVersionInfo("myLib", "2.0"))
       .setParams("String param", 1)
       .build();
       
  1. Loading Enums
Map<String, ProxyEnum> enum = ObjectBuilder.from(builder)
                                    .setClassName("MyClass$USStates")
                                    .buildEnum();
  1. Calling setters on a created object
List<MethodDesriptor> descriptors = Lists.newArrayList();
ObjectBuilder builder = ObjectBuilder.builder()
                .setClassName("org.mypackage.MyClass")
                .setParams("String param")
                .setDescriptors(descriptors);
// call setter
descriptors.add(MethodDesriptor.method("setProperty", "property name", "value"));
ProxyCallerInterface object = builder.build();
  1. Calling static methods
List<MethodDesriptor> settings = Lists.newArrayList();
ObjectBuilder builder = ObjectBuilder.from(builder)
                .setClassName("org.MyUtils")
                .setStaticObject(true)
                .setDescriptors(settings);
                
settings.add(MethodDesriptor.method("convert", "from this string"));
builder.build();