Javaでの互換性のないライブラリのバージョン衝突問題をrelocationや独自のクラスローダーによって解消する

java

Javaで自身や依存しているライブラリが異なるバージョンのライブラリに依存している場合、 通常、uber-jarにしたとしてもその中のクラスが優先的に読まれたりすることはなく、クラスパスの前にあるクラスがロードされることになるが、 互換性のないものがあると俗に言う JAR hell と呼ばれる状態に陥り、 実行時の NoSuchMethodError や意図しない挙動をする可能性がある。

例えば、guava 17.0 を含んでいる legacylib.jar と 32.1 を含んでいる modernlib.jar があり、 それぞれ 18.0 で削除された ByteStreams.asByteSource() と 20.0 で追加された ByteStreams.exhaust() を呼んでいる場合、 どちらの jar をクラスパスの前に持ってきても NoSuchMethodError が発生してしまう。この問題を解消する。 全体のコードは GitHub にある。

$ cat Legacy.java
...
public class Legacy {
    public static void foo() throws IOException {
        InputSupplier<InputStream> inputSupplier = () -> new ByteArrayInputStream("legacy ok".getBytes()); // interface removed in guava 20
        byte[] bytes = ByteStreams.asByteSource(inputSupplier).read(); // method removed in guava 18
        System.out.println(new String(bytes, "UTF-8"));
    }
}

$ cat Modern.java
...
public class Modern {
    public static void bar() throws IOException {
        InputStream inputStream = new ByteArrayInputStream("aaaaa".getBytes());
        ByteStreams.exhaust(inputStream); // method added in guava 20.0
        System.out.println("modern ok");
    }
}

$ java -cp myapp/target/myapp-1.0.0.jar:legacylib/target/legacylib-1.0.0.jar:modernlib/target/modernlib-1.0.0.jar net.sambaiz.Main
Exception in thread "main" java.lang.NoSuchMethodError: com.google.common.io.ByteStreams.exhaust(Ljava/io/InputStream;)J
        at net.sambaiz.modernlib.Modern.bar(Modern.java:13)
        at net.sambaiz.Main.main(Main.java:10)

$ java -cp myapp/target/myapp-1.0.0.jar:modernlib/target/modernlib-1.0.0.jar:legacylib/target/legacylib-1.0.0.jar net.sambaiz.Main
modern ok
Exception in thread "main" java.lang.NoSuchMethodError: com.google.common.io.ByteStreams.asByteSource(Lcom/google/common/io/InputSupplier;)Lcom/google/common/io/ByteSource;
        at net.sambaiz.legacylib.Legacy.foo(Legacy.java:16)
        at net.sambaiz.Main.main(Main.java:11)

なお、guava 20.0 で削除された InputSupplier interface も用いられているが、これに関しては 32.1 側にクラスが存在しないため衝突せずエラーになっていない。

relocationによる方法

maven-shade-plugin にはパッケージ名を書き換える relocation という設定項目がある。

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>3.2.4</version>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>shade</goal>
            </goals>
            <configuration>
                <relocations>
                    <relocation>
                        <pattern>com.google</pattern>
                        <shadedPattern>com.google.legacy</shadedPattern>
                    </relocation>
                </relocations>
            </configuration>
        </execution>
    </executions>
</plugin>

これによって衝突を回避することで互換性のないバージョンを両立させることができる。

$ jar tf legacylib/target/legacylib-1.0.0.jar
...
com/google/legacy/common/io/ByteStreams.class
com/google/legacy/common/io/CharSequenceReader.class
com/google/legacy/common/io/CharSink.class

$ java -cp myapp/target/myapp-1.0.0.jar:modernlib/target/modernlib-1.0.0.jar:legacylib/target/legacylib-1.0.0.jar net.sambaiz.Main
modern ok
legacy ok

ちなみに maven-shade-plugin によって jar に含められるライブラリのバージョンは maven の nearest definition で決まるので dependencies の順番を変えると jar に含まれるライブラリのバージョンが変わることがある。

$ mvn clean package dependency:tree
...
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ myapp ---
[INFO] net.sambaiz:myapp:jar:1.0.0
[INFO] +- net.sambaiz:legacylib:jar:1.0.0:compile
[INFO] |  \- com.google.guava:guava:jar:17.0-rc2:compile
[INFO] \- net.sambaiz:modernlib:jar:1.0.0:compile

dependencyManagement によってバージョンを固定することができる。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactI>guava</artifactId>
            <version>32.1.2-jre</version>
        </dependency>
    </dependencies>
</dependencyManagement>

独自のクラスローダーによる方法

この問題はアプリケーション単位でロードするクラスが選ばれることによって起きており、 次のような独自のクラスローダーによってライブラリごとにロードするクラスを変えることでも解消できる。 relocation はライブラリ側に手を入れる必要があったが、こちらは手を入れる必要がない。 ただ、引数に渡すインスタンスの作成など記述が煩雑になるのでそれ自体が実行時エラーの原因になり得る。

package net.sambaiz;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

public class MyClassLoader extends ClassLoader {
    private String jarFilePath;

    public MyClassLoader(ClassLoader parent, String jarFilePath) {
        super(parent);
        this.jarFilePath = jarFilePath;
    }

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        System.out.printf("findClass %s\n", className);
        try {
            JarFile jarFile = new JarFile(this.jarFilePath);
            JarEntry entry = jarFile.getJarEntry(className.replace('.', '/') + ".class");
            if (entry == null) {
                throw new ClassNotFoundException(className);
            }

            InputStream inputStream = jarFile.getInputStream(entry);
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            byte[] buffer = new byte[4096];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
            }

            byte[] bytecode = outputStream.toByteArray();
            return defineClass(className, bytecode, 0, bytecode.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(className);
        }
    }

    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        System.out.println(ClassLoader.getSystemClassLoader());

        MyClassLoader legacyClassLoader = new MyClassLoader(ClassLoader.getSystemClassLoader(), "legacylib/target/legacylib-1.0.0.jar");
        Class<?> legacyClass = legacyClassLoader.loadClass("net.sambaiz.legacylib.Legacy");
        Object legacyInstance = legacyClass.getDeclaredConstructor().newInstance();
        legacyClass.getMethod("foo").invoke(legacyInstance);;

        MyClassLoader modernClassLoader = new MyClassLoader(ClassLoader.getSystemClassLoader(), "modernlib/target/modernlib-1.0.0.jar");
        Class<?> modernClass = modernClassLoader.loadClass("net.sambaiz.modernlib.Modern");
        Object modernInstance = modernClass.getDeclaredConstructor().newInstance();
        modernClass.getMethod("bar").invoke(modernInstance);;
    }
}

ライブラリのパスはクラスパスにも含めていないが独自のクラスローダーによってロードできている。

$ java -cp myapp/target/myapp-1.0.0.jar net.sambaiz.MyClassLoader
jdk.internal.loader.ClassLoaders$AppClassLoader@277050dc
findClass net.sambaiz.legacylib.Legacy
findClass com.google.common.io.InputSupplier
findClass com.google.common.io.ByteStreams
...
legacy ok
findClass net.sambaiz.modernlib.Modern
findClass com.google.common.io.ByteStreams
findClass com.google.common.io.ByteStreams$1
findClass com.google.common.io.ByteStreams$LimitedInputStream
findClass com.google.common.io.ByteArrayDataInput
findClass com.google.common.io.ByteArrayDataOutput
modern ok

参考

shaded-jarの作り方 - Qiita