IndexedDB を試す
Web アプリや nw.js のデータ保存に利用してみたいので IndexedDB を試す。
ローカル DB 規格としては SQL と RDB の知見を活かせる Web SQL のほうが好みだけど W3C としては非推奨で Web Storage か IndexedDB 推しのようだ。
Web Storage は単純な KVS であるため、ひとつのキーで複数の値を管理したい場合は JSON 文字列にするなどの工夫が必要。IndexedDB なら標準で Object
や Array
を扱える。またデータベースとストアの概念があり、本格的なデータ管理に向く。
2015/2 現在の IndexeDB は実験段階であり標準化されていないが主要ブラウザの大半で動作する。Chrome 12、Firefox 16、IE 10、Safari も 8 から対応したとのことなので、趣味の開発であれば利用してもよさそうだ。
はじめに
今回作成するサンプルでは、JavaScript の依存管理に Browserify を利用する。IndexedDB 関連は単体モジュールとしておく。だいたい以下のような感じ。
module.exports = function() {
// private な変数 ( 接頭語にアンダースコアを指定 )
var _db;
// public な関数
this.open = function( callback ) {
}
};
利用側では以下のようにインスタンス化。
try {
var MusicStore = require( '../model/music-store.js' );
this.state.db = new MusicStore();
} catch( exp ) {
this.state.db = null;
alert( exp.message );
return;
}
UI 部分は React.js で実装。データ操作用インスタンスはルートになるコンポーネントの state として保持するように設計。
IndexedDB のデータ階層
IndexedDB で管理されるデータを階層であらわすと以下のようになる。
/Domain
├ Database 1
│ ├ Store 1
│ │ ├ Value 1
│ │ └ Value 2
│ └ Store 2
│ ├ Value 1
│ └ Value 2
└ Database 2
├ Store 1
│ ├ Value 1
│ └ Value 2
└ Store 2
├ Value 1
└ Value 2
Store は RDB でいうところのテーブルみたいなもの。ただし IndexedDB は KVS なのでスキマーレスである。主キーやユニーク制約的なものはあるけれど、データはどのような形式でも格納可能。そのためデータの一部分が Array
や Object
になってもよい。
この柔軟性により従属関係にあるものを表現する場合、関連テーブルを用いずそのまま階層を持たせたりできそう。あまり馴染みない世界なので KVS の定石とか理解できてない。この辺は少しずつ慣れてゆこう。
IndexedDB 定義
現在の IndexedDB は実験段階なので window.indexedDB
に定義されているとは限らない。そのためはじめにベンダー プレフィックス付きも含めた定義を調べる必要がある。面倒だけど以下のように判定と参照の保持をおこなっておく。
// IndexedDB チェック
var _indexedDB = ( window.indexedDB || window.mozIndexedDB || window.msIndexedDB || window.webkitIndexedDB );
if( !( _indexedDB ) ) {
throw new Error( 'IndexedDB not supported.' );
}
以降の処理を IndexedDB 前提で実装するため、未定義の場合は例外を発生させている。
データベースの open と Store 作成
データベースの open
と Store 作成を実装する。なお close
は自動的におこなわれるので、通常は意識することがない。
/**
* データベース。
* @type {Object}
*/
var _db = null;
/**
* データベースを開きます。
*
* @param {Function} callback 処理が終了した時に呼び出される関数。
*/
this.open = function( callback ) {
var request = _indexedDB.open( DB_NAME, DB_VERSION );
request.onupgradeneeded = function( e ) {
console.log( 'DB [ oepn ]: Success, Upgrade' );
_db = e.target.result;
_db.createObjectStore( DB_STORE_NAME, { keyPath: 'id', autoIncrement: true } );
e.target.transaction.oncomplete = function() {
if( callback ) { callback(); }
};
};
request.onsuccess = function( e ) {
console.log( 'DB [ oepn ]: Success' );
_db = e.target.result;
if( callback ) { callback(); }
};
request.onerror = function( e ) {
console.log( 'DB [ oepn ]: Error, ' + e );
if( callback ) { callback( e ); }
};
};
IDBFactory.open にはデータベース名とバージョン番号を指定する。初回または既存のものより新しいバージョン番号を指定された場合は IDBOpenDBRequest.onupgradeneeded が呼ばれて以降は onsuccess
になる。
このイベントで実行する処理は主に以下。今回のサンプルでは Store 作成のみ実装した。
- Store 作成
- バージョン更新によるマイグレーション
スキーマレスとはいえバージョン更新によってデータ構造が変更されることは十分にありえる。そのため onupgradeneeded
でバージョン番号を判定、必要に応じてマイグレーションしておく。iOS の Core Daa や Android の SQLiteOpenHelper
などでもお馴染みの設計。onupgradeneeded
、onsuccess
のイベント データには IDBDatabase
が指定される。これは以降の様々な処理で必要となるため Store 操作を実行するインスタンス内で共有できるように保持しておく。
次は Store の作成。IDBDatabase.createObjectStore を利用する。今回は Store 名だけでなく key path を指定。これは RDB でいう主キーのようなもの。更に autoIncrement
を true
とすることでユニークな値を自動生成してくれる。
私は RDB を利用するときも人工キーを主キーにする派だ。自動生成されシステム側がユニークさを保証してくれる値の存在は重宝する。また、key path を定義しておくと後述する Store へのデータ追加・更新も処理しやすくなる。
もうひとつ重要なことがあった。IndexeDB の処理は基本的に非同期。そのため処理の成否や終了を知るためにはコールバック関数が必要。この設計により Store 処理をモジュール化しようとすると外部インターフェースにもコールバックを設けることになる。コールバックのネストが辛いので Promise
の出番かも。
今回のサンプルでは手抜きして未実装だが Store 処理の実行を待つ間に UI 操作をブロックするとか、進捗やインジケーター表示もあったほうがよいだろう。
値の追加と更新
値の追加は IDBObjectStore.add、追加更新は IDBObjectStore.put を利用する。
/**
* 音楽情報を追加または更新します。
*
* @param {Object} music 音楽情報。id が有効値の場合は既存の情報を上書きします。
* @param {Function} callback 処理が終了した時に呼び出される関数。
*/
this.addItem = function( music, callback ) {
if( !( _db ) ) { return; }
var transaction = _db.transaction( DB_STORE_NAME, 'readwrite' );
var store = transaction.objectStore( DB_STORE_NAME );
var request = store.put( music );
request.onsuccess = function( e ) {
music.id = e.target.result;
if( callback ) { callback( null, music ); }
};
request.onerror = function( e ) {
console.log( e );
if( callback ) { callback( e, music ); }
};
};
今回は add/put
を使い分けず put
のみを利用した。Store に前述の key path を設定しておくと対象になるプロパティの有無で処理が分岐する。
プロパティがなければ値を新規作成。autoIncrement
が true
だったならユニークな値を生成してイベントデータに指定してくれる。これを取得してアプリ内の識別子に利用するとよい。プロパティを指定した場合、それに等しい key path を持つ値が存在するなら上書きされる。つまりこれを適切に運用すれば put
だけで値の追加と更新をまかなえて便利。
値の削除
値の削除は IDBObjectStore.delete でおこなう。
/**
* 音楽情報を削除します。
*
* @param {Number} id 音楽情報の識別子。
* @param {Function} callback 処理が終了した時に呼び出される関数。
*/
this.deleteItem = function( id, callback ) {
if( !( _db ) ) { return; }
var transaction = _db.transaction( DB_STORE_NAME, 'readwrite' );
var store = transaction.objectStore( DB_STORE_NAME );
var request = store.delete( id );
request.onsuccess = function( e ) {
if( callback ) { callback( null, id ); }
};
request.onerror = function( e ) {
if( callback ) { callback( e, id ); }
};
};
削除対象は key path に指定した値で判定。
autoIncrement
が true
で値を削除すると使用されていた key path は欠番となる。例えば key path の数値が 6
なら、これを消しても 6
が再利用されることはない。
...もしかすると再利用するための API があったりして。しかし存在するとしても key path を識別子とする運用だと混乱を招くだけだから欠番として扱うほうが都合よいはず。
値の読み込み
Store から値を読み込む処理は単体と複数に大別される。今回は後者だけ実装した。
/**
* 音楽情報を全件、読み取ります。
*
* @param {Function} callback 処理が終了した時に呼び出される関数。
*/
this.readAll = function( callback ) {
if( !( _db ) ) { return; }
var transaction = _db.transaction( DB_STORE_NAME, 'readonly' );
var store = transaction.objectStore( DB_STORE_NAME );
var request = store.openCursor();
var musics = [];
request.onsuccess = function( e ) {
var cursor = e.target.result;
if( cursor ) {
musics.push( cursor.value );
cursor.continue();
} else if( callback ) {
callback( null, musics );
}
};
request.onerror = function( e ) {
if( callback ) { callback( e ); }
};
};
IDBObjectStore.openCursor が成功するとリクエストに対して onsuccess が呼ばれる。範囲は openCursor の引数で制御できる。サンプルのように未指定なら全件になる。
onsuccess
のイベント データには IDBCursor が指定される。これは RDB でも馴染みぶかい[カーソル (データベース)](http://ja.wikipedia.org/wiki/%E3%82%AB%E3%83%BC%E3%82%BD%E3%83%AB_(%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9%29)である。`onsuccess` はそのままだと始めの 1 件で終了するのだが IDBCursor.continue を実行することで次の呼び出しがおこなわれる。最後の onsuccess
では IDBCursor が無効値になるので、これを判定すれば終了を検知できる。
そのため複数のデータを取得するなら以下のような処理となるだろう。
- データを格納する
Array
を用意 IDBObjectStore.openCursor
実行onsuccess
呼び出し- イベント データからデータ取得して
Array
へpush
- 3 〜 4 を
IDBCursor
が無効値になるまで繰り返す - 最後の
onsuccess
ならArray
を利用した処理を実行、例えば上層のコールバックへ通知するとか
値の全件削除
あまり実行する機会はなさそうだけど値の全件削除を実装してみた。これは IDBObjectStore.clear を利用する。
/**
* 音楽情報を全て消去します。
*
* @param {Function} callback 処理が終了した時に呼び出される関数。
*/
this.clear = function( callback ) {
if( !( _db ) ) { return; }
var transaction = _db.transaction( DB_STORE_NAME, 'readwrite' );
var store = transaction.objectStore( DB_STORE_NAME );
var request = store.clear();
request.onsuccess = function( e ) {
if( callback ) { callback( null ); }
};
request.onerror = function( e ) {
console.log( e );
if( callback ) { callback( e ); }
};
};
key path が autoIncrement
の場合、全件削除しても次に作成される値は最後に追加したものの続きからとなっていた。key path のリセットはおこなわれないようだ。
データベースの破棄
IndexedDB のデータベースは明示的に破棄しない限り Web ブラウザーに残り続ける。これが好ましくない場合は IDBFactory.deleteDatabase で抹消しよう。
/**
* データベースを破棄します。
*
* @param {Function} callback 処理が終了した時に呼び出される関数。
*/
this.dispose = function( callback ) {
if( !( _db ) ) { return; }
_db.close();
var request = _indexedDB.deleteDatabase( DB_NAME );
request.onsuccess = function( e ) {
console.log( 'DB [ dispose ]: Success' );
_db = null;
if( callback ) { callback(); }
};
request.onerror = function( e ) {
console.log( 'DB [ dispose ]: Error, ' + e );
if( callback ) { callback( e ); }
};
};
注意点として deleteDatabase
を実行する前に IDBDatabase.close を実行する必要がある。これなしでもデータベースは削除されるようだが onsuccess
や onerror
が呼び出されなかった。
あとデータベース全体を削除しているので当たり前の話だが key path はリセットされる。今回のサンプルでは、これと値の全消去の動きを見比べられるようにしている。
サンプル プロジェクト
今回の調査で作成したサンプルを公開する。
画面右のボタンでデータベースを操作できる。
UI | 機能 |
---|---|
Add | 値を 1 件、追加する。内容は固定値 ( フォームの入力内容は無視 )。 |
Update | リストで選択された値について、フォームに入力した内容で更新する。 |
Delete | リストで選択された値を削除する。 |
Clear | 値を全件、削除する。 |
Dispose | データベースを破棄する。ただし破棄後にデータベースを開きなおしているため、空のデータベースが残る。 |
検索も実装したかったのだけど、よい UI が思いつかなかった。これは後に IndexedDB を利用したアプリを作成したときの課題としたい。
clone して README.md の Installation & Build に書かれた手順を実行するとアプリがビルドされる。
- 2015/2/18 更新
gulp connect
タスクによる HTTP サーバー起動パスがプロジェクトのルートになっていなかったので修正。それにともないリポジトリのタグを追加して上記リンク先も変更した