Javaの実行時にクラスパスを追加する

2018年7月19日

問題

一般的にJavaアプリケーションを構成するものは、そのアプリ自体の.classからなる.jarファイル(これを仮にapp.jarとする)だけではない。サードパーティ製の多数の.jarファイルが必要になる。アプリの実行時に、これらの.jarファイルがクラスパス上に存在しなければアプリケーションは動作できない。

クラスパスの指定方法として、一般的には以下のようなものがある。

  • 1.コマンドラインにて指定する方法
java -cp A.jar;B.jar;C.jar -jar app.jar

※これ間違い、-cpと-jarは同時に指定できないとのこと。ご指摘くださった方ありがとう!

  • 2.jarファイルのマニフェスト内に記述する方法
Manifest-Version: 1.0
Main-Class: ....
Class-Path: ....
  • 3.サードパーティ製の.jarファイルをほぐしてしまい、.classの形にしてからapp.jarにまとめてしまう方法

いずれも問題がある。1.はいかにも面倒だし、jarファイルをダブルクリックした場合の起動には対応できない。2.はスタンダードな方法ではあるが、ライブラリの増減のある場合には面倒かもしれない。 3.は、サードパーティ製ライブラリがGPLライセンスの場合は問題である。ユーザ側で容易にライブラリの変更ができないからである。

もっと簡単な方法はないものか?例えば、「java -jar app.jar」あるいはダブルクリックして起動されると、特定のフォルダ(例えばapp.jarと同じフォルダ内のlibフォルダ)を探し、そこにあるすべての.jarファイルをクラスパスに追加してから起動するようなことができないものか?

※もちろん、libフォルダ内を探索するコード部分では、libフォルダ内の.jarファイルは使用しないものとする。

解決策の提案

解決策としては以下が考えられる。

  • クラスパス指定した新たなプロセスを起動する。
  • システムクラスローダに無理矢理クラスパスを指定する。
  • クラスパスを指定した新たなクラスローダを作成する。

クラスパス指定した新たなプロセスを起動する

「java -jar app.jar」として起動されたプロセスにはクラスパス指定されていないのだから、「java -cp 複数のjarファイル メインクラス」としてクラスパス指定された新たなプロセスを作成してしまえばよい。起動する側は、起動するだけの役割なので、起動後は単純に終了する。

※「java -cp ….;app.jar foo.bar.AnotherEntry」などとして、app.jarのメインクラスとは異なるメインクラスを明示的に指定する。

    Process process;
    try {
      process = Runtime.getRuntime().exec(クラスパス指定をしたコマンド);
    } catch (IOException ex) {
      ex.printStackTrace();
      return;
    }   
    ...
    System.exit(0); // プロセス起動に成功したので、このプロセスは終了

この方法には注意点がある。意図通りにプロセス起動されたかどうかはProcessオブジェクトからはよくわからない。とりあえずJava-VMが起動すればエラーも出ずにProcessオブジェクトが返されてしまう。実際にはProcessの標準出力等を調査して適切な起動が行われたかを調査した後にSystem.exit()すべきかと思われる。

また、この方法には極めて良い面がある。VM引数を指定できるのである。VM引数は起動時のみ指定できるため、例えば起動する側が設定ファイルを読み込み、それをVM引数として指定することができる。これにより、あらゆるVM引数をコマンドライン上で指定する必要がなくなる。それらは設定ファイルに記述しておけばよいのである。

java -Xmx256M -D.... -cp ....;app.jar foo.bar.AnotherEntry

システムクラスローダに無理矢理クラスパスを指定する(Java9で動作しない)

この方法は以下に記述されている。要するに、本来クラスローダは後からクラスパスを指定できるものではないのだが、リフレクションを使って無理矢理行うものである。これがうまく行くのかどうかは不明。
* Javaで実行時にクラスパスを追加する

2018/3/7追加

※※※ この方法はJava9では使用できない ※※※

なぜか?システムクラスローダでも、現在のクラスのローダでもどちらでも構わないのだが、このクラスローダはURLClassLoaderではなくなったからだ。以下のコードを実行してみると、

package sample;

import java.net.*;

