Java Press vol. 35, pp.76--85, March 2004.
Java プログラムの"舞台裏"大追跡

Javassist -- Java バイトコードを操作するクラスライブラリ -- 入門

先端J2EE サーバJBoss が,人知れず内部で行っていること

千葉 滋
東京工業大学・大学院情報理工学研究科

はじめに

Java バイトコードを変換するライブラリとしては Jakarta BCEL が有名です。BCEL は Java クラスファイルのデータ構造を直接操作する場合には便利ですが、Java クラスファイルの内部仕様、つまり Java バイトコードに詳しくないと使いこなせません。一方、Java バイトコードにあまり詳しくない開発者でもバイトコード変換を実装できるようにするライブラリが Javassist です。

バイトコード変換

はっきりいって、つい最近までJavaのバイトコードを変換する、書きかえる、といった話は、キワモノ系の話だったと思います。バイトコード変換とは、ごくごく一部の言語処理系の開発者が使う技術で、一般の開発者には縁遠いものでした。

そもそも Java バイトコードそのものが、一般の開発者にとっては詳細を理解する必要性のないものでした。正直いって、Java バイトコードの知識は教養以外の何ものでもなかったのではないでしょうか。Java バイトコードは Java の機械語だから、バイトコードで直接プログラムを書ければ、最適化された超高速なプログラムが書けるはず、という期待があるかもしれません。しかし現実には、下手に最適化したバイトコードを書くと、JIT (just-in-time) コンパイラが混乱して、かえって実行速度が低下します。

しかし Java バイトコードを取り巻く状況は、最近大きく変わりました。少なくとも、一般の開発者が使う多くのアプリケーション・フレームワーク(以下フレームワーク)の内部で、バイトコード変換の技術が当たり前のように使われるようになってきました。例えば、オープンソースのJ2EE サーバである JBoss や、同じくオープンソースのwebアプリケーション用フレームワークであるJakarta Tapestry は本章で紹介するJavassistを使って内部でバイトコード変換をおこなっています。

Java バイトコードは、コンパイラ等の言語処理系の開発者だけではなく、フレームワークの開発者にとっても必要な知識になってきているのです。一般の開発者にとっては、依然として教養かもしれませんが、以前に比べればずっと必須度の高い教養になってきたといえます。

バイトコード変換の恩恵

フレームワークは、なぜバイトコード変換を内部でおこなうのでしょうか。その答えは、そのフレームワークが提供する(広い意味での)APIを簡単にするためです。 フレームワークのAPIは一般に、フレームワーク自体の実装上の都合で複雑になりがちです。例えば、EJB を定義するときは API にしたがって多数のインタフェース定義を書きます。なんとなく、そんなものかと思って書いているわけですが、よくよく考えると、これは完全にEJBコンテナの実装の都合であり、可能ならばもっと簡素なAPIが望まれます。

最近の先進的なフレームワークは、そのAPIを簡素化するためにバイトコード変換を利用しています。Eclipseのような開発環境には、いくつかの質問に答えるだけで一部のソースコードを自動的に生成する機能や、リファクタリング等の目的でソースコードを自動的に書きかえる機能があります。このアイデアを応用し、開発者が簡素なAPIを使って書いたプログラムを、フレームワークがデプロイ時または実行直前に書きかえます。

この方式を使えば、開発者に公開するAPIとしては仮想的だが簡素なものを、実際にフレームワーク内部で使われるAPIとしては複雑なものを、と使い分けることができます。当然、開発者が書いたプログラムは仮想的なAPIを使って書かれているわけですから、そのままでは実行できません。そこで本物のAPIを使うように、フレームワークがプログラムを書きかえるのです。開発者が書いたプログラムは既にコンパイルされていますから、書き換えはバイトコードのレベルでおこなうことになります。

この方式を発展させ、例えばJBossは次期バージョンで、任意のJavaのクラスをEJBに自動変換する機能を提供しようとしています。JBoss内部でバイトコード変換を実行し、EJBに必要なメソッドやインタフェースを自動的に定義してくれるのです。

インストール

JavassistはJBossのサブプロジェクトであり、JBossのサイトから入手できます。以下のURLはJBossサイト内のJavassistのページを指しています。

