読者です 読者をやめる 読者になる 読者になる

バイトコード操作でロギング処理を追加する

はじめに

前回はただフィールドを追加するだけのコードを書きました。 なんのひねりもありません。 progret.hatenadiary.com

そして今回はメソッドの最初と最後にロギング処理を追加します。 今回は簡単化のため、標準出力に出力します。

今回の記事ではJavassistとByte Buddyを使ってロギング処理を追加してみたいと思います。 (cglibは心が折れた内部APIがすごい見えるので今回は取り扱いません)

今回は以下のクラスに対してメソッドの最初と最後にロギング処理を追加します。

  public class Some {
    public void someMethod() {
      System.out.println("some implementation");
    }
  }

まずはJavassistから

まずはJavassistで追加してみたいと思います。

  @Test
  public void testAddLogMethodWithJavassist() throws CannotCompileException, IOException, NotFoundException {
    ClassPool pool = ClassPool.getDefault();
    CtClass clazz = pool.get(Some.class.getName());
    for (CtMethod method : clazz.getMethods()) {
      if (method.getDeclaringClass()
        .equals(clazz) && !Modifier.isNative(method.getModifiers()) && !Modifier.isAbstract(method.getModifiers())) {
        method.insertBefore("System.out.println(\"" + method.getName() + ":start\");");
        method.insertAfter("System.out.println(\"" + method.getName() + ":end\");");
      }
    }
    byte[] bytes = clazz.toBytecode();
  }

JavassitのAPIでは、Javaのソース文字列ベースで操作が可能です。 今回はCtMethodのinsertBeforeとinsertAfterというメソッドを使って メソッドの前後に処理を追加しています。

ダブルクォートのエスケープが必要で辛い感じが少しありますが 大した点ではないので置いておきます。

Javassistにはいくつかの制約がありますが $から始まる特殊な変数名により、あらかじめ定められた値の参照をすることができます。(参考URL) ローカル変数は参照できません。

ところで、javassistや標準のリフレクションAPIにあるModifierクラスにisSyntheticがないのはなんででしょう・・・ 欲しいですね。

Byte Buddyで書いてみる

今度はByte Buddyで書いてみます。

  @Test
  public void testAddLogMethodWithByteBuddy() throws IOException {
    byte[] bytes = new ByteBuddy().rebase(Some.class)
      .visit(Advice.to(ExampleAdvice.class)
        .on(ElementMatchers.isMethod()
          .and(isDeclaredBy(Some.class))
          .and(not(isAbstract()))
          .and(not(isNative()))))
      .make()
      .getBytes();
  }

  public static class ExampleAdvice {
    @OnMethodEnter
    public static void enter(@Origin Method method) {
      System.out.println(method.getName() + ":start");
    }

    @OnMethodExit
    public static void exit(@Origin Method method) {
      System.out.println(method.getName() + ":end");
    }
  }

Byte BuddyはJavassistと違い、アノテーションを使った独自のAPIによって バイトコードの操作が可能です。

今回の例ではOnMethodEnterとOnMethodExitアノテーションを使ってメソッドの前後に処理を追加しています。 ちなみに、この例ではExampleAdviceクラスのstaticメソッドが実際に呼ばれるわけではなく インライン化されて呼ばれます。

また、アノテーションのOnMethodEnter/Exitのinlineプロパティをfalseにすることでインライン化しないことも可能です。

では、これで生成したバイトコードはどうなるでしょうか?

public class Some {
  public void someMethod() {
    System.out.println(Some.class.getDeclaredMethod("someMethod", new Class[0]).getName() + ":start");
    System.out.println("some implementation");
    System.out.println(Some.class.getDeclaredMethod("someMethod", new Class[0]).getName() + ":end");
  }
}

こんな感じになります。(一部簡略化しています。) methodを毎回取得する形になってしまいます。 これではいけません。余計な処理が走ってしまいます。 インライン化しなければ、解決することも可能かもしれませんが置いておきます。

では、これをRewriteしてみます。

Byte BuddyにはCustom Mapping APIというものがあり 自分で定義したアノテーションが付けられたadviceの引数に対して、独自のマッピング処理を追加することが可能です。 今回はメソッド名の取得がしたいだけなので 動的ではなく静的に実行が可能なはずです。

というわけで、MethodNameアノテーションを用意してみます。

  @Retention(RetentionPolicy.RUNTIME)
  @Target(ElementType.PARAMETER)
  public static @interface MethodName {
  }

ここで、@Retention(RetentionPolicy.RUNTIME)を付与していることに注意してください。 筆者はここでハマりました。

