MyBatisインターセプターを用いたデータ匿名化の実現

1. MyBatisインターセプターの概要

1.1 MyBatisインターセプターとは

MyBatisインターセプターは、MyBatisがSQL文を実行する際にその処理を傍受し、変更または拡張するためのプラグイン機構です。この機構により、MyBatisの実行フローの様々なポイントにカスタムロジックを挿入し、ログ記録、SQLクエリの動的な変更、パフォーマンス最適化などの独自動作を実装できます。

具体的には、MyBatisのコアコンポーネント(Executor, StatementHandler, ParameterHandler, ResultSetHandlerなど)のメソッド呼び出しをフックするカスタムプラグインとして定義されます。これにより、MyBatisのソースコードを変更することなく、その挙動を変えたり機能を強化したりすることが可能になります。

インターセプターで実現可能な主な機能は以下の通りです。

  • SQL文の動的な修正(例:ビジネスルールに基づいた条件追加)
  • SQL実行ログの記録、パフォーマンスモニタリング、実行時間統計
  • トランザクション制御やキャッシュロジックの実装

1.2 インターセプターの利用目的

MyBatisインターセプターを使用する主な理由は、コアコードに手を加えることなくMyBatisの機能を柔軟に拡張する必要があるためです。一般的な応用シナリオには以下のようなものがあります。

  • ログ記録: 各SQL文の実行状況(SQL自体、実行時間、結果など)を記録し、後の分析やデバッグに活用します。
  • SQLパフォーマンス監視: SQLの実行時間を計測し、パフォーマンス評価を行います。実行時間の長いSQLを特定し、最適化の対象とします。
  • SQL文の変更: インターセプターを介してSQL文を動的に変更できます。例えば、クエリに動的に条件を挿入したり、ソート順を変更したり、ページネーションロジックを追加したりします。
  • トランザクション制御: SQL操作の前、後、または特定のエラー発生時に、インターセプターを使用してトランザクション管理を強化できます。

2. MyBatisインターセプターの動作原理

2.1 コア概念

MyBatisインターセプターの動作における主要な概念は、InvocationInterceptorMethodTargetオブジェクトなどです。

  • Interceptor 全てのカスタムインターセプターが実装するインターフェースです。MyBatisは設定に基づいてこのインターフェースを実装するクラスを見つけ、呼び出します。
  • Invocation メソッド呼び出しをカプセル化したオブジェクトで、ターゲットメソッドの情報とその引数を含みます。Invocationオブジェクトを通じて、メソッドの実行を制御できます。
  • Method リフレクションによって取得されるターゲットメソッドそのものを表します。
  • Target 傍受されるオブジェクトです。例えば、ExecutorStatementHandlerなどがターゲットオブジェクトであり、インターセプターはTargetオブジェクトを介してこれらのオブジェクトの挙動にアクセスし、制御します。

2.2 インターセプターのライフサイクル

MyBatisにおけるインターセプターのライフサイクルは通常、以下の3つのフェーズで構成されます。

  1. プラグインの初期化: MyBatisが起動する際、設定された全てのインターセプターがロードされ、初期化されます。この段階でインターセプターは傍受ロジックを準備します。
  2. 傍受の実行: MyBatisが特定のSQL文を実行すると、インターセプターのintercept()メソッドがトリガーされます。この時、インターセプターは実行メソッドの引数を取得し、その実行を変更、拡張、または置き換えることができます。
  3. プラグインの破棄: MyBatisがシャットダウンされる際、インターセプターはリソースを解放し、占有していたメモリやスレッドなどをクリーンアップします。

2.3 傍受可能なターゲットオブジェクトとメソッド

MyBatisでは、主に以下の4つのオブジェクトが傍受の対象となります。

  • Executor SQL文を実行するコアオブジェクトです。そのupdate()query()などのメソッドが、実際のCRUD操作を担当します。
  • StatementHandler SQL文を処理するオブジェクトです。SQL文とパラメータのバインディングを行い、データベースに送信します。
  • ResultSetHandler SQLクエリの結果を処理するオブジェクトです。データベースから返されたResultSetをJavaオブジェクトに変換します。
  • ParameterHandler SQLパラメータのバインディングを処理するオブジェクトです。パラメータをSQL文に設定します。

3. データ匿名化インターセプターの実装

ここでは、実際の業務におけるデータ匿名化の要件を例に、MyBatisインターセプターの具体的な実装を見ていきましょう。

