へいへいブログ

📝📝📝📝📝📝

Room Persistence Libraryを訳してみた(※2017年5月末時点)

f:id:shaunkawano:20170530122939p:plain

自分へのメモ程度にRoom Persistence Library | Android Developersを日本語訳しましたので、この記事ではその内容を記載いたします。(※注意: この翻訳記事は2017年5月末時点での上記の公式ドキュメントの日本語訳です。)

雑に訳しています。タイポ、細かい表現の差異、その他誤りなどありましたら(あくまでも個人用メモとして殴り書きしておりますので、ご了承下さい。)、ご連絡頂けますと幸いです。なるべく早く修正致します。m(__)m

※2017年5月30日修正: 細かいタイポや文言の修正と、本記事の最下にCA.apk #3 - Google I/O 2017 報告会(2017/05/29開催)でのRoomに関する発表スライドを埋め込みました。

Room Persistence Library

コアフレームワークSQLコンテンツをサポートしている。API自体はパワフルだが、低レイヤーなものであるため使いこなすまでになかなかの時間と努力を必要とする。さらに、

  1. 生のSQLクエリにはコンパイルタイム検証・解析が存在しない。データグラフの変更に伴い、変更による影響を受けたSQLクエリを手動で更新する必要がある。時間がかかると同時に、バグの原因となりやすい。
  2. SQLクエリをJavaのデータオブジェクトに変換するために、たくさんの決まり文句のコードを利用する必要がある

RoomはSQLiteの抽象的レイヤーの機能を提供しつつ、上記1、2の問題を解決するためのライブラリ。

Roomには3つの主要なコンポーネントがある

1. Database

Database Holderを生成するために利用。@Databaseアノテーションを利用してEntityの一覧を定義し、Databaseクラスの内容にはDAOの一覧を記載。同様に、Databaseオブジェクトは根本的な接続のための中枢アクセスポイントである。

2. Entity

データベースの列を持つクラスを表す。それぞれのEntityに対してデータベーステーブルが生成され要素を保存する。Databaseクラスのentities配列でEntityを参照する必要がある。@Ignoreをフィールドに付与しない限り、Entityクラス内のすべてのフィールドはデータベース内に永続化される。

Note: 
Entityクラスには、(DAOクラスがフィールドに直接アクセスすることができる場合のみ)
空のコンストラクタ、もしくは最低1つのフィールドパラメーターを受け取るコンストラクタを定義する必要がある。

3. DAO

DAO(Data Access Object)のクラスもしくはインタフェースを表す。DAOとはRoomにおける主要のコンポーネントであり、データベースにアクセスするためのメソッドを定義する責務を持つ。@Databaseアノテーションが付与されているクラスは@Daoアノテーションが付与されているクラスを返す引数が存在しないabstractメソッドを持つ必要がある。コード生成を行うコンパイル時にRoomはこのクラスの実装コードを生成する。

Important:
クエリービルダーや直接クエリーを文字列として記載するのではなくDAOクラスからデータベースにアクセスをすることで、
データベース構成を別の要素に切り分けることができる。
さらにいえば、テストを行う際にはDAOによってデータベースアクセス処理を簡単にモック化することができる。
Note: 
Databseクラスの生成にはコストがかかり、
また複数のインスタンスにアクセスする必要があるケースはほとんどないため、
データベースオブジェクトの生成時にはSingletonデザインパターンに準拠するべきである。

Entities

クラスに@Entityアノテーションが付与されていて、かつそのクラスが@Databaseアノテーションが付与されているクラスのentitiesプロパティ内で参照されている場合、RoomはこのEntityクラスのデータベーステーブルを生成する。

デフォルトでは、RoomはEntityクラス内に定義されているフィールドすべてに対応するカラムを1つ1つ生成する。 もしEntityクラス内に永続化したくないフィールドが存在すれば、@Ignoreアノテーションを付与することで永続化を回避することができる。フィールドを永続化するために、Roomはフィールドにアクセスできる必要がある。フィールド自体にpublic修飾子を付与するか、セッターとゲッターを提供することができる。セッターとゲッターを利用する際には、RoomではJava Beans機構にそっている必要があることを忘れてはいけない。

Primary Key