デフォルトでは、メソッドの引数をマッピングしようとするので 例外が発生してしまいます。

では、MethodNameアノテーションを解決する、DynamicValueというインターフェースを実装したクラスを用意します。

  class MethodNameBinder implements DynamicValue<MethodName> {
    @Override
    public StackManipulation resolve(TypeDescription instrumentedType, MethodDescription instrumentedMethod, InDefinedShape target,
      Loadable<MethodName> annotation, Assigner assigner, boolean initialized) {
      if (target.getType()
        .asErasure()
        .isAssignableFrom(String.class)) {
        String name = instrumentedMethod.getName();
        return new TextConstant(name);
      }
      throw new IllegalStateException("not assignable type");
    }
  }

アノテーションが付与された引数の型がStringをAssignできるなら バイトコード上の定数としてメソッド名を返却します。

では、今回のCustom Mappingに合わせてExampleAdviceのクラスとテストコードに変更を加えます。

  @Test
  public void testAddLogMethodWithByteBuddy() throws IOException {
    byte[] bytes = new ByteBuddy().rebase(Some.class)
      .visit(Advice.withCustomMapping()
        .bind(MethodName.class, new MethodNameBinder())
        .to(ExampleAdvice.class)
        .on(ElementMatchers.isMethod()
          .and(isDeclaredBy(Some.class))
          .and(not(isAbstract()))
          .and(not(isNative()))))
      .make()
      .getBytes();
  }

  public static class ExampleAdvice {
    @OnMethodEnter
    public static void enter(@MethodName String methodName) {
      System.out.println(methodName + ":start");
    }

    @OnMethodExit
    public static void exit(@MethodName String methodName) {
      System.out.println(methodName + ":end");
    }
  }

こんな感じになりました。

実行してみると、Javassistと似たような形でバイトコードが出力されるでしょう。

まとめ

今回はバイトコード操作でロギング処理を追加してみました。

JavassitではJavaのソース文字列ベースでバイトコード操作が可能なのが特徴です。 これはASMで書く時よりもバイトコードに関する知識をあまりなくても非常に楽に取り扱うことができます。 (時と場合によりますが。)

一方、Byte Buddyでは、実際にソースからコンパイルしたバイトコードをベースにバイトコード操作が可能なのが特徴です。 これはJavassistに比べて優秀かというと、コンパイルの検証がコンパイル時になる、という点ではないでしょうか? 実行時に例外が減るのはいいことです。 (Advice次第ではありますが。)

しかし、Byte Buddyでは動的に解決した値に関してインライン化の制限が出てしまいます。 これに対しては、本来静的に解決可能な値であるなら、自分でカスタムした解決処理を追加することで回避できます。 非常に面白いライブラリですね。

どちらも面白いライブラリではありますが Javassistではバイトコードに近い内容の解析も可能なので 使う場面によってライブラリを選ぶべきかとは思います。

次は何書こうかなぁ。

バイトコード操作でフィールドの追加をする

バイトコード操作を使って 本記事では、フィールドを追加してみます。

対象クラスをAfterのような形に操作してみます。 (テストではstaticな内部クラス)

// Before
class Some{}

// After
class Some{
  private String foo;
}

Javassistでフィールドを追加する。

まずはJavassistで追加してみます。 手続きチックですが、特に難しい内容ではないかと思います。

  @Test
  public void testAddFieldWithJavassist() throws NotFoundException, CannotCompileException, IOException {
    ClassPool pool = ClassPool.getDefault();
    CtClass clazz = pool.get(Some.class.getName());
    CtClass string = pool.get("java.lang.String");
    CtField field = new CtField(string, "foo", clazz);
    field.setModifiers(Modifier.PRIVATE);
    clazz.addField(field);
    byte[] bytes = clazz.toBytecode();
  }

cglibでフィールドを追加する。

では次にcglibでフィールドを追加してみましょう。 ドキュメントを見たところ 以下のコードがサンプルとして書かれています。

  e.setStrategy(new DefaultGeneratorStrategy() {
    protected ClassGenerator transform(ClassGenerator cg) {
      return new TransformingGenerator(cg,
        new AddPropertyTransformer(new String[]{ "foo" },
                                   new Class[]{ Integer.TYPE }));
    }});

早速ですが、このコードは動きません。 というか、動きも想定していたのと違います。

動かないのは置いといて想定していた動きと違う、というのは若干いちゃもんなのですが 少し説明します。 上記コードが動作すると、$cglib_props_fooという名前でフィールドがprivateで定義された上で getFoo, setFooといった、Java Beansを意識した「プロパティ」という概念の追加のようです。

