実行時にクラスパスを追加する
問題
一般的にJavaアプリケーションを構成するものは、そのアプリ自体の.classからなる.jarファイル(これを仮にapp.jarとする)だけではない。サードパーティ製の多数の.jarファイルが必要になる。アプリの実行時に、これらの.jarファイルがクラスパス上に存在しなければアプリケーションは動作できない。
クラスパスの指定方法として、一般的には以下のようなものがある。
- 1.コマンドラインにて指定する方法
java -cp A.jar;B.jar;C.jar -jar app.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 app.jar」としてクラスパス指定された新たなプロセスを作成してしまえばよい。起動する側は、起動するだけの役割なので、起動後は単純に終了する。
※ただし、メインクラスとして同じものを指定しても同じ処理が続くだけであるので実際には 「java -cp ....;app.jar foo.bar.AnotherEntry」などとして異なるメインクラスを明示的に指定する。
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
システムクラスローダに無理矢理クラスパスを指定する
この方法は以下に記述されている。要するに、本来クラスローダは後からクラスパスを指定できるものではないのだが、リフレクションを使って無理矢理行うものである。これがうまく行くのかどうかは不明。
クラスパスを指定した新たなクラスローダを作成する
あるクラスから別のクラスを探し出せるかどうかは、クラスローダがどのように構成されているかによる。通常、アプリケーション(を構成するクラス)は「システムクラスローダ」によってロードされる。アプリを構成するクラスから別のクラスを呼び出せるかどうかは、システムクラスローダ及びその親(そのまた親)が呼び出し対象のクラスをロードできるかどうかにかかっている。
もし、このシステムクラスローダに既にクラスパスが指定されているのであれば問題無いのだが、そうではない。前述の方法は無理矢理システムクラスローダにクラスパスを指定する方法であった。これに対して、以下では正当な方法でクラスローダに動的に(その場で)クラスパスを指定する。
つまり、動的に取得した.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());