それぞれのEntityは最低1つのフィールドをPrimary keyとして定義する必要がある。フィールドが1つしか定義されていない場合であっても、@PrimaryKeyアノテーションを付与する必要がある。同様に、RoomにEntityに対して自動ID付与をしてほしい場合、@PrimaryKeyautoGenerateプロパティをセットすることができる。もしEntityが複数のPrimaryKeyを持っている場合には@Entity(primaryKeys = {"", ""}を利用することができる。 デフォルトでは、Roomはクラス名をデータベースのテーブル名として利用する。もし別名をテーブル名として利用したい場合には、@Entity(tableName="")プロパティを利用できる。

注意:
SQLiteにおけるテーブル名はケースセンシティブである。

tableNameプロパティ同様に、デフォルトではRoomはフィールド名をデータベース内のカラム名として利用する。 もし別名をカラム名として利用したい場合には@ColumnInfo(name = "")を利用できる。

Indexとユニーク性

データアクセスの方法によっては、特定のフィールドをインデックス化してクエリを高速化したい場合がある。 Entityに対してインデックスを追加するためには

@Entity(indices = {@Index("name"), @Index("last_name", "address")})

を利用できる。

ときにはデータベース内の特定のフィールド、またはフィールドの集合がユニークである必要がある。 これらの独自性を遵守するには

@Entity(indices= { @Index(value = {""}, unique = true }

を利用できる。

リレーショナルについて

SQLiteはリレーショナルデータベースであるため、オブジェクト間の関係性を細かく指定することができる。 ほとんどのORMライブラリはEntityオブジェクト同士の参照をサポートしているが、Roomは明示的にこれを禁止している。

補足

Entity間のオブジェクト参照の禁止

データベースから対応するオブジェクトモデルへの関係性のマッピングはよく知られている実践方法であり、特に遅延読み込みされるフィールドアクセスに対するパフォーマンスが良いサーバーサイドにおいてはよく機能する。 しかし、クライアントサイドにおいては遅延読み込みはうまくいかない。なぜなら読み込み処理がUIスレッドで発生しやすく、ディスク上の情報のクエリをUIスレッドで行うことは重大なパフォーマンス問題を引き起こす可能性があるため。UIスレッドにはアクティビティのレイアウト更新のための計算と描画におおよそ16msの時間しか用意されていないため、たとえクエリにかかる時間がたったの5msのみであったとしても、アプリケーションがフレームを描画するための時間は足りなくなってしまい、ユーザーが認識できるジャンクを発生させてしまう。さらにもっと悪い場合、複数処理が同時並行で走っている際や端末自体がディスクに負荷のかかる処理を行っているとクエリにかかる時間が単純に16msを超えてしまうかもしれない。しかし遅延読み込み処理を行わない場合だと、アプリケーションは必要以上のデータを読み込み、メモリー消費問題を起こしてしまう。

ORMライブラリはこれらの議論を開発者に託しているため、開発者は自分たちが最善だと思う方法でORMをアプリ開発に利用している。不運なことに、一般的には開発者は遅延読み込みするモデルをアプリケーションとUI間の両方で利用してしまうケースに陥ってしまう。

例えば、Authorオブジェクトを持つBookオブジェクトを複数読み込むようなUIを想定する。初期段階では遅延読み込みを利用したクエリ設計によってBookオブジェクト内にgetAuthor()メソッドを用意し、Authorオブジェクトを返却するようにしたとする。この設計により初回のgetAuthor()メソッド実行時にはデータベースへのクエリ処理が走る実装となる。月日が経ち、仕様変更などによりUIに今までのものに加えて筆者の名前を表示する必要が出てきた。getAuthor().getName()メソッドを追加することでとても容易にUI実装をすることができた:

authorNameTextView.setText(user.getAuthor().getName());

このような何事もないようなシンプルな変更によって、気づかぬうちにAuthorテーブルへのクエリ処理がメインスレッドで行われるようになってしまう。

これらの理由から、RoomはEntity間のオブジェクト参照を禁止し、代わりに開発者にアプリが本当に必要なデータのみを明示的にリクエストしなければいけないような設計になっている。

直接的なリレーションは行えないが、RoomはEntity間におけるForeignKey制約の定義を許容している。

@Entity(foreignKeys = @ForeignKey(entity = User.class,
                                  parentColumns = "id",
                                  childColumns = "user_id"))

ForeignKeyはとても強力で、参照されているEntityが更新されてたタイミングで何が起こるかを指定することができる。たとえば、UserオブジェクトがDBから削除されたら、SQLiteにユーザーのBook情報すべてを削除するように指定することができる。

詳細 https://sqlite.org/lang_conflict.html

オブジェクトのネスト

時々、オブジェクトが複数のフィールドを保持している場合であっても、そのEntityやPOJOクラスのオブジェクトをひとまとまりのデータベースロジックとして表現したい場合がある。それらの場合には、@Embeddedアノテーションを利用することで、特定のEntityクラス内に別の@Entityクラスを埋め込むことができる。

たとえば、UserクラスがAdressクラスタイプのフィールド(Addressクラスはstreet, city, state, post Codeのような複数のフィールドを保持したクラス)を内包することができる。

class Address {
    public String street;
    public String state;
    public String city;

    @ColumnInfo(name = "post_code")
    public int postCode;
}

@Entity
class User {
    @PrimaryKey
    public int id;

    public String firstName;

    @Embedded
    public Address address;
}

データベーステーブルはUserというテーブル名で、カラムはid, firstName, street, state, city, post_codeとなる

Note: Embeddedフィールドは同様に他のEmbeddedフィールドを内包することができる。

もしEntityが複数の同じタイプのEmbeddedフィールドを保持していた場合は、それぞれのカラムの別名性を保つためにprefixを付与することができる。

DAO(Data Access Objects)

Roomの主要なコンポーネントはDaoクラス。DAOはデータベースアクセス処理を簡潔に抽象化する。

Insert

@Insert public void insertBothUsers(User user1, User user2);
@Insert public void insertUsersAndFriends(User user, List<Friend> friends);

もし@Insertメソッドの受け取る引数の数が1つだった場合、メソッドは新しいrowIdを表すlong値を戻り値として返すことができる。もし引数が配列もしくはコレクション型であれば、戻り値にはlong[]またはListを指定できる。

https://www.sqlite.org/rowidtable.html

Update

@Update public void updateUsers(User… Users);

ほとんどの場合必要ないかもしれないが、更新がかけられた列の数を表すint型を戻り値として返すことができる。

Delete

@Delete public void deleteUsers(User… Users);

ほとんどの場合必要ないかもしれないが、削除された列の数を表すint型を戻り値として返すことができる。

@Queryアノテーションを使ったメソッド

データベース上の読み込み・書き込み処理を行うためのアノテーション。それぞれの@Queryメソッドはコンパイル時に検証され、問題があればコンパイルエラーが発生し通知が行われる。同様に、Roomはクエリメソッドの戻り値も検証し、もし戻り値のオブジェクト内のフィールド名が対応するクエリ結果のカラム名とマッチしない場合は、以下のいずれかの方法でアラートする。

  • いくつかのフィールド名だけマッチしていない場合は警告
  • 全てのフィールド名がマッチしていない場合はエラー

もしクエリがシンタックスエラーを含んでいる場合やテーブルがデータベース内に定義されていない場合も同様にコンパイル時にエラーとして通知する。

Queryにパラメーターを渡す

データベースアクセスをする際、大抵の場合はパラメータをクエリに渡してフィルター処理を行う。 このような場合にはRoomアノテーション内でメソッドパラメータを利用する。

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age > :minAge")
    public User[] loadAllUsersOlderThan(int minAge);
}

クエリがコンパイル時に処理される際、Roomは:minAgeバインド引数名がメソッド引数名と対応しているかを確認する。パラメータ名を利用した整合性チェックをRoomは行う。もしミスマッチが存在する場合にはエラーが発生する。

同様に複数の引数を渡したり1つの引数を複数回参照することができる。

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
    public User[] loadAllUsersBetweenAges(int minAge, int maxAge);

    @Query("SELECT * FROM user WHERE first_name LIKE :search "
           + "OR last_name LIKE :search")
    public List<User> findUserWithName(String search);
}

カラムの一部分のみを返す

大抵の場合、Entity内のいくつかのフィールドのみが必要な場合がある。たとえばUIに表示するのはユーザーのファーストネームとラストネームのみで、ユーザーに関する細かい情報すべてを読み取る必要がない場合など。そのような場合には必要なカラムからのみ情報を読み込むことで、変数へのリソース割当を少なくし、より早くデータベースクエリーを完了することにもつながる。

Roomではクエリで選択されるカラムの一覧が戻り値のオブジェクトとマッピングすることができれば、どんなJavaオブジェクトでも戻り値として指定することができる。

public class NameTuple {
    @ColumnInfo(name="first_name")
    public String firstName;

    @ColumnInfo(name="last_name")
    public String lastName;
}
@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user")
    public List<NameTuple> loadFullName();
}

