メインコンテンツへスキップ
  1. Posts/

[H2 Database] JSONカラムに対するConverterエラーの状況

NineKoo9
著者
NineKoo9
目次

要点まとめ
#

H2とMySQLはJSONタイプのデータをJDBCドライバーレベルでそれぞれ異なるJavaタイプにマッピングします。H2はbyte[]、MySQLはStringです。この差異のために、MySQL向けに書いたString<->オブジェクトConverterがH2のテスト環境では動作しません。

問題の状況
#

プロジェクトをテストしていたところ、以下の画像のようにConverterが見つからないというエラーが発生しました。

現在Spring Data JDBCを使用しており、Converterが正しく登録されていないのかどうか確認しましたが、Converterは正常に登録されていました。

状況をより具体的に把握するため、Data JPA・Data JDBC・H2・MySQLを切り替えながら確認したところ、このエラーはMySQLで開発しているときには発生せず、H2に接続したテストコードの実行時のみ発生していました。

Data JPAを使用する場合:

Error attempting to apply AttributeConverter … Caused by: java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.MismatchedInputException

Data JDBCを使用する場合:

No converter found capable of converting from type [byte[]] to type ~~

というエラーメッセージが表示されます。

状況を再現するために、以下のようなコードを用意しました。

public class Member {
    @Id
    @Column("MEMBER_ID")
    private Long id;
    private String username;
    private Address address;
    public Member(String username, Address address) {
        this.username = username;
        this.address = address;
    } 
}
// Data JPAのConverterとData JDBCのConverterで
// JacksonライブラリのObjectMapper.writeValueAsString()を使って
// Address <-> String の変換を行う
public class Address {
    private String street;
    private String city;
    private String state;
    private String zip;
    public Address(String street, String city, String state, String zip) {
        this.street = street;
        this.city = city;
        this.state = state;
        this.zip = zip;
    }
}
-- AddressオブジェクトをJSONタイプとして保存
CREATE TABLE MEMBER (
    MEMBER_ID BIGINT PRIMARY KEY AUTO_INCREMENT,
    USERNAME VARCHAR(255) NOT NULL,
    ADDRESS JSON
);

結論から言うと、これはH2データベースがJSONタイプを処理する方式がMySQLと異なることで発生するエラーでした。

H2とMySQLのJSONタイプ処理の違い
#

H2のJSONタイプ処理
#

-- String リテラルとして保存 → エスケープされたJSON String形式で保存される
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES('テスト名', '{"city":"seoul", "street":"nowon"}');
-- FORMAT JSON を明示 → きれいなJSON Object形式で保存される
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES('テスト名', JSON '{"city":"seoul", "street":"nowon"}');

H2は上のように1つ目のクエリを実行すると、MEMBER_ID 3番の行のようにADDRESSカラムにエスケープされたJSON String形式のデータが保存されます。

(以下ではMEMBER_IDを省略して1番・2番・3番と呼ぶことにします。)

そして2つ目のクエリのようにJSONというフォーマットを指定すると、1番・2番の行のようにきれいな形式で保存されます。

3番のようなエスケープされたJSON String形式をH2ではJSON Stringと呼んでいます。

H2ではこの動作について以下のように説明しています。

FORMAT JSONなしでStringを保存すると、文字列リテラルの中にJSON文字列が入った形式に暗黙的に変換されます。(’text’ -> JSON ‘“text”’)

JSONテキストを含むStringリテラルを渡したい場合は、明示的にFORMAT JSONを指定する必要があります。

INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?);
-- 2番目のバインド変数にFORMAT JSONを明示
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ? FORMAT JSON);

Data JPAやData JDBCを使ってデータアクセスメソッドを実行すると、1つ目のクエリのようにバインド変数形式のクエリが発行されることを見かけたことがあると思います。

1つ目のクエリでは3番の行のようにエスケープされたJSON String形式でデータが保存されます。

2つ目のクエリはバインド変数の後にFORMAT JSONを明示しています。この場合、1番・2番の行のようにきれいに保存されます。

