S-JIS[2008-07-26/2018-06-03] 変更履歴

JavaCompiler

JDK1.6から、Javaのコンパイルを行う(javacを起動する)クラスが用意された。
これを使えば、実行時にソースをコンパイルしてそのままロードして使うことが出来る。


とりあえず実行してみる

まずは軽く試しに「javac -version」を実行してみる。

import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
public class Version {

	public static void main(String[] args) {
		JavaCompiler c = ToolProvider.getSystemJavaCompiler();
		int r = c.run(null, null, null, "-version");	//javac -version
		System.out.println("戻り値:" + r);
	}
}

実行結果:

javac 1.6.0
戻り値:0

run()でコンパイラー(javac)が起動される。

第1引数はjavacの標準入力へ受け渡すInputStream、第2引数はjavacの標準出力を出力するOutputStream、第3引数はjavacの標準エラーを出力するOutputStream。
nullを指定すると、普通にプログラムを実行した際の標準出力・標準エラーが使われる。
だから例えばrun(null, System.out, System.out, …)とすれば、全てが標準出力に表示(出力)される。

run()の戻り値は、javacの戻り値。正常終了なら0。

第4引数以降はjavacに渡すオプション(可変長引数)。
"-version"を指定すれば「javac -version」を実行するのと同じになる。"-help"や"-X"も同様に使える。
んだけど、何故か「-J-version」は認識してくれない…まぁ別にいいけど。

javac: -J-version は無効なフラグです。
使い方: javac <options> <source files>
使用可能なオプションのリストについては、-help を使用します
戻り値:2

バージョン確認

JavaCompilerが対応しているコンパイル(JDK?)バージョンは、以下の様にして知ることが出来る。

import javax.lang.model.SourceVersion;
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
		JavaCompiler c = ToolProvider.getSystemJavaCompiler();
		Set<SourceVersion> set = c.getSourceVersions();
		System.out.println(set);
[RELEASE_3, RELEASE_4, RELEASE_5, RELEASE_6]

SourceVersionは、バージョンを表す列挙型
RELEASE_6はJDK1.6を表している模様。


キーワード確認

ちなみに、なぜかSourceVersionにはJavaのキーワード(ifとかwhileとか)かどうかを判断するメソッドがある。何かに使えるかなぁ?

import javax.lang.model.SourceVersion;
		SourceVersion.isKeyword("if")	…true
		SourceVersion.isKeyword("zzz")	…false
		SourceVersion.isKeyword("_")	…Java8以前はfalse、Java9はtrue [2017-09-23]

Java9では、バージョン毎にキーワードかどうかを確認するメソッドが追加された。[2017-09-23]

		SourceVersion.isKeyword("_", SourceVersion.RELEASE_9)

Java10でローカル変数を定義するvarという構文が加わったが、varは識別子扱い(キーワードではない扱い)。[2018-06-03]
(わざわざJavadocに「varはキーワードではない」と書かれている)


ソースファイルのコンパイルとクラスファイルのロード

ソースファイルをコンパイルするには、以下のようにする。

		File f = new File("C:/temp/Sample.java");
		File d = new File("C:/temp/classes");

		String[] args = {
			"-d", d.getAbsolutePath(),
			f.getAbsolutePath()
		};

		JavaCompiler c = ToolProvider.getSystemJavaCompiler();
		int r = c.run(null, null, null, args);
		System.out.println("戻り値:" + r);

コンパイルするだけだとあまり面白くないので、コンパイルしたクラスを読み込む(ロードする)ことを考えてみる。
デフォルトではソースファイルと同じ場所にクラスファイルが作られるので、独自のクラスローダーを作って読み込んでみる。

ちなみに、Sample.javaは以下のようなパッケージに属するクラスとする。

package sample.javac;

public class Sample {
}
		File d = new File("C:/temp/classes");

		URL[] urls = { d.toURI().toURL() };
		ClassLoader loader = URLClassLoader.newInstance(urls);	//クラスローダーを作成
		System.out.println(loader);

		Class<?> clazz = loader.loadClass("sample.javac.Sample");
		System.out.println(clazz);

