KINTO Tech Blog
android

Roomのマイグレーション

長谷川
長谷川
Cover Image for Roomのマイグレーション

はじめに

こんにちは、KINTOテクノロジーズの長谷川です。
普段はAndroidエンジニアとして、myrouteというアプリの開発をしています。
この記事ではmyrouteのAndroid開発を通じて経験した、データベースのマイグレーションについて紹介します。

記事の概要

RoomはAndroidにおいて、データをローカルに簡単に永続化することができる公式のライブラリです。データをデバイスに保存することはユーザー視点で、オフラインでアプリを使用できるようになるなど大きなメリットがあります。一方で開発者視点では、いくつか必要な作業があります。その一つがマイグレーションです。Room公式ではデータベースの自動マイグレーションに対応していますが、複雑なデータベースの変更を伴うアップデートなどでは手動で対応するケースもあります。本記事では簡単な自動マイグレーションから複雑な手動マイグレーションまで、いくつかのユースケースと共に紹介します。

正しくマイグレーションしないとどうなる?

ところで正しくマイグレーションに対応しないと、どうなるのでしょうか?アプリ内の対応状況にもよりますが、大きく分けて2パターンがあります。

  • アプリが落ちる

  • データが消える

「アプリが落ちる」の方は、Roomを触ったことがある方なら体験したことがあるかもしれません。それぞれの場合に応じて、以下のようなエラーが発生します。

  • DBバージョンが更新されたのに、適切なマイグレーションが提供されていない場合
A migration from 1 to 2 was required but not found. Please provide the necessary Migration path
  • スキーマを更新したのにDBバージョンがアップデートされていない場合
Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number.
  • 手動マイグレーションが正しく動作していない場合
Migration didn't properly handle: HogeEntity().

基本的には全て開発環境でも発生するはずなので、そこまで問題になることはないと思います。ただし後述するfallback~系の対応により、マイグレーションの失敗をもみ消していたりするととても気付きにくく、場合によっては本番環境だけ発生する可能性もあるので注意が必要です。

「データが消える」の方はどうでしょうか?実はRoomはDBオブジェクトの作成時にfallbackToDestructiveMigration()という関数を呼ぶことができます。これはマイグレーションに失敗した場合データを恒久的に削除して、アプリを正常に起動することができる関数です。上記で紹介したエラーなどの対策のためか、それともDBのマイグレーションは手間がかかるため避けたのかは分かりませんがたまに使われているケースを見ます。これを行うとマイグレーションに失敗した場合データが消えてしまう上に気づきづらいのでなるべくマイグレーションを正常に行うようにしましょう。

マイグレーションの4個のシナリオ

ここからはアプリ開発を行う上で発生しそうなスキーマの更新を4つの例とともに紹介します。

1. 新しいテーブルの追加

新しいテーブルを追加する場合は既存のデータに影響しないため、自動マイグレーションすることができます。
例えばDBバージョン1ではHogeClassというエンティティがあり、DBバージョン2でHugaClassというエンティティを追加した場合、以下のようにautoMigrationsAutoMigration(from = 1, to = 2)のような形で渡すだけで大丈夫です。

@Database(
    entities = [
        HogeClass::class,
        HugaClass::class, // 追加
    ],
    version = 2, // 1 -> 2
    autoMigrations = [
        AutoMigration(from = 1, to = 2)
    ]
)
abstract class AppDatabase : RoomDatabase() {}

2. テーブルの削除や名前変更、columnの削除や名前変更

削除と名前の変更に関しては自動マイグレーションが可能ですが、AutoMigrationSpecというものを定義する必要があります。
一番発生しそうなcolumn名の変更の例として、UserというエンティティのnameというcolumnをfirstNameという名前に変更したとします。

@Entity
data class User(
    @PrimaryKey
    val id: Int,
    // val name: String, // old
    val firstName: String,  // new
    val age: Int,
)

まずはAutoMigrationSpecを実装したクラスを定義します。そして、@RenameColumnというアノテーションを付与し、変更するcolumnに対して必要な情報を引数で渡します。
作成したクラスをAutoMigrationの対応するバージョンに渡し、それをautoMigrationsに渡します。

@RenameColumn(
    tableName = "User",
    fromColumnName = "name",
    toColumnName = "firstName"
)
class NameToFirstnameAutoMigrationSpec : AutoMigrationSpec

@Database(
    entities = [
        User::class,
        Person::class
    ],
    version = 2,
    autoMigrations = [
        AutoMigration(from = 1, to = 2, NameToFirstnameAutoMigrationSpec::class),
    ]
)
abstract class AppDatabase : RoomDatabase() {}

他にも@DeleteTable@RenameTable@DeleteColumnというアノテーションが用意されており、削除、名前の変更はこれにより簡単に対応を行うことができます。

3. columnの追加

