RealmSwift入門 -サーバーと自動同期できるRealm Platformを触ってみた-

こんにちは。モバイルアプリサービス部で iOS アプリエンジニアとして働き始めた田辺です。Realm Platform の使用を検討しているので記事を書くことにしました。

Realm Platform は、Realm Database に新しく Realm Object Server を統合したフレームワークです。リアルタイム同期、コンフリクト解決、イベントハンドリングなどのサーバーサイド機能を持つフレームワークです。Realm Database と Realm Object Server は連携し自動で同期します。

本記事の構成は以下になります。

  • Realm Database について
  • 公式サイトで用意されているサンプルプロジェクトを通して導入及び 2つの同期プロセスの違いを紹介
  • 料金
  • まとめ

Realm Database について

Realm Database はモバイルファーストなデータベースです。 基本的な使い方を知りたい方は本サイトの記事をご覧ください。

導入

今回は Realm Platform のクラウド版であるRealm Cloudを導入していきたいと思います。

Start with Realm Platform: Cloudと書いてあるボタンをクリックすると Realm Cloud のダッシュボードに遷移するので、右上の`Create Instance からインスタンスを作成します。

作成に成功すると以下の画像のようにインスタンスの情報がダッシュボードに表示されます。

`

作成したインスタンスをクリックすると Realm Cloud を体験できるデモプロジェクトへのリンクが用意されています。

今回は iOS 版のデモプロジェクトを使って Realm Platform を試すことにします。

2つの同期方法

Realm Platform にはデータ同期の方法があります。それぞれ

  • Query-based synchronization
  • Full Synchronization

と表現されています。サンプルプロジェクトも同期方法に対応して 2つあります。

データ同期に関する公式ドキュメントはSyncing Data - Realm Sync Docsになります。 ちょっと上手い訳が思い浮かばなかったので英語表記のままこの記事では扱います。

Query-based synchronization

Query-based synchronizationでは、アプリケーションが必要とするクエリを実行してそのクエリをsubscribeします。サーバー側でこのクエリが実行され、一致する全てのオブジェクトが同期されます。サブスクリプションが有効な間データの変更は同期されます。変更通知を受け取る API が用意されているのでその API を利用してアプリケーションを最新の状態に保ちます。

Full Synchronization

Full Syncrhonizationでは全てのコンテンツが自動でバックグラウンド同期されるので開発者側でどのデータを読み込む or 書き込むかを表現するsubscription APIを使用する必要がありません。開発者側でどのデータを同期するのか制御したい場合は Realm を分割する必要があります。 公式ドキュメントで紹介されている分割方法はグローバルな Realm データベースをベースパス以下に、ユーザーが個々に持つユーザースコープのパス以下に配置する方法でした。

使ってみる

2つあるサンプルプロジェクトのコードを見ていきますが、説明を補填する意味で必要なコードのみ抜粋する形で記事を進めます

サーバー側の準備

登録に成功したら入力したユーザー名が Realm Studio の Users タブに表示されます。以下の画面は Realm Studio というインスタンスの管理を行うことが出来るアプリケーションです。Realm Studio をインストールして開くとログイン画面が表示されるので Realm Cloud のインスタンスを建てたアカウントでログインするとこの画面を表示できます。Realm Studio についてもっと知りたい方はRealm Studio - Realm Sync Docsを参照してください。

Query-based synchronizationを使った TODO アプリ

認証

様々な認証方法があるがサンプルコードでは開発のみの利用ということで nickname 認証による登録・ログイン機能が実装されていました。

nickname 認証はパスワードを必要しない認証方法でプロトタイピングなどで便利な開発用で主に用いられる認証方法です。著しくセキュリティが脆弱なので開発環境のみで利用するようにしましょう。

サンプルコードではrootViewControllerに設定したUIViewControllerviewDidAppear(_:)メソッド内で認証が済んでいるか確認して済んでいれば TODO 一覧を表示する画面を表示して、ログインが済んでいないければ nickname 認証の入力フォームを表示してアカウントの登録、同一の文字列が送信された場合はログインさせて TODO 一覧を表示する画面に遷移させています。

if let _ = SyncUser.current {
  // ユーザーがログインしていればこちら
} else {
  // 登録 or ログインの入力画面表示(サンプルコードではUIAlertControllerを使用)
}

nickname 認証のコードは以下です。

let textField = alertController.textFields![0] as UITextField
let creds = SyncCredentials.nickname(textField.text!)

SyncUser.logIn(with: creds, server: Constants.AUTH_URL, onCompletion: { [weak self](user, err) in
    if let _ = user {
        self?.navigationController?.pushViewController(ProjectsViewController(), animated: true)
    } else if let error = err {
        fatalError(error.localizedDescription)
    }
})

データベースへの接続とデータの同期

認証が済んで次の画面に遷移するとタスクが所属するプロジェクト一覧画面が表示されます。

Query-based synchronizationサブスクリプションが作成されるまでサーバーからクライアントへのデータ同期は行われないというのが肝になっていて、サーバーと接続しただけではデータは表示されません。

サブスクリプションを作成してサーバーからデータを取得、取得したデータを元にデータを表示するまでのコードを見てみましょう。

