Javaにおけるクラスメタデータ取得とランタイムプロキシ設計の徹底解説

Javaのランタイム環境では、プログラム実行中にクラスの情報を読み取るメカニズムが存在します。これを反射(リフレクション)と呼びます。これにより、クラスローダー、フィールド、メソッド、コンストラクターなどのメタデータを動的に取得・操作できます。

あるインスタンスから対応するClassオブジェクトを取得する代表的な手法は以下の3つです。

public static void inspectClassMetadata(Employee emp) {
    // 実行時インスタンスから取得
    Class<?> clazzViaInstance = emp.getClass();
    // クラスリテラルによる取得
    Class<?> clazzViaLiteral = Employee.class;
    // 文字列名から動的に取得
    Class<?> clazzViaString = Class.forName("com.example.model.Employee");

    System.out.println(clazzViaInstance == clazzViaLiteral); // true
    System.out.println(clazzViaInstance == clazzViaString);  // true
}

変数名は異なりますが、これらは同一のメモリ領域(メタスペース)に存在するClassインスタンスを指します。JVMは同一クラスローダーで読み込まれたクラスに対して単一のClassオブジェクトを管理するため、等価比較は常に真となります。この実行時の型情報取得プロセス全体が反射技術の核心です。


次にプロキシパターンについて考察します。対象オブジェクトへのアクセスを仲介し、前後処理を追加する設計です。例として、輸送手配サービスを実装してみます。依頼者(クライアント)が直接手配をするのではなく、手配業者(プロキシ)に代行させ、業者は移動準備と完了報告を行うシナリオです。

public interface LogisticsService {
    void arrangeTransport();
}

public class DirectClient implements LogisticsService {
    @Override
    public void arrangeTransport() {
        System.out.println("クライアント: 輸送を手配しました");
    }
}

public class LogisticsAgent implements LogisticsService {
    private final DirectClient realSubject = new DirectClient();

    @Override
    public void arrangeTransport() {
        System.out.println("エージェント: 準備作業を開始");
        realSubject.arrangeTransport();
        System.out.println("エージェント: 完了処理を実行");
    }
}

呼び出し側はインターフェース経由で利用します。

public static void execute(LogisticsService service) {
    service.arrangeTransport();
}
// 出力: エージェント: 準備作業を開始 -> クライアント: 輸送を手配しました -> エージェント: 完了処理を実行

これは静的プロキシと呼ばれ、インターフェースを共有し、プロキシ側が実装を委譲する形式です。しかし、対象が増えるごとにプロキシクラスを個別に定義する必要があり、保守コストがかかります。


この課題を解決するのが動的プロキシです。Java標準ライブラリjava.lang.reflect.Proxyを用いることで、ランタイム時に代理オブジェクトを生成できます。newProxyInstanceメソッドは以下の引数を必要とします。

  • ClassLoader: 対象クラスをロードしたローダー
  • Class<?>[]: 実装するインターフェースの配列
  • InvocationHandler: 呼び出し処理を定義するハンドラー

汎用的なプロキシ生成ファクトリを設計します。

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class RuntimeProxyBuilder {
    private final Object targetObject;

    public RuntimeProxyBuilder(Object target) {
        this.targetObject = target;
    }

    @SuppressWarnings("unchecked")
    public <T> T buildProxy() {
        return (T) Proxy.newProxyInstance(
            targetObject.getClass().getClassLoader(),
            targetObject.getClass().getInterfaces(),
            (proxy, method, args) -> {
                System.out.println("[プロキシ] インターセプト開始");
                Object result = method.invoke(targetObject, args);
                System.out.println("[プロキシ] インターセプト完了");
                return result;
            }
        );
    }
}

利用例:

public static void main(String[] args) {
    DirectClient client = new DirectClient();
    LogisticsService proxyInstance = new RuntimeProxyBuilder(client).buildProxy();
    proxyInstance.arrangeTransport();
}

targetObjectObject型で保持しているため、異なる実装クラスに対して同じファクトリでプロキシを生成できます。特定の実装に縛りつけたい場合は、ファクトリ内で型を固定するか、コンストラクタ引数で制限を課せばよいでしょう。

プロキシパターンを採用する利点は、主に2点です。

  1. 実装クラスを隠蔽し、対外的にはプロキシ経由でのみアクセスさせるカプセル化が実現できる。
  2. トランザクション管理、ロギング、権限検証といった横断的関心事(Cross-cutting Concerns)を本番ロジックに一切手を加えずに追加・変更できる。

先述の例ではコンクリートクラスが存在しましたが、実装クラスを定義せずにインターフェースのみをプロキシ対象にすることも可能です。ORMフレームワークのMyBatisがMapperインターフェースで採用している手法です。

public interface DataAccessLayer {
    void executeQuery();
    void rollbackTransaction();
}

インターフェースに対するハンドラーは、実際の処理を実装せず、呼び出し情報をログ出力したり、後続の処理へ転送したりするだけで十分です。

public class InterfaceInvocationHandler implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("[インタフェースプロキシ] 呼び出し: " + method.getName());
        // 本来はSQL実行エンジン等に処理を委譲
        return null;
    }
}

生成ファクトリはインターフェースクラスを受け取り、配列に変換して渡します。

public class MapperProxyGenerator {
    @SuppressWarnings("unchecked")
    public static <T> T create(Class<T> iface) {
        return (T) Proxy.newProxyInstance(
            iface.getClassLoader(),
            new Class<?>[]{iface},
            new InterfaceInvocationHandler()
        );
    }
}

呼び出しテスト:

public static void main(String[] args) {
    DataAccessLayer mapper = MapperProxyGenerator.create(DataAccessLayer.class);
    mapper.executeQuery();
    mapper.rollbackTransaction();
}

出力結果から明らかなように、インターフェースで宣言されたメソッドはすべてハンドラーのinvokeメソッドにルーティングされます。この仕組みにより、実装クラスなしで動的なメソッド実装が可能になります。

MyBatisの内部実装も同様の設計思想に基づいています。Mapperインターフェースに対するプロキシはMapperProxyクラスが担い、生成はMapperProxyFactoryが行います。MapperProxyInvocationHandlerを実装し、invoke内でSqlSessionMapperMethodのキャッシュを組み合わせてSQL実行処理を制御しています。

// MyBatisのMapperProxyに相当する概念実装
public class SqlMapperProxy implements InvocationHandler {
    private final Class<?> mapperInterface;
    private final Map<Method, Runnable> methodCache = new ConcurrentHashMap<>();

    public SqlMapperProxy(Class<?> iface) {
        this.mapperInterface = iface;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.getDeclaringClass() == Object.class) {
            return method.invoke(this, args);
        }
        Runnable cachedTask = methodCache.computeIfAbsent(method, m -> {
            // ここでXMLマッピングやアノテーションから処理ロジックを構築
            return () -> System.out.println("SQL実行: " + m.getName());
        });
        cachedTask.run();
        return null;
    }
}

method.getDeclaringClass()で呼び出し元のクラス情報を取得し、標準的なObjectメソッド(equals, toStringなど)の場合は直接処理、Mapperインターフェースのメソッド場合はキャッシュされた処理タスクを実行します。JVMは動的に$Proxyクラスを生成し、呼び出しをこのハンドラーへ委譲するため、開発者は実装コードを書かずに宣言的なデータベース操作が可能になります。

タグ: Java Reflection dynamic-proxy MyBatis JVM

6月10日 19:42 投稿