Byte BuddyのAdvice APIを触ってみる

はじめに

前回から色々と触ってきた Byte Buddyというライブラリですが、自分の頭の整理のために ここに簡単にまとめておく。

Byte Buddyはバイトコード操作用のライブラリがあり その中にAdviceというAPIが存在する。

このAPIでは、メソッドの先頭や最後に処理を追加することができ、 次の登場人物が出てくる。

  • Adviceのためのクラス
  • Adviceのためのstaticメソッド
  • staticメソッドに付与@OnMethodEnter, @OnMethodExit

コードを見てもらった方が早いと思うので 前後してしまうが、コードを見せる。 次のコードがAdviceのためのコードだ。

  public static class ForFieldAdvice {
    @OnMethodEnter
    public static void enter(@Advice.FieldValue("string") String field, @Advice.FieldValue("LOG") Logger logger) {
      logger.tracef("ForReturnAdvice:field 'string' is {0}", field);
    }
  }

上記のようなクラスを記述することで追加するバイトコードを記述することができる。 このAPIで追加されたバイトコードはデフォルトではインライン化される。 インライン化しない場合は、OnMethodEnter, OnMethodExitのinlineオプションをfalseにすることで可能である。

勘のいい読者には順番が前後してしまったかもしれないが ライブラリに入っているAdvice APIアノテーションについてのまとめを記述していく。

と、その前に

Byte BuddyでAdviceAPIを使うための簡単なコードを次に記述しておく。

  @Test
  public void testForField() throws IOException, InstantiationException, IllegalAccessException {
    byte[] bytes = new ByteBuddy().rebase(Example.class)
      .visit(Advice.to(ForFieldAdvice.class).on(ElementMatchers.isMethod()))
      .make()
      .getBytes();
  }

rebaseで変更したいクラスを指定し(これはクラスパスから読み込まれるはずである) Advice.toでAdviceのためのクラスを指定する。 その後、onメソッドを利用し、Adviceするメソッドを絞り込む。 メソッドのシグネチャによっては、利用できないアノテーションもあるので それらについてはJavadocの方を参照していただきたい。 (ただまぁ、想像は容易いのでそこまで混乱するわけではないかと思う)

クラスローダーに読み込みすることも可能だが 自分の用途上、byte列で吐き出したかったのでgetBytesメソッドを利用している。 この辺は、ドキュメントを見ていただきたい。(あんまりないが)

Advice.FieldValue

このアノテーションが付与された引数ではフィールド名を指定することで、フィールドの参照が可能である。 もちろん、staticメンバも参照できる。

先程も見た次のコードは、メソッドの先頭にstringというString型のフィールドをログに吐く処理を追加するためのAdviceクラスである。 (※ここで利用しているLoggerはorg.jboss.loggingのLoggerである)

ここ以降、出来る限り、どのようなコードに置き換えられるかはコメントで示していく。

  public static class ForFieldAdvice {
    @OnMethodEnter
    public static void enter(@Advice.FieldValue("string") String field, @Advice.FieldValue("LOG") Logger logger) {
      logger.tracef("ForReturnAdvice:field 'string' is {0}", field);
      // --> LOG.tracef("ForReturnAdvice:field 'string' is {0}", this.field);
    }
  }

対して面白いところはないので、せっせと次に行く。

Advice.This

このアノテーションではバイトコードの操作をしているクラスのインスタンスの取得が可能だ。 staticメソッドはもちろんだが、コンストラクタから呼び出されるようなメソッドでこのアノテーションを利用するのは避けられたし。

  public static class ForThisAdvice {
    @OnMethodEnter
    public static void enter(@This Object arg, @FieldValue("LOG") Logger logger) {
      logger.tracef("ForThisAdvice:this is {0}", arg);
      // --> LOG.tracef("ForThisAdvice:this is {0}", this);
    }
  }

Advice.Origin

このアノテーションでは元々のメソッドの参照が取得可能である。 このアノテーションで取得したメソッドの参照は、リフレクションに置き換えられるので注意されたし。

  public static class ForOriginAdvice {
    @OnMethodEnter
    public static void enter(@Origin Method original, @FieldValue("LOG") Logger logger) {
      logger.tracef("ForOriginAdvice:original method is {0}", original);
      // --> LOG.tracef("ForOriginAdvice:original method is {0}", Example.class.getDeclaredMethod("someMethod", /* 省略 */));
    }
  }

Advice.AllArguments, Advice.Argument

これらのアノテーションではメソッドの引数の取得が可能である。 AllArgumentsアノテーションで取得したメソッドの参照はメソッドの引数の配列に置き換えられることに注意。

  public static class ForAllArgumentsAdvice {
    @OnMethodEnter
    public static void enter(@AllArguments Object[] args, @FieldValue("LOG") Logger logger) {
      logger.tracef("ForAllArgumentsAdvice:arguments is {0}", args);
      // --> LOG.tracef("ForAllArgumentsAdvice:arguments is {0}", new Object[]{arg0, arg1, arg2, /*... 省略 .. */});
    }
  }

  public static class ForArgumentAdvice {
    @OnMethodEnter
    public static void enter(@Argument(0) Object arg, @FieldValue("LOG") Logger logger) {
      logger.tracef("ForArgumentAdvice:1st argument is {0}", arg);
      // --> LOG.tracef("ForArgumentAdvice:1st argument is {0}", arg0);
    }
  }

