環境

  • Java 17
  • MyBatis 3.5.9
  • Spring Boot 2.6.2

MyBatisでJSONを扱うためにJSON専用のTypeHandlerを定義する

JsonTypeHandlerを作成する

MyBatisでJSONを扱うためには、JSON専用のTypeHandlerを定義する必要がある。

JsonTypeHandlerというクラス名でBaseTypeHandlerを継承して、PreparedStatementに値をセットするときやResultSetから値を取得するときのメソッドを実装する。実装内容は単純にJacksonのObjectMapperでJSONとJavaクラスの変換をしているだけなので難しい点はない。

MyBatisの独自TypeHandlerを定義する際に以下の2点を対応しなければいけない。

  1. mybatis.type-handlers-packageプロパティにTypeHandlerを置くパッケージ名を設定する
  2. @MappedTypesアノテーションでJSONに対応するJavaクラスを指定する

2の@MappedTypesの方法以外にも以下に引用するとおり、@MappedJdbcTypesもあるが、JSONを話題にしている今回は使用しない。「総称型(Genric Type)から適用」する件については、後半で記載する。

MyBatis は、このタイプハンドラーの総称型(Genric Type)から適用対象の Java タイプを自動判定しますが、この動作をオーバーライドする方法が2つあります。

  • typeHandler 要素に javaType 属性を追加する(例:javaType="String")
  • TypeHandler の実装クラスに @MappedTypes アノテーションを付加して適用対象の Java タイプのリストを指定します。javaType とアノテーションを両方指定した場合は javaType の指定が優先されます。

https://mybatis.org/mybatis-3/ja/configuration.html#typeHandlers

mybatis.type-handlers-packageプロパティ

application.propertiesに設定する。

例えばcom.example.mybatisjson.mybatis.typeパッケージにJsonTypeHandlerを置くなら、以下のように設定する。

# MYSQL
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost/test
spring.datasource.username=test
spring.datasource.password=test_password

# MyBatis
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.type-handlers-package=com.example.mybatisjson.mybatis.type # to use JsonTypeHandler
mybatis.type-aliases-package=com.example.mybatisjson.model
logging.level.com.example.mybatis=DEBUG

@MappedTypesアノテーションでJSONに対応するJavaクラスを指定する

JsonTypeHandlerクラスに@MappedTypesをつけ、JSONに対応するJavaクラスを指定する。

SELECT用のメソッドの戻り型に含まれるフィールドがList<String>で定義されていて、DBのJSONが["v1", "v2"]のようなリスト形式だったら、以下のようにList.classを設定する。

@MappedTypes({
        List.class,
})
public class JsonTypeHandler<T> extends BaseTypeHandler<T> {
    略
}

List以外にもJSONに対応するJavaクラスがあれば、列挙すればいい。列挙した分だけJsonTypeHanlderの実体が作られる。

二種類の総称型対応

総称型をラッピングする

先程のList<String>には対応できたが、List<独自のクラス>List<Enum型>の場合には、com.fasterxml.jackson.databind.JsonMappingExceptionが発生してしまう。

例えば、以下のような型を定義しているとする。

@Data
public class User {
    private Integer id;
    private String email;
    private String password;
    private List<Role> roles;

    public enum Role {
        ROLE_NORMAL, ROLE_ADMIN
    }
}

List<Role>はDB上、["ROLE_NORMAL", "ROLE_ADMIN"]のように保存できる。

しかし、DBからJavaにSELECTするとき、"ROLE_NORMAL"は文字列なのでStringとみなされEnumには変換できず、JsonMappingExceptionが発生する。

@MappedTypesにはList<Role>.classのような設定はJavaの文法上できないため、これを解決するにはList<Role>をジェネリクスを用いない型に変更するしかない。

Rolesという型を定義し、フィールドにList<Role>を持つようにラッピングすることで対応できる。

@Data
public class User {
    private Integer id;
    private String email;
    private String password;
    private Roles roles;

    @NoArgsConstructor(access = AccessLevel.PRIVATE) // for Jackson
    @AllArgsConstructor
    @Data
    public static class Roles {
        private List<Role> list;
    }

    public enum Role {
        ROLE_NORMAL, ROLE_ADMIN
    }
}

@MappedTypesにはList<Role>.classを指定できない代わりに、Roles.classを設定する。

DBに保存されるときは{"list": ["ROLE_NORMAL", "ROLE_ADMIN"]}のようになってしまうし、わざわざラッピングするクラスを作らねければいけないし、デメリットはあるが、問題なくSELECTした結果をJavaにマッピングできるようになった。

JsonTypeHandlerの実装

遅くなったが、ここでJsonTypeHandlerの実装を掲載する。

@MappedTypes({
        List.class, // ["v1", "v2"] は"v1"がStringとして扱われ、Enumで受け取ろうとするとJsonMappingExceptionがでる
        User.Roles.class // List<String>以外のListで受け取りたい時は、{"list": ["v1", "v2"]}のようにJSONを変えるといい
})
public class JsonTypeHandler<T> extends BaseTypeHandler<T> {

    private static final ObjectMapper OBJECT_MAPPER =
            new ObjectMapper()
                    .configure(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES, false)
                    .registerModule(new JavaTimeModule());