// クエリの購読
subscription = projects.subscribe(named: "my-projects")

// データの取得開始と最初の取得終了時にactivityIndicatorのアニメーションの停止
activityIndicator.startAnimating()
subscriptionToken = subscription.observe(\.state, options: .initial) { state in
    print("Subscription State: \(state)")
    if state == .complete {
        self.activityIndicator.stopAnimating()
    }
}

// 通知のステータスに応じた処理の振り分け
notificationToken = projects.observe { [weak self] (changes) in
    guard let tableView = self?.tableView else { return }
    switch changes {
    case .initial:
        tableView.reloadData()
    case .update(_, let deletions, let insertions, let modifications):
        // 変更されたデータの更新を反映
        tableView.beginUpdates()
        tableView.insertRows(at: insertions.map({ IndexPath(row: $0, section: 0) }),
                             with: .automatic)
        tableView.deleteRows(at: deletions.map({ IndexPath(row: $0, section: 0)}),
                             with: .automatic)
        tableView.reloadRows(at: modifications.map({ IndexPath(row: $0, section: 0) }),
                             with: .automatic)
        tableView.endUpdates()
    case .error(let error):
        fatalError("\(error)")
    }
}

データの取得と更新の部分は Realm Platform 用の実装になりますが、データの新規作成と削除は通常の Realm を扱う時と同じです。Realm の書き込みトランザクション内でデータを変更します。

try! self.realm.write {
  // データベースの書き込みトランザクション内
  // ここで追加、削除、更新を行う
}

データベースに変更が行われ、サブスクリプトしたクエリのデータ変更が行われる度にobserve(_:)が呼び出され、クロージャとして渡した更新時の処理が再度走ります。

画面遷移などで ViewController が開放されるときはdeinitializer内で通知の割当解除を忘れないようにします。

deinit {
    notificationToken?.invalidate()
    subscriptionToken?.invalidate()
    activityIndicator.stopAnimating()
}

Permission について

悪意のあるユーザーが他のユーザーのデータベースを参照して変更するのを防ぐため、Permissionの説明がサンプルプロジェクトの最後にあります。

サンプルプロジェクトではタスクをまとめる Project を、作成したユーザーのみが変更出来るように Realm の Permission を変更しています。

Realm ではroleというものをユーザーに割り当てて権限管理を行いますが、デフォルトでは登録したユーザーごとに固有の権限が与えられています。サンプルプロジェクトではこのroleを使って作成した Project の管理権限を作成者のみに付与しています。

この固有の権限はPersmissionUser.roleで参照出来ます。 Project のモデル定義を行っているコードにプロパティを追加します。

class Project: Object {
  // その他プロパティの定義
  let permissions = List<Permission>()
}

その後 Project の作成ごとに適切に権限を割り当てます。ログイン中のユーザーを取り出し、ユーザーの role に permission を与えて、どの権限を与えるか設定しています。サンプルプロジェクトでは読み取り権限、更新権限、削除権限が与えられています。

// 入力されたプロジェクト名の取得
let textField = alertController.textFields![0]
let project = Project()
project.name = textField.text ?? ""
​
try! self.realm.write {
    // 書き込みトランザクション
    self.realm.add(project)
    // ログイン中のユーザーの取得
​    let user = self.realm.object(ofType: PermissionUser.self, forPrimaryKey: SyncUser.current!.identity!)!
    // List<Permission>のinitialize
    let permission = project.permissions.findOrCreate(forRole: user.role!)
    permission.canRead = true
    permission.canUpdate = true
    permission.canDelete = true
}

Full Synchronizationを使った TODO アプリ

重複する部分は説明を省きつつ進めます。

Query-based synchronizationと異なり、サンプルプロジェクトは Project がなくなりタスクを表す Item 一覧を表示して CRUD 出来るだけのアプリになっています。 Item 一覧を表示する画面が一つ Realm から値を表示する最初の画面になるので items の型をResults<Item>にします。Query-based synchronizationの TODO アプリでは Project 一覧を表示する画面の projects プロパティがprojects: Results<Project>という型になっているのと同じです。

class ItemsViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
  let items: Results<Item>
}

Full Synchronizationを有効にする

Query-based synchronizationを使った TODO アプリとイニシャライザ内のコードが異なります。 realm プロパティに Realm インスタンスを代入して必要なクエリを定義していた前回と異なり、今回は Realm インスタンスの初期化時にfullsynchronizationを有効にします。その後は読み取りたいクエリを定義する部分は前回と同様です。

変更点は実はここだけです。取得したデータを元にデータを表示するまでのコードは前回と同様observe(_:)メソッドで定義します。あとは通常の Realm を扱うときと同じように書き込みトランザクション内でデータの作成・更新・削除を行うだけです。

サーバー側から見ると作成されたデータベースがごとに同期プロセスが異なることがわかります。

スキーマの変更

