黒魔術Byteman使ってみた

過去のエントリでも紹介したBytemanを実際に触ってみたのでとりまとめ。

参考サイト
公式:https://www.jboss.org/byteman
ダウンロード:https://www.jboss.org/byteman/downloads
バイトコード操作ツール、Bytemanを試す:http://d.hatena.ne.jp/Kazuhira/20131022/1382455739
Bytemanクイックリファレンス:http://d.hatena.ne.jp/nekop/20111226

この辺を読めば使い方はおおよそわかってくると思うので、実際のコードに入れてく使い方をつらつらと書いていきたいと思います。

前準備

まずは、Bytemanの黒魔術の被害者となるコードを作成。まあ日付をフォーマットして標準出力するだけです。
Eclipseなら「実行の構成」のJVMパラメータにByteman用の設定オプションを追加しておけばBytemanが使えます。

import java.text.SimpleDateFormat;

public class BytemanTest {

	public static void main(String[] args) {

		try{
			System.out.println("■Bytemanの実行テスト開始");

			//システム日付の呼出、日付の文字列(yyyyMMdd)変換
			String yyyymmdd;
			SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyyMMdd");
			yyyymmdd = dateFormatter.format(new java.util.Date());

			//例外注入のテスト用
			System.out.println("■Integer.parseInt(\"0\"): " + Integer.parseInt("0"));

			//結果出力
			System.out.println("■main内での結果: "+ yyyymmdd);
			System.out.println("■getYYYYMMDD()の結果: " + getYYYYMMDD());


			System.out.println("■Bytemanの実行テスト終了");

		}catch(Exception e){
			System.out.println("■例外発生");
		}
	}

	private static String getYYYYMMDD(){
		//システム日付の呼出、日付の文字列(yyyyMMdd)変換
		// (mainメソッド内での実施内容と同様)
		SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyyMMdd");
		return dateFormatter.format(new java.util.Date());
	}

}

何も使わずに実行したら、標準出力はこうなります。

実行結果
■Bytemanの実行テスト開始
■Integer.parseInt("0"): 0
■main内での結果: 20140318
■getYYYYMMDD()の結果: 20140318
■Bytemanの実行テスト終了

指定したメソッド開始時にトレースログ出力

ここで、Bytemanスクリプトの初歩である、メソッドの開始時(AT ENTRY)やメソッドの起動時(AT INVOKE)に標準出力にトレースを出力するスクリプトを加えてみます。

Bytemanスクリプト
# BytemanTestのmainメソッド「開始時」にトレースを標準出力
RULE trace main entry
CLASS BytemanTest
METHOD main
AT ENTRY
IF true
DO traceln("□Byteman: main() AT ENTRY")
ENDRULE

# SimpleDateFormatのformatメソッド「開始時」にトレースを標準出力
RULE trace entry simpledateformat format
CLASS java.text.SimpleDateFormat
METHOD format
AT ENTRY
IF true
DO traceln("□Byteman: java.text.SimpleDateFormat.format() AT ENTRY");
ENDRULE

# java.util.Dateのコンストラクタ「開始時」にトレースを標準出力
RULE trace date
CLASS java.util.Date
METHOD <init>
AT ENTRY
IF true
DO traceln("□Byteman: java.util.Date() AT ENTRY");
ENDRULE

# BytemanTestのmainメソッド内で
# java.text.SimpleDateFormat.format(Date)が「起動時」にトレースを標準出力
RULE trace invoke simpledateformat format
CLASS BytemanTest
METHOD main
AT INVOKE java.text.SimpleDateFormat.format
BIND NOTHING
IF true
DO traceln("□Byteman: java.text.SimpleDateFormat.format() AT INVOKE")
ENDRULE

なお、テキストファイルは文字コード UTF-8のBOMなしです。

結果がどうなるかというと、こんなかんじになります。

実行結果
□Byteman: main() AT ENTRY
■Bytemanの実行テスト開始
□Byteman: java.util.Date() AT ENTRY
□Byteman: java.util.Date() AT ENTRY
□Byteman: java.util.Date() AT ENTRY
□Byteman: java.text.SimpleDateFormat.format() AT INVOKE
□Byteman: java.text.SimpleDateFormat.format() AT ENTRY
□Byteman: java.text.SimpleDateFormat.format() AT ENTRY
■Integer.parseInt("0"): 0
■main内での結果: 20140318
■getYYYYMMDD()の結果: 20140318
■Bytemanの実行テスト終了

AT INVOKEとAT ENTRYのタイミングの違いについてですが、AT INVOKEは「対象ステップの処理開始時」、AT ENTRYは「メソッド内の処理に入った瞬間」でタイミングが異なるという理解でたぶんいいかなと。

スクリプトの文法についてはBytemanクイックリファレンスを見てもらうのが早いですが、CLASS句 METHOD句で対象を指定する。タイミングをAT/AFTER句で指定する。スコープ外のローカル変数などをDO句やIF句内で使いたいならBIND句で指定、IF句はDO句の実行可否を判定する場合に指定、といった形で覚えて置けばよいかと思います。

ちなみにRULE句で指定した名前がかぶるとエラーになるので注意してください。(何回かやらかしたw)

メソッドのリターンやパラメータのトレース出力