これでクラスが取得できたので、後はリフレクションを使えばインスタンス化するなりメソッドを呼び出すなりする事が出来る。
(ちなみに、クラスが見つけられないときはClassNotFoundExceptionが発生する。
…しかし例外が発生するまでちょっと時間がかかる(2〜3秒?)。何故だろう?)


コンパイル先を実行時のクラスパスと同じ場所にしておけば、独自のクラスローダーを作らなくてもロードできる。

		//ソースのディレクトリー
		File src = new File("C:/temp");

		//クラスのディレクトリー(今回の例では、実行時のクラスパスから決め打ちで…)
		String classpath = System.getProperty("java.class.path");
		String[] path = classpath.split(File.pathSeparator);
		File dst = new File(path[0]);
		System.out.println("コンパイル先:" + dst.getAbsolutePath());

		//コンパイルするクラス名
		String pkgName = "sample.javac";
		String clsName = "Sample2";

		//コンパイル実行
		String[] args = {
			"-d", dst.getAbsolutePath(),
			"-classpath", classpath,
			new File(src, clsName + ".java").getAbsolutePath()
		};
		JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
		int r = compiler.run(null, null, null, args);
		if (r != 0) {
			throw new RuntimeException("コンパイル失敗:" + r);
		}

		Class clazz = Class.forName(pkgName + "." + clsName);
		System.out.println("ロード:" + clazz);

コンパイルするクラスが実行時のクラスパス内に入っているインターフェースを実装しておけば、そのインターフェースを使ってオブジェクトにアクセスすることが出来る。

SampleInterface.java:

package sample;

public interface SampleInterface {

	public String getValue();
}

C:/temp/Sample2.java:

package sample.javac;

import sample.SampleInterface;

public class Sample2 implements SampleInterface {

	public String getValue() {
		return "サンプル2";
	}
}
		SampleInterface obj = (SampleInterface) clazz.newInstance();
		System.out.println(obj.getValue());

ファイルを介さないコンパイル

ソースファイルやクラスファイルを作らずに、メモリー上だけでコンパイルすることが出来る。

まず、ソース文字列を扱う(ソースファイル相当の)クラスを作成する。これはJavaCompilerのJavadocに載っている。

package jp.hishidama.sample.javac;

import java.net.URI;

import javax.tools.SimpleJavaFileObject;

/**
 * 文字列に格納されたソースを表すために使用するファイルオブジェクト。
 * 
 * @see javax.tools.JavaCompiler
 */
class JavaSourceFromString extends SimpleJavaFileObject {
	/**
	 * この「ファイル」のソースコード。
	 */
	protected final String code;

	/**
	 * 新しい JavaSourceFromString を構築。
	 * 
	 * @param name
	 *	このファイルオブジェクトで表されるコンパイルユニットのクラス名(FQCN)
	 * @param code
	 *	このファイルオブジェクトで表されるコンパイルユニットのソースコード
	 */
	public JavaSourceFromString(String name, String code) {
		super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE);
		this.code = code;
	}

	@Override
	public CharSequence getCharContent(boolean ignoreEncodingErrors) {
		return code;
	}
}

もうひとつ、コンパイルされたクラス(バイトコード)を保持するファイルオブジェクトも用意する。

package jp.hishidama.sample.javac;

import java.io.*;
import java.net.URI;

import javax.tools.SimpleJavaFileObject;

class JavaClassObject extends SimpleJavaFileObject {

	public JavaClassObject(String name, Kind kind) {
		super(URI.create("string:///" + name.replace('.', '/') + kind.extension), kind);
	}

	protected final ByteArrayOutputStream bos = new ByteArrayOutputStream();

	// 実際の出力用ストリームを返す
	@Override
	public OutputStream openOutputStream() throws IOException {
		return bos;
	}

	// コンパイルされたバイトコード
	public byte[] getBytes() {
		return bos.toByteArray();
	}

	/** ロードされたクラス */
	private Class<?> clazz = null;

