STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

RealmからGRDB.swiftへリプレイスした話

はじめに

この記事はSTORES Advent Calendar 2023 19日目の記事です。

こんにちは、STORES レジ チームのnekowenです。
STORES レジ アプリでは一部の設定値をRealm Swiftを使って管理していましたが、この度GRDB.swift(以下、GRDB)へ置き換えてリリースしました。

背景

Realmはオブジェクト指向のアプローチを採用しており、SQLiteを直接扱うよりも軽量かつ高速でデータを扱うことができます。
Realmはとても優れたデータベースフレームワークですが、運用していく中でいくつか問題も発生していました。

まず1つ目は、Xcode(Swift)のバージョンアップによりビルドに失敗するケースが過去ちらほら発生していました。最近ではあまり発生しなくなりましたが、今後バージョンアップ時のボトルネックになってしまわないかという不安がありました。

2つ目はRealm本体が規模の大きなライブラリのため、ビルドに時間がかかってしまうことがあります。RealmをPre Buildして扱う方法もありますが、別のパッケージ管理になってしまうこと、レジアプリでは原則Swift PMで管理したいことからこの点も悩ましい部分となっていました。

これらの問題からレジアプリではRealmの依存を減らす方針をチーム内で決め、暫定としてRealmで記録されていたデータの一部をUserDefaultsやキャッシュファイルとして書き出すように仕組みを変更していました。

しばらくはこの状態でも問題ありませんでしたが、新機能などの開発により将来的にAPIリクエスト数が増えていくことが見込まれ、キャッシュの重要性が高まっていることがわかりました。
そこでレジアプリとして将来的にキャッシュ機構を作ることを念頭に、チーム内でローカルDBをどう扱っていくか相談しました。

その結果、Realmの依存を減らすことは変わりませんが、キャッシュデータの複雑性を考えるとローカルDBがあった方がよく、Realmに代わる選択肢がないか模索することになりました。

Realm以外のモバイルデータベースとしてはGRDB.swift、SQLite.swiftと複数挙げられ、この中で軽量かつ機能が豊富なGRDB.swiftが良いのではないかと考えました。

GRDB.swiftとは

GRDB.swiftはSQLiteベースのデータベースフレームワークです。テーブルのデータ構造をActive Recordパターンで表現できるので、マッピング処理を自前で用意する必要がありません。またクエリビルダーも提供されているためSQLを書く必要がなく、手軽にデータの読み書きができます。

具体的にRealmで実装した時のコードを比べてみます。
例えばItemというデータがあり、読み書きする場合Realmでは以下のように書くことができます。

class Item: Object {
    @Persisted(primaryKey: true) var id: Int
    @Persisted var name: String = ""
}

let realm = try! Realm()

// アイテムの挿入
let item = Item()
item.name = "アイテムA"

try! realm.write {
    realm.add(item)
}

// アイテムの取得
let items = realm.objects(Item.self)

GRDBでは同じ処理を以下のように書くことができます。

struct Item: Codable, FetchableRecord, PersistableRecord {
    var id: Int64?
    var name: String
}

let dbQueue = try DatabaseQueue()

// テーブル作成
try dbQueue.write { db in
    try db.create(table: "items") { t in
        t.column("id", .integer).primaryKey()
        t.column("name", .text).notNull()
    }
}

// アイテムの挿入
let item = Item(name: "アイテムA")
try dbQueue.write {
    try item.insert($0)
}

// アイテムの取得
let items = try dbQueue.read {
    try Item.fetchAll($0)
}

RDBMSであるためデータ構造の違いを考慮する必要や、テーブル作成の手間はあるものの、データ操作の扱い方はRealmと似たような感覚でできるため、移行後の学習コストも抑えることができると考えました。

とはいえ本当にうまくレジアプリにGRDBを導入して良いか不安だった面もありました。そこで良いタイミングでMake部の活動があったので、その時間で試しに導入してみて違和感がないことを確認しました。

ちなみにMake部とは、テーマ不定でモノづくりを行う部活動です。第1回を実施したときの記事があるのでよければこちらもご覧ください。

product.st.inc

導入した結果と温度感についてチーム内で改めて相談し、GRDBに移行していくことになりました。

データの移行計画

