概要・前提
本記事は C# の Realm でできる基本的なこと/Realm の特徴的なところをコードを交えながら解説します。完全なソースコードは以下で参照できるようにしています。
Realm とはアプリでの利用を主目的としたデータベースです。同じ目的で使えるデータベースには SQLite や Apache Derby などがあります。Realm はモバイルアプリを強く意識して作られたもので速度が早いらしいです。私が Realm を覚える一番の動機です。
C# の Realm は Xamarin 限定のものという印象が強かったですが、少なくとも現時点ではどこでも自由に使えるようです。私は WPF アプリで使ってみようとしています。この記事でのサンプルは余計な複雑さを排除するためコンソールアプリを主体にしています。一部 WPF アプリ(≒GUI アプリ)固有の話題も挙げます。
Realm にはアプリで使う単なるデータベースの機能だけではなく、専用サーバーを使ってクラウド越しに内容を同期するという機能がありますが、存在のみしか知らないので本記事では扱いません。
本記事執筆時点の Realm.NET のバージョンは、3.3.0 です。本記事は、Realm 本家のページと合わせて読むことをお勧めします。
本記事執筆時点の日本語の説明ページは 1.6.0 のものです。バージョンは古いですが、十分通じる内容だと思います。最新の情報が知りたい場合は英語のページを読みましょう。
また、本記事には筆者の解釈や想像が含まれている点をあらかじめご了承ください。
Realm の特徴
1. オブジェクトデータベースである
Realm はオブジェクトデータベースです。クラスの定義がスキーマになり、オブジェクトを直接入れたり出したりできます。入れたり出したりできます。
CREATE TABLE などの DDL はありません。代わりに RealmObject を継承したクラスがテーブルの定義になります。プロパティがテーブルの項目です。主キーやインデックスなどの細かい設定はクラスのフィールドに専用の属性を設定します。
using Realms;
namespace Simple.Model
{
// RealmObject を継承したクラスがテーブルになる。
class Table1 : RealmObject
{
// プロパティがカラムになる。
// 主キーには PrimaryKey 属性を付ける。
[PrimaryKey]
public int PrimaryKey { get; set; }
public string Column1 { get; set; }
public string Column2 { get; set; }
// インデックスを作る項目には Indexed 属性を付ける。
[Indexed]
public string Column3 { get; set; }
}
}
INSERT/SELECT などの SQL もありません。オブジェクトの追加・取得・削除は専用のメソッドを使い、オブジェクトを直接入れたり、出したり、消したりします。WHERE など複雑なものは LINQ を使います。UPDATE は取得したオブジェクトのプロパティを直接書き換えます。
※ 本文中の realm 変数は、Realms.Realm.GetInstance によって取得した Realms.Realm クラスのインスタンスです。
realm.Add(new Table1
{
PrimaryKey = 1,
Column1 = "Value1",
Column2 = "Value2",
Column3 = "Value3"
});
var rowByPrimaryKey = realm.Find<Table1>(1);
using System.Linq;
// ...
foreach (var row in realm.All<Table1>().Where(i => i.Column1 = "Value2-1"))
{
// ...
}
Realm.All などで Realm が返す System.Linq.IQueryable オブジェクトは LINQ の一部機能をサポートしていないようです。以上の事例では Where を使っていますが、Select を使うと実行時例外が発生します。どうしても必要な場合は ToList などで一度リストにしてから Select するなどの回避方法があります。
var updateTarget = realm.Find<Table1>(1);
updateTarget.Column2 = "NewValue";
var deleteTarget = realm.Find<Table1>(1);
realm.Remove(deleteTarget);
リレーショナルデータベースを閲覧する汎用ツール群は使えません。専用の Realm Studio を使って閲覧します。
2. ライブラリ+アセンブリ書き換え技術で成り立っている
Realm は他の一般的なライブラリと同じように NuGet でライブラリをプロジェクトに追加して使います。Realm を入れると追加で Fody が入ります。
Fody に馴染みのない方もいるでしょう。ビルドしたアセンブリに追加の処理を書き込む強力な能力を持った AOP ツールです。FodyWeavers.xml はどういった追加処理を書き込むかを決定するための Fody の設定ファイルです。Realm を入れたプロジェクトをコンパイルするにはこの FodyWeavers.xml が必要になります。
Fody で有名なものに PropertyChanged.Fody があります。これは MVVM の実装を助けてくれるもので、プロパティの定義から INotifyPropertyChanged 向けの実装を組み込んでくれるスグレモノです。
Realm も Fody で RealmObject のプロパティにデータベースの遅延読み込みや更新処理などを組み込んでいます。ILSpy でコンパイル後の dll を見ると組み込まれたコードを確認できます。以下は Table1 クラスに埋め込まれたコードの一部です。
// ...
internal class Table1 : RealmObject
{
// ...
[Preserve]
[WovenProperty]
public string Column1
{
[CompilerGenerated]
get
{
if (base.IsManaged)
{
return base.GetStringValue("Column1");
}
return this.Column1;
}
[CompilerGenerated]
set
{
if (!base.IsManaged)
{
this.Column1 = value;
base.RaisePropertyChanged("Column1");
}
else
{
base.SetStringValue("Column1", value);
}
}
}
// ...
}
自分が実装した覚えのない動作をするのは Fody が組み込んだ処理が裏にあるということを覚えておくと良いでしょう。
3. トランザクションがある
Realm にもトランザクションの概念があります。オブジェクトの変更は専用のブロックで囲う必要があります。ブロックで囲わないと実行時例外になります。
realm.Write(() =>
{
realm.Add(new Table1
{
PrimaryKey = 1,
Column1 = "Value1-1",
Column2 = "Value1-2",
Column3 = "Value1-3"
});
var updateTarget = realm.Find<Table1>(1);
updateTarget.Column2 = "NewValue";
var deleteTarget = realm.Find<Table1>(1);
realm.Remove(deleteTarget);
});
ブロック内の変更は確定するまで外(別スレッド)からは見えません。
new Thread(() =>
{
// スレッド用に Realm オブジェクトを用意する。
var threadRealm = Realm.GetInstance(config);
var updateTarget = threadRealm.Find<Table1>(1);
// 更新もトランザクション内で行う。
threadRealm.Write(() =>
{
updateTarget.Column1 = "NewValue";
Console.WriteLine("Updated: {0}", threadRealm.Find<Table1>(1));
// トランザクションの完了を少し待機する。
Thread.Sleep(2000);
});
}).Start();
// 更新が済むまで少し待機する。
Thread.Sleep(1000);
// トランザクションが完了する前は変更内容が見えない。
// Realm オブジェクトは毎回取得しなおす。取得し直さないと他スレッドでの変更は見れない。
realm = Realm.GetInstance(config);
var noUpdateObject = realm.Find<Table1>(1);
Console.WriteLine("NoUpdate: {0}", noUpdateObject);
// トランザクションが完了するまで少し待機する。
Thread.Sleep(3000);
// トランザクションが完了したので変更内容が見える。
realm = Realm.GetInstance(config);
var updatedObject = realm.Find<Table1>(1);
Console.WriteLine("Updated: {0}", updatedObject);
Realm オブジェクトは Realm.GetInstance を実行したスレッド内でしか利用できません。別スレッドにインスタンスを渡してそのまま使うと実行時例外になります。そのためスレッドの中では別途 Realm オブジェクトを取得して書き込みを行っています。
また、別スレッドで書き込んだ内容は既に作成済みの Realm オブジェクトからは取得できません。再度 Realm.GetInstance で新しく取得しなおした Realm オブジェクトを使う必要があります。
Realm オブジェクトはデータベースへのアクセス毎に Realm.GetInstance で取得するのが基本パターンになると想像できます。
事例ではトランザクションの効果を確認するために Thread を使いましたが、Realm で書き込みを非同期に行うには Realm.Write の代わりに Realm.WriteAsync を使うのが一般的です。ただし、この Realm.WriteAsync は UI スレッドで実行した場合にのみ非同期処理になります。UI スレッド以外で実行した場合は Realm.Write と同様に同期処理になります。従ってコンソールで非同期に書き込みを行うにはスレッドを自分で作成する必要があります(この制約はいったい何のためのものなんだろう?)。
4. 自由自在にリレーションを作れる
RealmObject 間のリレーションは単方向/双方向・一対一/一対多/多対多を問わずに設定できます。自己参照や循環参照にも特別な制約はなく自由に設定できます。
リレーションを作るには RealmObject を継承したクラスをプロパティにします。
リレーションを作るためだけに主キーを設ける必要はありません1。主キーは明示的に主キーアクセスでオブジェクトを取得できるようにしたいときに設けます。
using System.Collections.Generic;
using Realms;
namespace Relation.Model
{
class Table1 : RealmObject
{
public string Name { get; set; }
// 一対一は単にプロパティを作る。
public Table2 Object1 { get; set; }
// 一対多は IList のプロパティを作る。
public IList<Table3> ObjectN { get; }
}
}
プログラム上のオブジェクトの参照関係はそのまま維持されます。同一のオブジェクトを複数のオブジェクトのプロパティに設定して参照の共有もできます。
これらのリレーションはアクセスするまで解決が遅延されます。そのため1つのオブジェクトを取得したことによってリレーションでつながったオブジェクトネットワークがいきなり全て読み込まれるということはなく、小さい初期オーバーヘッドで読み込めます。
また、双方向リレーションをサポートする特殊な機能として [Backlink] 属性を使うこともできます。[Backlink] 属性は IQueryable<T> 型のプロパティに設定します。[Backlink] 属性の引数には逆参照元のプロパティ名を指定します。
using System.Linq;
using Realms;
namespace Relation.Model
{
class Table2 : RealmObject
{
public string Name { get; set; }
// Table1.Object1 の逆参照
[Backlink(nameof(Table1.Object1))]
public IQueryable<Table1> Owners1 { get; }
// Table1.ObjectN の逆参照
[Backlink(nameof(Table1.ObjectN))]
public IQueryable<Table1> OwnersN { get; }
}
}
5. データベースから取得したオブジェクトには制約がある
RealmObject は2つの状態を持ちます。Standalone(=Unmanaged) と Managed の2つの状態です。状態によって RealmObject の挙動は大きく変わります。
単にインスタンスを自分で new した場合は Standalone です。この状態のオブジェクトは単なるデータクラスとして振る舞います。プロパティに制約はなく自由にアクセスできます。
Standalone のオブジェクトをデータベースに書き込むとその瞬間から Managed になります。データベースから取得したオブジェクトは最初から Managed です。この状態のオブジェクトは永続化された値を参照するようになります。また、プロパティの変更が永続化されるようになります。それに起因してプロパティのアクセスに以下の制約が発生します。
- トランザクションの制約
- スレッドの制約
トランザクションの制約とは、プロパティの変更にトランザクションが必要になるという制約です。トランザクション外でプロパティへの値の設定を行うと実行時例外になります。
スレッドの制約とは、取得時と異なるスレッドからプロパティにアクセスできなくなるという制約です。取得時と異なるスレッドからプロパティにアクセスすると実行時例外になります。
以上が Managed オブジェクトに発生する制約です。
Standalone から Managed への状態遷移はデータベースへの書き込みを行うことによって起こりますが、Managed から Standalone への逆の状態遷移はできません。
現在のオブジェクトの状態を確認するには IsManaged を使います。
Managed オブジェクトが持っている制約はとても強いものです。特に GUI アプリを作る場合には事前に知っておくべきことです。それを考慮して作る必要があります。
6. オブジェクトに変更通知機能がある
RealmObject は INotifyPropertyChanged を実装しており、変更通知を受け取ることができます。この機能は MVVM で GUI を構築する際に利用できる機能です。
変更通知機能は Standalone でも Managed でも使えます。Managed の場合、変更の通知はトランザクションが確定したタイミングで行われます。Realm.Find で別個にオブジェクトを取得したものでもデータベース内で同一のオブジェクトへの参照であれば変更通知が届きます。
以下は WPF での事例です。
using System.IO;
using System.Windows.Input;
using Prism.Commands;
using Realms;
using Notification.Model;
namespace Notification
{
class MainWindowViewModel
{
public const int COUNTER_KEY = 1;
public RealmConfiguration Config { get; }
public Counter Counter { get; }
public ICommand CountUpCommand { get; }
public MainWindowViewModel()
{
Config = new RealmConfiguration(Path.Combine(
Directory.GetCurrentDirectory(), "Notification.realm"));
var realm = Realm.GetInstance(Config);
Counter = realm.Find<Counter>(COUNTER_KEY);
if (Counter == null)
{
Counter = new Counter { CounterKey = COUNTER_KEY, Count = 0, };
realm.Write(() =>
{
realm.Add(Counter);
});
}
CountUpCommand = new DelegateCommand(CountUp);
}
public void CountUp()
{
var realm = Realm.GetInstance(Config);
realm.Write(() =>
{
// Realm.Find で取得しなおしたオブジェクト経由で変更しても変更通知は伝わる。
var target = realm.Find<Counter>(COUNTER_KEY);
// INotifyPropertyChanged による変更通知で画面の表示が自動で変わる。
target.Count++;
});
}
}
}
<Window x:Class="Notification.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Notification"
mc:Ignorable="d"
Title="MainWindow" Height="200" Width="200">
<DockPanel>
<!-- Managed オブジェクトのプロパティをバインドすることで変更の自動反映ができる -->
<TextBlock DockPanel.Dock="Top" Text="{Binding Counter.Count}" FontSize="50" HorizontalAlignment="Center" />
<Button DockPanel.Dock="Bottom" Content="Count Up" Command="{Binding CountUpCommand}" FontSize="30" />
</DockPanel>
</Window>
Managed オブジェクトのプロパティを Binding で表示しています。対象オブジェクトを別途 Realm.Find で取得して更新すると、INotifyPropertyChanged を通じて変更が伝わり即座に表示が切り変わります。
Managed オブジェクトにはトランザクションの制約があるため、双方向バインディングはできないことに注意してください。
7. マイグレーションコードを簡単に実装できる
アプリのバージョンアップにともなって、既存テーブルのスキーマが変わることがあるでしょう。アプリを作るにあたり、データベースのマイグレーションコードが実装できることは必須事項と言えます。Realm にも旧データを新データに適合させるためのマイグレーションコードを簡単に組み込む手段がちゃんと用意されています。
詳細については本家サイトを参照してください。
-
1つ1つのオブジェクトに Realm が内部的な主キーを独自に設定しているのではないかと想像しています。 ↩