現在の公開版はバージョン 2.6 ですが、これは昨年の8月にリリースされたものなので、そろそろ2.7がリリースされるはずです。最新版のソースコードは次のようにしてSourceForge.net から入手可能です。

    % cvs  -z3 -d:pserver:anonymous@cvs.sourceforge.net:/cvsroot/jboss co javassist
    

CVSから最新版のソースコードをチェックアウトしたら、ant でビルドできます。

    % ant
    

JBossのサイトからダウンロードするなり、最新版をantでビルドするなりすると、色々なファイルが手に入りますが、Javassistを利用するために必要なのはjavassist.jar という jar ファイルだけです。これを適当な場所にコピーすれば、インストールは完了です。本章では、このファイルはカレントディレクトリにコピーされていると仮定します。コンパイル、実行には、それぞれのコマンドに次のようなオプションをつけます。

    -classpath ".;javassist.jar"
    

Linux の場合は ;(セミコロン)ではなく、:(コロン)です。

Javassist の特徴

Javassistの特徴は、これを使えば、Javaバイトコードのことを意識しなくても、クラスファイルの内容を変更するプログラムを書けることです。JavassistのAPIはリフレクションAPI (java.lang.reflect パッケージ) によく似ており、クラスオブジェクトを通してクラスの定義を調べたり、変更したりすることができます。

例えばリフレクションAPIを使って、java.awt.Pointクラスのスーパークラスを調べるにはList1のように書きます。一方、同じ処理をするのにJavassist ではList2のように書きます。Javassistが提供するClassPoolオブジェクトは、クラスパスを管理し、クラスファイルをディスク等から実際に読み込む作業を担当します。ClassPoolのgetメソッドが返すCtClassオブジェクト(表1)は、リフレクションAPIのClassオブジェクトに対応し、クラスの定義を表すクラスオブジェクトです。Ctはcompile timeの意です。

    List 1:
    import javassist.*; public class List1 { public static void main(String[] args) throws Exception { Class c = Class.forName("java.awt.Point"); Class superClass = c.getSuperclass(); System.out.println(superClass.getName()); } }
    List 2:
    import javassist.*; public class List2 { public static void main(String[] args) throws Exception { ClassPool cp = ClassPool.getDefault(); CtClass cc = cp.get("java.awt.Point"); CtClass superClass = cc.getSuperclass(); System.out.println(superClass.getName()); } }

リフレクションAPIでは、クラスオブジェクトのnewInstanceメソッドを使って、そのクラスのオブジェクトを作成することができますが、Javassistではできません。両者は似たようなAPIを提供しますが、リフレクションAPIのClassオブジェクトはJava仮想機械(JVM)にロード済みのクラスを表し、JavassistのCtClassオブジェクトはまだロードされていない、クラスファイルの状態のクラスを表すからです。

    表1: CtClass のメソッド (抜粋)
    String getName() クラス名を得る void setName(String name) クラス名を変更 int getModifiers() 修飾子を得る void setModifiers(int m) 修飾子を変更 CtClass getSuperclass() スーパークラスを得る void setSuperclass(CtClass c) スーパークラスを変更 CtClass[] getInterfaces() インタフェースを得る void setInterfaces(CtClass[] i) インタフェースを変更 CtField[] getFields() 全フィールドを得る void addField(CtField f) フィールドを追加 CtMethod[] getMethods() 全メソッドを得る void addMethod(CtMethod m) メソッドを追加 CtConstructor[] getConstructors() コンストラクタを得る void addConstructor(CtConstructor c) コンストラクタを追加 void writeFile() クラス定義をファイルに保存 Class toClass() クラスをロード byte[] toBytecode() クラス定義の中身を得る

クラス定義の変更

リフレクションAPIではクラス定義を調べることしかできませんが、Javassistでは変更も可能です。現行のJVMでは、いったんロードされたクラスの定義の変更は強く制限されていますが、ロードする前であれば変更は自由だからです。ただし標準APIに含まれているjava.lang.Objectのようなクラスの変更はできません。変更できるのは開発者が定義したクラスだけです。

今、次のようなクラス Point3Dを定義したとします。

    public class Point3D {
        int z;
    }
    

このクラス定義をJavassistを使って変更してみましょう。今、Point3D.classはカレントディレクトリにあるとします。

List3のプログラムは、Point3Dクラスのスーパークラスをjava.awt.Pointに変更します。変更されたクラス定義は元のPoint3D.classファイルに上書きされます。writeFile メソッドは変更後の定義をカレント・ディレクトリのクラスファイルに保存します。

    List 3
    import javassist.*; public class List3 { public static void main(String[] args) throws Exception { ClassPool cp = ClassPool.getDefault(); CtClass p3 = cp.get("Point3D"); CtClass p2 = cp.get("java.awt.Point"); p3.setSuperclass(p2); p3.writeFile(); } }

上書きされたクラスファイルの中身を javap コマンドで見てみましょう。

    % javap Point3D
    

スーパークラスがjava.awt.Pointに変わったことがわかります。

変更後の定義をファイルに保存しないで、直接JVMにロードすることもできます。setSuperclassを呼んだ後、writeFileメソッドの代わりにtoClassメソッドを呼びます。List4のプログラムを実行すると、変更したクラス定義をロードし、リフレクションAPIを使って、スーパークラスの名前を表示します。この場合、元のPoint3D.classは上書きされません。

    List 4:
    import javassist.*; public class List4 { public static void main(String[] args) throws Exception { ClassPool cp = ClassPool.getDefault(); CtClass p3 = cp.get("Point3D"); CtClass p2 = cp.get("java.awt.Point"); p3.setSuperclass(p2); Class c = p3.toClass(); Class s = c.getSuperclass(); System.out.println(s.getName()); } }

toClassメソッドは、変更後のクラス定義をJavassist内部のクラスローダを使ってJVMにロードし、Classオブジェクトを返します。一般にJavaでは、異なるクラスローダからロードされたクラスを混ぜて使うときは、注意深いプログラミングが必要です。したがってtoClassメソッドの利用はあまり推奨されていませんが、このように CtClassオブジェクトをClassオブジェクトに簡単に変換することも可能です。本格的に変換したいときは、toClass メソッドではなく、toBytecodeメソッドを使って独自のクラスローダを定義することが推奨されています。toBytecodeメソッドは、変更後のクラスファイルの中身をbyte配列で返します。

フィールドやメソッドの追加

CtClass オブジェクトを使って、新しいフィールドやメソッドをクラスに追加することもできます。BCELでは、新しいメソッドを追加するとき、追加するメソッドの中身をバイトコードで与えなければなりません。一方、Javassistでは、メソッドの中身もJavaソースコードで与えることができます。

例として、先のPoint3Dクラスの定義にtoStringメソッドを追加するプログラムをList5に示します。プログラムを実行する前に Point3D.java をもう一度再コンパイルして、クラスファイルの中身を元に戻すのを、忘れないで下さい。このプログラムを実行すると、Point3Dクラスに

    public String toString() {
        return "(" + x + "," + y + "," + z + ")";
    }
    

というメソッドが新たに追加されます。

    List 5:
    import javassist.*; public class List5 { public static void main(String[] args) throws Exception { ClassPool cp = ClassPool.getDefault(); CtClass p3 = cp.get("Point3D"); CtClass p2 = cp.get("java.awt.Point"); p3.setSuperclass(p2); CtMethod m = CtNewMethod.make( "public String toString() {" + " return \"(\" + x + \",\" + y + \",\" + z + \")\"; }", p3); p3.addMethod(m); p3.writeFile(); } }

List5では、二重引用符を含む文字列を書くために、一部の二重引用符の前にエスケープ文字が入っています。CtMethod オブジェクト(表2)はリフレクションAPIの Methodオブジェクトに対応し、メソッドを表します。List5では、ソースコードを元にこのオブジェクトを新たに作成し、CtClassオブジェクトに追加しています。CtNewMethodクラスは、CtMethodオブジェクトのfactoryメソッドが多数定義されたクラスです。

    表2: CtMethod のメソッド (抜粋)
    String getName() メソッド名を得る void setName(String name) メソッド名を変更 int getModifiers() 修飾子を得る void setModifiers(int m) 修飾子を変更 void setBody(String src) メソッド本体を変更 void instrument(ExprEditor e) メソッドの一部を変更 void insertBefore(String src) メソッドの最初にコードを挿入 void insertAfter(String src, boolean asFinally) メソッドの末尾にコードを挿入