public class Main {
  public static void main(String[]args) {
    ClassLoader cl1 = ClassLoader.getSystemClassLoader();
    ClassLoader cl2 = Main.class.getClassLoader();    
    System.out.println("" + (cl1 == cl2));
    System.out.println("" + (URLClassLoader)cl1);    
  }
}

Java8では

true
sun.misc.Launcher$AppClassLoader@2a139a55

Java9では

true
Exception in thread "main" java.lang.ClassCastException: java.base/jdk.internal.
loader.ClassLoaders$AppClassLoader cannot be cast to java.base/java.net.URLClass
Loader
        at sample.Main.main(Main.java:13)

Java9では使用できないことを確認したが、一応どのようにするかを簡単に記述する。

    URLClassLoader systemClassLoader = (URLClassLoader)ClassLoader.getSystemClassLoader();
    Method method = URLClassLoader.class.getDeclaredMethod("addURL", new Class[] { URL.class });
    method.setAccessible(true);   
    urls.forEach(url-> {
      try {      
        method.invoke(systemClassLoader, url);
      } catch (Exception ex) {
        throw new RuntimeException(ex);
      }
    });

このurlsを取得する方法としては、以下の例。他の部分から抜き出したので少々冗長になっている。

public class JarCollection {

  /** フォルダ */
  public final File dir;

  /** 相対パスリスト */
  public List<String>relativePaths;

  /** ディレクトリを指定し、すべての.jarファイルを列挙する */
  public JarCollection(File dir) {
    this.dir = dir;
    relativePaths = new ArrayList<>();    
    new Object() {
      void gatherFiles(File parent, String relative) {
        Arrays.stream(parent.list()).forEach(name-> {
          File file = new File(dir, name);
          String relativeName = relative + name;
          if (file.isDirectory()) {
            gatherFiles(new File(parent, relativeName), relativeName + "/");
            return;
          }
          if (relativeName.toLowerCase().endsWith(".jar"))
            relativePaths.add(relativeName);          
        });  
      }
    }.gatherFiles(dir, "");
  }

  /** すべてのjarのURLストリームを得る */
  public Stream<URL>getURLs() {
    return getFiles().map(file->getURL(file));
  }

  /** 相対パスリストのストリームを得る */
  public Stream<String>getRelatives() {
    return relativePaths.stream();
  }

  /** ファイルリストのストリームを得る */
  public Stream<File>getFiles() {
    return getRelatives().map(name->new File(dir, name));
  }

  private URL getURL(File file) {
    try {
      return file.toURI().toURL();
    } catch (Exception ex) {
      throw new RuntimeException(ex);
    }
  }
}

以下のように使う。

JarCollection c = new jarCollection(new File("lib"));
Stream<URL>urls = c.getURLs();

クラスパスを指定した新たなクラスローダを作成する(新しいもの)

前項の方法はJava9では使用できなくなった。

今回の方法の原理としてはこうだ。

  • jarファイル中のAクラスのmainが呼び出されたら、その中で新たなクラスローダーを作成する。このクラスローダーの中には、その同じjarファイルの他に必要なjarファイルすべてが指定してある。これを使用して、同じjarファイル中のBクラスのmainを呼び出す。
  • ただし、Eclipse等の開発環境の実行ではその必要は全く無いので、通常通りBクラスのmainを呼び出すようにする。

以下のClassPathLoaderについては、実行中クラスのクラスパスもしくはjarファイルの取得を参照のこと。

import java.io.*;
import java.lang.reflect.*;
import java.util.*;
public class A {

