clas3hiftで挫折した経緯についてつらつらと。
Java/clas3hift でclas3hiftを紹介したが、現実的には殆ど使えない。SandBox環境でインナークラス・ネストクラス記法が使えないのは不便だし、なにより差し替え対象となるクラスに制限が大きすぎる。
10/10, 11, 12 の3連休をclas3hiftに費やした(実際には正味二日間ほど)が、どこで躓いたのか、何故あんなに制限だらけになっているのか、振り返ってみたい。なお思いつくままに適当に書いていくので、実際に手を動かした順番にはなっていない。
clas3hiftのSandBoxはインライン記法が使えない。実は実験コードを作っていた当初はインライン記法で作っていた。
// 実験してた時のコード。Clas3hiftがやってる事をmain()の中でやってる感じ。 public class Main { public static void main(String[] args) throws Exception { MyClassLoader MyCL2 = new MyClassLoader(Main.class.getClassLoader()); MyCL2.register("Foo0", "Foo1"); SandBox sb2 = MyCL2.surround(new SandBox() { public void sandbox() { String s = Foo0.getName(); System.out.println("foobar = " + s); } }); sb2.sandbox(); } }
注意したいのは、staticメソッドの中でインナークラスを作っている点。
これで動いていたので、喜んでJUnitのテストコードに組み込んだ。
@Test public void Cla() throws ClassNotFoundException { Clas3hift cs = new Clas3hift(); cs.play(new SandBox() { public void sandbox(Map<String, Object> result) { } }); }
ところが、JUnitのテストコードの場合はインスタンス化され、インスタンスメソッドとして実行される。
インスタンスメソッド内でのインナークラスの場合、Javaコンパイラがコンパイル時に外部インスタンスへの参照を注入する為のフィールドとコンストラクタを自動的に組み込んでしまう。
例えばSandBoxを使ってこういうクラスを作ってみる。
public class Test { public void m1() { SandBox sb = new SandBox() { public void sandbox(Map<String, Object> result) {} }; } }
インナークラスは"Test$1"というクラス名で、Test$1.classに出力される。これをjavapで解析してみると・・・
> javap -c Test$1 Compiled from "Test.java" class Test$1 extends java.lang.Object implements junit.extensions.clas3hift.SandBox{ final Test this$0; Test$1(Test); Code: 0: aload_0 1: aload_1 2: putfield #1; //Field this$0:LTest; 5: aload_0 6: invokespecial #2; //Method java/lang/Object."<init>":()V 9: return public void sandbox(java.util.Map); Code: 0: return }
"this$0"フィールドが増えているし、コンストラクタの引数に外部のTestクラスが必要になったりしている。Javaコードに戻せばこんな感じだろう。
class Test$1 extends java.lang.Object implements junit.extensions.clas3hift.SandBox{ final Test this$0; Test$1(Test t) { this$0 = t; super(); } // ... }
と言うわけで、実験時はstaticメソッド内でインナークラス化していたためコンストラクタはそのままだったが、JUnitのテストコードになるとコンストラクタが外部クラスに依存して書き換わってしまう。
clas3hift側としては外部クラス名がどうなるかなど分かりようも無いし、外部クラスを取り出す上手い方法も分からない。そのため、今のところは別クラスにSandBoxを分離する方針にしている。
思いつきで対応させるなら、以下のような対処になるだろうか。
実験を開始した当初はまさかBCELを導入する事になるなど思っていなかった。
独自CLのregister(String, String)メソッドも、実は最初は以下のように、差し替えるクラスのClassオブジェクトを取っていた。
register(String name, Class new_class)
使う時も
CL.register("test.stub.Foo0", test.stub.Foo1.class);
というのを想定していた。register()メソッドの中身はすごい単純で、
register(String old, Class new_class) { this._replaces.put(old, new_class); }
だけだった。独自CLでオーバーライドしているloadClass()は次のようになっているので、一見、これでOKな気がしていた。
protected final Class loadClass(String name, boolean resolve) throws ClassNotFoundException { Class clazz = (Class)_replaces.get(name); if (null != clazz) { return clazz; } else { ClassLoader parent = getParent(); Class r = super.loadClass(name, resolve); return r; } }
これで、SandBox中で
Foo0.staticMethod()
というのが出てくれば、loadClass()がtest.stub.Foo1クラスを返すようになるからいけるだろうと踏んでいたのだが・・・どうしても
java.lang.NoClassDefFoundError: test/stub/Foo0
という例外が出てしまう。
この段になってようやく、「あ、loadしたクラス名が実際は違うからエラーになるのか」という事に気づき、ここで「クラス名はクラスの中身にあるから・・・バイトコード弄らないと駄目かーーーー!!!」ということで、BCELを使い始めた次第。
BCELを導入して、とりあえず差し替えてみてstaticメソッドの戻り値が切り替わり、良かった良かった・・・と喜んだのも束の間だった。
「よし、ではインスタンスを作成してみて、インスタンスメソッドも切り替わるか確認してみよう」と以下のようなコードを書いた。
public void sandbox(Map<String, Object> result) { // ... Foo0 o = null; o = new Foo0(); // ... }
Foo0は裏側でFoo1に差し替えられている。Foo1はFoo0を継承している。
すると、JVMが new のところでCPU使用率フルに達し、応答しなくなる現象が発生した。
BCELでクラスのメソッドのコードをダンプしたりしてようやく原因が分かった。
まずFoo0クラスを厳密に書いてみると、次のようになっていた。
public class Foo0 extends java.lang.Object { public <init>() { super(); // = java.lang.Object.<init>() } }
"<init>"というのはバイトコードレベルでのコンストラクタメソッド名になる。
次にFoo1のクラスを示すと、次のようになる。
public class Foo1 extends Foo0 { public <init>() { super(); // = Foo0.<init>() } }
Foo0, Foo1のコンストラクタは指定していなかった為、上に示したのはJavaコンパイラが作成したデフォルトコンストラクタになる。
さて、Clas3hiftがBCELでFoo1クラスを書き換えるのは、クラス名とその親クラス名である。もちろんその前に、Foo0クラスは "Foo0$(衝突しないランダムコード)" というクラス名に置き換えてdefineClass()している。よってFoo1は次のように書き換わる。
public class Foo0 extends Foo0$(衝突しないランダムコード) { public <init>() { super(); // = Foo0.<init>() } }
コンストラクタの中までは書き換えていない。よって、この状態で
new Foo0()
したら、Foo0.<init>()の中でやはりFoo0.<init>()が呼ばれるという無限ループに突入する。
同様の理由で、Foo1側ではFoo0のstaticメソッドを呼び出せない。つまりFoo0のstaticメソッドをオーバーライドし、条件によってはFoo0側に委譲することが出来ない。
class Foo1 extends Foo0 { public int calcABC(int arg) { if (100 == arg) { return 101; // for test return } else { return Foo0.calcABC(arg); } } }
このようなstaticメソッドは作れない。なぜなら、Foo0.calcABC()の部分まではBCELで書き換えていない為、コンストラクタと同様、 argが100以外の場合は Foo0.calcABC() -> Foo0.calcABC() -> Foo0.calcABC()... と無限ループに突入してしまうからである。
これに対処しようとしたら、差し替え用のFoo1クラスのメソッドのインストラクションを逐一チェックし、Foo0クラスを参照しているところをFoo0$(衝突しないランダムコード)に置換する必要がある。そこまでできる技術力が今は無かった為、やむなく、インスタンスを作って使うタイプのクラスはサポート外とした。
まず以下のようなFoo0, Foo1クラスを用意する。
public class Foo0 { static String name = "Foo0"; }
public class Foo1 extends Foo0 { public Foo1() {} static String name = "Foo1"; }
Foo1クラスを、BCELを使ってダンプする。ダンプには後述のDumperクラスを使う。
> java -cp .;bcel-5.2.jar Dumper Foo1 ============================================== classname = Foo1 superclassname = Foo0 ---------------------------------------------- ---------------------------------------------- fields[0] = static String name name = name sign = Ljava/lang/String; synthetic = false ---------------------------------------------- methods[0] = public void <init>() name = <init> sign = ()V synthetic = false ret = void code = Code(max_stack = 1, max_locals = 1, code_length = 5) 0: aload_0 1: invokespecial Foo0.<init> ()V (1) 4: return Attribute(s) = LineNumber(0, 2) methods[1] = static void <clinit>() name = <clinit> sign = ()V synthetic = false ret = void code = Code(max_stack = 1, max_locals = 0, code_length = 6) 0: ldc "Foo1" (2) 2: putstatic Foo1.name Ljava/lang/String; (3) 5: return Attribute(s) = LineNumber(0, 3) ==============================================
staticフィールドが登場する為、"<clinit>"というクラス初期化用のメソッドがコンパイラにより追加されている。次の2行を見てみる。
0: ldc "Foo1" (2) 2: putstatic Foo1.name Ljava/lang/String; (3)
このように、ここでもしっかり"Foo1.name"フィールドとしてクラス名がセットされてしまっている。
Clas3hiftの現時点(2009/10)の実装ではこれまで見てきたように、メソッド内部のインストラクションまでは書き換えていない。
従って、もしこれをClas3hiftでテストしようとすると、Foo1がFoo0にリネームされてdefineClass()され、そのクラス初期化で "Foo1.name" フィールドに値をセットしようとする。Foo1クラスは当然Foo0にリネームされているので見つからない。
・・・というのが、差し替えるクラスにはstaticフィールドが使えなくなった理由である。
BCELを使うと、"javap -c -s" と同等の情報が取得できるようになる。Clas3hiftのように、BCELのJavaClassで操作していてクラスのバイトコードがオンメモリにしか存在しない場合は、次のようなダンプクラスがあると便利である。Clas3hiftのコードにも、似たようなコードを組み込んである。
import org.apache.bcel.Repository; import org.apache.bcel.classfile.JavaClass; import org.apache.bcel.classfile.Method; import org.apache.bcel.classfile.Field; public class Dumper { public static void dump(Class clazz) throws Exception { JavaClass jc = Repository.lookupClass(clazz); Dumper.dump(jc); } public static void dump(String clazz) throws Exception { JavaClass jc = Repository.lookupClass(clazz); Dumper.dump(jc); } public static void dump(JavaClass jc) throws Exception { String s = jc.getClassName(); System.out.println("=============================================="); System.out.println("classname = " + s); s = jc.getSuperclassName(); System.out.println("superclassname = " + s); System.out.println("----------------------------------------------"); String[] interfaces = jc.getInterfaceNames(); for (int i = 0; i < interfaces.length; i++) { System.out.println("interface[" + i + "] = " + interfaces[i].toString()); } System.out.println("----------------------------------------------"); Field[] fields = jc.getFields(); for (int i = 0; i < fields.length; i++) { System.out.println("fields[" + i + "] = " + fields[i].toString()); System.out.println(" name = " + fields[i].getName()); System.out.println(" sign = " + fields[i].getSignature()); System.out.println(" synthetic = " + fields[i].isSynthetic()); } System.out.println("----------------------------------------------"); Method[] methods = jc.getMethods(); for (int i = 0; i < methods.length; i++) { System.out.println("methods[" + i + "] = " + methods[i].toString()); System.out.println(" name = " + methods[i].getName()); System.out.println(" sign = " + methods[i].getSignature()); System.out.println(" synthetic = " + methods[i].isSynthetic()); System.out.println(" ret = " + methods[i].getReturnType()); System.out.println(" code = " + methods[i].getCode()); } System.out.println("=============================================="); } public static void main(String[] args) throws Exception { Dumper.dump(args[0]); } }
Javaのクラスについて初めて疑問を覚えたのが、JDBCのサンプルコードに出てきた
// JDBCドライバのロード Class.forName("org.postgresql.Driver");
である。初学者にとってはお馴染みであり、かつ「呪文」として「よく分からないけどこうしておく」という慣用句だと思う。
「・・・何コレ?」
というのが多分Classクラス、つまりJavaのクラスを読み込む仕組みについて疑問を覚えた最初だと思う。Javaは会社に入ってから覚えたので、多分2004年の春だと思う。
その後、2007年の5月位に、社内の技術研修でJavaの講師をする事になった。講師といっても、初心者向けにJavaを構成する技術要素であるとか、Javaと付き合う時にどの辺を押さえておくと楽か、JavaとOOPの関連は?という気軽なトピックを話した。
自分自身が疑問に感じていた "Class.forName()" について調べる機会としてIBMのdeveloperWorksの記事などにも手を広げ、ようやくClassLoaderであるとか、Servletコンテナでjarファイルの置き場所を間違えるとなんで動きがおかしくなるのかとか、ClassLoaderの委譲の仕組みとかを学ぶ事が出来た。
JavaVMSpecificationに初めて目を通したのもこの時だった。
で、2009年の10月、仕事で外の人達が作ったJavaのソースコードに機能を追加する事になった。
・・・単体テストコードが無かった。そして、中枢部分がstaticメソッドの集合であるユーティリティクラスを使いまくっていた為、単体テストコードを自分で書く余力も無かった。というか、そういった場合にどう単体テストコードを書けばよいのか見当が付かなかった。
というわけで、「実行時に、動的に、裏側で」クラスを差し替える Clas3hift (とその初期実験コード) に手を出す事になった。・・・といっても3連休だけだけど。
ようやくここで、実際にClassLoaderを作り、loadClass()を上書きし、defineClass()を使い、ClassLoaderのgetResourceAsStream()でCLASSPATH上のクラスファイルを読み出したりとかのコードを自分で試行錯誤しながら作る事が出来た。
さらにBCELにも手を出す事になった。社内研修の時の調査で JavaVM Spec に目を通し、バイトコードやインストラクション、classファイルのフォーマットについて「なんとなく」ではあるが頭に残っていたのが役に立った。
実に2004年から数えて5年目、ようやくClassLoaderの呪縛から解放された、とつくづく思う。自分の場合、こういう非常に低水準な部分での疑問について中々忘れる事が出来ず、ちまちまと別の仕事とかで手がかりを拾い集めたり偶然出会いつつ、数年後にようやく全て理解できた、というケースが大変多い。(その頃には世間はずっと先に進んでいるのだが・・・)
BCELは素直に面白いライブラリだと思う。バイト配列から実行時にクラスを定義できるJavaの柔軟さも、改めて面白いし便利だと思う。
締まりが付いていないが、そうした面白い世界を味わえたのだけでも、Clas3hiftに手を出した意義はあったと思う。
テストコードの書き方については、xUnitパターンや"WORKING EFFECTIVELY with LEGACY CODE"をきちんと読まなければ駄目だな、と思った。
王道を知らないから随分遠回りしている、とTDDについては特にそう思う。
最後の最後に、 clas3hift についてご意見や提案・突っ込みなどありましたら msakamoto-sf までメール下さい。「既にこういうライブラリで実装されているよ」という情報は特に歓迎します。
msakamoto-sf メールアドレス : sakamoto-gsyc-3s@glamenv-septzen.net
コメント