Subscribed unsubscribe Subscribe Subscribe

Islands in the byte stream

Technical notes by a software engineer

Android Orma入門

2015年の11月から開発を続けているAndroid用O/R Mapper Ormaですが、このほどv1.0.0をリリースしたので入門記事を書きました*1

UPDATE: この記事の対象はv4.1.0です。最新版はリポジトリでご確認ください。

Orma - ORM for Android

関連エントリ: ActiveAndroidからOrmaに移行するための4つのステップ - Islands in the byte stream

Table of Contents

Ormaとは何か

OrmaはAndroid用のORMです。実装としては SQLiteDatabase のラッパーです。ActiveAndroidのように簡単で、GreenDAO のように高速なORMを目指して開発しています。

開発の動機や特徴は AndroidのORMに求めること、あるいはOrmaを開発した話 を参照のこと。

パフォーマンスについては 天下一「AndroidのORM」武道会(2015年版)を参照のこと。これによれば、2015年12月の時点では、SQLiteDatabaseのラッパーORMとしてはGreenDAO, DBFlowに匹敵する速度を誇ります。またOrma内のベンチマークによれば、RealmやSQLiteOpenHelperによる手書きのコードにも引けをとりません。

速度以外では、次の2つが大きな強みだと考えています。

1. SchemaDiffMigrationによる自動マイグレーション

DBのマイグレーションはどの環境でも大きな悩みの種です。大抵のORMはマイグレーション機構を備えていますが、いずれも独特のノウハウが必要です。

Ormaはこの点を解消するため、SQLiteのDDL (Data Definition Language; CREATE TABLE + CREATE INDEX)をパースし、差分を自動的に検出してマイグレーションのためのSQLを発行する仕組みを持っています。あらゆるケースに対応できるわけではありませんが、多くのケースで安全にマイグレーションが可能となっています。

具体的には、以下のパターンは機械的に対応できます。

  • テーブルの追加
  • カラムの追加
  • カラムの削除
  • カラムの制約(e.g. UNIQUE)やデフォルト値の変更

以下のパターンは対応できないため、コードでマイグレーションステップを記述する必要があります。

  • テーブルのリネーム
  • カラムのリネーム

2. 補完に優しく型安全なインターフェイス

Ormaはコンパイル時にJavaのソースコードを生成します。これの生成したヘルパクラスが直接操作するインターフェイスとなるのです。

これにより、たとえばActiveAndroidであれば次のように書くところを:

public static List<Item> getAll(Category category) {
    return new Select()
        .from(Item.class)
        .where("Category = ?", category.getId())
        .orderBy("Name ASC")
        .execute();
}

Ormaでは次のように書きます。

public static List<Item> getAll(OrmaDatabase orma, Category category) {
    return orma.selectFromItem()
        .categoryEq(category)
        .orderByNameAsc()
        .toList();
}

ActiveAndroidだとクラスインスタンスや文字列を渡しているところがすべてメソッド名として存在していますね。これらのメソッドは補完の対象になるため、typoすることなくモデルクラスの操作ができるのです。またこれにより、型安全なプログラミングが可能になり、コンパイル時に多くの誤りを検出できるようになっています。

導入

annotation processorとランタイムライブラリからなるので、それを両方ともdependencies ブロックで宣言します。

// app/build.gradle
dependencies {
    compile 'com.github.gfx.android.orma:orma:4.1.0'
    annotationProcessor 'com.github.gfx.android.orma:orma-processor:4.1.0'
}

これでOrmaを使えるようになります。

モデルクラスの定義

Ormaでは、テーブルに対応するスキーマはPOJOにアノテーションを付加したものです。Ormaのannotation processorがアノテーションをみてヘルパークラスを生成します。

スキーマを定義するには、次のようにモデルクラスを@Table, @PrimaryKey, @Colum アノテーションで注釈します。カラムはデフォルトで NOT NULL になるので、NULLを許容する場合は @Nullable アノテーションが必要だということに注意してください。

@Table
public class Todo {

    @PrimaryKey(autoincrement = true)
    public long id;