	public void setDefinedClass(Class<?> c) {
		clazz = c;
	}

	public Class<?> getDefinedClass() {
		return clazz;
	}
}

ついでに、コンパイルエラーが起きたときにそれを処理するクラスも作っておこう。

これは必須ではない。このクラスのインスタンスを使う場所にはnullを渡しても大丈夫。その場合、コンパイルエラーが起きると、通常の標準エラーにメッセージが出力される。

package jp.hishidama.sample.javac;

import javax.tools.*;

class ErrorListener implements DiagnosticListener<JavaFileObject> {

	// コンパイルエラーが起きたときに呼ばれる
	public void report(Diagnostic<? extends JavaFileObject> diagnostic) {
		System.out.println("▼report start");
		System.out.println("errcode:" + diagnostic.getCode());
		System.out.println("line   :" + diagnostic.getLineNumber());
		System.out.println("column :" + diagnostic.getColumnNumber());
		System.out.println("message:" + diagnostic.getMessage(null));
		//System.out.println(diagnostic.toString());
		System.out.println("▲report end");
	}
}

エラーの例(Sample3.javaの中のpublic classがSample4):

▼report start
errcode:compiler.err.class.public.should.be.in.file
line   :2
column :8
message:string:///sample/Sample3.java:2: クラス Sample4 は public であり、ファイル Sample4.java で宣言しなければなりません。
▲report end

それから、ファイル相当のクラスを扱うマネージャーも自作する。
JavaCompiler#getStandardFileManager()でデフォルトのStandardJavaFileManagerというマネージャーを取得することも出来るが、これは実際のファイルを扱うものなので、今回の目的には適さない。
でもそのオブジェクトを使用して、出力用ファイル生成部分だけ独自クラスを使うように改造する。

package jp.hishidama.sample.javac;

import java.io.IOException;
import java.security.SecureClassLoader;
import java.util.*;

import javax.tools.*;
import javax.tools.JavaFileObject.Kind;

class ClassFileManager extends ForwardingJavaFileManager<JavaFileManager> {

	public ClassFileManager(JavaCompiler compiler, DiagnosticListener<? super JavaFileObject> listener) {
		super(compiler.getStandardFileManager(listener, null, null));
	}

	/** キー:クラス名、値:クラスファイルのオブジェクト */
	protected final Map<String, JavaClassObject> map = new HashMap<String, JavaClassObject>();

	// クラスファイルを生成するときに呼ばれる
	@Override
	public JavaFileObject getJavaFileForOutput(Location location, String className, Kind kind, FileObject sibling) throws IOException {
		JavaClassObject co = new JavaClassObject(className, kind);
		map.put(className, co); // クラス名をキーにしてファイルオブジェクトを保持しておく
		return co;
	}

	protected ClassLoader loader = null;

	@Override
	public ClassLoader getClassLoader(Location location) {
		if (loader == null) {
			loader = new Loader();
		}
		return loader;
	}

	/** コンパイルしたクラスを返す為のクラスローダー */
	private class Loader extends SecureClassLoader {

		@Override
		protected Class<?> findClass(String name) throws ClassNotFoundException {
			JavaClassObject co = map.get(name);
			if (co == null) {
				return super.findClass(name);
			}

			Class<?> c = co.getDefinedClass();
			if (c == null) {
				byte[] b = co.getBytes();
				c = super.defineClass(name, b, 0, b.length);
				co.setDefinedClass(c);
			}
			return c;
		}
	}
}

コンパイル実行時にマネージャーを渡してやると、色々な場面でそのマネージャーのメソッドが呼ばれる。
自分が独自拡張したい部分以外は既存のものを使えばいいので、ForwardingJavaFileManagerから派生している。このクラスの各メソッドは、コンストラクターで与えたマネージャーに処理を委譲(forward)してくれる。したがって独自拡張したい部分だけオーバーライドすればよい。
コンストラクターで与えるマネージャーはJavaCompiler#getStandardFileManager()で取得したものでいいだろう。