  public static void main(String[]args) throws Exception {   

    File classPath = ClassPathLocator.getLocation();
    ClassLoader classLoader;
    if (classPath.isDirectory()) {
      // フォルダの場合は開発環境で実行中
      classLoader = A.class.getClassLoader();
    } else {
      // ファイルの場合はjarファイル上で実行中、lib以下にライブラリがある
      Set<File>jars = LibraryUtil.getJars(new File(classPath.getParentFile(), "lib"));
      jars.add(classPath);
      classLoader = ClassLoaderUtil.newClassLoader(jars);
    }  

    Class<?>mainClass = classLoader.loadClass(B.class.getName());
    Method mainMethod = mainClass.getDeclaredMethod("main",  String[].class);
    mainMethod.invoke(null, new Object[] { args });
  }

}
public class LibraryUtil {
  public static Set<File>getJars(File libDir) {
    Set<File>files = doGetJars(libDir);
    return files;
  }  
  private static Set<File>doGetJars(File dir) {
    return Arrays.stream(dir.listFiles(f->f.getName().endsWith(".jar"))).collect(Collectors.toSet());
  }
}
public class ClassLoaderUtil {
  public static URLClassLoader newClassLoader(Set<File>files) {
    URL[]urls = files.stream().map(file->getURL(file)).collect(Collectors.toList()).toArray(new URL[0]);
    return new URLClassLoader(urls, null); // これについては下記の※を参照のこと
  }
  private static URL getURL(File file) {
    try {
      return file.toURI().toURL();
    } catch (Exception ex) {
      throw new RuntimeException(ex);
    }
  }
}

※Java8ではnull(親なし)で構わなかったが、Java9では動作が異なる。特定のライブラリが呼び出せなくなってしまうのだ。これを避けるには、nullではなく「ClassLoaderUtil.class.getClassLoader().getParent()」等を指定する。

クラスパスを指定した新たなクラスローダを作成する(古いもの)

※※※ 以下は非常にややこしい記述になっている。この前項の新しい方法を参照のこと ※※※

あるクラスから別のクラスを探し出せるかどうかは、クラスローダがどのように構成されているかによる。通常、アプリケーション(を構成するクラス)は「システムクラスローダ」によってロードされる。アプリを構成するクラスから別のクラスを呼び出せるかどうかは、システムクラスローダ及びその親(そのまた親)が呼び出し対象のクラスをロードできるかどうかにかかっている。

もし、このシステムクラスローダに既にクラスパスが指定されているのであれば問題無いのだが、そうではない。前述の方法は無理矢理システムクラスローダにクラスパスを指定する方法であった。これに対して、以下では正当な方法でクラスローダに動的に(その場で)クラスパスを指定する。

つまり、動的に取得した.jarファイルを追加のクラスパスとする新たなクラスローダを作成し、そのクラスローダでアプリのコードをロードさせるようにすればよいのである。

public class ClassPathLauncher {

  private Class callingClass;
  private String mainMethodClass;
  private String[]args = new String[0];
  private ArrayList<URL>classPaths = new ArrayList<URL>();

  /** 作成する */
  public ClassPathLauncher(Class callingClass, String mainMethodClass) {
    this.callingClass = callingClass;
    this.mainMethodClass = mainMethodClass;
  }

  /** mainの引数を設定する */
  public void setArgs(String[]args) {
    this.args = args;
  }

  /** クラスパスを追加する */
  public ClassPathLauncher addClassPath(File dir) throws Exception {
    classPaths.add(toUrl(dir, true));
    return this;
  }

  /** Jarファイルディレクトリを追加する。 */
  public ClassPathLauncher addJarsDir(File dir, boolean recursive) throws Exception {
    for (File file: dir.listFiles()) {
      if (file.isDirectory()) {
        if (recursive) addJarsDir(file, true);
        continue;
      }
      classPaths.add(toUrl(file, false));
    }
    return this;
  }

  /** Jarファイルを追加する */
  public ClassPathLauncher addJars(File[]jarFiles) throws Exception {
    for (File file: jarFiles) {
      classPaths.add(toUrl(file, false));
    }
    return this;
  }

  /** スタート */
  @SuppressWarnings("unchecked")
  public void start() {
    // クラスローダを作成。スタート
    try {
      ClassLoader classLoader = createClassLoader();
      Class startClass = classLoader.loadClass(mainMethodClass);
      Method method = startClass.getDeclaredMethod("main", new Class[] { String[].class });
      Thread.currentThread().setContextClassLoader(classLoader);
      method.invoke(null, new Object[] { args });
    } catch (Exception ex) {
      ex.printStackTrace();
      System.exit(1);
    }
  }