それでは、こちらがcglibで書いたソースです。

  @Test
  public void testAddFieldWithcglib() throws IOException, Exception {
    GeneratorStrategy generator = new DefaultGeneratorStrategy() {
      @Override
      protected ClassGenerator transform(ClassGenerator cg) throws Exception {
        return new TransformingClassGenerator(cg, new ClassEmitterTransformer() {
          @Override
          public void end_class() {
            if (!TypeUtils.isAbstract(getAccess())) {
              this.declare_field(Constants.ACC_PRIVATE, "foo", Type.getType(String.class), null);
            }
            super.end_class();
          };
        });
      }
    };
    byte[] bytes = generator.generate(new ClassReaderGenerator(new ClassReader(Some.class.getName()), ClassReader.EXPAND_FRAMES));
    Dump.dump(bytes);
  }

サンプルコードとして記述したので今回は無名クラスでDefaultGeneratorStrategyを拡張する形で記述しました。 また、AddPropertyTransformerの親クラスである、ClassEmitterTransformerを拡張する形で フィールドの追加処理をend_classメソッドをオーバーライドして記述しました。

いくつかASMのAPIが顔を覗かせています。 また、なぜかsnake_caseのメソッドがいます。私、気になります!

なんかこう、もう少しやりようがあったのかもしれないですが 内部APIをそこまで覗く気になれなかったので置いておきます。

cglibにはEnhancerというクラスがいたりするのですが こいつではクラスの生成からインスタンスの生成までを隠蔽する形になっていたので 今回は利用できませんでした。バイトコードの生成がしたかったので。

ClassLoaderのdefineClassのメソッドで生成したバイト列を取得するようなものを作れば 取得ができるかと思います。 あるいはAttach API(参考)を使えば、バイトコードを取得できるかと思いますが いずれにしてもcglibでバイトコードを取得するのはオーバーエンジニアリングですね(※)。

※この記事を最後まで書いていて思いましたが Enhancerが生成するバイト列をどこかのメソッドをオーバーライドすれば取得できるかもしれません。

Byte Buddyでフィールドを追加する

Byte Buddyでフィールドを追加してみます。 楽ちんです。

メソッドチェインで書けて気持ちいいですね。

  @Test
  public void testAddFieldWithByteBuddy() throws IOException {
    byte[] bytes = new ByteBuddy().rebase(Some.class)
      .defineField("foo", String.class, Visibility.PRIVATE)
      .make()
      .getBytes();
    Dump.dump(bytes);
  }

まとめ

JavassistやByte Buddyではバイトコードの操作が楽な印象を受けます。 というか対象とする範囲がcglibとは違うんでしょうね。

また、cglibやByte BuddyではASMのラッパーとして書かれていますが JavassistではASMに依存してません。強い。

今回は簡単なサンプルでしたが 次はメソッドの追加でもやってみることにします。

ラムダ式、完全マスターした。

今日はここのところずっと調べていたラムダ式、およびメソッド参照について調べていましたので
それをこの記事でまとめたいと思います。

まずはじめに。

ここに、テストコードを含む、ラムダ式およびその他のコードをあわせた、3種類のコードを用意した。
もちろん、ラムダ式(というかメソッド参照)に加えて、staticなinner classとinner classを利用したコードである。

public class LambdaTest {  
  private static void privateMethod(String str) {  
  
  }  
  
  @Test  
  public void test1() {  
    System.setProperty("jdk.internal.lambda.dumpProxyClasses", ".");  
    Consumer<String> consumer = LambdaTest::privateMethod;  
    consumer.accept("lambda");  
  }  
  
  private static class StaticNestedClass implements Consumer<String> {  
    @Override  
    public void accept(String str) {  
      privateMethod(str);  
    }  
  }  
  
  @Test  
  public void test2() {  
    new StaticNestedClass().accept("staticNestedClass");  
  }  
  
  private class InnerClass implements Consumer<String> {  
    @Override  
    public void accept(String str) {  
      privateMethod(str);  
    }  
  }  
  
  @Test  
  public void test3() {  
    new InnerClass().accept("innerClass");  
  }  
}  

今回見るのはこのクラスをコンパイルしたときに吐き出される、バイトコードが中心となる。

ラムダ式・メソッド参照とは

まずはじめに、Java8から、ラムダ式およびメソッド参照の機能が追加された。
この機能はどういう機能かというと
関数型インターフェースをラムダ式と呼ばれる記法で定義することができたり
インスタンスやクラスのメソッドを関数型インターフェースとして取り扱いやすくするための機能である。

初めに見せたコード中に存在する、以下のコードはメソッド参照を利用して
クラス・メソッドを関数型インターフェースに変換している(うまい言い方が思い浮かばないが、要は委譲である)。

    Consumer<String> consumer = LambdaTest::privateMethod;  

