データベースのアップグレード
IndexedDBがわかりにくい理由の一つに、データベーススキーマの「アップグレード」処理をプログラム中に書かなければならない点が挙げられます。IndexedDBはJavaScriptオブジェクトを読み書きすることができ、「NoSQL」を標榜しているDBのほとんどがそうであるように、静的なスキーマを必要としません。スキーマを持たないのに、なぜ「スキーマのアップグレード」という処理が必要なのでしょうか?
IndexedDBにおけるJavaScriptオブジェクトの「入れ物」は「オブジェクトストア」と呼ばれ、RDBで言うところの「テーブル」に近い概念です。1つのオブジェクトストアには、どんな種類のJavaScriptオブジェクトでも格納するができますが、多くの場合は「構造がほぼ同一の」オブジェクトを読み書きすることになります。それらのオブジェクトを識別するための値は「キー」と呼ばれます。
また、オブジェクトストアにはJavaScriptのプロパティ値に対して「インデックス」を生成することができ、プロパティ値で検索結果を絞り込むことが可能です。
このように、IndexedDBは「オブジェクトストア」「インデックス」と言った特別な内部構造を持つため、時にデータベースを「アップグレード」して、それらを追加/削除する必要が生じるのです。
前置きが長くなりましたが、アップグレードに関する処理を解説していきます。
アップグレード処理は、IndexedDBのオープン直後に行わなくてはなりません。openメソッドの引数には「名前」と「バージョン番号」を指定しますが、バージョン番号が現在のDBと異なる場合、openの結果はupgradeneeded(DBのアップグレードが必要)というイベントを発生させることになります。このイベントハンドラが終了するまでの間が「アップグレード可能な」期間です。このタイミングを逃すと、データベースのスキーマ変更を行うことはできなくなります。
オブジェクトストアを作る、または削除するためのメソッドは、データベースオブジェクトが保持しています。
db.createObjectStore(dbName, options) db.deleteObjectStore(dbName)
createObjectStoreの第二引数には、キーとなるプロパティの名前や「IDを整数値の自動採番によって生成するか」を指定できます。例えば、キープロパティの名前を"id"、IDを自動採番する場合は以下のようなコードになります。
var store = db.createObjectStore("TestStore", { keyPath: "id", autoIncrement: true});
createObjectStoreの戻り値は、オブジェクトストアを表すオブジェクトです。オブジェクトストアは以下のようなメソッドを持ち、インデックスの生成と削除を行うことができます。
store.createIndex(name, keyPath, options) store.deleteIndex(name)
createIndexの引数は「インデックスの名前」「インデックスを設定するプロパティのパス」「unique(インデックス中の値がユニークならばtrue)、multientry(インデックス中の値が配列ならばtrue)と言った設定を行うためのオプション」となります。
今回のデモにおいては、これらのメソッドを以下のように用いています。
まず、データベースのスキーマが変更された時は、以前のオブジェクトストアを削除して作りなおすために、以下のようなメソッドを用意しました。
function recreateObjectStore(db, storeName, createOpts) { var storeNames = db.objectStoreNames; // 既ににオブジェクトストアが存在すれば削除 for ( var i = 0, n = storeNames.length; i < n; i++) { if (storeNames[i] === storeName) { db.deleteObjectStore(storeName); } } // オブジェクトストアの作成 var store = db.createObjectStore(storeName, createOpts); return store; }
データベースをオープンする際に指定したバージョン番号が現在のDBと食い違い、アップグレードが必要な場合は、`upgradeneeded`イベントが発生します。
var req = indexedDB.open(DB_NAME, latestVersion); req.onupgradeneeded = function(event) { db = req.result; var currentVersion = event.oldVersion; // 現在のバージョンと最新バージョンの間に差異があったら、 // アップグレード処理を実行 if (currentVersion != latestVersion) { // 順に呼び出し upgrade(currentVersion, latestVersion, callback); } else { typeof callback == "function" && callback(); } };
アップグレード時の処理は以下のようになります。フィードの情報と、フィードに含まれるエントリ情報は、それぞれ異なるオブジェクトストアを定義します。また、それぞれに対して検索やソートに用いるプロパティにインデックスを設定しています。
// URLを主キーとしてエントリを保存するオブジェクトストアを作成 var feedStore = recreateObjectStore(db, FEED_STORE, { keyPath : "feedUrl" }); // フィードの更新日時に対してインデックスを作成 feedStore.createIndex("date", "date"); // フィードエントリを保存するオブジェクトストア var feedEntryStore = recreateObjectStore(db, FEED_ENTRY_STORE, { keyPath : link" }); // エントリの更新日時と、フィードのURLに対してインデックスを作成 // 更新日時は一覧のソートに、フィードURLはフィードごとの絞り込みの際に用いられる feedEntryStore.createIndex("date", "date"); feedEntryStore.createIndex("feedUrl", "feedUrl");
実際のコードでは、スキーマの今後のバージョンアップに備えて、バージョンごとのアップグレード処理をカプセル化した関数の配列を用意し、その配列をループすることでアップグレードを実現しています。興味がある方はコードを参照してください。
オブジェクトストアの操作
データベースのオープン(またはアップグレード)に成功したあとは、一般的なデータ処理(RDBであればSELECT/INSERT/UPDATE/DELETE)を行なってアプリケーションを実現していくことになります。
データ処理の起点となるのは、トランザクションの開始です。トランザクションを開始するには、データベースオブジェクトが持つtransactionメソッドを使用します。
tx = db.transaction(storeNames, mode)
このメソッドの第一引数は、トランザクション中に操作対象となるオブジェクトストアの名称(配列により複数指定可能)です。また、第二引数のmodeは、IDBTransaction.READ_ONLY(読み取り専用)かIDBTransaction.READ_WRITE(読み書き可能)のいずれかを指定します。
戻り値は、トランザクションを表すIDBTransactionオブジェクトです。このオブジェクトが持つobjectStoreメソッドを呼び出すと、オブジェクトストアを取得できます。
store = tx.objectStore(storeName)
オブジェクトストアに対してJavaScriptオブジェクトを読み書きするために使用できる、主なメソッドを以下に示します。
- get(value, key):キーを指定して値を取得
- put(value, key):キーを指定して値を挿入または置換
- delete(key):キーを指定して削除
これらのメソッドは全て非同期で動作するため、結果を取得するには、DBのオープン時と同様successやerrorといったイベントを処理する必要があります。以下に、getやputを使用しているコードをサンプル中から抜粋します。
// スターの付け外しを行うために、フィードエントリをDBから読みだして状態を変更する // トランザクションの開始とオブジェクトストアの取得 var objectStore = db.transaction([FEED_ENTRY_STORE], IDBTransaction.READ_WRITE).objectStore(FEED_ENTRY_STORE); // URLをキーとして、エントリを取得する var req = objectStore.get(url); // オブジェクトの取得に成功 req.onsuccess = function(e) { // フィードエントリを取得 var entry = req.result; // スターの状態を反転 entry.star = !entry.star; // エントリを保存 var request = objectStore.put(entry); request.onsuccess = function() { document.getElementById('star_'+entry.url).src = entry.star ? MARK_ON : MARK_OFF; }; request.onerror = console.log; };
カーソルを使用したオブジェクトの読み出し
オブジェクトストアからデータを取得するには、キーを指定してオブジェクトを取得する(getメソッド)ほかにも、キーやインデックスから範囲検索を行うことで、複数のオブジェクトを一気に取得することもできます。検索結果は「カーソル」という形で表現され、結果の先頭から末尾までを順に走査していくことができます。
検索をおこなってカーソルを取得するには、openCursorというメソッドを使用します。
- openCursor(range, direction):指定された範囲(range)のデータを取得します。また、directionで取得順序を指定したり、両方指定しないことで全データの取得ができます。実際の値を取り出すには、openCursorで取り出したオブジェクトをonsuccessコールバック内でcontinue()して全列回して取得します。
サンプル内でopenCursorを使用しているコードを抜粋します。ポイントはcurReq.onsuccessが複数回呼ばれること、カーソルの終了条件を変数cursorがnullかどうかで判定していることです。
// カーソルをオープンする var curReq = feedEntryStore.openCursor(); // カーソルのオープン、もしくはcontinueに成功 curReq.onsuccess = function() { var cursor = curReq.result; // カーソルが終了した if (!cursor) { typeof callback == "function" && callback(results); return; } // カーソルから値を取り出す var entry = cursor.value; results.push(entry); // カーソルを次に進める cursor.continue(); };
検索の範囲を指定する
範囲を絞って検索を行うには、openCursor()の第1引数にIDBKeyRangeのオブジェクトを渡します。IDBKeyRangeは、以下のようなファクトリメソッドを用いてオブジェクトを生成します。
- upperBound(bound, open):boundで指定した範囲よりも「小さい」範囲を表す。範囲にbound自身を含むかどうかをopenで指定
- lowerBound(bound, open):boundで指定した範囲よりも「大きい」範囲を表す。範囲にbound自身を含むかどうかをopenで指定
- bound(lower, upper, lowerOpen, upperOpen):下限と上限を同時に指定
- only(value):範囲ではなく、固定値を指定
サンプル中では、エントリ一覧のページング処理(スクロール位置が下に達すると、次のデータを読み込みます)を行う際に、範囲を指定した検索を行なっています。エントリ一覧は常に更新日時の降順でソートされています。次のページを読み込む際には、前回の検索結果の中で「一番古い更新日時」を上限とした範囲指定を行い、検索を行います。
// baseTimeは、前回の検索結果の中で一番古い更新日時 var range = IDBKeyRange.upperBound(baseTime, false);
この範囲オブジェクトを指定して、インデックスから値を読み出します。
var curReq = feedEntryStore.index("date").openCursor(range, IDBCursor.PREV);
また、openCursorの第二引数(取得順序)に指定する値は、IDBCursorインターフェースに宣言されている定数です。
- NEXT:昇順
- NEXT_NO_DUPLICATE:昇順(重複なし)
- PREV:降順
- PREV_NO_DUPLICATE:降順(重複なし)
上のコードでは、降順でデータを取得するためにIDBCursor.PREVを指定しています。
その他、IDBCursorオブジェクト使用できるメソッドやプロパティはこちら(英語)をご覧ください。
まとめ
IndexedDBは非同期のAPIが多少扱いづらく、また、現時点ではブラウザ間の差異も大きいため、利用するには一定の慣れを必要とします。
しかし、IndexdDBを使用することで、ネイティブアプリにも負けない機能性と操作感を持つWebアプリケーションを実装することが可能になります。ぜひサンプルをお試しになり、IndexedDBアプリケーションのきびきびとした動作を体感してください。