columnの追加は個人的には一番発生する可能性が高いと思います。ここではUserというエンティティに対して、heightという身長を表すcolumnを追加したとしましょう。

@Entity
data class User(
    @PrimaryKey
    val id: Int,
    val name: String,
    val age: Int,
    val height: Int, // new
)

columnの追加は手動マイグレーションが必要です。理由はRoomにheightのデフォルト値を教えるためです。以下のようにMigratonを継承したオブジェクトを作成し、Databaseオブジェクト作成時にaddMigration()に渡すだけです。database.execSQLの中で必要なSQLステートメントを記述します。

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE User ADD COLUMN height Integer NOT NULL DEFAULT 0"
        )
    }
}

val db = Room.databaseBuilder(
  context,
  AppDatabase::class.java, "database-name"
)
  .addMigrations(MIGRATION_1_2)
  .build()

4. 主キーを追加する

筆者が経験したアプリでは、主キーを追加するケースもありました。テーブル作成時に想定していた主キーだけでは一意性が保てず、他のcolumnを主キーに追加する場合です。例えばUserテーブルにて、idが今まで主キーでしたが、nameも主キーにして複合主キーになったとします。

// DBバージョン 1
@Entity
data class User(
    @PrimaryKey
    val id: Int,
    val name: String,
    val age: Int,
)

// DBバージョン 2
@Entity(
    primaryKeys = ["id", "name"]
)
data class User(
    val id: Int,
    val name: String,
    val age: Int,
)

この場合はAndroidに限った話ではありませんが、新たにテーブルを作り直す方法が一般的です。下記のSQLステートメントではUserNewというテーブルを新しい主キーの条件で作成し、Userテーブルの情報をコピーします。その後既存のUserテーブルは削除して、UserNewテーブルの名前をUserに変更します。

val migration_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("CREATE TABLE IF NOT EXISTS UserNew (`id` Integer NOT NULL, `name` TEXT NOT NULL, `age` Integer NOT NULL, PRIMARY KEY(`id`, `name`))")
        database.execSQL("INSERT INTO UserNew (`id`, `name`, `age`) SELECT `id`, `name`, `age` FROM User")
        database.execSQL("DROP TABLE User")
        database.execSQL("ALTER TABLE UserNew RENAME TO User")
    }
}

マイグレーションが正しく動作するか確かめよう!

ここまで紹介したマイグレーションの例以外にも、複雑なケースはたくさんあります。筆者が関わっているアプリでも外部キーが関わり合っているテーブルの変更などがありました。そのような場合SQLステートメントをゴリゴリ書いていくしかないのですが、本当にそのSQLが正しく動いているか確認したくなります。そのためにRoomにはMigrationをテストする方法が提供されています。

以下のテストコードでmigrationが適切にできているかテストすることができます。なおテストを行う場合、予め各DBバージョンのスキーマをエクスポートしておく必要があります。詳しくはスキーマをエクスポートするを参考にしてみてください。昔のDBバージョンのスキーマをエクスポートしていなかった場合も、gitのタグなどから過去のバージョンを特定し、スキーマをエクスポートしておくことをお勧めします。

ポイントとしてはmanualMigrationsというリストで定義された変数のように、プロダクションコードとテストコードで実行するmigrationとして同じ値を参照することです。これにより、プロダクションコードでmigration5_6を追加したとしても、テストコードが自動でそれを検証してくれるため安心です。


// production code
val manualMigrations = listOf(
    migration1To2,
    migration2To3,
    // 3->4は自動マイグレーションとする
    migration4To5,
)

// test code
@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java,
    )

    @Test
    @Throws(IOException::class)
    fun migrateAll() {
        helper.createDatabase(TEST_DB, 1).apply {
            close()
        }

        Room.databaseBuilder(
            InstrumentationRegistry.getInstrumentation().targetContext,
            AppDatabase::class.java,
            TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            openHelper.writableDatabase.close()
        }

    }
}

まとめ

RoomのマイグレーションをいくつかのUseCaseで紹介しました。
手動マイグレーションはなるべく避けたいですが、そのためにはチーム全体でテーブル設計をしっかり行うことが鍵だと思います。またDBバージョンごとにスキーマをエクスポートすることも忘れずに行いましょう。そうしないと、gitなどで履歴を遡ってスキーマをエクスポートして、それを検証する未来の開発者がちょっと大変ですからね。
以上になります。

参考

https://developer.android.com/training/data-storage/room/migrating-db-versions?hl=ja

Facebook

関連記事 | Related Posts

We are hiring!

【iOS/Androidエンジニア】モバイルアプリ開発G/東京・大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。

【部長・部長候補】/プラットフォーム開発部/東京

プラットフォーム開発部 について共通サービス開発GWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームの開発を手がけるグループです。KINTOの名前が付くサービスやTFS関連のサービスをひとつのアカウントで利用できるよう、様々な共通機能を構築することを目的としています。