Javaの例外のスタックトレースを操作する(しない)
Javaで、例外を作成するメソッドを作りたくなることが数年に1回くらいある。で、大抵はそんなこと実はやらないほうがよくて、やらないんですけど、まあやることになったとします。やるなよ。
そんでこんなメソッドを作る。
private Exception makeException() { return new Exception(); }
まあ本来はメソッドで切り分けようというくらいだから、例外のメッセージを作るのが面倒くさいとか、単純にnewで作って済まないとか、いろんな事情があるとは思うんですけど省略。
このメソッドを、どこか別の機能から呼び出して使う。
public void testFunction() { // 何か処理 throw makeException(); } private static Exception makeException() { return new Exception(); }
こんな風にやった時、スタックトレースの一番上はtestFunctionになるのかmakeExceptionになるのか。
つまりスタックトレースってThrowableオブジェクトがnewされた時に作られるのか、throwされた時に突っ込まれるのか。そういえば知らない。
一応、Throwable#setStackTraceというメソッドがあるので、どちらも可能といえば可能。
throwされた時に突っ込まれるなら例外の発生場所を正確に把握することができるんだけど、でもcatchした例外を再度throwしてもスタックトレースは変わったりしないので、実は答えは「newされた時」しかない。
ただ、newされた時にスタックトレースが作られると、makeExceptionみたいなメソッドを作った時に、スタックトレースのトップがmakeExceptionになってしまう。
いや別にmakeExceptionが一番上でもその次の要素を見ればいいじゃんという話もあるんだけど、ライブラリをいくつか経由してたりするとcauseのcauseのcauseとかになってたりして、下の方に行くほど重要な筈なのに何故か下に行くほど省略されていて、「え、スタックトレース4つしか表示されないの、これほとんどフレームワークの中じゃん」みたいになる。だからスタックトレースはできるだけ短くしておきたい。
ということで、makeExceptionみたいなメソッドを作ろうとするとこういう感じになるわけです。
private static Exception makeException() { Exception e = new Exception(); StackTraceElement[] stackTrace = e.getStackTrace(); e.setStackTrace(Arrays.copyOfRange(stackTrace, 1, stackTrace.length)); return e; }
例外をnewした後にスタックトレースの一番上を取り除いて返す。これで例外の発生源はmakeExceptionの中ではなく、少なくともmakeExceptionを呼び出した場所になります。
実際にはRuntimeExceptionを投げたくなったりとか色々あるのでGenericsを使って定義すると思うんですが、それはまあいい。
途中でスタックトレースのサイズが1以上であることを決め打ちしたコードがあるけど、Throwableオブジェクトはnewした時点で絶対にスタックトレースは1以上になるので、まあいいじゃないですかこれくらい。length<=0でIllegalStateExceptionやErrorを投げるとかでもいいんですが。
で、大抵はこれを使ってコードを書いている途中とかで、このメソッドが不要であることに気付いたり、余計に話をややこしくしている事に気付いたりして、もうちょっと上のレベルから設計を見直す事になります。
気付かなかった事にして続けます。
Exceptionの基底クラスのThrowableのコンストラクタの実装はこんな感じになっていて、
public class Throwable implements Serializable { //(略) private static final StackTraceElement[] UNASSIGNED_STACK = new StackTraceElement[0]; //(略) public Throwable() { fillInStackTrace(); } //(略) public synchronized Throwable fillInStackTrace() { if (stackTrace != null || backtrace != null /* Out of protocol state */ ) { fillInStackTrace(0); stackTrace = UNASSIGNED_STACK; } return this; } private native Throwable fillInStackTrace(int dummy); //(略) }
まあnativeの先までは見てないんですけど、dummyって何だよという。それはどうでもいいんですが、ここでおそらくスタックトレースを作っていて、いかにもスタックトレースが格納されていそうなstackTraceにはUNASSIGNED_STACKを入れている。このUNASSINED_STACKという定数は空配列なので、なんだこりゃということになる。
getStackTraceの実装を見てみる。
public StackTraceElement[] getStackTrace() { return getOurStackTrace().clone(); } private synchronized StackTraceElement[] getOurStackTrace() { // Initialize stack trace field with information from // backtrace if this is the first call to this method if (stackTrace == UNASSIGNED_STACK || (stackTrace == null && backtrace != null) /* Out of protocol state */) { int depth = getStackTraceDepth(); stackTrace = new StackTraceElement[depth]; for (int i=0; i < depth; i++) stackTrace[i] = getStackTraceElement(i); } else if (stackTrace == null) { return UNASSIGNED_STACK; } return stackTrace; } //(略) native int getStackTraceDepth(); //(略) native StackTraceElement getStackTraceElement(int index);
getOurStackTraceを読んでいて、実際のスタックトレース取得処理はgetOurStackTraceがやっている。ちなみにprintStackTraceもgetOurStackTraceを呼んでいる。
で、stackTraceが空じゃなければstackTraceを返して、空だった場合にはまたgetStackTraceDepthやgetStackTraceElementというnativeメソッドでどこからか取ってきている。
なんでfillInStackTraceと同時にこの辺やらないんだろうと思ったけど、まあJNIの仕様的に配列をそのまま取得はできないし、コンストラクタで必ずfillInStackTraceが呼ばれるから内部状態エラーが起きる事もないし、ややこしいけどこれでいいんだろう。
とにかく、これでめでたくスタックトレースはJavaコードの中に出現することになりました。
スタックトレースの構築方法がなんとなく想像ついたところで、よく見るとfillInStackはpublicメソッドなので、外から呼べる事に気づきます。というかjavadocに書いてあります。
なので、なんか上の方で書いたmakeExceptionの中での怪しいスタックトレース操作なんてやらなくても、
Exception e = makeException();
e.fillInStackTrace();
throw e;
とやれば、fillInStackTraceを呼んだ行が一番上に来るようなスタックトレースが構築されます。ただし、これだとfillInStackTraceを2回呼び出すことになるので、それが少々気になる。結構重いし。
いや、そもそもこんなことやらなくていいんですけど。
設計を見直そう。
ところで、fillInStackTraceはfinalにはなっていないので、オーバーライドしてしまえばExceptionをnewしてもスタックトレースが作られなくなって、その分newがかなり軽くなります。
スタックトレースが必要無い時に便利なテクニックです。
Play FrameworkのHTTPレスポンスを返す時に使うRedirectやResultの基底クラスのFastRuntimeExceptionクラスは、こんな風にfillInStackTraceを潰してありますね。
play1/framework/src/play/utils/FastRuntimeException.java at master · playframework/play1 · GitHub
かしこい。みんなもソース読もう。
しかしこういう事をすると、前半でmakeExceptionの中でスタックのトップを消す処理をしていましたが、ああいうところでIndexOutOfBoundExceptionが出てしまうようになります。
なのでスタックトレースが0やnullになることは無いなんて甘い予測を立てたりせずに、真面目にスタックトレースの深さをチェックしておいた方が良いでしょう。プログラミングって難しい。
まあでもこの場合は、さすがにスタックトレースがnullになることはありませんよね。たぶん。いや、どうだろう。プログラミングって難しい。