追記として、H2のドキュメントには以下のように記載されています。

To set a JSON value with java.lang.String in a PreparedStatement use a FORMAT JSON data format (INSERT INTO TEST(ID, DATA) VALUES (?, ? FORMAT JSON)) or use setObject(parameter, jsonText, H2Type.JSON) instead of setString().

H2 Documentation

まとめると、純粋なJDBCのPreparedStatementを直接使う場合は、以下の2つの方法でこの問題を解決できます。

// 方法1: SQLにFORMAT JSONを明示
PreparedStatement ps = conn.prepareStatement(
    "INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ? FORMAT JSON)"
);
ps.setString(2, jsonString);

// 方法2: setObject()にタイプヒントを指定
PreparedStatement ps = conn.prepareStatement(
    "INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?)"
);
ps.setObject(2, jsonString, H2Type.JSON);

しかしSpring Data JPAやData JDBCは、PreparedStatementをフレームワークが内部的に生成しパラメーターバインディングまで管理するため、開発者が特定のパラメーターに対してFORMAT JSONsetObject()のタイプヒントを直接指定することができません。結局Spring Dataの環境では上記の2つの方法はいずれも適用しづらいです。

以下のSQLログを見ると、Spring DataはAddressの値をjava.lang.String型としてバインドしています。そのためH2ではエスケープされたJSON String形式で保存されます。

そしてこのデータをSELECTして取得すると、以下のようにエスケープされた文字列リテラルがさらにもう一重包まれた形になっています。

MySQLのJSONタイプ処理
#

INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES('テスト名', '{"city":"seoul", "street":"nowon"}');
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?);

MySQLでJSONタイプを扱う方法はいくつかありますが、上記のクエリのように特定のフォーマット形式を指定しなくても、以下の画像のようにエスケープされたString形式ではなくきれいなJSON形式でDBに保存されます。

そのためSpring Dataの技術を使っても問題ありません。

byte[] vs String — 保存方式が異なる理由
#

まず、エスケープ処理されたデータもJSONタイプとして正しいデータです。実際に通常のString(varchar)はJSONとして有効ではないため、JSONタイプを指定したH2のカラムにはINSERTできません。

H2ではJSONをbyte[]またはStringとして扱います。保存した結果にも差が出て、byte[] でH2のJSONカラムに保存するとJSON Object(エスケープされていないきれいな形式)が保存されます。

しかし、AddressオブジェクトのようにJacksonライブラリのObjectMapper.writeValueAsString()を使ってStringに変換し、JSONテキスト形式のリテラルをH2のJSONカラムに保存すると、Stringリテラルにエスケープ処理が施されて保存されます。

この差異がSELECT時のConverterエラーの直接的な原因となります。Data JDBCとData JPAのそれぞれでどのように問題が発生するか見ていきましょう。

Data JDBCにおけるエラーの原因と解決
#

エラーの原因 — H2ドライバーのbyte[]マッピング
#

Data JDBCはConverter(DBのソース値 -> Address)を選ぶとき、各DBドライバーが提供するタイプのフォーマットを基にConverterを選択します。つまり、H2では保存された値とJava Objectのマッピングにおいて、JSONタイプはbyte[]型にマッピングします。

H2ドライバー内部のValueToObjectConverterを見ると、JSONタイプはvalue.getBytes()を呼び出してbyte[] 型として返していることが確認できます。

H2ドライバー内部のValueToObjectConverter

そしてData JDBCはクエリ実行後に得た結果(H2 ResultSet)をエンティティに変換しようとするときにConverterが必要であり、適切なConverterのタイプを探す際にH2 ResultSetに保存された値のタイプをヒントとして取得します。H2はこの過程でJSON値の場合にbyte[]型を渡します。

図で大まかに表すと以下のような流れです。(実際よりかなり単純化していますので、理解のための参考として見てください。)

そのため、以下のようなString -> Address Converterを登録していても、それが見つからないというエラーが発生します。