これをラムダ式で書くと以下のようになる。
JavaScriptだとarrow functionは=>になるので、よく間違える。

    Consumer<String> consumer = (str) -> privateMethod(str);  
    // 次のような形でも記述可能  
    //Consumer<String> consumer = (String str) -> privateMethod(str);  

ラムダ式・メソッド参照の簡単な概略については以上となる。

内部クラスとは何が違うのか

では、今回用意した3種類のコードは何が違うのか、というと。
javapしてみます。
まずは、InnerClassから

$ > javap -v LambdaTest$InnerClass.class  
...省略  
 public void accept(java.lang.String);  
  descriptor: (Ljava/lang/String;)V  
  flags: ACC_PUBLIC  
  Code:  
    stack=1, locals=2, args_size=2  
       0: aload_1  
       1: invokestatic  #23                 // Method com/github/wreulicke/lambda/LambdaTest.access$0:(Ljava/lang/String;)V  
       4: return  
    LineNumberTable:  
      line 42: 0  
      line 43: 4  
    LocalVariableTable:  
      Start  Length  Slot  Name   Signature  
          0       5     0  this   Lcom/github/wreulicke/lambda/LambdaTest$InnerClass;  
          0       5     1     t   Ljava/lang/String;  
...省略  

いくつか合成メソッドが生成されているが、一旦置いておく。
今回ソースで実装したacceptメソッドの記述を見ると
invokestaticでcom/github/wreulicke/lambda/LambdaTest.access$0:(Ljava/lang/String;)Vといったシグネチャのメソッドを呼び出している。

ソース上ではこんなものは定義していない。

では、LambdaTestのjavapをしてみよう。

$ > javap -v LambdaTest$InnerClass.class  
...省略  
  static void access$0(java.lang.String);  
    descriptor: (Ljava/lang/String;)V  
    flags: ACC_STATIC, ACC_SYNTHETIC  
    Code:  
      stack=1, locals=1, args_size=1  
         0: aload_0  
         1: invokestatic  #66                 // Method privateMethod:(Ljava/lang/String;)V  
         4: return  
      LineNumberTable:  
        line 8: 0  
      LocalVariableTable:  
        Start  Length  Slot  Name   Signature  
...省略  

コンパイラにより、バイトコード内に合成メソッドが定義されている。これを呼び出しているようだ。
中の処理はinvokestaticで実装で記述したprivateMethodの呼び出しをしているだけである。

じゃあStaticNestedなクラスでは何を呼び出しているかというと、一緒の合成メソッドを呼び出してあった。
なので飛ばす。

ではお待ちかねのメソッド参照で記述されたバイトコードを見てみよう。
ちなみに、ラムダ式を記述したときは、クラスファイルは生成されない。なので、ダンプした。
バイトコードのダンプの仕方は一番下にある。というかソースに書いてる。

...省略  
$ > javap -v LambdaTest$$Lambda$1.class  
  public void accept(java.lang.Object);  
    descriptor: (Ljava/lang/Object;)V  
    flags: ACC_PUBLIC  
    Code:  
      stack=1, locals=2, args_size=2  
         0: aload_1  
         1: checkcast     #14                 // class java/lang/String  
         4: invokestatic  #20                 // Method com/github/wreulicke/lambda/LambdaTest.privateMethod:(Ljava/lang/String;)V  
         7: return  
...省略  

おや?さっきと違ったコードが吐き出されている。
invokestaticで合成メソッド経由ではなく直接privateメソッドを呼び出している。

この点が内部クラスprivateMethodを呼び出した時に違う点だ。(他のアクセス修飾子の場合で網羅的に調べてない点についてはごめんなさい。)

自分はこの時にバイトコードにはアクセス修飾子は関係ないのかと勘違いしてしまった。
ちなみに、ASMでラムダ式が吐くようなバイトコードを吐いたところ、IllegalAccessErrorが発生したので
ちゃんとアクセス修飾子による制限は存在するようだ。

ラムダ式・メソッド参照で記述されたインスタンスはどこからやってくるのか

これについては既存の資料に大抵記述されている。

ラムダと invokedynamic の蜜月
Java8におけるindyとLambdaの絶妙な関係、もしくはSAMタイプを継承する内部クラスの.classファイルはどこへ行ったの? - uehaj's blog
Lambda が indy に出会ったら ... Java etc.../ウェブリブログ

invokedynamicとbootstrapメソッドに関して

