S-JIS[2007-11-12/2007-11-14] 変更履歴
javaの実行時にクラスの中身を変更することの出来るライブラリー。
中でやっている事はバイトコードの変更だが、変更内容をjavaソースの形で指定できるところがJavassistの優れている点。
JavassistはJBossのサブプロジェクトだが、元々は千葉 滋さんが作ったのだそうだ。すごいなぁ
参考
|
Eclipseから使う場合、「C:\Program Files\Java\javassist-3.6\javassist.jar」をビルドパスに追加する。
ソースを添付する際のパスは「C:/Program Files/Java/javassist-3.6/src」。
Javassistを使ったクラスの変更方法の概要は、以下のような感じ。
/** * クラスの内容を変更する。 * * @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();
バイトコードを 変更しない場合 |
writeD()を writeI()に 変更した場合 |
writeD()を 削除した場合 |
test1 start |
test1 start |
test1 start |
上で作った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
」を指定する。
JDK1.5から、main()の呼び出しより前に実行されるメソッドを指定できるようになった。
ここでバイナリコードの変更用のクラスを登録できる。まさにその為に用意された機能っぽい。アスペクト指向の流れかな?
ただ、jarファイルのマニフェストを使わないといけないので、軽く試すにはちょっと面倒か。
参考:
まず、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); } }
第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){} } } }
次に、「上記の事前実行クラスを指定したマニフェスト」の入った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」という属性に事前実行クラス名を設定する。
そして、実行する際に、上記の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); } }
リフレクションを使った実行時アノテーションの取得と似たような感じだね。
ローカル変数用アノテーションの取り方が分からないところも同じだ(爆)