
Kotlin + SpringBootで動的なCRUDを作成
-
2023年8月1日
はじめに
こんにちは、サーバーサイドを担当しているCです。
今回は動的なCRUDについて共有したいと思います。
CRUDを実現する方法として、静的な仕組みと動的な仕組みを用いるものがあります。
今回は既に存在するデータモデルを用いた動的なCRUDの実装について紹介したいと思います。
静的
まずは、静的な仕組みからご紹介したいと思います。
静的の場合、下記のように、定義されているデータモデルに対するリクエストが必要です。
そして決められたリクエストから決められたオブジェクトやパラメータなどを用いてデータの操作を行っています。リクエストやデータ型が決まっているため、処理がシンプルで素早く完了する特徴があります。
String name = getName();
class MasterOne(
@Id val id: String,
val name: String,
val nestedModel: NestedModel = NestedModel(),
)
class MasterOneRequest(
val id: String,
val name: String,
)
fun insertMasterOne(request: MasterOneRequest) {
val save = MasterOne(
id = request.id,
name = request.name,
)
this.mongoTemplate.insert(save)
}
しかし、この方法だと新しい項目やデータモデルを追加した場合、それに合わせた新しい処理を実装する必要があります。対象のデータモデルが多い場合は実装にかなりの時間を要します。
動的
対象のデータモデルが多い場合は、動的な仕組みで実装時間の短縮を図ることができます。しかし、静的な仕組みとは違って、コンパイル時にデータ型が判定されないため、Kotlinのリフレクションが必要になります。リフレクションで対象のデータモデルの生成ができていれば、ランタイム時にオブジェクト生成やパラメータ設定が可能になるので、動的な仕組みが実現できるようになります。
一つの方法として、下記のように、パッケージ情報とクラス名を渡してランタイム時にMasterOneクラスを判定する方法があります。
package com.cruddemo.infrastructure.mongodb.model
class MasterOne(
val id: String,
val name: String,
val nestedModel: NestedModel = NestedModel(),
)
fun getClazz(classPath: String, masterName: String): KClass {
val className = "${classPath}.${masterName}"
return Class.forName(className).kotlin
}
もちろん、パッケージ情報やクラス名をコードに書かずに、DBや設定ファイルに移動すればCRUD処理を触ることなくデータ操作が可能になります。
this.getClazz("com.cruddemo.infrastructure.mongodb.model", "MasterOne")
CRUD
CRUD処理をするにはSpringBootのコントローラーやリクエストマッピングなどが必要になりますが、この部分は一般的な方法を取っているため、この記事では省きます。
Create
まずはデータを受け取ります。静的でも動的でもデータはJSON形式で受け取りますが、動的の場合は静的のように「request: MasterOneRequest
」
などの型を定義する必要なく、「Map<String, Any>
」であらゆるデータ型のマッピングができます。
@PostMapping
fun create(@RequestParam master: String, @RequestBody data: Map<String, Any>): Any {
}
データを受け取ったら、リフレクションで取得した「KClass
」を用いて、下記のようにGSONでオブジェクトにマッピングします。
fun createObjectFromMap(data: Map<String, Any>, kClass: KClass): Any {
val json = Gson().toJson(data)
return this.gson.fromJson(json, kClass.java)
}
次に、「mongoTemplate
」を利用してデータをDBに保存します。
fun createData(classPath: String, masterName: String, data: Map<String, Any>): Any {
val clazz = this.getClazz(classPath, masterName)
val insertData = this.createObjectFromMap(data, clazz)
return this.mongoTemplate.insert(data)
}
これで、登録したいデータが指定されているクラスと同じであれば、静的に定義しなくても登録できることが確認できました。
Createについてはこれで完了です。
class MasterOne(
@Id val id: String,
val name: String,
val nestedModel: NestedModel = NestedModel(),
)
{
"id":"test2",
"name":"name-test",
}
Read
ここでのポイントはPOST「@PostMapping
」を利用することです。
@PostMapping
fun read(@RequestParam master: String, @RequestBody query: Map<String, Any>): Any {
}
原則GETを利用することになっていますが、その場合は「@RequestParam
」からクエリパラメータを取得する必要があります。もちろん「@RequestParam
」からでもMap形式で受け取ることは可能ですが、下記のようなサブドキュメント部分「NestedModel
」のクエリが難しくなります。
class MasterOne(
@Id val id: String,
val name: String,
val nestedModel: NestedModel = NestedModel(),
)
class NestedModel(
val nestedName: String = "",
val nestedNumber: Long = 0,
)
次はクエリの変換が必要になります。今回は「mongoTemplate
」を利用しているので「Criteria
」に変換したほうがやり易いと思います。JSONでクエリをもらうと、値、配列またはMapの受け取りができます。下記のようにデータ配列の場合は「`in`
」にし、単体の場合は「`is`
」にします。
fun createCriteria(value: Any, fieldKey: String): Criteria {
return when (value) {
is Collection<*> -> Criteria.where(fieldKey).`in`(value.mapNotNull { it })
else -> Criteria.where(fieldKey).`is`(value)
}
}
そして、もらったクエリ形式が「Map<String, Any>
」のため、その「entry
」を見て、リフレクションでもらった「kClass
」を利用してデータを照らし合わせることができます。この方法で基本的なクエリができますが、「NestedModel
」のように入れ子構造になっている部分はできません。メソッドがかなり複雑になるので、応用編で詳述します。
fun createCriteriaList(dataMap: Map<String, Any>, kClass: KClass<*>): List {
val criteriaList = mutableListOf()
dataMap.forEach { entry ->
val value = entry.value
val member = kClass.memberProperties.find { entry.key == it.name }
?: throw RuntimeException("FIELD NAME NOT EXISTS")
val fieldKey = when {
member.javaField != null &&
member.javaField!!.getAnnotation(Id::class.java) != null -> "_id"
else -> member.name
}
criteriaList.add(this.createCriteria(value, fieldKey))
}
return criteriaList
}
「Criteria
」の用意ができたら「Query()
」を追加して、「mongoTemplate.find
」すれば該当クラスにマッピングしてくれているので、これでReadも完了です。
fun readData(classPath: String, masterName: String, queryRequest: Map<String, Any>): Any {
val clazz = this.getClazz(classPath, masterName)
val query = Query()
this.createCriteriaList(queryRequest, clazz).forEach { query.addCriteria(it) }
return this.mongoTemplate.find(query, clazz.java)
}
Update
Updateの場合は既存のデータを変更するために「key: Map<String, Any>
」が必要になるので、下記のように「UpdateRequest
」を定義してコントローラーで受け取った方がやり易いです。
class UpdateRequest(
val key: Map<String, Any>,
val data: Map<String, Any>
)
@PutMapping
fun update(@RequestParam master: String, @RequestBody request: UpdateRequest): Any {
val key = request.key
val data = request.data
}
{
"key": {
"id": "test"
},
"data": {
"id": "test",
"name": "name-test2",
"nestedModel": {
"nestedNumber": 1
}
}
}
「key: Map<String, Any>
」とdata: Map<String, Any>
を受け取ったらReadのように「Key」を取得し、存在しない場合はエラーを出します。「Data」はCreateと同じ方法でオブジェクトにマッピングしてDBに保存します。
こちらの方法はUpdateの時に差分だけではなく、項目全体を渡す必要がありますが、仕組みとしてはシンプルになります。これでUpdate処理が完了です。
fun updateData(classPath: String, masterName: String, key: Map<String, Any>, data: Map<String, Any>): Any {
val clazz = this.getClazz(classPath, masterName)
val query = Query()
this.createCriteriaList(key, clazz).forEach { query.addCriteria(it) }
val originalData = this.mongoTemplate.findOne(query, clazz.java)
?: throw RuntimeException("DATA NOT EXISTS")
val newData = this.createObjectFromMap(data, clazz)
return this.mongoTemplate.save(newData)
}
Delete
DeleteではReadと同じ方法で消したいデータ「key: Map<String, Any>
」を渡せば削除することができます。
@DeleteMapping
fun delete(@RequestParam master: String, @RequestBody key: Map<String, Any>): Any {
}
ここまでで作成してきたメソッドを利用すると、DBのデータ削除ができるようになります。これでDelete処理も完了です。
fun deleteData(classPath: String, masterName: String, key: Map<String, Any>): Any {
val clazz = this.getClazz(classPath, masterName)
val query = Query()
this.createCriteriaList(key, clazz).forEach { query.addCriteria(it) }
return this.mongoTemplate.findAndRemove(query, clazz.java)
?: throw RuntimeException("DATA NOT EXISTS")
}
応用編
サブドキュメントクエリ
前述したReadでは、内容をシンプルにするためにサブドキュメントの部分を省きましたので、ここで述べたいと思います。サブドキュメントに対応するためには、下記のような処理が必要になります。ここで注目すべきポイントは3つです。
fun createCriteriaList(dataMap: Map<String, Any>, kClass: KClass<*>, parentFieldKey: String): List {
val criteriaList = mutableListOf()
dataMap.forEach { entry ->
val value = entry.value
if (kClass == Map::class) {
val nestedKey = "$parentFieldKey.${entry.key}"
when (value) {
is Map<*,*> ->
criteriaList.addAll(
this.createCriteriaList(value as Map<String, Any>, kClass, nestedKey)
)
else ->
criteriaList.add(
this.createCriteria(value, nestedKey)
)
}
return@forEach
}
val member = kClass.memberProperties.find { entry.key == it.name }
?: throw RuntimeException("FIELD NAME NOT EXISTS")
val fieldKey = when {
member.javaField != null
&& member.javaField!!.getAnnotation(Id::class.java) != null ->
"_id"
else ->
member.name
}
val joinedFieldKey = when (parentFieldKey) {
"" -> fieldKey
else -> "$parentFieldKey.$fieldKey"
}
if (value is Map<*,*>) {
criteriaList.addAll(
this.createCriteriaList(value as Map<String, Any>, member.returnType.jvmErasure, joinedFieldKey)
)
return@forEach
}
criteriaList.add(this.createCriteria(value, joinedFieldKey))
}
return criteriaList
}
1つ目は「parentFieldKey
」です。サブドキュメントに入ると元のKeyが必要になるので、対象の子まで渡して変換する必要があります。
val joinedFieldKey = when (parentFieldKey) {
"" -> fieldKey
else -> "$parentFieldKey.$fieldKey"
}
2つ目はクエリが「Map<*,*>
」形式かどうかのチェックです。サブドキュメントの場合はデータがMap形式になるので、そこから同じ処理を続けるために再帰で行います。
if (value is Map<*,*>) {
criteriaList.addAll(
this.createCriteriaList(value as Map<String, Any>, member.returnType.jvmErasure, joinedFieldKey)
)
return@forEach
}
3つ目はクラスのデータ型が「Map::class
」形式であるかのチェックです。リフレクション先がMapの場合もあって、クラスのように扱うとエラーになるので、Mapの場合の対応も必要になります。これでサブドキュメント部分のクエリができるようになります。
if (kClass == Map::class) {
val nestedKey = "$parentFieldKey.${entry.key}"
when (value) {
is Map<*,*> ->
criteriaList.addAll(
this.createCriteriaList(value as Map<String, Any>, kClass, nestedKey)
)
else ->
criteriaList.add(
this.createCriteria(value, nestedKey)
)
}
return@forEach
}
さいごに
これで動的なCreate、Read、Update、Delete(CRUD)が可能になりました。
もちろん、様々な方法がありますが、今回の場合はデータモデルが既に存在していたので、リフレクションでクラスを生成する方法について紹介しました。今回は差分だけのUpdateや値変換などは行っていませんが、基本的な部分はこれで十分だと思うので、参考になれば幸いです。