環境
- 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点を対応しなければいけない。
- mybatis.type-handlers-packageプロパティにTypeHandlerを置くパッケージ名を設定する
@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つ。
- abstractにして
@MappedTypes
を消し、JsonTypeHandler
自体をTypeHandlerとして扱うのではなく、継承したクラスをTypeHandlerとして扱うようにする 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の文法上設定できない問題をクリアする。
以下のようにJsonTypeHandler
をList<Role>
に限定した上で継承し、コンストラクタでJsonTypeHandler
にTypeReference
を渡すことで、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