Javaでカスタムアノテーションを使ったデバッグ
Javaにおけるアノテーションは、コードに追加情報を与えるもので、フレームワーク等で利用されます。たとえば @Overrideは、オーバーライドしていることを明示するアノテーションで、Eclipseはこのアノテーションがあるメソッドがオーバーライドとして成立していない場合エラーを表示します。
基本的にはメタデータ扱いですが、リフレクションを利用する事でそれらを読みだすことができ、DI(依存性注入)などにも用いられます。
筆者はエントリーポイントにデバッグ用のコードを書いて消し忘れるといった事があるので、今回はこれを使ってデバッグ用のメソッドを作成したいと思います。
基本構成
今回のカスタムアノテーションでは、コードは主に3つのパートに分かれます。
ひとつは、実際に利用しているコード、もうひとつは、カスタムアノテーションの定義、最後に、カスタムアノテーションがついているメソッドを検索して実行するコード(ランナー)です。
カスタムアノテーションの作成
先にカスタムアノテーションを作成します。
アノテーションは、クラスタイプを @interfaceとすることで作成できます。ここでは CustomTest という形で生成しました。
CustomTest.java
public @interface CustomTest {
...
}
この中に、値を格納するための要素を追加します。
要素は、プリミティブ型(int, float, boolean)、String、Classタイプ、等の値を設定できます。
アノテーションからはインスタンスを受け取ることはできないので、任意のクラスはクラスタイプとしてしか受け取れません。
プリミティブ型やStringの配列は受け取ることができます。
要素の定義の方法は、「 型 名前() 」となり、オプションでデフォルト値を設定できます。
()がついているのは、は内部的には特殊なメソッドとして定義されるからのようです。
ここでは、valueにデバッグ名、detailに詳細説明、priorityに実行順序を設定する前提で作成しました。
CustomTest.java
public @interface CustomTest {
String value();
String detail() default "";
int priority() default 1;
}
valueという名前は特殊な特性を持ち、これだけアノテーション付与時に名前を省略する事ができます(複数要素がある場合で、他の要素の値も設定する際は必要です)。
アノテーションのターゲットと、保持ポリシー
作成するアノテーションには、ターゲットと保持ポリシーという概念があります。
ターゲットは、アノテーションの付与が何に対して有効かを指定するもので、クラス、メソッド、フィールド等を指定します。
ターゲットの指定は、アノテーション定義に@Targetアノテーションで指定します。今回はメソッドに限定するので、次のようになります。
CustomTest.java
@Target(ElementType.METHOD)
public @interface CustomTest {
...
}
特に制限をしない場合はTargetアノテーションを付与しません。複数の指定をする場合は配列で渡します[例:@Target({ElementType.METHOD, ElementType.TYPE})]
保持ポリシーは、コード内のアノテーションがどこまで保持されるかを指定するものです。値はSOURCE、CLASS、RUNTIMEとあります。
- SOURCE
ソースコード内のみでコンパイル時には破棄されます。
- CLASS
コンパイル後にコード内に残りますが、実行時にはリフレクションからその値を利用する事ができません。
- RUNTIME
コンパイル後に残り、リフレクションから値を取得する事ができます。
今回はコードの設計上 RUNTIME としました。全体として次のようになりました。
CustomTest.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomTest {
String value();
String detail() default "";
int priority() default 1;
}
デバッグ用のメソッドを作成
実際に利用しているコードのデバッグメソッドにカスタムアノテーションを加えます。
MainClass.java
@CustomTest("デバッグ1")
public static void debug1 {
...
}
@CustomTest(value="デバッグ2",detail="デバッグ1の後に実行されます",priority=2)
public static void debug2 {
...
}
メソッド内で、リフレクションを使って、アノテーションの値を読み込みむことができます。これは保持ポリシーがRUNTIMEの状態でないと利用できません。
アノテーションは Method クラスの getAnnotation から取得できます。メソッド名指定してメソッドクラスを取得するのが通常ですが、スレッドのトップから実行中のメソッドを取得することも可能です。
MainClass.java
@CustomTest("デバッグ1")
public static void debug1() {
try {
// メソッド名を直接指定する方が軽量です
Method currentMethod = MainClass.class.getDeclaredMethod("debug1");
CustomTest annotation = currentMethod.getAnnotation(CustomTest.class);
if (annotation!=null) {
System.out.println(annotation.value());
System.out.println(annotation.detail());
}
} catch(NoSuchMethodException e) {
e.printStackTrace();
}
}
@CustomTest(value="デバッグ2",detail="デバッグ2はデバッグ1の後に実行します",priority=2)
public static void debug2(String[] args) {
// 動的にメソッド名を取得するために現在のスタックトレース要素を取得
StackTraceElement currentElement = Thread.currentThread().getStackTrace()[1];
try {
// 現在のクラスのMethodオブジェクトを取得
Method currentMethod = MainClass.class.getMethod(currentElement.getMethodName(), String[].class);
CustomTest annotation = currentMethod.getAnnotation(CustomTest.class);
if (annotation!=null) {
System.out.println(annotation.value());
System.out.println(annotation.detail());
}
} catch(NoSuchMethodException e) {
e.printStackTrace();
}
}
各メソッドで引数を持つ場合は、getMethod や getDeclaredMethod の第2引数で引数の型を渡す必要があります。
受け取った引数から、getClass()を呼ぶことでも取得できますが、nullを受けた場合にエラーになるので気をつけましょう。
総合的に考えるとこの段階で動的にメソッドを取得してアノテーションを解析するのはあまりいい方法ではありません。ここで取得した各メソッドは、ランナーでも取得する必要がありますので、そちらから取得したアノテーションのインスタンスを引数に受ける方がスマートだと思います。
MainClass.java
@CustomTest("デバッグ1")
public static void debug1(CustomTest annotation) {
if (annotation!=null) {
System.out.println(annotation.value());
System.out.println(annotation.detail());
}
}
ここでは、設計の都合上でデバッグ用のメソッドは静的メソッドとして定義していますが、動的メソッドにすることも可能です。
ランナーの定義
ランナーでは、クラス名を受け取ってそのテストクラスを実行するように設計します。
CustomTestRunner.java
public class CustomTestRunner {
public static void main(String[] args) {
Class<?> cTest;
//args = new String[] {MainTask.class.getName()};
if (args==null || args.length < 1) {
System.out.println("クラスを指定してください");
return;
}
for(String arg: args) {
try {
cTest = Class.forName(arg);
} catch(ClassNotFoundException e) {
System.out.println("クラスが見つかりませんでした");
continue;
}
//対象のメソッドをリスト化して優先順位でソート
List<Method> lstMethods = Arrays.stream(cTest.getDeclaredMethods())
.filter(m->m.isAnnotationPresent(CustomTest.class))
.sorted(Comparator.comparingInt(m->m.getAnnotation(CustomTest.class).priority()))
.collect(Collectors.toList());
for (Method m : lstMethods) {
CustomTest annotation = method.getAnnotation(CustomTest.class);
try {
//動的メソッドの場合は第1引数にMainTaskのインスタンスを設定します
m.invoke(null,annotation);
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
e.printStackTrace();
}
}
}
}
}
コード全体として、引数にクラス名を渡し、渡したクラスに今回デバッグ用に設けたアノテーションが設定されていれば、それを実行するという流れになります。
筆者がサンプルコードをChagGPTから教えてもらった際によくわからなかったので、lstMethods部分のコードを読み解いておきます。
アノテーションが設定してあって、単一のCustomTestクラスを引数に持つメソッドを取得した後に、アノテーションの値であるpriorityの値の昇順にリスト化する作業がワンライナーで書かれています。
この通り実装しないといけないという部分ではありません。
- Arrays.stream
Arrays.streamを使うことで、特定のクラスの配列を簡単にフィルターしたり、ソートしたりする機能が提供されるStreamオブジェクトに変換します。
その後の、filterや、sorted、collectはStreamにより提供されている機能です。
- getDeclaredMethods
getDeclaredMethods は指定されたクラスのメソッドをMethodクラスの配列として返します。
ここではMethod配列はMethodストリームに変換されます。
- filter
filterはPredicateクラスを引数にうけ、その実行結果がtrueのもののみをフィルタリングします。Pridicate<T>クラスはジェネリクスの型を引数にうけとり、boolean値を返すクラスで単一のメソッド testを持ちます。
単一メソッドクラスは、ラムダ式に置き換えることができるので、置き換えられています。また、ラムダ式の中身がひとつの式の時は{}や return キーワードも省略できます。
全体として、m.isAnnotationPresent(CustomTest.class) が true のものだけのストリームに変換されます。
先ほどあったようにデバッグを実行する側でannotationインスタンスを受け取るようにする場合は、ここのフィルターの条件を次のようにしてAnnotationを引数に受けるものだけを抽出するようにします。
m->{ if (m.isAnnotationPresent(CustomTest.class)) { Class<?>[] cs = m.getParameterTypes(); if (cs.length==1 && cs[0]==CustomTest.class) { return true; } else { System.out.println("対象のメソッドの引数定義が不正です:" + m.getName()); return false; } } return false; }
- sorted
Comparatorクラスを引数にとり、その基準でストリームを並べ替えます。
- Comparator.comparingInt
compareingIntはintを基準にしたCompareatorを生成します。
ラムダ式になっている部分を通常表記に戻すと次のようになります。
new ToIntFunction<Method>(){ @Override public int applyAsInt(Method value) { return value.getAnnotation(CustomTest.class).priority(); } };
ToIntFunction<Method>はジェネリクスで指定したクラスの引数を受け取り、intの戻り値を返す関数オブジェクトです。Functionクラスで <Method,Integer>として引数とMethod、戻り値をIntegerに指定する際と似ていますが、ToIntFunctionは intを返すのでボクシングのオーバーヘッドの削減が見込めます。
- collect
collectは引数に指定したCollectorを使って値を収集します。他にわかりやすいCollectorにはSetに変換する Collectors.toSet(); があります。
このメソッドにより、Stream が List に変換されます。
デバッグ実行時のコンソール出力をファイルに保持
各クラスのtestメソッドにファイル出力を実装してもいいですが、アノテーションのfilePathに指定したファイルに、コンソールと同じ出力を保存するようにしてみました。
...
//先ほどのコードの、「 for (Method m : lstMethods) { ~ 」の部分を次のように差し替えます。
FileOutputStream fos;
//標準出力とエラーの元の出力先を保持
PrintStream pDefault = System.out;
PrintStream pDefaultErr = System.err;
//既存のコンソールにも出力するためのPrintStream
PrintStream teePrintStream = null;
//ファイル出力用のPrintStream
PrintStream printStream = null;
for (Method m : lstMethods) {
CustomTest annotation = m.getAnnotation(CustomTest.class);
printStream = null;
if (annotation.filePath()==null || annotation.filePath().equals("")) {
fos = null;
} else {
try {
fos = new FileOutputStream(annotation.filePath(),true);
printStream = new PrintStream(fos);
//printStreamがfainalでなくそのまま渡せないのでラップ
AtomicReference<PrintStream> ref = new AtomicReference<>();
ref.set(printStream);
teePrintStream = new PrintStream(System.out) {
@Override
public void write(byte[] buf, int off, int len) {
//コンソールへ出力
super.write(buf, off, len);
//ファイルへ出力
//ラッパーからprintStreamを取得
ref.get().write(buf, off, len);
}
};
} catch (FileNotFoundException e) {
e.printStackTrace();
fos = null;
}
}
if (fos == null || printStream==null) {
System.setOut(pDefault);
System.setErr(pDefaultErr);
} else {
System.setOut(teePrintStream);
System.setErr(teePrintStream);
}
try {
//実行時刻の出力
System.out.println(ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z")));
//クラスとメソッドの出力
System.out.println(arg + "." + m.getName());
//動的メソッドの場合は第1引き数にインスタンスを設定
m.invoke(null,annotation);
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
e.printStackTrace();
} finally {
if (printStream!=null) {
printStream.close();
}
if (teePrintStream!=null) {
teePrintStream.close();
}
}
}