    @Column(indexed = true)
    public String title;

    @Column
    @Nullable // allows NULL (default: NOT NULL)
    public String content;

    @Column(indexed = true)
    public boolean done;
}

モデルのための制約はほとんどなにもないので、任意のインターフェイスを実装したりベースクラスを継承してしてかまいません。モデルに求められる条件は以下のいずれかです。

  • 空のpublicコンストラクタがあること
  • カラムのすべてを受け取るコンストラクタがあること(コンストラクタに@Setterの付加が必要)

もちろんモデルクラスをSerializableParcelableにすることもできます。Ormaはモデルに対して特別なことを要求しないようにしており、次のようにすべてのフィールドをfinalにした不変クラスにすることすらできます。なおこの場合はコンストラクタに @Setter アノテーションが必要です。

@Table
public class Todo {

    @PrimaryKey(autoincrement = true)
    public final long id;

    @Column(indexed = true)
    public final String title;

    @Column
    @Nullable
    public final String content;

    @Column

    @Column(indexed = true)
    public final boolean done;

    @Setter
    public Todo(long id, String title, String content, boolean done) { /* ... */ }
}

@PrimaryKey のオプション

@PrimaryKey には2つのオプションがあります。

  • boolean auto (defaultは true) - PKをSQLiteに生成させたくない場合はfalseにしてください
  • boolean autoincrement (defaultは false) - AUTOINCREMENT の付加の有無です。これを有効にするとPKが単調増加するようになり、ソートが意味のある操作になります

サーバーサイドから取得した値にidが含まれるのであれば、 auto = false にして外からidを与えるようにしてください。

@Column のオプション

@Column には複数のオプションがあります。ここでは代表的なオプションを紹介します。

  • boolean indexed (defaultはfalse)- インデクスが作られて、クエリビルダに該当カラム用のヘルパが生成されます。たとえば @Column(indexed = true) String title とすると、後述する SelectorRelation などに titleEq(), titleIn() などのメソッドが追加されます。
  • boolean unique (defaultは false) - UNIQUE制約がつけられます。なお@PrimaryKeyはSQLの仕様で自動的にUNIQUEになります
  • String defaultExpr - カラム定義のときに DEFAULT句を追加します。これが有効なのは新しくカラムを追加するときです。NOT NULL なカラムを新しく追加するときは、自動マイグレーションのために必ずこれが必要となっています。

その他のオプションについては @Column を参照してください。

データベースハンドル OrmaDatabase

モデルを定義したら一度ビルドしてください。するとOrmaDatabaseTodo_Relationなどのヘルパクラスが生成されて参照できるようになります。

Ormaの操作は OrmaDatabaseクラスのインスタンス経由で行います。これをDagger2などのDIで @Singleton にしておくことをおすすめします。

@Singleton
@Provides
public OrmaDatabase provideOrmaDatabase(Context context) {
    return OrmaDatabase.builder(context)
            .build();
}

デフォルトの設定だと、デバッグビルド時に実行するSQLをlogcatに出力したりメインスレッドでの書き込みで例外を投げたりします。これらはbuilderの設定で変更できます。

CRUD操作

さて、データベースハンドルを作れたらCRUD操作をしましょう。OrmaのCRUD操作には2つの体系があります。

1. Inserter, Selector, Updater, Deleterヘルパを使った生のSQLに近い操作

これらは Inserter<Todo>, Todo_Selector, Todo_Updator, Todo_Deleter のような名前で参照します。

Inserter<Todo> の実体はプリペアドステートメントで、次のように使います。

OrmaDatabase orma = ...;
Todo todo = ...;

// 単発でINSERTするだけならこれで
orma.insertIntoTodo(todo);

// 何度もINSERTするならInserter<T>経由で
Inserter<Todo> inserter = orma.prepareInsertIntoTodo();
inserter.execute(todo);
inserter.executeAll(Arrays.asList(todo));
// RxJavaのSingle<T>で実行スレッドを変えつつ非同期実行
inserter.executeAsObservable(todo)
    .subscribeOn(Schedulers.io())
    .subscribe(...);