Advice.Return

このアノテーションではメソッドの戻り値の取得が可能だ。 このアノテーションはOnMethodExitのアノテーションがつけられたadviceのメソッドでのみ利用可能である。

  public static class ForReturnAdvice {
    @OnMethodExit
    public static void exit(@Return Object returns, @FieldValue("LOG") Logger logger) {
      logger.tracef("ForReturnAdvice:returninig value is {0}", returns);
      // -->
      //     Object result = "any";
      //     LOG.tracef("ForReturnAdvice:returninig value is {0}", result);
      //     return result;
    }
  }

Advice.Thrown

このアノテーションではメソッド内で発生した特定の例外に対して、任意の処理を追加することが可能だ。 またreadOnlyメンバをfalseにして、Thrownアノテーションが付けられたメソッドの引数をnullにすることで exceptionの握りつぶしをすることが可能である。

  public static class ForThrownAdvice {
    @OnMethodExit(onThrowable = IOException.class)
    public static void exit(@Thrown(readOnly = false) Throwable e, @FieldValue("LOG") Logger logger) {
      if (e != null)
        logger.tracef("ForThrownAdvice:1st this is {0}", e);
      if (e instanceof UncheckedIOException) {
        e = null;
      }
    }
  }

これは元のコードと変更後のコードが長くなるので 分けて示す。

  // 元々のメソッド
  public void forThrown() {
    throw new RuntimeException("test io exception");
  }

  // Adviceが追加されたメソッド
  public void forThrown() {
    try {
      throw new RuntimeException("test io exception");
    } catch (IOException ex) {
      if (ex != null) {
        LOG.tracef("ForThrownAdvice:1st this is {0}", (Object) ex);
      }
      if (ex instanceof UncheckedIOException) {
        ex = null;
      }
      if (ex != null) {
        throw ex;
      }
    }
  }

OnMethodExitで指定したクラスのcatch文が追加された後に ForThrownAdviceで追加したバイトコードが追加されている。

このThrownアノテーションとOnMethodExitのアノテーションの関係が非常にややこしいので 気が向いたら調べる。(たぶん調べない) (OnMethodExitにはsuppressedとかいうパラメータもある。。。ややこしい。。。)

Advice.Enter

これはOnMethodEnter, OnMethodExitで連携する機能である。 OnMethodEnterの戻り値がOnMethodExitで取得することが可能だ。

 public static class ForEnterAdvice {
    @OnMethodEnter
    public static String enter(@FieldValue("string") String string, @FieldValue("LOG") Logger logger, @FieldValue("any") String any) {
      if (string.equals("test")) {
        return string;
      }
      else if (string.equals(any)) {
        return any;
      }
      return "return value";
    }

    @OnMethodExit
    public static void exit(@Enter String enter, @FieldValue("LOG") Logger logger) {
      logger.tracef("OnMethodEnter returnd '{0}'", enter);
    }
  }

これもまた、差分が長くなるので使った例を次に示す。

  // 元々のメソッド
  public void forArgs(String string, int test) {}

  // Adviceで追加されたメソッド(整形している)
  public void forArgs(final String string, final int test) {
    LOG.tracef("OnMethodEnter returnd '{0}'", (Object) 
       (this.string.equals("test") ? this.string:
         (this.string.equals(this.any) ? this.any : "return value")
       )
    );
  }

Advice.Unused, StubValue

すまん、利用シーンがいまいち分からない。 デフォルトの値を渡してくれる。(numericなら0, referenceならnull) ので、解説しない。

まとめ

ここまででByte BuddyのAPIを見てきたが 他にもたくさんのAPIがあるので、興味があれば見てみてほしい。(ドキュメントはあんまりないが)

今回利用したアノテーションだけでなく、CustomMapping APIを利用して 独自処理でアノテーションを付与したパラメータの解決も可能である。 それは前回の記事で少し書いたので興味があれば見てほしい。

さて、色々あったが Byte BuddyはASMのラッパーとして書かれていて Byte BuddyのGithugにあるWikiによると 既にいくつかのプロジェクトで使われているようだ。 Spock、HibernateやMockitoなどが使っている模様。

Byte Buddyには他にもAgentBuilderや何やらLambda, HotSwapに関するAPIも存在しているので もう少し遊んでみることにする。

これは前回の記事でも書いたが Byte Buddyは何が魅力かというと、コンパイラで型検証したバイトコードを基にバイトコード操作ができるだろう。 これまでバイトコード操作のコードはあまり書いてこなかったが ASMやJavassistを使っていて辛かったのが、実行するまで分からない点が存在することである。

また、それはそれとしてByte Buddyで辛い点としてはstatic importを多用する。これにはいくつか思い出す作業が発生する。 これは辛い。人間はそこまで覚えていられない。

一方で、JavassistやASMで強い点というとバイトコードの情報を利用した柔軟なバイトコード操作が記述できることであるが これはしばしば、低レベルなAPIになりがちである。 筆者はJava8で追加されたTypeAnnotation属性に関するバイトコード操作を記述した際に体感した。 ASMはバイトコードと友達になった気分になれる。(JavassistにはTypeAnnotationのAPIは存在するがアノテーションの数しか取れない。)

というわけで、オチもなくこの記事を締めたいと思う。

次の記事は何を書こうかなぁ・・・。

Enjoy Java.