Spring Data R2DBC + r2dbc-mysqlでJSONを取り扱うときにハマったので備忘録として書き残しておきます。
結論としては、Spring Data R2DBCのCustom Conversionsを利用しました。
なお、本記事ではコードを原則Kotlinで記述しています。
課題
下記のようなSQLで作成されるテーブルをMySQL上に持つとします。
create table article
(
id bigint unsigned auto_increment,
category_ids json not null,
constraint article_pk primary key (id)
);
--- data exmaple
INSERT INTO article
VALUES (null, json_array(1, 2));
このテーブルは「記事」エンティティに関するテーブルで、カテゴリIDのリストを保持したカラムを持ちます。
そして、このテーブルに対応する「記事」エンティティを次のように定義し、ReactiveCrudRipositoryインタフェースを追加します。
data class Article(
@Id
val Id: Long? = null,
val categoryIds: List<Int>,
)
interface ArticleRepository : ReactiveCrudRepository<Article, Long>
これで、articleRepository.findById(1)
のようにしたら問題なくエンティティを取得できそうな気もしますが、下記のようなエラーが発生します。
Failed to convert from type [java.lang.String] to type [java.lang.Integer] for value ‘[1]’; nested exception is java.lang.NumberFormatException: For input string: “[1]”
原因
色々調べてみたところ、r2dbc-mysqlでは現在、JSONタイプをサポートせず、VARCHARとしてパススルーさせているようです。
ドライバ「側」が複数のライブラリ等に対するサポートを行うのは望ましくないからという考えからそのような実装になっているようですね。
JSON mapping is typically an application-level concern (JSON-P, JSON-B) because all transformations and bindings are reflected on application or client-level. Having support directly in the driver comes with several drawbacks as drivers need to either commit to a single library or provide support for multiple implementations (Jackso, GSON, Apache Johnzon). This isn't typically the thing you want to deal with.
該当のIssueでは、Custom Codecsを提供するとのことでしたので、リファレンスをみつつ実装するかーと思ったのですが・・・
2020/5/7時点の最新版であるR2DBC MySQL 0.8.2.RELEASEにおいてはCustom Codecインタフェースが公開されていない不具合が解消されていないようで、まだこの方法は利用できないみたい。
対策
結局、冒頭で述べた通りですがSpring Data R2DBCのCustom Conversionsという機能を利用することにしました。
まず、こちらを参考にReadingConverterとWritingConverterを実装します。
ReadingConverterでは、取得したRowから該当するカラムのデータを取り出し、エンティティを戻り値としています。
この際に、categoryIds
はJacksonによってList<Int>
に変換をしています。
@ReadingConverter
class ArticleReadConverter : Converter<Row, Article> {
override fun convert(source: Row): Article {
val id = source.get("id", Long::class.java)
val categoryIdsString = source.get("category_ids", String::class.java)
val mapper = ObjectMapper()
val categoryIds = mapper.readValue(categoryIdsString, object : TypeReference<List<Int>>() {})
return Article(id = id, code = code!!, name = name!!, categoryIds = categoryIds)
}
}
WritingConverterでは、Article
をOutboundRow
に変換しています。
この際、公式のドキュメントではSettableValue.from
メソッドを利用していますが、現在こちらのメソッドは非推奨になっているのでParameter.from
等を使いましょう。
@WritingConverter
class ArticleWriteConverter : Converter<Article, OutboundRow> {
override fun convert(source: Article): OutboundRow {
val row = OutboundRow()
with(row) {
put("id", Parameter.fromOrEmpty(source.id, Long::class.java))
put("category_ids", Parameter.from(ObjectMapper().writeValueAsString(source.categoryIds)))
}
return row
}
}
最後に、AbstractR2dbcConfiguration
クラスを継承した設定クラスを実装し、getCustomConverters
メソッドで実装したReadingConverterとWritingConverterを登録します。
@Configuration
class R2dbcConfiguration(
private val connectionFactory: ConnectionFactory
) : AbstractR2dbcConfiguration() {
override fun connectionFactory() = connectionFactory
override fun getCustomConverters() = mutableListOf<Any>(ArticleReadConverter(), ArticleWriteConverter())
}
これで、「記事」エンティティを取得したり永続化したりできるようになりました!
Appendix
- spring-data-r2dbc - Mapping
- Spring Data R2DBC - Reference Documentation #17.3. Mapping Configuration
下は、StackOverflowで質問して、結局自己解決したのでセルフ回答したやつ。
最後までお読みいただきありがとうございました。