// Data JDBCのConverter
@Slf4j
@RequiredArgsConstructor
@ReadingConverter
public class JsonToAddressConverter implements Converter<String, Address> {
    private final ObjectMapper objectMapper;
    @Override
    public Address convert(String source) {
        try {
            return objectMapper.readValue(source, Address.class);
        } catch (IOException e) {
            log.info("JSON型をAddressオブジェクトへの変換に失敗しました。");
            throw new RuntimeException(e);
        }
    }
}

H2はJSONデータをbyte[]にマッピングするため、INSERTのときにString型ではなくbyte[]でマッピングすればJSONタイプの読み書きが両方正常に動作します。しかしこの場合、MySQLではINSERT時にエラーが発生します。そしてH2のためだけにJSONタイプとして扱われるオブジェクトをbyte[]に変えることは適切な解決策とはいえません。

MySQLでエラーが発生しない理由
#

MySQLはこの過程でJSON値の場合にString型をマッピングします。 そのため例外は発生しません。(追記として、INSERTのときもString型で入れる必要があります。そのためbyte[]型はエラーになります。)

解決方法
#

Data JDBCではまだこの問題が解決されていないようです。個人的にオープンソースへ貢献したいという気持ちから原因を調べましたが、

  1. データをINSERTするとき、H2はJSONフォーマットを指定する必要があります。そうでなければbyte[]型に変換して保存する必要がありますが → MySQLではエラーが発生します
  2. H2とMySQLのドライバーはJSONタイプを互いに異なるフォーマットで渡します → マッチングするConverterのタイプが変わります。H2はbyte[]とオブジェクト間の変換、MySQLはStringとオブジェクト間の変換戦略を設定する必要があります

このような複数のDBエンジンの型変換の差異を汎用的に処理するためには、単純にソースコードを数行修正するだけでは解決できないことが分かりました。

そのため、現在選択できる最善の方法は3つあります。

1つ目は、Spring Boot Profileを分離して、テストコードではH2専用Converterを登録する方法を適用することです。このConverterはbyte[] ↔ Addressを変換します。

2つ目は、H2とMySQLのJSONタイプに設定したフィールドをVARCHARまたはTEXTタイプに変更することです。TEXTタイプを指定したカラムにJSON形式の文字列を保存することで、上記の問題を解決できます。ただしこの方法ではDBレベルのJSONバリデーションが失われます。JSON_EXTRACT()のようなJSON関数も使えなくなりますが、アプリケーションでJSONデータに対するDBクエリ(検索、フィルタリングなど)を行う必要がなく、単純にシリアライズされたオブジェクトを保存・取得する用途のみであれば、このトレードオフは十分許容できます。

3つ目は、Dockerコンテナ上でテストコードを実行する方法もあります。TestContainersを使えばDockerコンテナ上でMySQL DBサーバーを起動し、その上でテストコードを実行できます。この方法は本番環境とテスト環境を一致させられる点で優れていますが、テストを一度実行するのに時間がかかるというデメリットがあります。

私の場合は2つ目の方法を選択しました。1つ目のProfile分離でH2用Converterを作成する方法は、今後の管理が複雑になる恐れがあると判断したためです。JSONに変換して管理すべきクラスが増えるほど、それらすべてに対して別途テスト用Converterを用意しなければなりません。プロジェクトでJSONカラムに対してDBレベルのクエリが不要だったため、H2とMySQLの両方で差異を妥協できるTEXTタイプで保存する2つ目の方法を選択しました。

Data JPAにおけるエラーの原因と解決
#

エラーの原因 — エスケープされた文字列のデシリアライズ失敗
#

JPAはConverterを選ぶとき、Data JDBCとは異なる方法で動作します。Data JDBCは接続しているDBのドライバーにタイプを問い合わせるのに対し、JPAは変換しようとするタイプをあらかじめ把握しています。そしてそのタイプに合わせてResultSetから値を取り出します。

図で簡単に表すと以下のようになります。