  /**
   * 新たなクラスローダーを作る。
   * <p>
   * 理由としては、コマンド引数や設定ファイルから取得したディレクトリから自由にクラスをロード
   * したいから。クラスパスを指定するという方法もあるが、柔軟性がない。
   * </p>
   * <p>
   * クラスローダーは階層をなしている。このメソッドのクラスは既にロード済であるが、そのクラス
   * ローダの下に新たなクラスローダを作成してはいけない。なぜなら、ロードすべきクラスが上位
   * 階層のクラスローダのサーチパスに存在する場合は、そのクラスローダがロードしてしまうから
   * である。
   * </p>
   * <p>
   * これでは何がまずいかというと、上のクラスローダがロードしたクラスから下のクラスローダの
   * 管轄するクラスは参照できないから。だから、既存のクラスローダの下
   * に新たなクラスローダを作成しても、既存のクラスローダでロードしたものからは、下位のクラス
   * ローダでロードしたものを参照できない。
   * </p>
   * <p>
   * これを解決するため、本クラス(このメソッドが存在するクラス)をロードしたクラスローダの
   * URL情報だけを取得し、その上のクラスローダの下に新たなクラスローダを作成する。
   * </p>
   */
  private ClassLoader createClassLoader()  throws Exception {

    // このクラスをロードしたローダを取得し、そのURLを得る。
    ClassLoader mainLoader = callingClass.getClassLoader();
    {
      URL[] mainUrls;
      if (mainLoader instanceof URLClassLoader)
        mainUrls = ((URLClassLoader)mainLoader).getURLs();
      else
        mainUrls = new URL[] { new File(".").toURI().toURL() };
      for (int i = 0; i < mainUrls.length; i++)
        classPaths.add(mainUrls[i]);
    }

    // 新しいクラスローダを作成する。「mainLoaderの親」を親とする。
    return new URLClassLoader(
      classPaths.toArray(new URL[0]),
      mainLoader.getParent()
    ) {
       @Override
       protected String findLibrary(String libname) {
         return ClassPathLauncher.this.findLibrary(libname);
       }
    };
  }

  /** FileをURLに変換する。dir=true時はディレクトリとする */
  private URL toUrl(File file, boolean dir) throws MalformedURLException {
    String filePath = file.toString();
    filePath = filePath.replace('\\', '/');
    if (filePath.charAt(0) != '/') filePath = "/" + filePath;
    if (dir) filePath = filePath + "/";
    URL url = new URL("file", null, filePath);
    return url;
  }

  /**
   * <p>
   * クラスローダ内でネイティブライブラリが必要になった時点でこのメソッドが
   * 呼び出される。このメソッド内で、環境非依存のネイティブライブラリ名から
   * 環境依存の絶対ファイルパス名に変換してそれを返す。
   * </p>
   * <p>
   * 本来、javaのネイティブライブラリはPATH変数あるいはjava.library.pathで示された
   * ディレクトリから。あるいはSystem.load()にて直接絶対パスを指定することにより
   * ロード可能となっているが、新規のクラスローダを使用しているからなのかどうもうまく
   * いかない。
   * </p>
   */
  protected String findLibrary(String libname) {
    return null;
  }
}

これを以下のように呼び出す。

public class ServerStart {
  public static void main(String[]args) {
    File libDir = new File("lib");
    if (!libDir.exists()) {
      System.err.println("directory not found:" + libDir);
      System.exit(1);
    }
    try {
      ClassPathLauncher launcher = new ClassPathLauncher(ServerStart.class, "foo.bar.ServerMain");
      launcher.setArgs(args);
      launcher.addJarsDir(libDir, false);
      launcher.start();
    } catch (Exception ex) {
      ex.printStackTrace();
    }
  }
}

ClassPathLauncherで新たに作成するクラスローダは、システムクラスローダの子とはしない(理由はコメントを参照)。そうではなく、システムクラスローダに指定されているURL(クラスパス)と、動的に取得したURL(クラスパス)をあわせ持つクラスローダとする。こうすることで、アプリケーションを構成するクラスと、サードパーティ製jar内のクラスが同じ一つのクラスローダによってロードされることになる。