Realm Platform は、スキーマの変更を反映させるマイグレーションのプロセスがローカル Realm を扱うときと異なります。

  • 非破壊的なスキーマの変更は自動でマイグレーションが適用される
  • スキーマの変更は広報互換性を持ちます。古いクライアントは新しいものと同期し続ける
  • スキーマからフィールドを削除してもデータベースからそのフィールドは削除されない。
    • 代わりに Realm がそのフィールドを無視する。オブジェクトを作成するときはそれらのプロパティには null が設定される。非Optionalなフィールドにはゼロや空を表す値が設定される。
  • スキーマバージョンを設定する必要はない
  • マイグレーションブロックを含めてはいけない

マイグレーションブロックやスキーマバージョンを知らない方は前回の RealmSwift の記事か公式ドキュメントを参照してください。

非破壊的なスキーマの変更

class User: Object {
  @objc dynamic var name = ""
}

User に age というフィールドを追加したいとします。

class User: Object {
  @objc dynamic var name = ""
  @objc dynamic var age = 0
}

マイグレーションを行いたいスコープで同期しているサーバーの URL を引数に渡してRealm.Configurationを Realm インスタンスの初期化時に渡すと変更が反映されます。

let syncServerURL = URL(string: "接続先URL/Users")!
let config = Realm.Configuration(syncConfiguration: SyncConfiguration(user: user, realmURL: syncServerURL))
let realm = try! Realm(configuration: config)

破壊的なスキーマの変更

破壊的な変更を行う場合は新しい Realm を作成する必要があります。開発中であれば手動でアプリ内の Realm ファイルを削除することもできます。 先述の通り Realm Platform で同期される Realm はローカル Realm と異なりマイグレーションブロックに対応していません。 新しいスキーマを使用して新しく Realm Platform で同期される Realm を作成し、古いRealmから新しいRealmにデータをコピーします。

破壊的な変更にあたる変更は以下です。

  • モデル定義で同じプロパティ名で型を変更する場合
  • プライマリキーの変更Optional
  • モデル定義で定義されているプロパティのOptional -> 非 Optional または非 Optional -> Optional への変更

User の name プロパティの型を非 Optional から Optional に変更してみます。

class User: Object {
 @objc dynamic var name = ""
}

class UserV2: Object {
 @objc dynamic var name: String? = nil
}

var syncServerURL = URL(string: "接続先URL/Users")!
var config = Realm.Configuration(syncConfiguration: SyncConfiguration(user: user, realmURL: syncServerURL))
// Limit to initial object type
config.objectTypes: [User.self]

let initialRealm = try! Realm(configuration: config)

syncServerURL = URL(string: "接続先URL/UsersV2")!
config = Realm.Configuration(syncConfiguration: SyncConfiguration(user: user, realmURL: syncServerURL))
config.objectTypes: [UserV2.self]

let newRealm = try! Realm(configuration: config)

スキーマの変更はローカル Realm の方がマイグレーションブロックが使える分手軽に感じました。

Realm Platform 利用上の注意点

サーバーとの通信が変更の度に行われるので、単一のトランザクションにおける一括ロードや大きなオブジェクトの同期などには特に注意が必要です。 サーバーへの負荷解消のため、Query-based synchronizationではクエリのUnsubscribingは適切に行いましょう。 他にもスレッドの問題など実装の際に気をつけるべき問題があります。データ同期に関するより詳しい説明はSyncing Data - Realm Sync Docsを参照してください

※サブスクリプションのunsubscribeについて

検索画面で特定のクエリのサブスクリプションを作成して、検索画面から異なる画面に遷移したとしてもサブスクリプションは有効なままになっています。 この時サブスクリプションをunsubscribeしないとサーバーへの負荷がかかったままになってしまいます。unsubscribeする方法はサブスクリプションの作成時に有効期限を設定する方法と明示的にunsubscribeする方法は2つあります。

let subscription = realm.objects(User.self).filter("age > 18").subscribe()
// 明示的なunsubscribe
subscription.unsubscribe()

// 定義時に有効期間内に使用されていなければ自動でサブスクリプションをunsubscribeするtimeToLiveを設定する
let subscription = realm.objects(Item.self).sorted(byKeyPath: "timestamp", ascending: false).subscribe(named: "hoge", limit: 0, update: true, timeToLive: 30.0, includingLinkingObjects: [])

料金

Realm Pricingに詳細は記載されていますが、Standard プラン以外は応相談となっていますので Standard プランのみ説明します。

Relam Platfomr Cloud 版

  • 月 30$
  • 最初の30日は評価版として無料試用可
  • 帯域幅月間 20GB
  • 10000 までの同時接続
  • 3インスタンスまで

Realm Platform Self-Hosted

サーバにセルフホスティングする場合の値段になります。

  • 年間 2000$~
    • 月額アクティブデバイス(MAD)あたりの価格とのこと
  • インスタンス数の制限はなし

まとめ

Realm Platformの日本語の情報はあまりなかったので公式ドキュメントとコードの往復でした。 自分が今回想定している用途はサーバーサイド側からローカルの Realm を最新の状態に反映させたいだけなので、Full Synchronizationによる Realm Cloud の同期を使って実装すれば要件を満たせそうだなと感じました。次の記事では開発用になっている認証や Permission 周りも本番環境に合わせて実装しながら簡易なアプリケーションを作って記事にしたいと思います。

参考・関連