バイトコードにinvokedynamic命令というものが存在しており
これはbootstrapメソッドと呼ばれるバイトコード内の情報を元に動的に呼び出し先を変更することができる機能である。
javapしてみると確認できる。
BootstrapMethodsという属性が存在しており
それに対して呼び出すメソッドとメソッドに渡される引数がバイトコード上に記述されている。

$ > javap -v LambdaTest.class  
...省略  
BootstrapMethods:  
  0: #77 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/Metho  
Type;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;  
    Method arguments:  
      #78 (Ljava/lang/Object;)V  
      #79 invokestatic com/github/wreulicke/lambda/LambdaTest.privateMethod:(Ljava/lang/String;)V  
      #80 (Ljava/lang/String;)V  
...省略  

LambdaMetafactory.metafactoryメソッドが呼び出される
第一引数のMethodHandles$LookupオブジェクトはJVMによって動的に解決される?。(詳しくは知らない。)

この時metafactoryメソッドによって生成されたCallSiteをJVMが実行して
ラムダ式で実装すべき型を実装したインスタンスが返却される。
今回のコードではConsumerである。

オマケ:ラムダ式を記述したクラスにInnerClassとしてLookupオブジェクトが定義される。

タイトル通りではあるが、ラムダ式を記述した際、JavaバイトコードにはInnerClasses属性が追記されている。アクセス権限を付与するためであろうか?

$ > javap -v LambdaTest.class  
...省略  
InnerClasses:  
     public static final #88= #84 of #86; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles  
...省略  

ラムダ式のクラスがロードされるまでのおおまかな流れ

大まかな流れとしては以下のようになる。
1. invokedynamicでLambdaMetafactory#metafactoryメソッドが呼ばれる
2. ラムダ式のクラスが生成される
3. CallSiteオブジェクトが返却される
4. 返却されたCallSiteオブジェクトがinvokedynamicの流れで実行される
5. ラムダ式で実装されたインスタンスが生成される。

どうやってクラスが生成されているか。

これは他のブログの方が書いてはいるが、ここにまとめておく。

ラムダ式が実際にどうやってロードされるのかを見てみよう。
invokedynamicで呼び出されたbootstrapメソッド内で
InnerClassLambdaMetafactoryインスタンス化された後、
細かいところを省略すると(というか読んでない)
buildCallSiteメソッドを呼び出し、CallSiteオブジェクトを返却している。

    public static CallSite metafactory(MethodHandles.Lookup caller,  
                                       String invokedName,  
                                       MethodType invokedType,  
                                       MethodType samMethodType,  
                                       MethodHandle implMethod,  
                                       MethodType instantiatedMethodType)  
            throws LambdaConversionException {  
        AbstractValidatingLambdaMetafactory mf;  
        mf = new InnerClassLambdaMetafactory(caller, invokedType,  
                                             invokedName, samMethodType,  
                                             implMethod, instantiatedMethodType,  
                                             false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);  
        mf.validateMetafactoryArgs();  
        return mf.buildCallSite();  
    }  
  

では、buildCallSiteメソッドでは何をやっているかというと
spinInnerClassを呼ぶと、ラムダ式のクラスが生成されて返却されている。

    @Override  
    CallSite buildCallSite() throws LambdaConversionException {  
        final Class<?> innerClass = spinInnerClass();  
        ...省略  
    }  

spinInnerClassでは何をやっているかというと
JDK内部に持っているASM(バイトコード操作のライブラリ)を用いて、ラムダ式で実装するべきクラスのバイトコードを生成している

また、ラムダ式をダンプした時から気になっていた、
内部クラスを記述したときの合成メソッドを呼び出すのではなく
内部クラスでもないのにinvokestaticでprivateメソッドを直接読んでいる件に関しては
spinInnerClassの最後の行にある
このメソッドによって内部クラスになっているのであろう。

        return UNSAFE.defineAnonymousClass(targetClass, classBytes, null);  

オマケ:ラムダ式で生成されたバイトコードをダンプするには?

ラムダ式が生成するクラスはシステムプロパティのキー「jdk.internal.lambda.dumpProxyClasses」に
ダンプ先を指定することでダンプすることが可能である。

ちなみに、このプロパティの読み取りはInnerClassLambdaMetafactoryのstatic初期化子に記述されているので
InnerClassLambdaMetafactoryクラスが読み込まれる前にダンプ先を設定しておかなければならない。

まとめ

今回はラムダ式というかメソッド参照がどんなバイトコードを生成するのかについて調べた。
楽しい。

ラムダ式が分からない?そんなあなたに朗報!!
ダンプしてバイトコードを調べよう!!Javaプログラマならわかるよね!!

雑な煽りをして今日の記事は終わりです。