新しいクラスの定義

Javassistを使えば、新しいクラスの定義をJavaプログラムの中で作り出すこともできます。例えばList6を実行すると、分数を表す以下のようなクラスを定義し、そのクラスファイルを生成します。

    public class Fraction {
        int numerator = 1;
        int denominator = 1;
    }
    

List6の中のCtField オブジェクトはリフレクションAPIのFieldオブジェクトに対応し、フィールドを表します。

    List 6:
    import javassist.*; public class List6 { public static void main(String[] args) throws Exception { ClassPool cp = ClassPool.getDefault(); CtClass cc = cp.makeClass("Fraction"); CtField f1 = CtField.make("int numerator = 1;", cc); cc.addField(f1); CtField f2 = CtField.make("int denominator = 1;", cc); cc.addField(f2); cc.writeFile(); } }

メソッドの中身の変更

Javassistを使えば、既存のメソッドの冒頭や return 文の直前にコードを挿入することも容易にできます。例えば CtMethod オブジェクトの insertBefore や insertAfter メソッドを使えば、挿入するコードを Java ソースコードで指定できます。変数m がCtMethod オブジェクトを表しているとすると、

    m.insertBefore("System.out.println(\"OK\");");
    

で m が表すメソッドの冒頭に println メソッドの呼び出しを挿入できます。

ExprEditor の利用

メソッドの冒頭や最後にコードを挿入するだけでなく、メソッドの途中に現れるコードを別なコードで置き換えることもできます。そのためには、javassist.expr パッケージのExprEditor クラスを使います。

ExprEditor オブジェクトを引数として CtMethod の instrument メソッドを呼ぶと、そのCtMethodオブジェクトが表すメソッドの中身が調べられます。メソッド呼び出しやフィールドのアクセスが見つかると、instrumentメソッドはイベントを発生してExprEditorオブジェクトに通知します。ExprEditorオブジェクトのメソッドを適当に定義してやることで、見つかったメソッド呼び出しなどのコードを別なコードに置換することができます。

例として、List7に示した Hello クラスの main メソッドの中に現れる say メソッドの呼び出しを、hi メソッドの呼び出しに置換してみましょう。そのような置換を、Helloクラスをコンパイルして得られる Hello.class を変更して実行するプログラムは、List8のようになります。まず、main メソッドを表す CtMethod オブジェクトを得るのに getDeclaredMethod を呼びます。引数は得たいメソッドの名前です。本来は、メソッドの名前だけでなくシグネチャもgetDeclaredMethodに渡すべきですが、その名前のメソッドがオーバロードされていないときは、シグネチャは省略できます。

    List 7:
    public class Hello { public void say() { System.out.println("Hello"); } public void hi() { System.out.println("Hi"); } public static void main(String[] args) { System.out.println("start..."); new Hello().say(); } }

List8ではExprEditor のサブクラスを無名クラスとして定義しています。instrumentメソッドは、main メソッドの中にメソッド呼び出し式を見つけるたびにイベントを発生させて、この無名クラスのオブジェクトの edit メソッドを呼び出します。editの引数の MethodCall オブジェクトは、instrumentが見つけたメソッド呼び出し式を表します。このオブジェクトのgetClassNameやgetMethodNameを使えば、呼ばれたメソッドのクラスや名前がわかります。これを利用して、上のプログラムではHelloクラスのsayメソッドの呼び出しであるときだけ、置換をおこなうようにしています。

    List 8:
    import javassist.*; import javassist.*; import javassist.expr.*; public class List8 { public static void main(String[] args) throws Exception { ClassPool cp = ClassPool.getDefault(); CtClass cc = cp.get("Hello"); CtMethod m = cc.getDeclaredMethod("main"); m.instrument(new ExprEditor() { public void edit(MethodCall m) throws CannotCompileException { if (m.getClassName().equals("Hello") && m.getMethodName().equals("say")) m.replace("$0.hi();"); } }); cc.writeFile(); } }