    private final Class<T> type;

    // @MappedTypesに列挙した分だけJsonTypeHandlerがインスタンス化され、列挙したクラスがtypeに設定される
    public JsonTypeHandler(Class<T> type) {
        this.type = type;
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
        String json = toS(parameter);
        ps.setString(i, json);
    }

    @Override
    public T getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String json = rs.getString(columnName);
        return toT(json);
    }

    @Override
    public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String json = rs.getString(columnIndex);
        return toT(json);
    }

    @Override
    public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String json = cs.getString(columnIndex);
        return toT(json);
    }

    private T toT(String json) {
        try {
            return OBJECT_MAPPER.readValue(json, type);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    private String toS(T t) {
        try {
            return OBJECT_MAPPER.writeValueAsString(t);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}

JsonTypeHandlerを継承して総称型に対応する

別の方法として、JsonTypeHandlerを継承して総称型に対応してみる。

まずは先程のJsonTypeHandlerの実装を変更する。変更点は2つ。

  1. abstractにして@MappedTypesを消し、JsonTypeHandler自体をTypeHandlerとして扱うのではなく、継承したクラスをTypeHandlerとして扱うようにする
  2. private final Class<T> type;private final TypeReference<T> type;に変更する

2のTypeReference<T> typeについては、OBJECT_MAPPER.readValue(json, type);で効果を発揮する。ObjectMapper#readValueの第二引数で一番よく使うのはRoles.classのようにクラスを渡す方法だが、List<Role>.classのように総称型を渡したい場合には、com.fasterxml.jackson.core.type.TypeReferenceを使う必要がある。使い方はnew TypeReference<List<Role>>() {}のようにnewして第二引数に渡す。

はじめの方で、TypeHandlerとJavaクラスを紐付けるために@MappedTypesが必要ということを記載していたとき、「総称型(Genric Type)から適用」する件については後半で記載するとした。ここでMyBatisのTypeHandlerの総称型による自動判定を採用して、@MappedTypesに総称型をJavaの文法上設定できない問題をクリアする。

以下のようにJsonTypeHandlerList<Role>に限定した上で継承し、コンストラクタでJsonTypeHandlerTypeReferenceを渡すことで、OBJECT_MAPPER.readValue(json, type);OBJECT_MAPPER.readValue(json, new TypeReference<List<Role>>() {});となる。

public class JsonListRoleTypeHandler extends JsonTypeHandler<List<User.Role>> {
    public JsonListRoleTypeHandler() {
        super(new TypeReference<>() {
        });
    }
}

@MappedTypesのときはList.classとしか設定できなかったので、OBJECT_MAPPER.readValue(json, type);OBJECT_MAPPER.readValue(json, List.class);であって、JsonMappingExceptionが発生していたのに対し、TypeReferenceを採用することで問題がなくなった。

JsonTypeHandlerの実装

最後にcom.fasterxml.jackson.core.type.TypeReferenceと総称型から自動判定するMyBatisの機能を組み合わせて問題解決するJsonTypeHandlerの実装を掲載する。

この方法では@MappedTypesと違ってJavaのクラスごとに対応するTypeHandlerを作成しないといけないため、クラス数が膨らんでしまう。そのためHolderクラスを用意して、内部にたくさん書けるように工夫した。

public class JsonTypeHandlerHolder {
    public static class JsonListRoleTypeHandler extends JsonTypeHandler<List<User.Role>> {
        public JsonListRoleTypeHandler() {
            super(new TypeReference<>() {
            });
        }
    }

    public abstract static class JsonTypeHandler<T> extends BaseTypeHandler<T> {

        private static final ObjectMapper OBJECT_MAPPER =
                new ObjectMapper()
                        .configure(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES, false)
                        .registerModule(new JavaTimeModule());

        private final TypeReference<T> type;

        public JsonTypeHandler(TypeReference<T> t) {
            type = t;
        }

        @Override
        public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
            String json = toS(parameter);
            ps.setString(i, json);
        }

        @Override
        public T getNullableResult(ResultSet rs, String columnName) throws SQLException {
            String json = rs.getString(columnName);
            return toT(json);
        }

        @Override
        public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
            String json = rs.getString(columnIndex);
            return toT(json);
        }

        @Override
        public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
            String json = cs.getString(columnIndex);
            return toT(json);
        }

        private T toT(String json) {
            try {
                return OBJECT_MAPPER.readValue(json, type);
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
        }

        private String toS(T t) {
            try {
                return OBJECT_MAPPER.writeValueAsString(t);
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

Mapper, DDL

SQL周りのコードを参考のため記載する。

Mapper

@Mapper
public interface UsersMapper {
    @Select("select * from users where id = #{id}")
    User selectById(int id);

    @Insert("""
            insert into users
            set email = #{email}
              , password = #{password}
              , roles = #{roles}
            """)
    int insert(User user);
}

DDL

CREATE TABLE `users` (
  `id` int NOT NULL AUTO_INCREMENT,
  `email` varchar(255) NOT NULL,
  `password` varchar(255) NOT NULL,
  `roles` json DEFAULT NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `email_UNIQUE` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=91 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci