S-JIS[2007-11-12/2007-11-14] 変更履歴

Javassist

javaの実行時にクラスの中身を変更することの出来るライブラリー。
中でやっている事はバイトコードの変更だが、変更内容をjavaソースの形で指定できるところがJavassistの優れている点。

JavassistはJBossのサブプロジェクトだが、元々は千葉 滋さんが作ったのだそうだ。すごいなぁ

参考

インストール

  1. ダウンロードサイト(SourceForge.net)からアーカイブをダウンロードしてくる。
    バージョン3.6の場合、javassist-3.6.GA.zip
  2. アーカイブを適当な場所に展開(解凍)する。
    javassist-3.6というディレクトリが出来る。
  3. ディレクトリを適当な場所に移動させる。(後からそこにパスを通すので、どこでもよい)
    今回はJDKと同じ場所「C:\Program Files\Java\javassist-3.6」にしてみた。

Eclipseから使う場合、「C:\Program Files\Java\javassist-3.6\javassist.jar」をビルドパスに追加する。
ソースを添付する際のパスは「C:/Program Files/Java/javassist-3.6/src」。


コーディングの概要

Javassistを使ったクラスの変更方法の概要は、以下のような感じ。

  1. 変更したいクラスを(クラス名を元に)見つける
    (通常はJavaVMがクラス群を管理しているが、それを直接扱うことは出来ないので、Javassistが提供しているClassPoolというものを使う)
  2. 変更用ルーチンを呼び出す
    (この変更サブルーチン(のクラス)を自分で作る
  3. 変更したクラスをクラスローダーに追加する
    (クラスローダーに追加しておくことにより、後続の処理で普通に「new」を使ってインスタンス生成をすることが出来る)
	/**
	 * クラスの内容を変更する。
	 *
	 * @param className 変更したいクラスの名前
	 */
	public void convert(String className) throws Exception {

		// 変更したいクラスを見つける
		ClassPool classPool = ClassPool.getDefault();
		CtClass cc = classPool.get(className);

		// 変更用ルーチンを呼び出す
		ExprEditor editor = new SampleEditor(); //自作の変更用クラス
		cc.instrument(editor);

		// クラスローダーに登録する
		Class thisClass = this.getClass();
		ClassLoader      loader = thisClass.getClassLoader();
		ProtectionDomain domain = thisClass.getProtectionDomain();
		cc.toClass(loader, domain);
	}

なお、事前に通常のクラスローダーによってロード(されて固定)されたクラスに関しては変更することが出来ない。


変換したいクラスが以下のようなSample1というクラスだった場合、

patckage jp.hishidama.sample;

class Sample1 {
	〜
}

呼び出しは

		convert("jp.hishidama.sample.Sample1");

となる。


変更のコーディング

変更するルーチンはExprEditorというJavassistのクラスを経由して呼び出される。

ExprEditorはdoit()というメソッドがバイトコードの逐次解釈を行い、メソッド呼び出しやフィールド定義などが見つかると、それに該当したedit()というメソッドを呼び出すようになっている。
すなわち、そのedit()をオーバーライドして自分で行いたい変更を行う。

import javassist.CannotCompileException;
import javassist.expr.ExprEditor;
import javassist.expr.MethodCall;

class SampleEditor extends ExprEditor {

	/**
	 * メソッド呼び出しのときに呼ばれる
	 */
	@Override
	public void edit(MethodCall m) throws CannotCompileException {

		// 呼び出すメソッド名がwriteDだったらwriteIの呼び出しに変更する
		String name = m.getMethodName();
		if (name.equals("writeD")) {
			m.replace("$0.writeI($$);");
		}
	}

}

edit()の引数であるmのreplace()というメソッドを呼び出すことにより、元々のプログラムを置き換えることが出来る。

$0や$$は、変換を容易にする為に用意されている、Javassistのキーワード。
$0はメソッド呼び出しの対象オブジェクト、$$は全引数を意味する。

参照: SMGのJavassistチュートリアル 4.イントロスペクションとカスタマイズ


m.replace("");」にすれば、メソッド呼び出しそのものを削除することが出来る。
これでデバッグログの出力メソッドを実行時に削除することが出来る!と思ったら、、、
ただし、「test(new Object(),a+b);」のように引数に演算が入っている呼び出しの場合、引数内の演算(new Object()a+b)だけは残り、実行される。
デバッグログ出力メソッドを引数内の演算ごと削除するライブラリー(エージェント)

ちなみに削除の実態は、メソッド呼び出しがあった部分のバイトコードをNOP(no operation:何もしない命令)で埋め尽くす方法みたい。
たぶんJITコンパイラによってNOPの集団は削除されると思うけど。


実行例

class Sample1 {
	protected DebugLog dbgLog = new DebugLog();

	public void test1() {
		System.out.println("test1 start");

		dbgLog.writeD("debug1");
		dbgLog.writeI("info1");
		dbgLog.writeE("error1");

		System.out.println("test1 end");
	}
}
class DebugLog {

	public void writeD(String message) {
		System.out.println("D: " + message);
	}

	public void writeI(String message) {
		System.out.println("I: " + message);
	}

	public void writeE(String message) {
		System.out.println("E: " + message);
	}
}

実行する部分はこんな感じ↓

		convert("jp.hishidama.sample.Sample1");

		Sample1 s = new Sample1();
		s.test1();
Sample1#test1()の実行結果
バイトコードを
変更しない場合
writeD()を
writeI()に
変更
した場合
writeD()を
削除した場合
test1 start
D: debug1
I: info1
E: error1
test1 end
test1 start
I: debug1
I: info1
E: error1
test1 end
test1 start
I: info1
E: error1
test1 end

上で作ったconvert()には、クラス名を文字列で指定する必要がある。
しかしこれだと、複数のクラスを変更したい時はいちいち指定してやらないといけないので大変だし、間違った名前にしていても実行するまで分からないし、Eclipseの(クラス名変更の)リファクタリング機能の対象にもなってくれない。

(名前に関して)だからと言ってこういう工夫をすると…

		convert(Sample1.class.getName());

実行時例外が発生する。
Exception in thread "main" javassist.CannotCompileException: by java.lang.LinkageError: loader (instance of sun/misc/Launcher$AppClassLoader): attempted duplicate class definition for name: "jp/hishidama/sample/Sample1"

JavaVMが 「Sample1.class」を評価(実行)した時点でデフォルトのクラスローダーによってSample1がロードされてしまうので、convert()が呼ばれてももうSample1を変更することが出来ない為。


クラスローダー内で変換

クラスローダーを独自実装し、その中でバイトコードを変換してやるようにすれば、ひとつずつ変換したいクラスを指定しなくてもよくなる。
(クラスが使用(ロード)されるタイミングで常に呼ばれるから)

※クラスローダーに詳しくないので、本当にこんなコーディングでいいのかどうか疑わしい…小さいサンプルプログラムでは動いてるけど

SampleClassLoader.java:

package jp.hishidama.sample;

import java.security.ProtectionDomain;

import javassist.ClassPool;
import javassist.CtClass;

/** Javassistを使ってクラスを変更するクラスローダー */
public class SampleClassLoader extends ClassLoader {

	public SampleClassLoader(ClassLoader parent) {
		super(parent);
	}

	private ExprEditor editor = new SampleEditor();

	@Override
	protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {

		// "java"で始まるパッケージはシステム系なのでデフォルトのクラスローダーに任せる
		if (name.startsWith("java") || name.startsWith("sun.")) {
			return super.loadClass(name, resolve);
		}

		try {
			// ロードしたいクラスをJavassist内から取得
			ClassPool classPool = ClassPool.getDefault();
			CtClass cc = classPool.get(name);
			if (!cc.isFrozen()) {
				// 変更可能なときだけ更新
				cc.instrument(editor);
			}

			// 自分のクラスローダーを指定してJavaVMのクラスに変換
			ProtectionDomain pd = this.getClass().getProtectionDomain();
			Class c = cc.toClass(this, pd);

			// resolveClass()が何してるんだか知らないけど、ClassLoaderの真似して呼んでおく
			if (resolve) {
				resolveClass(c);
			}
			return c;

		} catch (Exception e) {
			// e.printStackTrace();
			return super.loadClass(name, resolve);
		}
	}
}

独自クラスローダーを使うには、javaコマンドのVM引数のシステムプロパティ「java.system.class.loader」でクラスローダーを指定する。
-Djava.system.class.loader=クラスローダー名

> java -Djava.system.class.loader=jp.hishidama.sample.SampleClassLoader -cp classes;〜\javasist-3.6.jar Main
import jp.hishidama.sample.Sample1;

public class Main {
	public static void main(String[] args) {
		Sample1 s = new Sample1();
		s.test1();
	}
}

なお、「java.system.class.loader」でクラスローダーを指定する場合、publicで、引数を1つ(親となるクラスローダーを指定する)だけ持つコンストラクターが必要。

Eclipse3.2から実行する場合は、「構成および実行(N)」の「引数」タブの「VM引数(G)」に 「-Djava.system.class.loader=jp.hishidama.sample.SampleClassLoader」を指定する。


main()より前に実行されるメソッド

JDK1.5から、main()の呼び出しより前に実行されるメソッドを指定できるようになった。
ここでバイナリコードの変更用のクラスを登録できる。まさにその為に用意された機能っぽい。アスペクト指向の流れかな?
ただ、jarファイルのマニフェストを使わないといけないので、軽く試すにはちょっと面倒か。

参考:


premainメソッドの用意

まず、main()より前に呼び出されるクラスを用意する。
クラス名は何でもいいが、「public static void」で名前が「premain」というメソッドを用意する必要がある。
要は「main」と同様で「premain」という特殊な名称のメソッドという感じ。
premainの引数は「(String)」か「(String, Instrumentation)」が可能。基本は後者だと思うけど

package jp.hishidama.sample;

public class PreMain {

	public static void premain(String agentArgs) {
		System.out.println("premainが呼ばれたよ!" + agentArgs);
	}
}

agentArgsについては後述

第2引数instを用意すると、インストラクション(バイトコード)絡みの情報が入ってくるらしい。これにバイトコード変換用クラスを登録することが出来る。

	public static void premain(String agentArgs, Instrumentation inst) {
		// 独自の変換クラスを登録する。
		inst.addTransformer(new SampleTransformer());
	}

SampleTransformer.java:

package jp.hishidama.sample;

import java.io.*;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.expr.ExprEditor;

public class SampleTransformer implements ClassFileTransformer {

	// インターフェースClassFileTransformerのメソッドの実装
	public byte[] transform(ClassLoader loader, String className,
			Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
			byte[] classfileBuffer) throws IllegalClassFormatException {

		// System.out.println("transfom called: " + className);

		return transform(classfileBuffer);
	}

	private ExprEditor editor = new SampleEditor();

	// 変換本体
	protected byte[] transform(byte[] byteCode) {
		InputStream is = null;
		try {
			// バイト列をストリームに入れ、そこからJavassistのクラスを新規作成
			ClassPool classPool = ClassPool.getDefault();
			is = new ByteArrayInputStream(byteCode);
			CtClass cc = classPool.makeClass(is);

			// クラスを変更する
			cc.instrument(editor);

			// 変更後のバイト列を取得して返す
			return cc.toBytecode();

		} catch (RuntimeException e) {
			throw e;
		} catch (Exception e) {
			throw new RuntimeException(e);
		} finally {
			if (is != null) try{ is.close(); }catch(Exception e){}
		}
	}
}

premain用jarファイルの作成

次に、「上記の事前実行クラスを指定したマニフェスト」の入ったjarファイルを作成する。
このjarファイルには、そのマニフェストだけ入っていればよい。(class本体はjavaコマンド実行時にクラスパスに指定されていればいいみたい)

build.xml:

<?xml version="1.0" encoding="Shift_JIS"?>
<project name="sample_premain" default="make_premain_jar" basedir=".">
	<target name="make_premain_jar">
		<jar jarfile="premain.jar">
			<manifest>
				<attribute name="Premain-Class" value="jp.hishidama.sample.PreMain" />
			</manifest>
		</jar>
	</target>
</project>

「Premain-Class」という属性に事前実行クラス名を設定する。


premainメソッドの実行

そして、実行する際に、上記のjarファイルを事前実行用として指定する。

javaコマンドのVM引数の「-javaagent」にjarファイルの場所とファイル名を付ける。
また、引数を与えることも出来る。ここで与えた引数premainメソッドのagentArgsに渡される。

> java -javaagent:bin/premain.jar -cp classes;〜\javasist-3.6.jar Main
> java -javaagent:bin/premain.jar=zzz -cp classes;〜\javasist-3.6.jar Main

Eclipse3.2から実行する場合は、「構成および実行(N)」の「引数」タブの「VM引数(G)」に「-javaagent:bin/premain.jar」を指定する。


アノテーションの読み込み

Javassist3.1以降では、クラスやメソッドにアノテーションが付いているかどうかをチェックすることが出来る。[2007-11-14]

		ClassPool classPool = ClassPool.getDefault();
		CtClass cc = classPool.get("jp.hishidama.sample.サンプル");

		Object[] as = cc.getAnnotations();
		for (Object a : as) {
			if (a instanceof StringAnnotation) {
				StringAnnotation s = (StringAnnotation)a;
				System.out.println("サンプルアノテーション発見!" + s.value());
			} else {
				System.out.println("その他のアノテーションだぜ!" + a);
			}
		}

CtClass#getAnnotaions()の戻り型は何故かObjectの配列だが、instanceofやアノテーションへのキャストはちゃんと使える。
(AnnotationクラスでなくObjectクラスなのは、JDK1.4でも使えるようにする為かな?)

JavaVMでは(javaの実行時には)、有効範囲が実行時(RUNTIME)になっているアノテーションしか取得できない。
しかしJavassistではクラス(CLASS)(デフォルトの有効範囲)のアノテーションも取得することが出来る。
なので、classファイル内のアノテーションを解析して何か処理したいならJavassistを使うといいかも。

classファイルを読み込んでアノテーションを処理するには、以下のようにする。

		InputStream is = new FileInputSream("C:/workspace/sample/classes/jp/hishidama/sample/サンプル.class");
//		InputStream is = this.getClass().getResourceAsStream("サンプル.class");

		// InputStreamから読み込み
		ClassPool classPool = ClassPool.getDefault();
		CtClass cc = classPool.makeClass(is);
		is.close();

		// クラス
		dump("クラス", cc.getAnnotations());

		// フィールド
		CtField[] fs = cc.getFields();
		for (CtField f : fs) {
			dump("フィールド", f.getAnnotations());
		}

		// コンストラクター
		CtConstructor[] cs = cc.getDeclaredConstructors();
		for (CtConstructor c : cs) {
			dump("コンストラクター", c.getAnnotations());
		}

		// メソッド
		CtMethod[] ms = cc.getDeclaredMethods();
		for (CtMethod m : ms) {
			dump("メソッド", m.getAnnotations());

			// パラメーター(引数)
			Object[][] pas = m.getParameterAnnotations();
			for (Object[] p : pas) {
				dump("パラメーター", p);
			}
		}
	public static void dump(String message, Object[] as) {
		System.out.println(message);
		for (Object a : as) {
			System.out.println(a);
		}
	}

リフレクションを使った実行時アノテーションの取得と似たような感じだね。
ローカル変数用アノテーションの取り方が分からないところも同じだ(爆)


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