もしカラム数が多すぎる場合や指定されたカラムが戻り値に指定されたクラスと対応できない、存在しない場合には警告が表示される。

NOTE: そのような場合には@Embeddedアノテーションを利用できる。

引数としてCollectionを利用する

時にはランタイム時にしか正確な引数の数がわからないような場合がある。たとえばいくつかの地区に関する情報を取得したい場合、などだ。Roomは引数がCollectionであるかどうかを理解できるため、Collectionの場合には自動的にクエリが拡張されランタイム時に正確な数の引数がメソッドに与えられる。

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    public List<NameTuple> loadUsersFromRegions(List<String> regions);
}

Observable Queries

データが更新されたタイミングでアプリケーションのUIも自動で更新したい場合がある。そのためにはRoomのDaoのメソッドの戻り値をLiveData型にする。Roomはデータベース更新と同時にLiveDataの更新に必要なコードすべてを生成する。

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    public LiveData<List<User>> loadUsersFromRegionsSync(List<String> regions);
}
(!!ここはどう訳すのか、まだ理解が曖昧です。。!!)
バージョン1.0では、Roomはクエリでアクセスされたテーブルの一覧を利用してLiveDataオブジェクトの更新が必要かどうかを判断する?

RxJava

同様に、RoomではRxJava2のPublisherとFlowableオブジェクトをクエリの戻り値として指定できる。