3.1 カスタム匿名化アノテーション

まず、どのクラスのどのプロパティが匿名化の対象となるかを指定するために、カスタムアノテーションを定義します。これにより、匿名化が必要なフィールドを明確にマークできます。

package com.example.app.annotation;

import com.example.app.anonymization.AnonymizationType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * フィールドにデータ匿名化を適用するためのアノテーション。
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SensitiveData {
    AnonymizationType type();
}

3.2 匿名化戦略の定義

次に、匿名化の具体的な方法、すなわち匿名化戦略を定義します。プロパティの種類によって匿名化の方法は異なるため、例えば氏名であれば姓のみを残し、身分証明書番号や電話番号であれば中間部分をマスクするといった処理が必要です。ここでは、異なる匿名化処理を列挙型(Enum)で定義し、それぞれの戦略で正規表現による置換ロジックを指定します。

package com.example.app.anonymization;

import java.util.function.Function;
import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * データ匿名化の戦略を定義する列挙型。
 */
@Getter
@AllArgsConstructor
public enum AnonymizationType {

    FULL_NAME(s -> s.replaceAll("^(.)(.*)$", "$1*")), // 例: 山田太郎 -> 山田*
    IDENTIFICATION_NUMBER(s -> s.replaceAll("(\\d{6})\\d{8}(\\w{4})", "$1********$2")), // 例: 123456********ABCD
    CONTACT_NUMBER(s -> s.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1-****-$2")), // 例: 090-xxxx-xxxx
    STREET_ADDRESS(s -> s.replaceAll("(.{4}).*(.{4})", "$1****$2")); // 例: 東京都港区****新橋

    private final Function<String, String> anonymizerFunction;
}

3.3 匿名化処理の実行インターフェース

匿名化処理は、特定のプロパティ値を正規表現と照合して置換することで行われます。ここでは、JDK8で導入されたFunctionインターフェースを利用し、文字列を別の文字列に変換する処理を抽象化します。

package com.example.app.anonymization;

import java.util.function.Function;

/**
 * データ匿名化処理を実行するための関数型インターフェース。
 * Stringを受け取り、匿名化されたStringを返す。
 */
public interface DataAnonymizerFunction extends Function<String, String> {
    // Function<String, String>のapplyメソッドをそのまま利用
}

3.4 データ匿名化インターセプターの実装

結果セットを匿名化する必要があるため、傍受の対象はResultSetHandlerとなり、特にhandleResultSetsメソッドが重要です。

ResultSetHandlerインターフェースの主なメソッドは以下の通りです。

public interface ResultSetHandler {
    <E> List<E> handleResultSets(Statement var1) throws SQLException;
    <E> Cursor<E> handleCursorResultSets(Statement var1) throws SQLException;
    void handleOutputParameters(CallableStatement var1) throws SQLException;
}

この中で、handleResultSetsメソッドが、SQLクエリの結果をリスト形式で返す主要なメソッドです。

以下に、カスタムデータ匿名化インターセプターの具体的な実装を示します。

package com.example.app.plugin;

import com.example.app.annotation.SensitiveData;
import com.example.app.anonymization.AnonymizationType;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.sql.Statement;
import java.util.List;
import java.util.stream.Stream;

/**
 * MyBatisのResultSetHandlerを傍受し、結果セット内の機密データを匿名化するインターセプター。
 */
@Component
@Intercepts(@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = Statement.class))
public class DataObfuscationInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 元の結果セットを取得
        List<Object> resultList = (List<Object>) invocation.proceed();
        // 結果セット内の各レコードを匿名化
        resultList.forEach(this::processAnonymization);
        return resultList;
    }

    /**
     * オブジェクト内の機密フィールドを検索し、匿名化処理を実行します。
     *
     * @param targetObject 匿名化処理の対象となるオブジェクト
     */
    private void processAnonymization(Object targetObject) {
        if (targetObject == null) {
            return;
        }
        Class<?> targetType = targetObject.getClass();
        // SystemMetaObjectを使用してオブジェクトをラップし、リフレクションアクセスを容易にする
        MetaObject objectWrapper = SystemMetaObject.forObject(targetObject);

        // 宣言された全てのフィールドをストリームとして処理
        Stream.of(targetType.getDeclaredFields())
              .filter(field -> field.isAnnotationPresent(SensitiveData.class)) // SensitiveDataアノテーションが付与されたフィールドのみをフィルタリング
              .forEach(field -> applyFieldAnonymization(objectWrapper, field)); // 各フィールドに匿名化を適用
    }

    /**
     * 特定のフィールドに匿名化ロジックを適用します。
     *
     * @param objectWrapper 対象オブジェクトのMetaObject
     * @param targetField 匿名化対象のフィールド
     */
    private void applyFieldAnonymization(MetaObject objectWrapper, Field targetField) {
        String fieldName = targetField.getName();
        Object fieldValue = objectWrapper.getValue(fieldName);

        // フィールド値がnullでなく、かつString型である場合のみ匿名化を実行
        if (fieldValue instanceof String) {
            SensitiveData fieldAnnotation = targetField.getAnnotation(SensitiveData.class);
            AnonymizationType anonymizationStrategy = fieldAnnotation.type();
            String obfuscatedValue = anonymizationStrategy.getAnonymizerFunction().apply((String) fieldValue);
            objectWrapper.setValue(fieldName, obfuscatedValue);
        }
    }
}