置換をおこなうのはMethodCall のreplaceメソッドです。上のプログラムでは、見つかった sayメソッドの呼び出し式を下の文で置換します。

    $0.hi();
    

これは同じオブジェクトのhiメソッドの呼び出しを意味します。$0はJavassistが提供する特殊な変数で、メソッド呼び出し式を置換する場合、元のメソッド呼び出しの相手オブジェクトを表します。

なお、置換されるのはsayメソッドの呼び出し式であって、式文全体ではないことに注意して下さい。上の例では

    new Hello().say();
    

という式文のうち、sayメソッドの呼び出しの部分だけが、hiメソッドの呼び出しに変わります。Helloオブジェクトの作成自体は置換後も変わりなくおこなわれます。

特殊変数

MethodCallのreplaceメソッドに渡す、置換後のコードのソースにはJavassistが定める特殊変数を含めることができます。これらの特殊変数は、置換される元のコードの実行時情報を表します。例えば、呼ばれたメソッドに渡される実引数の値は先頭から順に $1、$2、$3、… という特殊変数で表されます。先に説明したように、特殊変数$0はメソッドが呼び出される相手先のオブジェクトを表します。

この他に重要な特殊変数(文法上は変数ではありませんが)は、$proceedです。これは、置換される元の式に書かれていた処理を実行するために使います。

例えば先のプログラムでは

    m.replace("$0.hi();");
    

となっていましたが、replaceの引数に渡すソースコードを次のように変えると、sayメソッドを呼び出す直前にメッセージを表示するようになります。

    { System.out.println("** say() **");
    $_ = $proceed($$); }
    

全体を波括弧で囲ってブロックにすると、そのブロックをコンパイルしたコードで元のメソッド呼び出し式を置換します。

ブロックの中に2つ文がありますが、

    $_ = $proceed($$);
    

が、置換される元のコードの処理を実行するための文です。これは、元のコードの内容や、メソッドの名前、シグネチャにかかわらず、常に同じです。

$$は、元のメソッド呼び出し式の実引数列を表す特殊変数です。$1、$2、… を使っても同じ結果が得られますが、$$を使うと実引数列の個数にかかわらず常に$$と書けます。上の例でも、sayメソッドの引数はないので、$proceed() と明示的に引数を書かないようにしてもよいのですが、$proceed($$)と書いておけばJavassistが実引数の個数を考慮して適切にコンパイルしてくれます。

$_は、置換される元のコードの計算結果を表す特殊変数です。上のブロックの実行終了後のこの変数の値が、置換後の新しいコードの計算結果となります。say メソッドの場合は戻り値の型がvoidなので、この変数に何を代入しても無視されますが、変数自体は利用可能です。$_の型は置換される元の式の型と同じです。元の式の型がvoid型の場合はObject型です。またこの場合、$proceedの戻り値はnullです。

Generic なプログラム

GenericといってもJava genericsのことではありません。replaceメソッドに渡す置換後のコードのソースは、可能な限りgenericに、包括的・汎用的に書けた方が便利です。置換される式ごとに別々にソースコードを用意しなければならないとすると、大変面倒なことになります。

できるだけ包括的なソースコードを書けるように、Javassistは、置換される元の式で使われている型の違い等を吸収するための特殊変数を用意しています。例えば置換される元の式がメソッド呼び出しであるとき、$argsは実引数列をObject型の配列に変換したものを表します。引数の型がintのような基本データ型の場合は、対応するIntegerのようなwrapperクラスのオブジェクトに変換されます。

$argsは得られた実引数列を使って、別なメソッドをリフレクションAPIで呼び出すときなどに便利です。例えば変数mがMethodオブジェクトであるとすると、

    Object result = m.invoke($args); 
    

は、変数mが表すメソッドを呼び出します。invokeの引数の型はObject型の配列なので、$argsが利用できないと大変面倒です。置換される元のメソッド呼び出しの引数の型に応じて、引数列をObject型の配列に変換するコードを個別に書かなければなりません。