android.arch.persistence.room:rxjava2

Dependencyを追加することで戻り値にPublisherもしくはFlowableを指定できる。

Cursorへの直接アクセス

もしアプリケーションのロジック上、帰ってきたデータベースのRowに直接アクセスする必要がある場合には、Cursorオブジェクトを戻り値として指定することができる。

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age > :minAge LIMIT 5")
    public Cursor loadRawUsersOlderThan(int minAge);
}
注意:
Rowが実際に存在するかどうか、またはどのような値がRowに存在するのかの保証がないため、Cursor API利用は推奨されていない。このような実装がすでにアプリ内に存在し、リファクタリングが容易にいかない場合にのみ利用するべきである。

複数テーブルクエリ

クエリには、結果を計算するために複数テーブルにアクセスする必要があるものがある。Roomはどのようなクエリ文であっても記述することを許容しているため、join文も記載することができる。RxJava2のFlowable, PublisherやLiveDataを戻り値として指定している場合には、クエリで参照されているすべてのテーブルをエラー検知のために監視する。

TypeConverter

(筆者メモ: MoshiのTypeAdapterのような機能。) @TyperConverterアノテーションをメソッドに付与し、@TypeConvertersアノテーションをDBクラス、Daoのメソッド、Entityクラス、など様々な場所に付与することで利用できる。

@Database(entities = {User.java}, version = 1)
@TypeConverters({Converter.class})
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

データベースマイグレーション

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();

static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
                + "`name` TEXT, PRIMARY KEY(`id`))");
    }
};

static final Migration MIGRATION_2_3 = new Migration(2, 3) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("ALTER TABLE Book "
                + " ADD COLUMN pub_year INTEGER");
    }
};

Migrationクラスを作成する。ランタイム時にRoomはMigrationクラスのmigrate()メソッドを順番に実行する。 もし必要なマイグレーション処理がない場合にはRoomはDBを再構築するので、データは消える。

Migration完了後にRoomはスキーマを確認しMigrationが正しく行われたかどうかを確認する。もし失敗した場合にはExceptionが発生する。

Testing migrations

データベーススキーマをエクスポートすることでマイグレーション処理のテストを事前に行うことができる。

Exporting Schemas

Roomはコンパイル時にスキーマ情報をJSONファイルに吐き出すことができる。スキーマをエクスポートするには build.gradleに以下を追加する:

android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = ["room.schemaLocation":
                             "$projectDir/schemas".toString()]
            }
        }
    }
}

エクスポートされたJSONファイルはバージョン管理しておくことで今後マイグレーションテストの際、Roomから古いスキーマのデータベース生成を行うことができる。

android.arch.persistence.room:testing 

dependencyを追加する。 スキーマ管理を行っているパスをasset folderとして指定する。

android {
  … 
  sourceSets {
    androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
  }
}

testingパッケージにはMigrationTestHelperクラスが存在し、このrunMigrationsAndValidate()メソッドを実行することでhelperクラスが自動でスキーマ変更を検証する。データの変更に関しては自分で検証する必要がある。


最後に

上記が自分が行ったRoomの公式ドキュメントの和訳の全文となります。メモ程度に書いたものですので、あくまで参考程度に気になった箇所をつまむ程度に読んでいただけると幸いです。

また、2017年5/29日にCA.apkにてRoomに関する発表を行いましたので、Roomに関する情報をお求めの方はこちらも合わせて御覧ください。