このためData JPAはData JDBCと異なり、Converterが見つからないというエラーは発生しません。JPAはAttributeConverter<Address, String>でDBカラムのタイプがStringであることを既に知っているので、ResultSetからgetString()を呼び出して値を取得します。

しかしDBから読み取った値をString値に変換した後、以下のようなConverterを使うことになります。

// Data JPAのConverter
@Slf4j
@RequiredArgsConstructor
@Converter(autoApply = true)
public class AddressConverter implements AttributeConverter<Address, String> {
    private final ObjectMapper objectMapper;
    @Override
    public String convertToDatabaseColumn(Address address) {
        try {
            return objectMapper.writeValueAsString(address);
        } catch (Exception e) {
            log.info("AddressタイプをJsonに変換できません。");
            throw new RuntimeException(e);
        }
    }
    @Override
    public Address convertToEntityAttribute(String source) {
        try {
            return objectMapper.readValue(source, Address.class);
        } catch (Exception e) {
            log.info("JsonタイプをAddressタイプに変換できません。");
            throw new RuntimeException(e);
        }
    }
}

この過程でDBから読み取ったString型のvalueは以下のようになっています。

JacksonライブラリのObjectMapperは、エスケープ処理された文字列をさらに一重包んだStringリテラルをAddressオブジェクトに変換できません。

そのため、Addressオブジェクトを生成する過程でMismatchedInputExceptionのエラーメッセージが出力されました。

上の画像を再掲します。

補足として、JPAは1番・2番の行のようにきれいに保存されたJSONデータについては、ConverterのソースタイプをStringにしてもbyte[]にしても両方読み取ることができます。JPAのJdbcValueExtractorがConverterで宣言されたタイプに応じてgetString()またはgetBytes()を適切に呼び出し、H2ドライバーがJSONデータに対して両方のメソッドをサポートしているためです。

しかしData JDBCは、1番・2番の行のようにきれいに保存されたJSONデータであっても、Address ↔ Stringの変換戦略の場合、byte[]との変換戦略ではないという理由で変換ができません。

解決方法 — hypersistence-utils
#

JPAでは2021年に、MySQL・Oracle・PostgreSQL・H2のようなDBを使う際にJSONタイプの変換を可能にしたhypersistence-utilsをVlad Mihalcea氏が作ってくださっています。

@Getter
@NoArgsConstructor
@ToString
@Table(name = "MEMBER")
@Entity
public class JpaMember {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String username;
    @Type(JsonType.class) // このアノテーション一つでH2でもJSONタイプとして保存
    private Address address;
    public JpaMember(String username, Address address) {
        this.username = username;
        this.address = address;
    }
}

@Type(JsonType.class)を宣言すると、HibernateがJsonType内部でシリアライズ/デシリアライズを処理するため、別途@Convert(converter = AddressConverter.class)を一緒に使う必要はありません。JsonTypeが各DBエンジンに合った保存/取得戦略を内部で分岐処理しているためです。

以下のリンクを参考にしてください。

How to map a JSON column with H2, JPA, and Hibernate

hypersistence-utilsの内部を分解する
#

実際にデバッグしてJsonTypeの内部を覗いてみると、核心はJsonJdbcTypeDescriptor内部のresolveJdbcTypeDescriptor()メソッドでした。

JsonJdbcTypeDescriptor内部のresolveJdbcTypeDescriptor()

このメソッドは接続されたDBの方言(Dialect)を検出してJDBCタイプの処理戦略を分岐します。H2Dialectの場合はJsonBytesJdbcTypeDescriptor.INSTANCEを返しますが、これは先ほど確認した「H2ではbyte[]で保存するとエスケープなしのきれいな形で保存される」という原理をそのまま適用したものです。

結局hypersistence-utilsはDBの方言を検出して、H2であればbyte[]ベースの戦略を、その他のDBでは各環境に合った戦略を選択することで、DBエンジンごとに異なるJSON処理の差異を内部で吸収していたのでした。