はじめに
新卒で入社した会社では「リフレクションは危険なので使わないように」と教えられた記憶があります。
そして、転職先のプロダクションコードではリフレクションが各所で使われており、「えっいいの・・・?大丈夫なの・・・?」と思いました。
一応「リフレクションは危険なものである」という教えは徹底されているようで、新たにリフレクションを使ったコードを追加しようもんなら「他の方法はないのか」と詰められる様が思い浮かびます。
「まあ、ちゃんと危険さが周知されてるならええか」と納得したのはいいものの、結局私自身「なぜ危険なのか」「なせ危険なのに淘汰されないのか」を理解していないかもと思いましたので、今回まとめます。
参考:
入門Javaのリフレクション
Java のリフレクションでできること
リフレクションとは
リフレクションとは、クラス名・メソッド名・フィールド名といった「名前の文字列」さえ知っていれば、クラス内部に動的にアクセスできる仕組みです。
名前さえ知っていれば自由にアクセス・操作できるためとても強力な機構です。
知らずに使うにはあまりに力が強いのです。しっかりと手綱を握れるようになりましょう。
実務で避けられる理由
冒頭に書いた通り、実務では「危険」「使うな」と教えられてきました。なぜでしょうか?
型安全性が失われる
Personクラスがあります。
public class Person {
public Person() {
}
public void hello() {
System.out.println("Hello, World");
}
}
Appクラスからリフレクションを使ってPerson#helloを実行してみましょう。
public class App {
public static void main(String[] args) throws Exception {
Class<?> personClass = Class.forName("reflect.clazz.Person");
Object person = personClass.getConstructor().newInstance();
Method hello = personClass.getDeclaredMethod("hello");
hello.invoke(person);
}
}
> Hello, World
うまくいきました。
ところで、「名前」を指定するときは、タイポがつきものですね。
通常の方法でインスタンスを作ってメソッドを実行した場合、タイポしたらコンパイルエラーとなります。
またIDEによりタイポする暇もなく自動で補完してくれる場合もありますね。
リフレクションの場合はどうでしょうか?タイポしてみましょう。
public class App {
public static void main(String[] args) throws Exception {
Class<?> personClass = Class.forName("reflect.clazz.Person");
Object person = personClass.getConstructor().newInstance();
Method hello = personClass.getDeclaredMethod("helo");
hello.invoke(person);
}
}
コンパイルエラーが出ません。実行します。
> Exception in thread "main" java.lang.NoSuchMethodException: reflect.clazz.Person.helo()
at java.base/java.lang.Class.getDeclaredMethod(Class.java:2850)
at reflect.App.main(App.java:9)
Javaはコンパイル時に型の不一致を検知することで型安全性を保証していますが、リフレクションではこれが行われません。
通常の実装では起こり得ないバグが発生するのです。
カプセル化を破壊できる
先ほどのPersonクラスにprivateなインスタンス変数を追加してみましょう。
メソッドhelloではこの値を出力するようにしています。
public class Person {
private String name = "田中";
public Person() {
}
public void hello() {
System.out.println("こんにちは、私は" + name + "です");
}
}
通常、このnameはPersonクラスからしか見えません。privateなので。
しかし、リフレクションを使うと、「どこからでも」見ること、触ることができるようになってしまいます。
public class App {
public static void main(String[] args) throws Exception {
Class<?> personClass = Class.forName("reflect.clazz.Person");
Object person = personClass.getConstructor().newInstance();
Field name = personClass.getDeclaredField("name");
name.setAccessible(true);
name.set(person, "高橋");
Method hello = personClass.getDeclaredMethod("hello");
hello.invoke(person);
}
}
> こんにちは、私は高橋です
セキュリティリスクがある
「外部からクラス名とメソッド名を受け取り、それを実行する」というロジックを書いてみましょう。
public void doSomething(String className, String methodName) throws Exception{
Class<?> clazz = Class.forName(className);
Object instance = clazz.getConstructor().newInstance();
Method method = clazz.getDeclaredMethod(methodName);
method.invoke(instance);
}
このようなコードがプロダクションコード内にあると、これを悪用される可能性があります。
攻撃者は上記のメソッドに「何か悪いことをするクラス名とメソッド名」を渡すだけです。
「何か悪いことをするロジック」が簡単に実行されてしまいます。しかも、「何か悪いことをするクラス、メソッド」の中身はどうとでも書けるので、やりたい放題できてしまいます。
超危険ですね。
パフォーマンスが悪い
通常のメソッド呼び出しと比べてリフレクションは遅いです。
Javaは一度に全てをコンパイルするのではなく、必要な分だけコンパイルするJITという機構があります。
JITコンパイラはそれぞれのクラスやメソッドをどのタイミングでコンパイルするのが最も効率いいかを計算しているのですが、リフレクションの場合はこの最適化が効きにくいです。
また、「名前」からクラスの実装を見つけにいかないといけないので、メソッド探索が毎回必要になります。
さらに、invokeによるメソッド実行には、「ネイティブ呼び出し」を伴います。JVM内部の処理を呼び出す必要があるので、通常のメソッド呼び出しでは必要としない範囲も広く使うことになるのです。
どこで使われているか
リフレクションの怖いところを見てきました。こんな怖いもの、間違って使っちゃう前に無くして欲しいですよね。
無くならないのは理由があります。使い所があるからです。というか、めっちゃよく使われています。
一言で言うと、「実務では使わないが、ライブラリやフレームワークの内部でよく使われている」です。
以下はあくまで一部です。
Spring
例えばDIコンテナで使われています。
@Component
class UserService {
@Autowired
private UserRepository repo;
}
上記のような実装をした場合、以下のようなイメージで処理されます。
-
@Autowiredがついたフィールドを探す(UserService.class.getDeclaredFields()) -
repoフィールドに値を入れたいけどprivateなので可視性を変更する(setAccessible(true)) -
repoフィールドに入れるインスタンスを作る(UserRepository.class.getDeclaredConstructor().newInstance()) -
repoフィールドに値を入れる(repoField.set(userServiceInstance, new UserRepository()))
Jackson/Gson
JSON文字列をオブジェクトに変換するロジックは、リフレクションで実現されています。
String json = "{ \"name\": \"alice\" }";
User u = new ObjectMapper().readValue(json, User.class);
上記の実装をした場合、以下のようなイメージで処理されます。
-
Userインスタンスを作る(User.class.getDeclaredConstructor().newInstance()) -
Userのフィールドを列挙する(clazz.getDeclaredFields()) -
Userのフィールドの可視性を変更(setAccessible(true)) - JSON文字列から読み取った値を格納(
field.set(user, value))
Hibernate/JPA
O/Rマッパーについては、上記のJacksonと似たイメージ。
ResultSetからObjectへの変換を考えてみると、同じようなことをしていそうなのがわかると思います。
JUnit
実行して欲しいテストは@Testをつけます。
ここまで読んでくださった皆様は「リフレクションで@Testがついたメソッドを探している」と理解いただけるのではないでしょうか。
リフレクションの代替案
ここまで見てきた通り、「実務でリフレクションを使うのは避けましょう」「ライブラリを作るのであれば(セキュリティリスクに気をつけながら)使ってもいいかも」というのがリフレクションの位置付けです。
しかし、「実務」で「リフレクションを使うしかないかも」という状況に陥ってしまうことがあるかもしれません。
そんな時のために代替案をまとめておきます。
クラス名が文字列で渡される場合
文字列からクラスを錬成するのはリフレクションの得意とするところです。
でも必ずしもリフレクションを使わずとも実装できます。
やってくる文字列(指定されるクラス名)がある程度予想できるのであれば、Mapで文字列とクラスを紐づけておけば、リフレクションを避けられます。
Map<String, Supplier<Handler>> registry = Map.of(
"foo", FooHandler::new,
"bar", BarHandler::new);
Handler h = registry.get(className).get();
処理を動的に切り替えたい
それぞれの処理を別々のクラスとして実装します。
さて、これをどう呼び分けましょうか。
答えはシンプルです。Strategyパターンを使いましょう
別のjarの処理を実行したい
読み込んだjarの特定の処理を実行したい場合、クラスをimportできないとなると、リフレクションしか道がなさそうに思えます。
そこで確認して欲しいのが、Java標準のServiceLoaderです。
なんというか、「ほぼリフレクション」ではあるのですが、公式が出しているのもあって、自分でリフレクションの実装をするよりは圧倒的に安心して使えると思います。
ServiceLoaderの使い方のイメージは以下参照ください
もっとも、DIコンテナが使えるならそちらを使うべき、なんだと思います。
privateフィールドやprivateメソッドに触りたい
まずは「本当に触る必要があるのか」を考えましょう。ドメイン設計上、外から触るべきものですか?
「外から触るべきもの」なのであれば、もともとprivateにしていたことが誤りだったと言えます。プロダクションコードの可視性を変更しましょう。
あるいは、「テスト」の場合は一味違います。
@VisibleForTestingというアノテーションがあります。
テストのためだけにprivate以外を指定する場合にこのアノテーションをつけておくと、プロダクションコード(テスト以外のコード)でこのフィールドやメソッドにアクセスするコードをコンパイルエラーとしてくれます。
よって、テストの場合は「@VisibleForTestingをつけて」「可視性を変更(まずはパッケージプライベートとする)」しましょう。
リフレクションを使う場合に守るべきこと
ライブラリやフレームワークを作る時、あるいは実務でどうしても必要になった時、リフレクションを使うときには何に気をつけるべきでしょうか。
リフレクション以外の方法を検討する
ライブラリやフレームワークを作るのであれば、後述の注意点に気をつけながら、選択肢の一つとして採用して良いでしょう。
そうではないとき(実務の実装の場合)、しつこいようですが、本当にリフレクション以外の方法はないでしょうか?
これを検討する際は、「修正したい箇所」にとどまらず、DBやフロントも含め、プロジェクト全体を見直してください。
本当にリフレクションしか方法がないですか?
外部入力をそのまま使わない
途中で見た通り、外部からクラス名やメソッド名を受け取って実行する、なんてことは絶対にしないでください。
やりたい放題されてしまいます。
privateを無闇に開けない
多くのプロダクションコードは、オブジェクト指向において重要な「カプセル化」を実現したコードなはずです。
その設計を簡単に壊すことになるので、可視性の変更は慎重にいきましょう。
そして「開けたらしめる」が原則です。開けっぱなしにしないでください。
Field f = clazz.getDeclaredField("secret");
f.setAccessible(true);
try {
// 操作
} finally {
f.setAccessible(false);
}
FieldやMethodはキャッシュする
上で見た通り、「パフォーマンスが悪い」です。
何度も同じものを取得するのは避けましょう。
失敗時の処理を設計する
リフレクションは実行するまで「失敗」がわからないので「もし失敗したら」「なるべく失敗しないように」を考えておくことが肝要です。
以下に案を置いておきます。
「デフォルトの処理」を用意しておく
クラスやメソッドが見つからなかった場合に備えて、デフォルトの処理を実装しておき、例外発生時にはそちらを実行するという考えです。
try {
Class<?> clazz = Class.forName(className);
Object instance = clazz.getConstructor().newInstance();
Method method = clazz.getDeclaredMethod(methodName);
method.invoke(instance);
} catch (Exception e) {
defaultProcess();
}
ホワイトリストを用意する
存在することがわかっているものはリフレクションを実行する、そうでないものはその時点でエラーとする、という考えです。
Set<String> allowed = Set.of("com.example.Foo", "com.example.Bar");
if (!allowed.contains(className)) {
throw new SecurityException("not allowed");
}
try {
return (Handler) Class.forName(className).getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException(e);
}
テストを念入りに行う
Javaの強みである「静的解析」が適用されません。通常起こり得ない不具合が発生します。
通常起こり得ない不具合は、文字通り普段は意識しない問題なので見逃されやすいです。
頭で考えても無駄です。実際に全パターンを動かして確かめるべきです。
おわりに
リフレクションは正しく使えば超強力な武器になります。
でもとても素人が扱える代物じゃないな、と思います。
リフレクションはライブラリやフレームワークで実際よく使われますが、ライブラリやフレームワークが100%正しい使い方ができているのかというと、当たり前ですがそんなことはありません。
例えば、2021年12月ごろ、IT業界を騒然とさせたApache log4jの脆弱性は、まさしくリフレクションにより任意のコードが実行可能である(やりたい放題できちゃう)、というものでした。
リフレクションの使用はくれぐれも慎重にいきましょう。