Selector, Updater, Deleter はプリペアドステートメントではなくクエリビルダです。

Todo_Selector:

Todo_Selector selector = orma.selectFromTodo()
    .doneEq(true)
    .orderByIdDesc();
// Todo_SelectorはIterableなので直接forループすることができる
for (Todo todo : selector) { ... }
// その他コレクションライクなメソッドたち
Todo todo = selector.get(0);
boolean empty = selector.empty();
int count = selector.count();
List<Todo> = selector.toList();

// Cursorを直接触ることもできる
Cursor c1 = selector.execute();
// 集積関数もOK
Cursor c2 = selector.executeWithColumns("max(id)", "min(id)");

Todo_Updater:

// 特定のtodoを done = true にする
orma.updateTodo()
    .idEq(todo.id)
    .done(true)
    .execute();

Todo_Deleter:

// 特定のtodoを削除する
orma.deleteFromTodo()
    .idEq(todo.id)
    .execute();

これらのヘルパーメソッドの特徴は、SQLを想像しやすいインターフェイスであることです。しかし、通常はOrmaDatabaseを直接使うのではなく、Relation経由でこれらのクラスを生成することをおすすめします。

2. Relationヘルパ経由でモデルクラスを中心とした操作

Relation(具象クラスはTodo_Relationなど)はあるモデルに対応するヘルパークラスで、ほかのヘルパークラスを生成する窓口でもあります。このクラスは、あるモデルを操作するときのヘルパークラスの中心となることを意図しています。

また、Relationインスタンスは検索条件とソート条件を持つことができます。これにより、あるtableのサブセットであり特定の順番を持つRelationを作れます。

たとえば、Todoモデルには次のようなメソッドを定義するとします。 autoincrement = true のとき、primary keyは単調増加するので、このrelationからリストを得るとTodoオブジェクトを新しい順に取得できます。

@Table
class Todo {
    // ...

    // すべてのTodo
    public static Todo_Relation relation(OrmaDatabase orma) {
        return orma.relationOfTodo().orderByIdDesc();
    }

    // Todoのうち、done = true なサブセット
    public static Todo_Relation relationInDone(OrmaDatabase orma) {
        return relation(orma).doneEq(true);
    }

    // Todoのうち、 done != true なサブセット
    public static Todo_Relation relationInNotDone(OrmaDatabase orma) {
        return relation(orma).doneNotEq(true);
    }
}

RelationからはさらにInserter, Selector, Updater, Deleterを生成できます。このときSelectorはRelationの検索条件とソート条件を引き継ぎ、UpdaterとDeleterは検索条件を引き継ぎます。次のコード例を参照ください。

// 以下は orma.selectFromTodo().orderByIdDesc().toList()と同じ
Todo.relation(orma).toList();

// 以下は orma.deleteFromTodo().doneEq(true).execute() と同じ
Todo.relationInDone(orma).deleter().execute();

Relationはまた、 OrmaListAdapterOrmaRecyclerViewAdapter を使うときに必要です。詳細は以下を参照のこと。

example/ListViewFragment.java

example/RecyclerViewFragment.java

ほかのモデルクラスとの関連(associations)

has-oneやhas-manyなどのassociationsもある程度サポートされています。

モデルの関連は、Javaのクラス的には直接保持するように見えるdirect associationsと、 SingleAssociation<T> 経由で保持するindirect associationsがあります。どちらもテーブル定義的には親が子のprimary keyを持つ実装となっているので、マイグレーションすることなくいつでも交換可能です。

他のモデルを直接保持する(direct associations)

direct associationsはあるモデルが別のモデルを直接保持する形のassociationsです。SELECT で子のカラムも一気にとってくるため*2、場合によってはindirect associationsよりも高速です。なにより、通常のPOJOとして記述するだけなのでクセがなく使いやすいというのも特徴です。

使い方はexampleにあるCategoryとItemをみてください。この二つのモデルの関連は、 a category has many items / an item belongs to a category となっています。

