ORMラッパーライブラリRoomをRealmと取り換えてみる(Kotlin)


良くドメイン駆動設計では「データベースの実装は気にするな」とか言いますが、実際にDB周りを気にせず開発することは少ないですよね。DBの実装を切り替えられるとは言え実際に切り替える人も滅多にいません。メリットは皆無ですが試しにやってみようと思います。具体的にはRealmで書いたDBアクセスをRoomに置き換えます。

Kotlin用のRoomのサンプルをダウンロードする

正確にはRoom & Rx Java (Kotlin)ですがAndroidStudioでサンプルがダウンロードできます。
プロジェクトを開けていない画面かFile>new>Import Sampleで

ただしプロジェクトに問題があってビルドできませんし、RxJavaは使わないのでPersistanceディレクトリの中身だけもらいます。
entity

@Entity(tableName = "users")
data class User(@PrimaryKey
                @ColumnInfo(name = "userid")
                val id: String = UUID.randomUUID().toString(),
                @ColumnInfo(name = "username")
                val userName: String)

公式にdata classがサポートされているのは嬉しいですね。

dao

@Dao
interface UserDao {

    /**
     * Get a user by id.

     * @return the user from the table with a specific id.
     */
    @Query("SELECT * FROM Users WHERE userid = :id")
    fun getUserById(id: String): Flowable<User>

    /**
     * Insert a user in the database. If the user already exists, replace it.

     * @param user the user to be inserted.
     */
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUser(user: User)

    /**
     * Delete all users.
     */
    @Query("DELETE FROM Users")
    fun deleteAllUsers()
}

FlowableはRxJavaのクラスなので後で取り除きます。これに限らずListなどのコンテナも使えます。

database

@Database(entities = arrayOf(User::class), version = 1)
abstract class UsersDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao

    companion object {

        @Volatile private var INSTANCE: UsersDatabase? = null

        fun getInstance(context: Context): UsersDatabase =
                INSTANCE ?: synchronized(this) {
                    INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
                }

        private fun buildDatabase(context: Context) =
                Room.databaseBuilder(context.applicationContext,
                        UsersDatabase::class.java, "Sample.db")
                        .build()
    }
}

コンパニオンオブジェクトを使っていますがKotlinなのでSingletonのオブジェクトを別に作るほうがそれっぽい気がします。

モジュールを作る

DDDなのでDatabaseはモジュールに隔離します。
基になったプロジェクトは以前紹介したこれです。

build.gradleはこんな感じ。

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'


dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"

    // Room用に追加
    implementation "android.arch.persistence.room:runtime:1.0.0"
    kapt "android.arch.persistence.room:compiler:1.0.0"

    // DDDなのでモデルのモジュールに依存する
    compile project(path: ':fehsbattlemodel')
}

entityをRealm用に作ったクラスからコピペします。

@RealmClass
open class RealmArmedHero(
        @PrimaryKey
        var nickname: String = "",
        var baseName: String = "",
        var weapon: String = "NONE",
        ...
) : RealmObject() {
    fun toModelObject(): ArmedHero {}
}
@Entity(tableName = "heroes")
data class RoomArmedHero(
        @PrimaryKey
        var nickname: String = "",
        var baseName: String = "",
        var weapon: String = "NONE",
        ...
) {
    fun toModelObject(): ArmedHero {}
}

RealmはEntityとなるクラスを継承して機能を自動生成する都合上、@RealmClassとopen,RealmObjectの継承が必要です。
一方、Room側はEntityのクラスはそのまま使います。@Entity(tableName = "")アノテーションをつけるだけです。敢えてカラム名は指定せず極力手を抜いてみます。

daoはRealm側にも対応するものがありますが名前が良くわかりません。サンプルはContentって名前でしたが…。

object RealmArmedHeroContent : RealmContent<ArmedHero>() {
    /** realmのkotlin用ハンドラ */
    private var realm: Realm by Delegates.notNull()

    /** 初期化ブロック。テーブル変更時などはここでマイグレーションすることになる */
    init {
        realm = Realm.getDefaultInstance()

        realm.executeTransaction {
            //            realm.deleteAll()
        }
    }

    override fun delete(item: ArmedHero): Int {
        val results = realm.where(RealmArmedHero::class.java).equalTo("nickname", item.name).findAll()
        realm.executeTransaction {
            results.deleteAllFromRealm()
        }
        return results.size
    }

    override fun deleteById(id: String): Int {
        val results = realm.where(RealmArmedHero::class.java).equalTo("nickname", id).findAll()
        realm.executeTransaction {
            results.deleteAllFromRealm()
        }
        return results.size
    }

