Springのproxyとfinalメソッド、それからnull
SpringのRepositoryとComponentアノテ、動きに違いがあるのか、、
— こざけ (@s_kozake) 2018年2月17日
Repositoryだとフィールドがnullになってハマった
ようやく🍥Repositoryでフィールドの値がnullになる原因が分かった、、
— こざけ (@s_kozake) 2018年2月19日
メソッドをfinal指定してました
サブクラスベースのProxyではメソッドやクラスにfinalをつけてはいけないとあれほど_:(´ཀ`」 ∠):
ここら辺、どこか詳しい資料ありますか?
— こざけ (@s_kozake) 2018年2月19日
CGLIBを用いてサブクラスベースのProxyを実現している状況で、メソッドをfinalにした場合、内部でどのような状況になってnullのインスタンスを見るようになったかという
まあ、そもそもfinal切るなって話なのですが
こざけさんがこんな感じでわちゃわちゃしていたので、詳しい資料はどこにあるか分からないけれど、なぜそうなったのか解説しようと思います。
なぜそうなるのか概ね理解していたけれど、理解していなかった点を調べる過程で私自身も学ぶことがあったので、正直こざけさんには調べるきっかけを作ってくれてありがとうございますという感じです!
@Repositoryとproxy
@Repository をクラスに付けると、そのクラスのproxyが作られます。
proxyてなんやねんって話ですが、これは実行時に生成される該当クラスのサブクラスで、他のコンポーネントにインジェクションされるのはこのproxyのインスタンスです。 実際のクラスのインスタンスへはproxyのインスタンスを経由して委譲される仕組みになっています。
このproxyの仕組みには、コンポーネントの利用者がスコープを意識しなくてよくなるという利点があります。
どういうことか、順を追って見ていきましょう。
proxyという概念が無いDIコンテナ
DIコンテナにはスコープというものがあります。
これはコンポーネントのライフサイクルを表すもので、Webアプリで動くDIコンテナであれば「リクエストスコープ」や「セッションスコープ」を持っています。 「リクエストスコープ」はその名の通りHTTPリクエストの開始から終了までの間に有効となるスコープです。 「セッションスコープ」もその名の通りでセッションを開始してから破棄されるまでの間に有効となるスコープです。
では「セッションスコープのコンポーネント」から「リクエストスコープのコンポーネント」を使用することを考えてみましょう。 コードで書くとこんな感じ。
//※これはSpringではない架空のDIコンテナのコード
@SessionScope
public class Hoge {
@Inject
private Fuga fuga;
public void action() {
fuga.process();
}
}
@RequestScope
public class Fuga {
//フィールドやメソッドの定義は省略
}
この Hoge と Fuga を使った処理の流れは、
- HTTPリクエストを受け取る
- セッションを作成する
- Hoge のインスタンスを作成する
- Fuga のインスタンスを作成する
- Hoge に Fuga をインジェクションする
- Hoge の action メソッドを実行する
- Fuga の process メソッドを実行する
- HTTPリクエストが終了する
- Fuga のインスタンスをDIコンテナから破棄する
- HTTPレスポンスを返す
という感じ。
あんまり問題なさそうに見えますが、 Fuga の process メソッドが実行中に同じユーザーからもう一つHTTPリクエストが来たらどうなるでしょうか?
- HTTPリクエストAを受け取る
- セッションを作成する
- Hoge のインスタンスを作成する
- Fuga のインスタンスを作成する(リクエストAのスコープ)
- Hoge に Fuga をインジェクションする
- Hoge の action メソッドを実行する
- Fuga の process メソッドを実行し始める
- HTTPリクエストBを受け取る
- Fuga のインスタンスを作成する(リクエストBのスコープ)
- Hoge に Fuga をインジェクションする……!?
とまあ、こんな感じで Hoge にインジェクションされる Fuga がバッティングするわけです。
そういうわけでproxyの無いDIコンテナでは、あるスコープのコンポーネントには、それよりも小さいスコープ(ライフサイクルが短いと言い換えても良さそう)のコンポーネントをインジェクションできませんでした。 proxyが無くてこのような制約のあるDIコンテナとしてはSeasar2が挙げられます。
proxyがあるDIコンテナ
先に述べた問題をproxyがどのように解決するのか見ていきましょう。
Fuga のproxyをコードで書くとこんな感じになります。
//実際には実行時にクラスファイルが生成されるのでソースコードは存在しない
//あくまでもproxyのイメージ
public class FugaProxy extends Fuga {
private Container container;
public void process() {
Fuga component = container.getComponent(Fuga.class);
component.process();
}
}
コード上のコメントにも書きましたが、proxyは実際には実行時にクラスファイル(というかインメモリ上にバイトコードのデータ)として生成されるのが普通なので、ソースコードはありません。 コード例は雰囲気です。
コードを見てみると process メソッドが呼ばれるとDIコンテナからproxyではない実際の Foo インスタンスが取り出され、そのインスタンスの process メソッドが呼ばれています。
先ほどのproxyが無いDIコンテナで Fuga がバッティングしてしまったシナリオを、proxyがあるDIコンテナではどうなるか見てみましょう。
- HTTPリクエストAを受け取る
- セッションを作成する
- Hoge のインスタンスを作成する
- Fuga のインスタンスを作成する(リクエストAのスコープ)
- Fuga のproxyを作成する
- Hoge に Fuga のproxyをインジェクションする
- Hoge の action メソッドを実行する
- Fuga のproxyの process メソッドを実行し始める
- Fuga のproxy内でDIコンテナから実際の Fuga インスタンス(リクエストAのスコープ)を取り出して process メソッドを実行し始める
- HTTPリクエストBを受け取る
- Fuga のインスタンスを作成する(リクエストBのスコープ)
- Hoge の action メソッドを実行する
- Fuga のproxyの process メソッドを実行し始める
- Fuga のproxy内でDIコンテナから実際の Fuga インスタンス(リクエストBのスコープ)を取り出して process メソッドを実行し始める
このようにproxyを経由してDIコンテナから異なる Fuga インスタンスを取り出して process メソッドを実行できました。
以上のことからproxyがあるDIコンテナではスコープを意識することなくインジェクションを行えることが分かりました。
finalメソッドからフィールドを参照したらnullになった理由
当初の問題に戻りましょう。 「 @Respository を付けたクラスの final なメソッドを実行するとインジェクションされたはずのフィールドへアクセスしたときに NullPointerException が発生した」という問題です。
次のコードを見てください。
@Repository
public class Foo {
@Autowired
private Bar bar;
public String method1() {
return String.format("%s%n%s%n", bar, getClass());
}
public final String method2() {
return String.format("%s%n%s%n", bar, getClass());
}
}
method1 と method2 はどちらもフィールド bar と自分自身のクラスを文字列にして返しています。 異なる点は method2 は final であるということだけです。
method1 の実行結果はこちら。
com.example.demo.Bar@1db75e25
class com.example.demo.Foo
method2 の実行結果はこちら。
null
class com.example.demo.Foo$$EnhancerBySpringCGLIB$$a65476ff
当初の問題と同じように final な方の method2 では bar が null になっていますね。
ただ、クラス名にも注目してください。 method1 では Foo となっていますが method2 は Foo のproxyになっています。
ここで Foo のproxyがどうなっているのか、再び雰囲気でコードにしてみましょう。
//雰囲気
public class Foo$$EnhancerBySpringCGLIB$$a65476ff extends Foo {
Container container;
@Override
public String method1() {
Foo component = container.getComponent(Foo.class);
return component.method1();
}
//method2はfinalなのでoverrideできない
}
ご覧の通り method1 はコンテナから実際のインスタンスを取り出して委譲していますが、 method2 は final なため override できずに実際のインスタンスに委譲されることなく実行されてしまいます。
proxyは、コンテナから実際のインスタンスを取り出して委譲するものなので、proxyに対してコンポーネントをインジェクションする意味はありません。 実際、元のクラスでインジェクション対象となっているフィールドはproxyでは null になります。
以上が、proxyが作られるクラスに定義された final メソッドがインジェクション対象のフィールドを参照している場合に NullPointerException になる理由です。
コンストラクタインジェクションとproxy
さて、ここからが私も初めて知った事柄になります。 久しぶりにびっくりした。
次のようにコンストラクタインジェクションしているクラスを考えてみましょう。
@Repository
public class Foo {
private final Bar bar;
public Foo(Bar bar) {
this.bar = Objects.requireNonNull(bar);
}
public String method1() {
return String.format("%s%n%s%n", bar, getClass());
}
public final String method2() {
return String.format("%s%n%s%n", bar, getClass());
}
}
proxyの雰囲気コードはこんな感じ。
//雰囲気
public class Foo$$EnhancerBySpringCGLIB$$a65476ff extends Foo {
Container container;
public Foo$$EnhancerBySpringCGLIB$$a65476ff(Bar bar) {
super(bar);
}
@Override
public String method1() {
Foo component = container.getComponent(Foo.class);
return component.method1();
}
//method2はfinalなのでoverrideできない
}
この場合、生成されるproxyのコンストラクタ引数 bar には何が入るのでしょうか?
先に述べた通り、proxyのインジェクション対象フィールドにはインジェクションする意味はありません。 かと言って null を渡すとスーパークラスのコンストラクタで Objects.requireNonNull によって NullPointerException がスローされます。
それではSpringはproxyをインスタンス化するときに何を渡すのでしょうか?
色々とがんばってソースコードを追っかけた末に分かったのですが、コンストラクタを呼ばずにインスタンス化していました。 何を言っているのか分かりませんね?
proxyのインスタンス化にはObjenesisというライブラリの次のクラスが使われていました。 (実際には org.springframework.objenesis にrepackageされています)
クラスのJavadocに次の記載があります。
Instantiates an object, WITHOUT calling it’s constructor, using internal sun.reflect.ReflectionFactory
お、おう……! って感じですが、JDKのinternalなクラスを使ってコンストラクタを呼ばずにインスタンス化していました。 だからフィールドが null だった、というわけですね。
コンストラクタ呼ばれないってどういうことだよ……と思いながら SunReflectionFactoryInstantiator を眺めていたら newConstructorForSerialization というメソッド名が出てきて、そういえばシリアライズされたオブジェクトをデシリアライズする時ってコンストラクタ呼ばれないんだっけ、とか雑な記憶で雑に思いました。 とか書いておくと詳しい人がコメントくれるはずです。 他力本願。
まとめ
- DIコンテナにはproxyを経由してスコープを意識せずに使える機能がある
- proxyは実行時にサブクラスを生成して実現するので final メソッドを使うとマズい
- SpringではproxyはJDKのinternalなクラスを使用してコンストラクタ呼び出し無しにインスタンス化される(その結果フィールドが null になる)
こんな感じで、割と真っ黒な黒魔術に辿り着いた感があって楽しかったです。 こざけさん、ありがとう!
ちなみに
警告みたいなの欲しいですねー
— opengl-8080 (@opengl_8080) 2018年2月20日
INFO レベルですが、ログは出ているみたいです。
018-02-22 22:22:14.298 INFO 25770 --- [ restartedMain] o.s.aop.framework.CglibAopProxy : Final method [public final java.lang.String com.example.demo.Foo.method2()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.
「Java本格入門」を読んだ
せろさん に献本して貰いました。 ありがとうございます!
この本はJavaの入門書ではありますが「本格」と付いているだけあって、
- コレクションの実装に踏み込んでどの場面でどの実装を使うべきか
- Map#computeIfAbsent の現場あるあるな使い方
- 正規表現関連のオブジェクト( Pattern 、 Matcher )と String のメソッドの使い分け
など、実践的な内容が含まれています。
また、Java 8を前提として書かれてはいますが、 ファイル操作に関してはJava 6までのやり方( java.io )とJava 7以降のやり方( java.nio.file )が、 日時操作に関してはJava 7までのやり方( java.util.Date 、 java.util.Calendar )とJava 8以降のやり方( java.time )が両方書かれています。 趣味プログラミングならいつでも最新のバージョンを使っていれば良いですが、仕事では案件によっては保守だけに徹しており古いやり方でコードを読み書きする必要がある場合もあるでしょう。 この本ならレガシーと最新のどちらのやり方も学ぶ事ができます。
他にも、文法的に許可されてはいるが実践的な観点から書かない方が良いコードを教えてくれたり、 インターフェースと抽象クラスの良い使い分け方を示してくれたり、 執筆陣の知識・経験を元に書かれていることが単なる初心者向けの入門書とは異なるところだと感じました。
私の感覚でいうと、まったくの新人ではなく現場に出て2年目あるいは3年目ぐらいがこの本を読むのに適したレベルかな、と思います。
間違いと思われる記述
いくつか間違いがあったので挙げておこうと思います。
35ページ、 >> 演算子の説明
正負の符号を表すビットは保持し、それ以外の空いたビットは0埋めする。
右方向の算術シフトは空いたビットを0埋めするのではなく、符号と同じビットで埋めます。
82ページ、インターフェースの説明
インターフェースは必ずpublicになるため、インターフェース名の前に書くpublicは省略することができます。
インターフェース名の前の public を省略するとパッケージプライベートになります。
また、ネストしたインターフェースは private にすることもできます。
156ページ、メソッド参照の文法
これは間違いというより、表の内容が足りていないと思われます。
staticメソッドを参照する記法のひとつとして次のものを挙げています。
{クラス名}::{メソッド名}
でもこれ、場合によってはインスタンスのメソッドを参照する記法としても使えます。
例えば、次のようなコードです。
//{クラス名}::{メソッド名}の記法でインスタンスのメソッドを参照している
ToIntFunction<String> f = String::length;
int i = f.applyAsInt("foobar");
197ページ、ラムダ式内での例外について
ラムダの中に記述した処理で例外が発生する可能性があります。 それが検査例外だった場合は、捕捉しないと、コンパイルエラーが発生します。
Stream APIで使われるラムダ式、つまり Function や Predicate であればそうですが、 例えば Callable のラムダ式であれば call メソッドは throws Exception と宣言されているので Exception を catch する必要はありません。
その他の軽い間違い
108ページ、フィボナッチ数を求める定義とコードが書かれていますが、定義を見ると F0 = 0 F1 = 1 Fn = ... となっていますが、 直後に記載されている昔ながらの for ループを使ったコードは i <= 1 のときに 1 を代入しているので、 定義とコードが合ってないですね。
それと134ページ、「Mapインターフェースでの要素の取得、変換」に値の集合を取得するメソッドとして valueSet が挙げられていますが values の間違いだと思います。 なお、 values の戻り値は集合( Set )ではなくコレクション( Collection )です。
あと、めっちゃ細かいところで言うと、183ページにAutoClosable、Closableという記述がありますが、 正しくはAutoCloseable、Closeableです。 (めっちゃ分かりづらいですが……)
まとめ
このようにいくつか間違いと思われる記述もありましたが、 これらは前半の章(文法の解説などのまさに入門的な章)に集中していました。
読み進めるにつれてより実践的な内容が増えており、 現場で使える知識を付けることができるのではないでしょうか。