3.5 テストシナリオ

データベースに以下のスクリプトを実行してテストデータを準備します。

DROP TABLE IF EXISTS `app_users`;
CREATE TABLE `app_users` (
  `user_id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主キーID',
  `full_name` VARCHAR(50) NOT NULL COMMENT '氏名',
  `user_age` INT(11) NOT NULL COMMENT '年齢',
  PRIMARY KEY (`user_id`)
) ENGINE=INNODB AUTO_INCREMENT=101 DEFAULT CHARSET=utf8mb4;

INSERT INTO `app_users` (`user_id`, `full_name`, `user_age`) VALUES(101,'田中一郎','35');
INSERT INTO `app_users` (`user_id`, `full_name`, `user_age`) VALUES(102,'鈴木花子','29');
INSERT INTO `app_users` (`user_id`, `full_name`, `user_age`) VALUES(103,'佐藤健太','42');

次に、UserInfoエンティティクラスを定義します。匿名化対象のフィールドには@SensitiveDataアノテーションを付与します。

package com.example.app.model;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.app.annotation.SensitiveData;
import com.example.app.anonymization.AnonymizationType;
import lombok.Data;

@Data
@TableName("app_users")
public class UserInfo {

    @TableId
    private Long userId;

    @SensitiveData(type = AnonymizationType.FULL_NAME)
    private String fullName;

    private Integer userAge;
}

そして、テストクラスを作成し、ユーザーリストを取得する操作を実行します。

package com.example.app.test;

import com.example.app.mapper.UserInfoMapper;
import com.example.app.model.UserInfo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest
public class AnonymizationIntegrationTest {

    @Autowired
    private UserInfoMapper userInfoMapper;

    @Test
    void testDataAnonymization() {
        List<UserInfo> userList = userInfoMapper.selectList(null);
        userList.forEach(System.out::println);
    }
}

上記のコードを実行すると、以下のような出力結果が得られます。

UserInfo(userId=101, fullName=田*, userAge=35)
UserInfo(userId=102, fullName=鈴*, userAge=29)
UserInfo(userId=103, fullName=佐*, userAge=42)

fullNameフィールドが正しく匿名化されていることが確認できます。

4. 考察

これまでのケーススタディを通じて、MyBatisインターセプターの利点と考慮すべき点を以下にまとめます。

利点:

  • 非侵襲性: インターセプター機構を利用することで、MyBatisのコアソースコードを変更することなく、機能をカスタマイズできます。
  • 柔軟性: SQL操作の複数のフェーズで介入できるため、多種多様な機能を実装する柔軟性があります。

考慮すべき点:

  • パフォーマンスオーバーヘッド: インターセプターの数が多すぎたり、ロジックが複雑すぎたりすると、パフォーマンスが低下する可能性があります。
  • デバッグの複雑さ: インターセプターの実行プロセスはやや暗黙的であるため、デバッグ時に困難に遭遇する場合があります。

したがって、インターセプターは必要最小限に留めるべきです。もし同じ対象オブジェクトを傍受する複数の機能がある場合は、それらを単一のインターセプターに統合することで、インターセプターの作成数を減らし、管理を容易にすることができます。

タグ: MyBatis インターセプター データ匿名化 Spring Boot アノテーション

7月22日 16:24 投稿