メソッドのリターン値やパラメータを取りたい場合、以下のような変数表現でバインドする事になります。
変数のスコープが有効な範囲内であれば、普通にメソッド内のローカル変数も指定できます。
ただ、中身がちゃんと入っているかはByteman側のコードの呼び出しタイミングとの兼ね合いになるので、
うまく出力できない場合はソース上の処理順序などを考えながらAT句のタイミングを変えるなどしてみてください。

パラメータやリターン値に関する変数表現はこんなのがあります。

  • $1, $2, $3...

- メソッドパラメータ

  • $!

- リターン値、AT EXITかAFTER INVOKEのみ有効

  • $^

- スローされた例外、AT THROWで有効

  • $#

- メソッドのパラメータ数

Bytemanクイックリファレンス

パラメータをトレース出力したいとか、処理結果をトレース出力したい場合なんかは上のものが使えます。
例えば、SimpleDateFormat.format()のリターンをトレースで出したいような場合はこんなかんじのスクリプトを書く事になります。

Bytemanスクリプト
# 特定メソッド(SimpleDateFormat.format()のリターンをトレース出力)

RULE trace yyyymmdd
CLASS BytemanTest
METHOD main
AFTER INVOKE java.text.SimpleDateFormat.format
BIND yyyymmdd = $!
IF true
DO traceln("□Byteman: yyyymmdd:" + yyyymmdd.toString())
ENDRULE
実行結果
■Bytemanの実行テスト開始
□Byteman: yyyymmdd:20140318
■Integer.parseInt("0"): 0
■main内での結果: 20140318
■getYYYYMMDD()の結果: 20140318
■Bytemanの実行テスト終了

上記の例だと、AFTER INVOKE、つまりメソッドの処理が終わった段階なので、
13行目のyyyymmdd = dateFormatter.format(new java.util.Date());が処理された後となり、
yyyymmddが普通に呼び出せる状態になってるので、普通にprintlnできるわけですね(たぶん)。

タイミングによっては変数からトレース出した方がいいかもしれませんけど、
基本IF句で使うものかなーという雑感。

特定メソッドの呼び出し時に例外をスロー

エラー系の試験とかで明示的に例外を投げたいという場合もあると思います。
そんな場合は以下のようなBytemanスクリプトを作成します。

Bytemanスクリプト
# IntegerのparseIntメソッド「開始時」に例外
RULE throw Exception Injection
CLASS BytemanTest
METHOD main
AT INVOKE java.lang.Integer.parseInt
IF true
DO traceln("□Byteman: 例外注入");throw new NumberFormatException()
ENDRULE

parseIntが呼び出されたら、例外をスローするスクリプトですね。
ここでスローできる例外の種類ですが、対象のメソッドのthrow句に指定されてるものか、RuntimeExceptionのみです。
ありとあらゆる例外を好きなタイミングで出せるわけではないので、そこは注意です。

実行結果
■Bytemanの実行テスト開始
□Byteman: 例外注入
Exception in thread "main" java.lang.NumberFormatException
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:57)
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
	at java.lang.reflect.Constructor.newInstance(Constructor.java:526)
	at org.jboss.byteman.rule.expression.ThrowExpression.interpret(ThrowExpression.java:231)
	at org.jboss.byteman.rule.Action.interpret(Action.java:144)
	at org.jboss.byteman.rule.helper.InterpretedHelper.fire(InterpretedHelper.java:169)
	at org.jboss.byteman.rule.helper.InterpretedHelper.execute0(InterpretedHelper.java:137)
	at org.jboss.byteman.rule.helper.InterpretedHelper.execute(InterpretedHelper.java:100)
	at org.jboss.byteman.rule.Rule.execute(Rule.java:684)
	at org.jboss.byteman.rule.Rule.execute(Rule.java:653)
	at BytemanTest.main(BytemanTest.java:16)

特定メソッドの結果を入れ替える

特定のメソッドのリターンを入れ替える、なんていう黒い使い方もできます。
やり方としてはメソッドの起動時(AT ENTRY)にBytemanスクリプトを呼び出して、
Bytemanスクリプト側で先にreturnを返しちゃうような使い方ですね。

Bytemanスクリプト
# 特定メソッド(BytemanTest.getYYYYMMDD()のリターン入替)
RULE trace exchange getYYYYMMDD
CLASS BytemanTest
METHOD getYYYYMMDD
AT ENTRY
BIND NOTHING
IF true
DO traceln("□Byteman: BytemanTest.getYYYYMMDD()のリターンを置き換える"); return "20130101"
ENDRULE

上記のスクリプトを使うとあら不思議、getYYYYMMDD()からは固定で"20130101"が返ってくるようになります。

実行結果
■Bytemanの実行テスト開始
■Integer.parseInt("0"): 0
■main内での結果: 20140318
□Byteman: BytemanTest.getYYYYMMDD()のリターンを置き換える
■getYYYYMMDD()の結果: 20130101
■Bytemanの実行テスト終了

本当はnew Date()の内容をシステム日付以外で置き換えたかったんですが、なぜかうまく行かず・・・。

総評

  • トレースログを後から差し込めるのは便利
  • 例外も好きな時に出せるのでモジュールの単体やモジュール間結合試験の際にもどうぞ。JUnitからも@BM系アノテーションで使えます。
  • 特定のメソッド差し替えなんかは特定条件の試験を再現するのに便利

throwsをちゃんと書いてない、とか「全部グローバル変数!」みたいなJavaの基本的な設計に則ってない設計で作られたアプリではうまく使えないと思いますが、

そんなもんは自業自得じゃ!