※このような構成とせず、新たなクラスローダをシステムクラスローダの子としてしまうと、アプリ内のクラスからサードパーティ製jar内のクラスを呼び出すことはできない。ClassNotFoundExceptionとなる。

注意事項

もしアプリケーションがプラグイン構成となっており、プラグインをアプリ実行中に追加するなど動的に呼び出したい場合は、プラグインのjarを呼び出すクラスローダをまた新たに作成することになるが、その場合には以下ではいけない。

 URLClassLoader loader = new URLClassLoader(new URL[] { url });

デフォルトでは、新たなクラスローダの親はシステムクラスローダとなるからである。
ClassPathLauncherを使用した場合には、以下のようにしなければならない。

 URLClassLoader loader = new URLClassLoader(
            new URL[] { url }, getClass().getClassLoader());

おまけ:java.library.pathを指定する

ネイティブライブラリを呼び出すためのパスを動的に追加する方法は、
実行時にjava.library.pathを変更するに記述がある。

要するに、プログラム実行が開始したら既にClassLoaderのstaticフィールドであるuser_pathsにjava.library.pathを元にした検索パスが設定されており、その後でjava.library.pathを変更しても意味は無いということ。

そして、これはstaticフィールドなので、新たなクラスローダを作ろうが何をしようが全く影響することはできない。変更するには、リフレクションを使ってこのフィールドを書き換えるしかないのだ。

以下のような具合である(ここではuser_pathsを書き換えることにしている)。

    Field userPathsField = ClassLoader.class.getDeclaredField("usr_paths");
    userPathsField.setAccessible(true);
    List<String>paths = new ArrayList<>(Arrays.asList((String[])userPathsField.get(null)));
    for (String path: paths) {
      System.out.println("" + path);
    }
    String p = "some directory";
    paths.add(p);
    userPathsField.set(null, paths.toArray(new String[0]));

ただ、最近のライブラリでは、(Windowsの場合)直接的に.dllを読まなければならないケースは稀のように思われる。というのも、jnaやある種のシリアルポートライブラリの中身を見てみればわかるが、jar内のコードでマシンを判定し、jar内に格納されたネイティブライブラリを自動でロードするようになっているからだ。

おまけ2:zipに固めた複数のjarファイルをロードできないか?

結論としては、できないようだ。Reading Classes found in a Jar packaged in a Zipfileに議論がある。

つまり、こういうこと。複数のjarファイルがあり、それを一つのzipファイルの形にまとめる。

ZIPファイル中に存在するそれぞれのjarファイルのURLとしては、”jar:file:/c:\foo\bar\sample.zip!mail.jar”等となるのだが、これらをURLClassLoaderに指定すれば、zipを解凍せずともロードできるんじゃね?という話なのだが、実際にやってみてもできなかった。

jarファイルは独立したファイルである必要があるようだ。

ちなみに、URLStreamHandlerFactoryを指定することで、できないものかやってみた。

  URLClassLoader classLoader = new URLClassLoader(jarUrls, null, new MyURLStreamHandlerFactory());
  ......
  ......
  static class MyURLStreamHandlerFactory implements URLStreamHandlerFactory {
    @Override
    public URLStreamHandler createURLStreamHandler(String protocol) {
      if (protocol.equals("jar")) {
        return new MyURLStreamHandler();
      }
      return null;
    }    
  }

  static class MyURLStreamHandler extends URLStreamHandler {
    @Override
    protected URLConnection openConnection(URL u) throws IOException {
      String urlStr = u.toString();
      /* どういうわか
       * jar:file:/c:\foo\bar\sample.zip!mail.jar
       * という指定が、以下の形になってくるので修正
       * jar:jar:file:/c:\foo\bar\sample.zip!mail.jar!/
       */
      URL url = new URL(urlStr.substring(4, urlStr.length() - 2));
      return new JarURLConnection(url) {
        @Override
        public void connect() throws IOException {
        }        
        @Override
        public JarFile getJarFile() throws IOException {
          System.out.println("getJarFile");
          throw new RuntimeException();
        }        
      };
    }
  }

しかし、最終的にはJarFileオブジェクトが必要になってしまい、結局は「jarファイル」でなければならなくなる。