$argsと対になって使う特殊変数に$rがあります。$rは正しくは変数ではなく型名です。これは置換される元のコードの型を表します。元のコードがメソッド呼び出し式なら、$rは戻り値の型を表します。

$rはキャスト式の中でだけ使うことができます。例えば、上でinvokeメソッドの実行結果を代入した変数resultに対し、次のような処理をおこなえます。

    $_ = ($r)result;
    

キャスト($r)は特別な意味をもち、もし戻り値の型($_の型でもある)がintのような基本データ型のときは、Integerのようなwrapperクラスからint型へ値の変換をおこないます。

ビジターパターン支援ツール

Javassistを使った簡単なプログラムの例として、ビジターパターンに従ったプログラミングを支援するツールを作ってみましょう。ビジターパターンは、木構造をたどるプログラムを書くときのパターンですが、プログラミングが面倒なので有名なパターンです。

今、木を構成するノードのクラスが Num、Var、Plus、 Minus、… だったとします。例としてPlusクラスの定義を示します。

    public class Plus extends Node {
        private Node left, right;
        public Plus(Node n1, Node n2) {
            left = n1; right = n2;
        }
        public Node getLeft() { return left; }
        public Node getRight() { return right; }
    }
    

ビジターパターンに従うと、まず次のようなインタフェースを定義しなければなりません。

    public interface Visitor {
        void visitNum(Num n);
        void visitVar(Var v);
        void visitPlus(Plus p);
            :
    }
    

Visitorインタフェースは、全てのノードのクラスに対応する visitXXX (XXXは各ノードのクラス名)という名のメソッドを含まなければなりません。さらに、全てのノードのクラスXXXごとに

    public void accept(Visitor v) { v.visitXXX(this); }
    

というメソッドを宣言しなければなりません。

支援ツールの使い方