has-many使いやすいとは言いがたいのですが、OrmaDatabaseをアプリケーションレベルでsingletonにするといくらか使いやすくなると思います。

SingleAssociation<T> を使う関連(indirect associations)

indirect associationsはあるモデルが他のモデルを直接保持するのではなく、 SingleAssociation<T> として持ちます。SELECT のときに子のカラムを取得せず、必要になったタイミングで SELECT を発行する*3ので、状況によってはdirect associationsよりも高速かもしれません。しかし使用感に独特のクセがあるので通常はdirect associationsのほうが扱いは簡単でしょう。

ほかのクラスの埋め込み(embedded objects)

ほかのモデルクラスとの関連ではなく、オブジェクトをシリアライズしてカラムに直接保存するという方法もあります。たとえば、java.util.Dateandroid.net.Uriなどはこの方法で扱いますし、フィールドの検索が必要なければ任意のリソースオブジェクトを埋め込んでもいいでしょう。

type adapterはこのようなときにクラスのシリアライズとデシリアライズを行うための仕組みです。 @StaticTypeAdapter で注釈したクラスの静的メソッドを生成されるコードに直接記述するので、ジェネリクスでは表現できない long などのプリミティブ型も扱えます。

たとえば、Google Play Servicesの LatLngのtype adapterは次のように実装します。

@StaticTypeAdapter(
        // 対象となる型
        targetType = LatLng.class,
        // シリアライズしてどの型にするか(ほかには long, double, byte[] などを指定可能)
        serializedType = String.class
)
public class LatLngAdapter {

    // nullチェックはこのメソッドを呼び出す前に行われるので不要
    @NonNull
    public static String serialize(@NonNull LatLng source) {
        return source.latitude + "," + source.longitude
    }

    // nullチェックはこのメソッドを呼び出す前に行われるので不要
    @NonNull
    public static Location deserialize(@NonNull String serialized) {
        String[] values = serialized.split(",");
        return new LatLng(
            Double.parseDouble(values[0]),
            Double.parseDouble(values[1]));
    }
}

デフォルトで提供しているtype adapterもいくつかあります。こちらはユーザ定義とは違ってシリアライザ・デシリアライザの実装だけですが、カスタムtype adapterの実装の参考にはなると思います。

library/BuiltInSerializers.java

なおjava.util.Datejava.sql.{Date,Time,DateTime}のtype adapterの差は、java.util.Dateがミリセコンドの整数にシリアライズするのに対し、java.sql.* のtype adapterは文字列にシリアライズするという違いがあります。java.sql.DateTimeをつかうと、Stethoなどで直接DBをみたときにわかりやすいうえ、SQLiteの日付演算も行えるのが利点です。

RxJava 2.0 Integration

Ormaデフォルトの設定ではメインスレッドでの書き込み操作を禁止しています*4。これは、DB書き込みはそこそこ時間のかかる処理なので、それによってメインスレッドをロックしないためです。そこで、RxJavaインテグレーションによって処理をバックグラウンドで行うことにより、スムーズに動くアプリケーションを作成できます。

RxJavaインテグレーションの一覧はRxJava2Test.java にあるとおりです。基本的には、RxJavaのobservableファミリ(Observabel<T>Single<T>など)を返す版のメソッドが fooAsObserable()fooAsSingle() という名前で提供されています。

マイグレーション

Ormaの特徴はなんといってもマイグレーションです。テーブルの追加は気軽にできますし、カラムの追加はちょっとだけコツがいりますがマイグレーションステップを手書きするよりはマシでしょう。そしてSQLiteでは地獄のように難しいカラムのリネームも、手書きでステップを記述する必要こそあるものの比較的簡単にできます。

マイグレーションの全体像はざっと migration/README.md に書いています。

またexample/MainFragment もいい例で、example appが起動するときに必ずさまざまなmigrationが走るようになっています。テーブルやカラムのリネームも行っています。このマイグレーションのログはMainActivityのスクリーンに表示します。migrationの疑問はexample appをいじると解消するかもしれません。

関係するクラスは SchemaDiffMigration, ManualStepMigration, OrmaMigration です。

