
こんにちは、アプリケーションエンジニアの難波です。
BraveridgeのIoTモニタリングシステムでは、SQL関連の実装にMyBatisを利用しています。
今回はMyBatisのInterceptorを実装する機会があったので、備忘録も兼ねてブログを書きました。
物理削除するか、論理削除するか
DBを操作するアプリケーションを開発するときによく問題となるのは、DBから削除したデータの取り扱いです。
・物理削除 : 実際にデータを削除する
・論理削除 : 削除フラグを立てるだけで、実際にはデータを削除しない
個人的な経験に基づく話になりますが、論理削除は実装したくありません。
論理削除だと削除データを削除しないので、データの肥大化やSQLで常に削除フラグを意識する必要があるためです。
ですが、物理削除だと削除してしまったデータを復活させるには、バックアップからレストアするなど手間がかかります。
どちらも一長一短です。
そこで、削除データを別の場所に保存しておく、という手法を採用します。
TableのTriggerで削除データを残す
SQL Server
SQL Server だと、以下のようにtrashという削除データ保存テーブルを用意してTriggerで実現できます。
trashテーブルのDDL
CREATE TABLE trash
(
trash_id INT PRIMARY KEY IDENTITY,
contents NVARCHAR(max) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
テーブル table1 からデータを削除したときに実行されるTriggerを定義します。
CREATE TRIGGER row_deleted ON table1 FOR DELETE AS
INSERT INTO trash (contents)
SELECT (SELECT * FROM deleted AS table1 FOR XML AUTO) FROM deleted
GO
これで、table1からデータを削除すると、trashテーブルにXMLで削除データが保存されます。
ただし、このTriggerには
「table1から大量にデータを削除するときに削除時間が長くなる = 長時間テーブルロックする」
という問題があります。
MySQL
MySQLで同様のTriggerを作成する場合、データを削除するテーブルと同じ項目を持つtrashテーブルを用意する必要があります。
この方法だと、機能追加などでテーブルに項目追加した時に問題が発生することが考えられます。
システム運用を考えるとあまり良い手法ではありません。
MyBatis Interceptor
実際のシステム運用では、削除データを復活させる頻度は多くありません。
そのため、削除データをDBのテーブルではなく、ログファイルなどテキストファイルに保存していても、そこまで問題にはなりません。
そこで、MyBatisのInterceptorで、削除したデータをログに出力する機能を実装します。
実装したMyBatisの削除データロガーInterceptorのソースコード (Kotlin)
@Intercepts(
Signature(type = Executor::class, method = "update", args = [MappedStatement::class, Any::class]),
)
@Component
class SqlDeleteInterceptor : Interceptor {
override fun intercept(invocation: Invocation): Any {
return invocation.proceed()
}
}
InterceptsアノテーションのSignatureに
type = Executor::class, method = "update"
をセットします。
これで、insert, upate, delete を実行する前に独自処理を追加することができます。
override fun intercept(invocation: Invocation): Any {
if (invocation.args.count() < 2) {
return
}
val mappedStatement = invocation.args[0] as? MappedStatement ?: return
if (mappedStatement.sqlCommandType != SqlCommandType.DELETE) {
return
}
return invocation.proceed()
}
mappedStatement.sqlCommandType をチェックすることで、SQLがdeleteかどうか判別できます。
val parameter = invocation.args[1]
val boundSql = mappedStatement.getBoundSql(parameter)
val connection = (invocation.target as? Executor)?.transaction?.connection ?: return
val sql = boundSql.sql.replace("DELETE FROM", "SELECT * FROM")
.let {
if (it.contains("LIMIT")) it
else "$it LIMIT 100"
}
val ps = connection.prepareStatement(sql)
(parameter as? MapperMethod.ParamMap<*>)?.let {
boundSql.parameterMappings.toList().forEachIndexed { i, parameterMapping ->
ps.setObject(i + 1, parameter[parameterMapping.property].toString())
}
} ?: run {
parameter?.let { ps.setObject(1, it) }
}
boundSql.sql に実行予定のSQL文がセットされています。
select 文に置換して安全のために LIMIT 100 を追加しています。
ps.executeQuery().use { rs ->
while (rs.next()) {
(1..rs.metaData.columnCount)
.joinToString { rs.getString(it) ?: "" }
.let { logger.info("Row : $it") }
}
}
最後にselect文を実行して、取得した内容をログに出力します。
ーーーーーーーーーーーーーーーーー
出力されるログ
Row : value1, value2, value3
ーーーーーーーーーーーーーーーーー
テーブルから取得したデータをそのままログファイルに出力すると、個人情報がログに残ってしまう場合があります。
その場合は、テーブル名と項目名を指定してログに書き出す内容から排除するなど、対応する必要があると思います。
まとめ
MyBatisのInterceptorで削除データをログ出力する機能を実装しました。
この方法だと、大量データの削除時に、長時間テーブルをロックするなど悪影響がありません。
また、ログ出力する削除データの種類を必要に応じて選択することも可能です。