ここでは、以上の一連の作業を手作業ではなく、自動で実行するプログラムを書きます。このプログラムVisitorMakerは次のような手順で動かします。

    (1) 木のノードのクラスのソース(Plus.javaなど)を全てコンパイル。これらのクラスは仮に treeパッケージに含まれるとする。

    (2) VisitorMakerを次のように実行する。

      java VisitorMaker tree/*.class
      

    このプログラムは、ノードのクラスのクラスファイル(Plus.classなど)を変更して accept メソッドを挿入する。また、Visitor インタフェースの定義を Visitor.java として生成する。

    (3) Visitor.java や、それを実装したクラスをコンパイル。 VisitorMakerは、Visitor.javaをコンパイルして得られるVisitor.classを直接生成することもできます。しかし、Visitorを実装するクラスを書くことを考えると、Visitorのソースコードがあった方が便利です。このため、VisitorMakerはクラスファイルではなく、ソースファイルを生成します。

プログラムの解説

VisitorMakerのプログラムをList9に示します。このプログラムは、コマンド行引数として、ノードのクラスのクラスファイル名を取ります。クラスファイルの名前をJavaのクラス名に変換するのがgetClassNameメソッドです。

    List 9:
    import javassist.*; import java.io.*; public class VisitorMaker { public static void main(String[] args) throws Exception { FileWriter file = new FileWriter("Visitor.java"); PrintWriter out = new PrintWriter(new BufferedWriter(file)); out.println("public interface Visitor {"); ClassPool cp = ClassPool.getDefault(); CtClass ivisitor = cp.makeInterface("Visitor"); for (int i = 0; i < args.length; i++) { CtClass cc = cp.get(getClassName(args[i])); String name = cc.getSimpleName(); String src = " void visit" + name + "(" + cc.getName() + " node);"; ivisitor.addMethod(new CtMethod(CtClass.voidType, "visit" + name, new CtClass[] { cc }, ivisitor)); out.println(src); CtMethod m = CtNewMethod.make( "public void accept(Visitor v) {" + " v.visit" + name + "(this);" + "}", cc); cc.addMethod(m); cc.writeFile(); } out.println("}"); out.close(); } private static String getClassName(String classFile) { String name = classFile; if (classFile.endsWith(".class")) name = name.substring(0, name.length() - 6); return name.replace(File.separatorChar, '.'); } }

このプログラムはPrintWriterオブジェクトを使ってVisitor.javaを生成する一方、mainメソッドのfor文の中で、ノードのクラスひとつひとつにacceptメソッドを挿入します。VisitorインタフェースのvisitXXXメソッドの定義もfor文の中でVisitor.javaに出力されます。変数ccはacceptメソッドの挿入先のノードのクラスを表します。変数mは挿入されるacceptメソッドを表すCtMethodオブジェクトです。

変数ivisitorはVisitorインタフェースを表すCtClassオブジェクトをさします。Visitorインタフェースの定義はVisitor.javaにソースコードの形で出力されるのに、なぜivisitorが必要なのか不思議に思うかもしれません。これはJavassistがacceptメソッドをコンパイルできるようにするためです。acceptメソッドはVisitorインタフェースのvisitXXXメソッドを呼び出します。ところが、Visitorインタフェースを表すCtClassオブジェクトが存在しないと、JavassistはVisitorの定義がわからず、visitXXXメソッドは未定義であるとコンパイル・エラーにしてしまいます。これを避けるために、acceptメソッドを表すCtMethodオブジェクトを作成する直前に、Visitorインタフェースを表すCtClassオブジェクトにvisitXXXメソッドの定義を追加しているのです。

AOP エンジンとしての Javassist

Javassistは汎用のJavaバイトコード変換ライブラリですが、アスペクト指向プログラミング(AOP)の支援ツールを作るためのライブラリとして使うことを強く意識して開発されています(脚注1)。代表的なAOP言語であるAspectJをはじめ、Java言語用の多くのAOP言語処理系はバイトコード変換をおこないます。Javassistは、そのようなバイトコード変換を簡単に実装できるようにAPIが設計されています。

例えばAspectJの基本機能は、プログラムの実行が指定された場所に到達し、かつ与えられた条件を満たしているとき、別のコードの断片(アドバイスと呼ばれます。ある種のメソッドと考えてもよいでしょう)を自動的に実行するというものです。AspectJのこの機能を実装するためには、AspectJコンパイラがプログラム中の指定された場所(例えばメソッド呼び出し式)の前後に、条件が満たされているときにアドバイスを呼び出すコードを自動的に挿入しなければなりません。ExprEditorクラスを利用すれば、このようなコードの挿入は容易です。

Javassistを使って作られたAOP支援ツールの例として、以下では筆者の研究室の薄井義行氏が開発中のBugdelを紹介します。これはEclipseのプラグインで、アスペクト指向によるデバッグを支援するツールです。

AOPを応用したデバッグ

AOPの応用例としてデバッグ支援は有名です。デバッグ用のログ出力のために println メソッドの呼び出しを直接デバッグ対象のプログラム中に書き込むと、書き込んだprintlnの呼び出しをデバッグ終了後に取り除き忘れたり、取り除く際に誤って周辺のプログラムを書き換えてしまったりしがちです。C言語の#ifのようなマクロ機能が使えれば、この問題はなくなりますが、プログラムは非常に読みにくくなってしまいます。

AspectJを使えばログ出力に関連するコードをアスペクトとして別のファイルに分離できるので、このような問題を回避できます(図1)。アスペクトとは、アドバイスと、それをどこで、どのような条件のときに呼び出すかの指定(ポイントカットといいます)をいくつか組にしてまとめたものです。AspectJでは、アスペクトはクラスと並ぶモジュール化の単位です。

ログ出力に関連するコードをまとめたアスペクトでは、printlnメソッドを呼び出してログを出力するコードがアドバイスとなります。このアドバイスは、ログ出力が必要な場所にプログラムの実行が到達したとき呼ばれるようにします。具体的にどこで呼ばれるかは、ポイントカットとしてアスペクトの定義の中に記述します。

アスペクトを使う利点は、C言語の#ifのように簡単にログ出力をオン・オフできる一方、デバッグの対象となるプログラムにデバッグ・コードを含めなくて良い点です。上で示したようなアスペクトを書けば、コンパイルされるソースファイルに、そのアスペクトを含めるか否かを選択するだけで、残りのソースファイルを修正することなく、ログ出力をオン・オフできます。


図1

Bugdel

Bugdelは、Eclipse上のデバッグ専用のAOPシステムです(図2)。AspectJと異なり、Bugdelではどんなログ出力をどこで実行すべきかを、ブレークポイントを指定するときのように、グラフィカル・ユーザインタフェース(GUI)を通して指定できます。もちろん、デバッグの対象となるプログラムは一切変更されません。

AspectJ用のEclipseプラグインも提供されているので、Eclipse上でAspectJを使いながらデバッグをおこなうことは可能です。しかしAspectJで同等のことをするには、Aspect Jの文法を学んで、ログ出力をどこで実行すべきか、プログラムとして記述しなければなりません。このため誰もがすぐに使い始められるわけではありません。一方、Bugdelの場合はGUIを通して指定できるので、AspectJの文法のような新しい事柄をあまり学ばなくても使うことができます。

BugdelではGUIを通してログ出力をおこなう場所を指定できますが、一カ所ずつ個別に指定するだけでなく、特定のパターンに合致する場所を複数まとめて指定することもできます。BugdelはあくまでAOPシステムなので、パターンによる指定のようなAspectJのよい点は積極的に取り込んでいます。しかしながら、AspectJは汎用のAOP言語なので、デバッグ作業には必ずしも最適ではありません。このため、BugdelはAspectJでは対応できないことにも対応しています。例えば、行番号を使ってアドバイス(ログ出力)を呼び出す場所を指定できます。またアドバイスの中で、アドバイスが呼び出される場所で有効な局所変数を参照することができます。


図2

おわりに

Javassistのようなプログラム変換ライブラリは、なかなか一般の開発者の目には留まらないかもしれません。しかし、これらは最近のアプリケーション・フレームワークの開発にはかかせません。本稿によって、最近のJavaアプリケーション・フレームワークの実装に対する読者の理解が深まれば幸いです。

コラム: Javassist のライセンス

JavassistはMPL (Mozilla Public License) と LGPL (GNU Lesser General Public License) のデュアルライセンス(再配布の際に片方または両方を選べる)の下で配布されています。Javassistの上位プロジェクトであるJBossはLGPLの下で配布されており、Javassistのライセンスは例外といえますが、これはLGPLのあいまいさのためです。

LGPLはそのソフトウェアの商用利用を認めますが、そのソースコードは全て公開されなければなりません。LGPLで配布されているソフトウェアがライブラリの場合、全てのソースコードとはライブラリのソースコードを意味し、そのライブラリを使って動いているソフトウェアのソースコードは含まれません。ところがこのライブラリの定義がくせものなのです。

LGPLにおけるライブラリの定義は、主にオペレーティングシステムの共有ライブラリ(.so や .dll ライブラリ)を想定しているので、少々古くさく、Javaのライブラリにそのまま当てはめようとすると、あいまいな点が多数現れてしまうのです。例えばjavassist.jarを展開してクラスファイルを取り出し、別なクラスファイルと一緒にして sealed jar ファイルを作った場合はどうなるか、などです。

この問題は判例も出ておらず、正確なところは誰もわからない状態です。弁護士によって言うことが違うので、自社製品にLGPLライブラリを使用禁止とする企業がある一方、使用可とする企業もあるようです。大手企業ほど、何とかは危うきに近寄らずで、LGPLを避ける傾向にあります。

一方のMPLは制限が緩いのでLGPLのような問題は起きません。公開しなければならないソースコードは、元のソフトウェアに含まれていたファイルとその修正部分だけだからです。ただしApache Software Licenseなどとは異なり、一定のソースコード公開の義務があります。

Javassistは元々MPLの下で配布されていたのですが、JBossのサブプロジェクトとなったときにLGPLとのデュアルライセンスとなりました。LGPLへの一本化も検討されましたが、上記の理由で既存のJavassistユーザから反対の声が上がり見送られたのです。その一方で、JBossとの整合性も必要なので、LGPLも選べるようになりました。

オープンソース・ソフトウェアも、単なる無料ソフトウェアである間はライセンスの細部は問題になりません。しかし商用を視野に入れると、まだまだ色々な問題が残っているようです。