SchemaDiffMigration

デフォルトのmigration engineです。DDLの差分からマイグレーション用SQLを生成して実行します。特性は冒頭で述べたとおりです。

ManualStepMigration

スキーマバージョンごとのstepを定義して実行するmigration engineです。独自のスキーマバージョンと履歴管理をDB内の専用テーブルで行います。スキーマバージョンのデフォルトはアプリケーションの BuildConfig.VERSION_CODE です。

マイグレーションステップは OrmaDatabase.Builder#migrationStep() で定義します。

OrmaMigration

まずManualStepMigrationを呼び出し、その後SchemaDiffMigrationを呼び出すmigration engineです。builder経由で生成し、ManualStepMigrationのためのstepを定義します。migration stepはスキーマのupgradeにもdowngradeにも対応できるようになっていますが、ほとんどの場合upgradeだけ十分でしょう。migration stepでは任意のSQLを発行できますが、OrmaDatabaseはまだ初期化されていないので使えません。

OrmaDatabase.BuilderはデフォルトでSchemaDiffMigrationを使いますが、OrmaDatabase.Builder にmigration stepを渡すと内部で使われるmigration engineがOrmaMigrationになります。

int VERSION_2;
int VERSION_3;

OrmaDatabase orma = OrmaDatabase.builder(context)
    // register change(), which is used both in upgrade and downgrade
    .migrationStep(VERSION_3, new ManualStepMigration.ChangeStep() {
        @Override
        public void change(@NonNull ManualStepMigration.Helper helper) {
            Log.(TAG, helper.upgrade ? "upgrade" : "downgrade");
            helper.execSQL("DROP TABLE foo");
            helper.renameTable("books", "Book");
            helper.renameColumn("books", "publisherId", "publisher");
        }
    })
    .build();

スキーマのバージョニング

Orma v2.1.0 以降

Ormaは SQLiteOpenHelper に依存しません。Orma自身は常にマイグレーションを起動し、具体的な処理はすべてmigration engineに移譲します。

  • OrmaMigration は無条件に ManualStepMigrationSchemaDiffMigration (SDM) を起動します
  • ManualStepMigration (MSM) は SQLiteDatabase#version から起動するステップを判断します。MSMが使うデフォルトのバージョンはアプリケーションの BuildConfig.VERSION_CODE です。なおMSMの挙動は SQLiteOpenHelper と互換性があるので、これまで SQLiteOpenHelper ベースのマイグレーションを使っている場合でもMSMに移行できますし、逆もしかりです。
  • SDM はDDLのSHA2-256ハッシュ(OrmaDatabase.SHEMA_HASH)をベースにしてマイグレーションの起動を判断します。このmigration engineは冪等なのですが、毎回走らせると時間がかかるので、このチェックは単純に不要な処理をしないようにするためです。

Orma v2.1.0 未満

Ormaは SQLiteOpenHelper のスキーマバージョン管理機能を使ってマイグレーションを起動します。

OrmaMigrationとSDMのデフォルトの設定だと、デバッグビルドではApplicationInfo#lastUpdateTimeを分に換算した値を、リリースビルドではBuildConfig.VERSION_CODEをスキーマバージョンとして使います。これは、インストール直後に1度だけマイグレーションを実行するためです。 いずれのmigration engineも、何度も呼び出しても問題ないようにはなっていますが、数百ミリ秒かかるので頻繁には行いたくないのです。

またそれとは別にOrmaMigration.BuilderではMSMへのバージョンも設定できますが、v1.1.1の時点ではデフォルトで BuildConfig.VERSION_CODE が使われるので、特に理由がない限り設定の必要はありません。

ToDo

既知の問題や今後の予定はgithub issuesで管理しています。ここにないものは(突発的に思いついて対応しないかぎり)予定にないため、なにか要望があれば教えてください。

*1:初版は2016年1月。なおOrmaのアップデートとともにこの記事もアップデートしています。

*2:いわゆるeager loading

*3:いわゆるlazy loading

*4:このチェックはOrmaDatabase作成時に無効にすることもできます。