GRDBでデータを扱うようにするには、すでにRealmに書き込まれているデータをGRDBへ移動させる必要があります。 そこで以下の手順で移行計画を見積もり、実装しました。

  1. 移行が必要なModelの洗い出し
  2. Realmで使われているクラスのリネーム
  3. RealmからGRDBへデータ移行

1. 移行が必要なModelの洗い出し

まずは移行が必要なRealmのModelを洗い出し、影響範囲がどれくらいあるのか確認しました。
ざっとみたところホームのカスタマイズで使われる設定値とホームのメモの2箇所で使われていることが把握できました。

ホームのカスタマイズはアイテムやカテゴリーといった要素の表示・非表示、タブの並び順を変更できる機能で、それらのパラメータがRealmで管理されています。
影響範囲としても一機能に限られ、これらのデータをGRDBに移動させれば良いことがわかりました。

ホームのカスタマイズ画面

2. Realmで使われているクラスのリネーム

次にRelamで使われているクラスをGRDBで再利用するためリネームを行いました。 一例として、ホームのアイテムタブの設定は以下のようなクラスで管理されています。

public class CustomSettingEntity: Object {
    @Persisted(primaryKey: true) var id: String
    @Persisted var hidden: Bool = false
}

これらのクラス名にRealmというPrefixをつけました。
また基本的にこのクラスはRealm専用で将来的に使われなくなる想定なので、deprecatedであることを明示するようにしました。

@available(*, deprecated)
public class RealmCustomSettingEntity: Object {
    @Persisted(primaryKey: true) var id: String
    @Persisted var hidden: Bool = false
}

Swift側のコードはこれでOKなのですが、RealmではSwiftで定義されたクラス名を元にデータがマッピングされるため、このままではRealmが CustomSettingEntity ではなく RealmCustomSettingEntity を読みにいってしまうため既存のデータが読み取れなくなってしまいます。そのためRealm側で保持されているクラス名も一緒に変更します。

Realmのデータを変更する場合はマイグレーション処理の中で行います。
といってもやることはそこまで複雑ではなく、migration.create を呼び出しリネーム先のデータを作り、古いデータをコピー、最後にリネーム前のデータを削除すればリネーム処理の完了です。これをRealmで管理しているデータ数分行います。

try Realm(configuration: .init(schemaVersion: 2, migrationBlock: { migration, oldSchemaVersion in
    ...
    if oldSchemaVersion < 2 {
        migration.enumerateObjects(ofType: "CustomSettingEntity") {
            RealmCustomSettingEntity.migrateToV3(migration: migration, old: $0, new: $1)
        }
        ...
    }
}))

@available(*, deprecated)
public class RealmCustomSettingEntity: Object {
    ...
    public static func migrateToV2(migration: Migration, old: MigrationObject?, new _: MigrationObject?) {
        guard let old else { return }

        let newModel = migration.create("RealmCustomSettingEntity")
        newModel["id"] = old["id"]
        newModel["hidden"] = old["hidden"]

        migration.deleteData(forType: "CustomSettingEntity")
    }
}

3. RealmからGRDBへデータ移行

最後にRealmからデータを読み取りGRDBへ書き込む処理を実装しました。
GRDBにはデータベーススキーマの変更がしやすいようマイグレーションの管理機能が備わっています。
実装者はマイグレーションのバージョンと移行処理を記述することで、GRDB側で現在のデータベーススキーマバージョンと比較して必要なマイグレーション処理を行ってくれるようになります。

Realmのデータ移行もこのタイミングで行いたいので、以下のようにデータを挿入する処理を追加しています。

var migrator = DatabaseMigrator()

migrator.registerMigration("ver1") { db in
    ...
    // RealmデータをGRDB側のデータベースに追加
    if let value = realmData.customSetting {
        try db.execute(sql: "INSERT OR IGNORE INTO custom_setting(id, hidden) VALUES(?, ?)", arguments: [value.id, value.hidden])
    }
}

// マイグレーションの実行
try migrator.migrate(db)

最後に

一連の実装を含め、GRDBに移行されたバージョンが10月にリリースされました 🎉
データ移行の都合上Realmをアプリに含んでいる状態にはなりますが、データ移行がほぼ完了すればGRDBのみに切り替えていく予定です。
また今回の対応でローカルDBの基盤そのものは整えられたので今後キャッシュ機構を作る際にも活躍してくれることでしょう。