    override fun createOrUpdate(item: ArmedHero): ArmedHero {
        item.apply {
            realm.executeTransaction {
                realm.copyToRealmOrUpdate(RealmArmedHero(name, baseHero.name, weapon.value, refinedWeapon.value, assist.value, special.value, aSkill.value, bSkill.value, cSkill.value, seal.value, rarity, levelBoost, boon.name, bane.name
                        , defensiveTerrain, atkBuff, spdBuff, defBuff, resBuff, atkSpur, spdSpur, defSpur, resSpur))
            }
        }
        return item
    }

    override fun allItems(): List<ArmedHero> {
        return heroDao.allHeroes().map { e -> e.toModelObject() }
    }

    override fun getById(id: String): ArmedHero? = heroDao.getHeroById(id)?.toModelObject()

}

※クラスを直接指定するちょっと古いコードです。

@Dao
interface HeroDao {

    @Query("SELECT * FROM heroes WHERE nickname = :id")
    fun getHeroById(id: String): RoomArmedHero

    @Query("SELECT * FROM heroes")
    fun allHeroes(): List<RoomArmedHero>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertHero(hero: RoomArmedHero)

    @Query("DELETE FROM heroes")
    fun deleteAllHeroes()

    @Query("DELETE FROM heroes WHERE nickname = :id")
    fun deleteHero(id: String)
}

@Queryアノテーションの中にSQLを記述します。@Insert(onConflict = OnConflictStrategy.REPLACE)でcreate/updateになりそうです。(試し忘れ)
Room側のDaoは実際にはレポジトリを継承してDaoを呼び出すオブジェクトが必要になります。

object RoomArmedHeroContent : ModelObjectRepository<ArmedHero> {

    var appContext: Context? = null
    val heroDao get() = UsersDatabase.getInstance(appContext!!).heroDao()

    override fun delete(item: ArmedHero): Int {
        heroDao.deleteHero(item.name)
        return 1
    }

    override fun deleteById(id: String): Int {
        heroDao.deleteHero(id)
        return 1
    }

    override fun createOrUpdate(item: ArmedHero): ArmedHero {
        item.apply {
            heroDao.insertHero(RoomArmedHero(name, baseHero.name, weapon.value, refinedWeapon.value, assist.value, special.value, aSkill.value, bSkill.value, cSkill.value, seal.value, rarity, levelBoost, boon.name, bane.name
                    , defensiveTerrain, atkBuff, spdBuff, defBuff, resBuff, atkSpur, spdSpur, defSpur, resSpur))
        }
        return item
    }

    override fun allItems(): List<ArmedHero> {
        return heroDao.allHeroes().map { e -> e.toModelObject() }
    }

    override fun getById(id: String): ArmedHero? = heroDao.getHeroById(id)?.toModelObject()
}

やってることはほとんど同じですね。SQL相当のものをリポジトリに書くかDaoに書くかどうかくらいです。

Entityが増えたDBはこんな感じに。

@Database(entities = arrayOf(User::class, RoomArmedHero::class), version = 1)
abstract class UsersDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao

    abstract fun heroDao(): HeroDao

    companion object {

        @Volatile private var INSTANCE: UsersDatabase? = null

        fun getInstance(context: Context): UsersDatabase =
                INSTANCE ?: synchronized(this) {
                    INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
                }

        private fun buildDatabase(context: Context) =
                Room.databaseBuilder(context.applicationContext,
                        UsersDatabase::class.java, "Sample.db").allowMainThreadQueries()
                        .build()
    }
}

@Database(entities = arrayOf(User::class, RoomArmedHero::class), version = 1)に対象のEntityを追加して、Daoも増やします。
allowMainThreadQueries()は別スレッドに分けずにアクセスするための記述です。面倒なので追加しましたが無くて済むならないほうが良いでしょう。

テスト

@RunWith(AndroidJUnit4::class)
class RoomInstrumentedTest {
    @Test
    @Throws(Exception::class)
    fun useAppContext() {
        // Context of the app under test.
        val appContext = InstrumentationRegistry.getTargetContext()

        appContext.deleteDatabase("Sample.db")

        RoomArmedHeroContent.appContext = appContext
        val modelHero = ArmedHero(StandardBaseHero.get("エフラム")!!,"new エフラム")
        RoomArmedHeroContent.createOrUpdate(modelHero)
        val insertedArmedHero = RoomArmedHeroContent.getById("new エフラム")
        assertEquals("new エフラム",insertedArmedHero!!.name)
    }
}

Modelに宣言したインタフェース経由で普通にアクセスできました。実機でも同じように取り換えて動かせます。同時に使うこともできましたがきっと意味はないでしょう。

感想

・両方Androidに依存しているので今回は意味は無いが、サーバサイドに移植するときにアノテーションにSQLを書くライブラリ、例えばmyBatisと共用するとかなら意味があるかも?
・Realmのほうが色々楽ではあるがSQLの自動生成などが絡んでくるとRoomも悪くないか?
・Realmはカラムが増える分にはマイグレーション抜きでも動いたりするけどRoomは厳しい