今回はクラスファイル生成時に専用のファイルオブジェクトを返したいので、getJavaFileForOutput()をオーバーライドしておく。
このメソッドはクラスファイルに書き込む際に呼ばれる。
ちなみに、このメソッドで返したファイルオブジェクトのopenOutputStream()が呼ばれ、そこで返したOutputStreamにバイトコードが出力される。

また、マネージャーにはクラスローダーを返すメソッドがある。
コンパイルされたバイトコードを実際のClassオブジェクトにするには、ClassLoader#defineClass()を使うしかない(というか、他の方法は知らない…)
現在使用されているローダーデフォルトのgetClassLoader()で返されるローダーに コンパイルしたクラスを登録できれば楽なんだけど、それは無理っぽい気がする。(例えばURLClassLoaderは、そのインスタンスを作る際に、クラスファイルが置かれているディレクトリーをURLで指定する。今回はメモリー上に格納しているので、URL指定なんか無理でしょう)
そこで、独自のクラスローダーを定義し、getClassLoader()でそれを返すようにしてやる。

この独自クラスローダーでは、クラスを探す際に呼ばれるfindClass()をオーバーライドしておく。
自分が保持しているクラス名のときだけバイトコードからClassに変換し、それを返す。
(ちなみに、クラスファイルオブジェクトを保持しているマップは独自クラスローダーの外側のマネージャーのフィールド。でもこの独自クラスローダーはstaticでない内部クラスなので、そのフィールドには自由にアクセスできる)


(ようやく。)実際のコンパイル部分は、以下のようになる。[/2008-08-21]

	protected DiagnosticListener<? super JavaFileObject> listener = new ErrorListener();

	protected JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();

	/** コンパイル実行 */
	public <T> Class<T> compile(String class_name, String source_code) {
		JavaFileObject fo = new JavaSourceFromString(class_name, source_code);

		List<JavaFileObject> compilationUnits = Arrays.asList(fo);
		List<String> options = Arrays.asList(
					"-classpath", System.getProperty("java.class.path")
				);
		JavaFileManager manager = new ClassFileManager(compiler, listener);
		CompilationTask task = compiler.getTask(
						null,
						manager,	//出力ファイルを扱うマネージャー
						listener,	//エラー時の処理を行うリスナー(nullでもよい)
						options,	//コンパイルオプション
						null,
						compilationUnits	//コンパイル対象ファイル群
					);

		//コンパイル実行
		boolean successCompile = task.call();
		if (!successCompile) {
			throw new RuntimeException("コンパイル失敗:" + class_name);
		}

		ClassLoader cl = manager.getClassLoader(null);
		try {
			@SuppressWarnings("unchecked")
			Class<T> c = (Class<T>)cl.loadClass(class_name);
			return c;
		} catch (ClassNotFoundException e) {
			throw new RuntimeException(e);
		}
	}

呼び出しは以下のようにする。

// ソース文字列を生成
	StringWriter sw = new StringWriter();
	PrintWriter pw = new PrintWriter(sw);

	pw.println("package sample.string;");
	pw.println("import sample.SampleInterface;");
	pw.println("public class Sample3 implements SampleInterface {");
	pw.println("public String getValue() {");
	pw.println("return \"実行時にファイルを使わずコンパイル!\";");
	pw.println("}");
	pw.println("}");

	pw.close();
	String src = sw.toString();
	System.out.print(src);

//コンパイルを実行
	Class<SampleInterface> c = compile("sample.string.Sample3", src); //キャストが間違っていても、ここではエラーにならない

//インスタンスを生成して呼び出す
	SampleInterface s = c.newInstance();	//もしClassのキャストが間違っていたら、ここで例外が発生する
	System.out.println(s.getValue());

別にStringWriterやらPrintWriterやらを使う必要はなく、StringやStringBuilderで構わないんだけど、改行を入れるのが面倒だったので。
改行が無くてもコンパイルできるけど、もしコンパイルエラーになったら、全ソースが1行に入ってる状態になるので訳が分からない^^;


Java目次へ戻る / 新機能へ戻る / 技術メモへ戻る
メールの送信先:ひしだま