Javaスタックトレースから呼び出し元を取得する



「Javaのスタックトレースから呼び出し元を取得する」というお題のウェブページはいくらでもあるのだが、スタックトレース全体を得たとしても必要となるのは一行だけなので、それを取得するライブラリを作成した。

テストとその結果

このライブラリのテストは以下

package sample;

public class CallerStackTest {

  public static void main(String[]args) {
    new Foo().test();
    new Bar().test();
  }

  public static class Sample {
    CallerStack callerStack = new CallerStack(Sample.class);
    public void sample() {
      System.out.println("sample called from " + callerStack.get());
    }
  }

  public static class Foo {
    void test() {
      new Sample().sample();
    }
  }

  public static class Bar {
    void test() {
      new Sample().sample();
    }
  }
}

結果は以下で、必要な部分しか表示されない。

sample called from sample.CallerStackTest$Foo.test(CallerStackTest.java:19)
sample called from sample.CallerStackTest$Bar.test(CallerStackTest.java:25)

ライブラリコード

このライブラリのコードは以下

package sample;

import java.io.*;
import java.util.*;
import java.util.regex.*;
import java.util.stream.*;

/**
 * あるメソッドが呼び出された場合の、その呼出元のスタックを文字列として取得する。
 * <p>
 * countは何行分を取得するかの指定、デフォルトは1
 * </p>
 * <p>
 * これは、あくまでも無視クラス(ignoreClasses)の外部から呼び出されたときのスタックを取得するもので、ignoreClasses内部での呼び出しは
 * 解析しない。逆に無視クラスは複数指定できる。この理由は以下。
 * </p>
 * <ul>
 * <li>同じクラス内でのメソッドを区別するのが難しい。例えば、スタックトレース文字列の上では、メソッドオーバロードを区別できず、やるとすればソースコード行で
 * 区別するしかない。
 * <li>一群のクラスの外から呼び出された場合を追跡したい。例えば、複数のクラスから構成されるライブラリがあったとし、それらのクラス群の外からの呼び出しを
 * 検出したい。
 * </ul>
 * @author ysugimura
 */
public class CallerStack {

  /** 無視するクラス名 */
  private final List<String>ignoreClassNames;

  /** 取得するスタックトレース行数 */
  private int count = 1;

  /** 無視するクラスを指定する。 */
  public CallerStack(Class<?>...ignoreClasses) {
    ignoreClassNames = Arrays.stream(ignoreClasses).map(c->c.getName()).collect(Collectors.toList());
    ignoreClassNames.add(CallerStack.class.getName());
  }

  /** 無視するクラスを指定する。スタック取得行数を指定する(デフォルトは1) */
  public CallerStack(int count, Class<?>...ignoreClasses) {
    this(ignoreClasses);
    this.count = count;
  }

  /** 呼び出しスタックを取得する */
  public String get() {
    // スタックトレースを文字列として取得し、改行で分割し、空行、"at"のみの行を捨てる
    List<String>lines = 
        Arrays.stream(getStackTrace().split("[ \t\n]"))
          .map(s->s.trim()).filter(s->s.length() > 0 && !s.equals("at"))
          .collect(Collectors.toList());

    // 例外名称を捨てる
    lines.remove(0);

    // 無視するクラスを捨てる
    lines = lines.stream().filter(line->!matchesIgnore(line)).collect(Collectors.toList());

    // 最初のcount個の行に限定し、それらを改行コードで接続する。
    return lines.stream().limit(count).collect(Collectors.joining("\n"));
  }

  /** クラス名以降のドットから開始するメソッド名称以下のパターン */
  private static Pattern METHOD_NAME = Pattern.compile("^\\.[^\\(]+\\(.+$");

  /** 指定された無視クラスに一致するか */
  private boolean matchesIgnore(String line) {
    for (String className: ignoreClassNames) {
      if (!line.startsWith(className)) continue;
      String methodName = line.substring(className.length());
      if (!METHOD_NAME.matcher(methodName).matches()) continue;
      return true;
    }
    return false;
  }

  /** スタックトレースを取得 */
  private String getStackTrace() {
    Exception e = new RuntimeException();
    StringWriter s = new StringWriter();
    PrintWriter p = new PrintWriter(s);
    new RuntimeException().printStackTrace(p);
    p.flush();
    return s.toString();    
  }  
}

注意事項

コメントにもあるが、注意事項としては、あくまで無視対象クラス(この場合はSample)の外のクラス(Sampleのメソッドを呼び出しているクラス)のみが得られるということ。Sample内の他のメソッドからの呼び出しは追跡されない。

この理由は、スタックトレース文字列の仕様にある。例えば、スタックトレース文字列ではメソッドオーバロードによるメソッド名の違いがわからないため、どのメソッドからの呼び出しかは「ソース行」を元にして判断しなければならず、これはあまりに面倒なのでパスした。

もう一つは、複数のクラスから構成されるある種のライブラリの外からの呼び出しを検出したいということ。このために、無視クラスを複数指定できるようにしてある。