WebOS Goodies

WebOS の未来を模索する、ゲームプログラマあがりの Web 開発者のブログ。

WebOS Goodies へようこそ! WebOS はインターネットの未来形。あらゆる Web サイトが繋がり、共有し、協力して創り上げる、ひとつの巨大な情報システムです。そこでは、あらゆる情報がネットワーク上に蓄積され、我々はいつでも、どこからでも、多彩なデバイスを使ってそれらにアクセスできます。 WebOS Goodies は、さまざまな情報提供やツール開発を通して、そんな世界の実現に少しでも貢献するべく活動していきます。
Subscribe       

iOS8 で IndexedDB を使うための 5 つの注意点

遅ればせながら、 iOS8 がついにリリースされましたね。 OS レベルでのたくさんの機能追加にあわせて、 Web ブラウザ (Safari) の HTML5 対応も大きく進展しました。そのひとつが、クライアントサイドの NoSQL データベースである IndexedDB のサポートです。 IndexedDB を使うことで、Webブラウザ側に大量のデータをキャッシュし、効率的に検索できるようになります。しかも iOS8 がサポートしたことで現行のほとんどの Web ブラウザで動作するようになったので、活用する場面が増えていくことは間違いありません。

私が開発しているフィードリーダー Feedeen でも、少し前から一部のデータのキャッシュに使い始めていて、 iOS8 でもそのまま動く...はずだったのですが、まあ世の中そう甘くはありません。サポート直後によくあることとはいえ、 iOS8 の IndexedDB 実装はまだいろいろ不具合があり、いくつか手直しが必要になりました。これから IndexedDB を使い始めようという方も多いと思うので、現時点で私が把握している注意点を以下に書き留めておきます。

ちなみに、 Mavericks 上の Safari 7.1 でサポートされた IndexedDB も iOS8 とほぼ同じ実装になっているようです。以下の事柄は、 Safari 7.1 でもそのまま当てはまります。

IndexedDB の基礎

iOS8 特有の話をする前に、 IndexedDB の使い方を簡単に説明しておきます。今後 HTML5 アプリケーションの重要な技術要素のひとつになることは確実なので、まだ試したことがなければ、ぜひこの機会に使ってみてください。

まず、お馴染みの feature detection を行います。 window.indexedDB が定義されていれば、 IndexedDB が利用できます。少し古いブラウザ(IE9 とか)や、 iOS8 でも UIWebKit を使っている場合(たぶん現時点では Safari 以外全滅)では IndexedDB が使えないので、当面はキャッシュ用途で限定的に使うことになるでしょう。

if(window.indexedDB) {
  // IndexedDB を使った処理
}

IndexedDB がサポートされているなら、 window.indexedDB.open() を使ってデータベースに接続します。引数は接続するデータベースの名前と、バージョン番号(任意の正の整数)です。そして、 onupgradeneeded, onsuccess, onerror に適切なコールバック関数を設定します。

var openReq = window.indexedDB.open('db1', 1);
openReq.onupgradeneeded = function(e) {
  // スキーマ定義・変更などの処理
};
openReq.onsuccess = function(e) {
  // 接続成功時の処理
};
openReq.onerror = function(e) {
  // 接続失敗時の処理
};

open() に指定されたデータベースが存在しなかったとき、または既存のデータベースのバージョン番号が oepn() の第二引数よりも小さかったときは、 onupgradeneeded に指定した関数が呼ばれます。この中でオブジェクトストア(RDB でいうテーブルのようなもの)の追加・削除などができます。

openReq.onupgradeneeded = function(e) {
  var conn   = e.target.result;
  var oldVer = e.oldVersion; // 以前のデータベースのバージョン番号
  if(oldVer < 1) {
    // store1 という名前のオブジェクトストアを作成
    conn.createObjectStore('store1', { keyPath: 'key' });
  }
};

createObjectStore() の第二引数には以下のフィールドを持つオブジェクトが指定できます。

フィールド名説明
keyPathプライマリキーとなるフィールド名を指定する
autoIncrementtrueを指定すると、プライマリキーに連番の数値を自動で設定する

データベースへの接続が成功したら、 onsuccess に指定した関数が呼ばれます。そのイベントターゲットの result フィールドに以後の操作で使用する IDBDatabase オブジェクトが格納されているので、それを保存しておきます。

var conn = null;
openReq.onsuccess = function(e) {
  conn = e.target.result;
};

データベースに値を保存したり、保存した値を取得したりするときは、まず最初に transaction() でトランザクションを作成します。第一引数は操作対象のオブジェクトストア名(複数可...なんだけどなぁ)、第二引数は readonly(書き込み操作はエラーになる)、readwrite(読み書き可)のいずれかです。readwrite を指定したトランザクションは並列実行できない(かもしれない)ので、書き込み操作が不要なら readonly を指定したほうがパフォーマンスが高くなります。

var tx = conn.transaction(['store1'], 'readwrite');

値をデータベースに保存するには、トランザクションの objectStore() で保存先のオブジェクトストアを取得し、その add() メソッドを呼び出します。第一引数は保存する値で、任意のフィールドを持つオブジェクトが指定できます(createObjectStore() の keyPath で指定したフィールドは必須)。

var store = tx.objectStore('store1');
store.add({ key: 'key1', value: 'value1' });

保存した値を取得する場合は get() を使います。引数は取得対象のプライマリキーの値です。IndexedDB はすべて非同期で動作するので、結果を取得するには、返り値の onsuccess にコールバック関数を設定します。

var store = tx.objectStore('store1');
var req = store.get('key1');
req.onsuccess = function(e) {
  var value = e.target.result; // -> { key: 'key1', value: 'value1' }
};

非常にざっくりとですが、以上が IndexedDB の基本的な使い方です。このほか、セカンダリインデックスを作成して任意のフィールドを検索対象にしたり、カーソルで範囲検索したりといった機能があるので、詳細は仕様を参照してください。日本語だと、 IBM developerWorks の記事がわりと幅広く解説してます。

iOS8 の IndexedDB 実装の注意点

それでは本題である iOS8 に特有の注意点なのですが、私の知るかぎり、以下の 5 つがあります。

  • onupgradeneeded の oldVersion がおかしい
  • add() 等の第二引数に undefined が指定できない
  • onversionchange が呼び出されない
  • 複数オブジェクトストアのトランザクションが開けない
  • オブジェクトストア間でプライマリキーが重複できない

後半がわりと致命的なのですが、まあ個々に説明していきます。

onupgradeneeded の oldVersion がおかしい

仕様では、「open() が呼ばれたときに該当するデータベースがなかった場合、新たに生成して、そのバージョン番号を0にする」となっています。このため、データベースが新たに作成された際の onupgradeneeded に渡される oldVersion は 0 になるはずです。

openReq.onupgradeneeded = function(e) {
  var conn   = e.target.result;
  var oldVer = e.oldVersion; // 最初、これは0のはず
  if(oldVer < 1) {
    conn.createObjectStore('store1', { keyPath: 'key' });
  }
};

しかし、 iOS8 の場合、この値が 0x8000000000000000 などという、とんでもない値になっており、上記の if(oldVer < 1) が偽になってオブジェクトストアが作成されないという結果に。仕方ないので、 Feedeen では以下のように & をとってから使っています。

var oldVer = e.oldVersion & 0x7fffffffffffffff;

対処はそれほど難しくないですが、こんなしょっぱなの基本的な部分がバグっているあたり、テストしてんのかオイという不安が頭をよぎります。

add() 等の第二引数に undefined が指定できない

createObjectStore() の第二引数になにも指定しなかった場合、プライマリキーの値を add() の第二引数で指定することになっています。

store.add({ value: 'value1' }, 'key1');

逆に keyPath を指定している場合、この第二引数は省略されなくてはいけません。このあたりをふまえつつ、 add() を簡単に呼び出すための以下のラッパー関数を考えてみましょう。

function addToObjectStore(conn, storeName, value, optKey) {
  var tx = conn.transaction([storeName], 'readwrite');
  var store = tx.objectStore(storeName);
  return store.add(value, optKey);
}

この関数は、 iOS8 で keyPath を指定したオブジェクトストアに対しては使用できません(エラーになります)。たとえ以下のように optKey を省略して呼び出したとしてもです。

addToObjectStore(conn, 'store1', { key: 'key1', value: 'value1' });

この場合、 add() の第二引数には undefined が渡されるのですが、 iOS8 はこれを値の省略とはみなしてくれないようです。したがって、上記のラッパーは以下のように場合分けする必要があります。

function addToObjectStore(conn, storeName, value, optKey) {
  var tx = conn.transaction([storeName], 'readwrite');
  var store = tx.objectStore(storeName);
  if(optKey === undefined) {
    return store.add(value);
  } else {
    return store.add(value, optKey);
  }
}

たしかに WebIDL を厳密に解釈すると iOS8 の挙動が正しい気がしますが、一般的な JS の感覚とはずれますし、実際に Chrome や Firefox は undefined を省略とみなすので、油断しているとハマります。

onversionchange が呼び出されない

例えば複数のタブで同じデータベースを開いており、片方のページでデータベースのバージョンを変更した場合、そのまま実行を続けるとデータベースバージョンの不整合が起こってしまいます。これに対処するため、他の接続でバージョン変更が発生した場合に呼び出される onversionchange というイベントが用意されています。これにより、古い接続を閉じるなどの処理が可能になります。

conn.onversionchange = function(e) {
  conn.close();
  // 再接続などの処理
};

しかし、私が試した限り、 iOS8 ではこのコールバックは呼び出されないようです。このため、複数のタブで接続している場面でシームレスにバージョン変更を行うのは難しい気がします。

対処としては、他の接続によってバージョン変更がブロックされた際に呼ばれる onblocked を使って、タブを閉じるようにユーザーに通知するのが良いかもしれません。

var openReq = window.indexedDB.open('db1', 2);
openReq.onblocked = function(e) {
  alert('他のタブをすべて閉じて、このページをリロードしてください');
};

バージョン変更時の挙動については私も検証不足なので、今後追試していきます。

複数オブジェクトストアのトランザクションが開けない

transaction() の第一引数にオブジェクトストア名の配列を渡すことで、ひとつのトランザクションで複数のオブジェクトストアを操作できるのですが、 iOS8 ではこれが動きません。

var openReq = window.indexedDB.open('db1', 1);
openReq.onupgradeneeded = function(e) {
  var conn = e.target.result;
  conn.createObjectStore('store1');
  conn.createObjectStore('store2');
};

openReq.onsuccess = function(e) {
  var conn = e.target.result;
  var tx   = conn.transaction(['store1', 'store2'], 'readwrite');
  // ...
};

上のコードを実行すると、 transaction() の呼び出しで「リクエストされたデータベースオブジェクトが見つからない」みたいなエラーになります。はっはっは、まさかねーと思いつつ下のコードを実行してみたら、あっさり動きました。

var openReq = window.indexedDB.open('db1', 1);
openReq.onupgradeneeded = function(e) {
  var conn = e.target.result;
  conn.createObjectStore('store1,store2');
};

openReq.onsuccess = function(e) {
  var conn  = e.target.result;
  var tx    = conn.transaction(['store1', 'store2'], 'readwrite');
  var store = tx.objectStore('store1,store2');
  var req1  = store.add({ value: 'value' }, 'key1');
  req1.onsuccess = function(e) {
    var req2 = store.get('key1');
    req2.onsuccess = function(e) {
      var value = e.target.result; // -> { value: 'value' }
    };
  }
};

つまり iOS8 の transaction() は、第一引数の値を真っ先に文字列化してしまっているわけです。だんだんと香ばしい匂いがしてきましたね。

対処法としては、できないものはできないで諦めるしかないんじゃないでしょうか。

オブジェクトストア間でプライマリキーが重複できない

これは以下の記事でも報告されていたものです。

http://www.raymondcamden.com/2014/9/25/IndexedDB-o...

要約すると、「2つのオブジェクトストアに対して、同じプライマリキーで値を保存すると、先に保存した方の値が消えてしまう」です。コードにすると以下になります。

var openReq = window.indexedDB.open('db1', 1);
openReq.onupgradeneeded = function(e) {
  var conn = e.target.result;
  conn.createObjectStore('store1', { keyPath: 'key' });
  conn.createObjectStore('store2', { keyPath: 'id' });
};

openReq.onsuccess = function(e) {
  var conn = e.target.result;

  // 2つのオブジェクトストアに同じプライマリキーで値を順番に保存
  var tx1 = conn.transaction(['store1'], 'readwrite');
  tx1.objectStore('store1').add({ key: 'key1', value: 'value1' });
  tx1.oncomplete = function() {
    var tx2 = conn.transaction(['store2'], 'readwrite');
    tx2.objectStore('store2').add({ id: 'key1', value: 'value1' });

    // 後に保存した値が亡き者になる
    tx2.oncomplete = function() {
      var tx3 = conn.transaction(['store1'], 'readonly');
      var req = tx3.objectStore('store1').get('key1');
      req.onsuccess = function(e) {
        var value = e.target.result; // -> undefined
      };
    }
  };
};

うん、ダメだよね。どうしてこうなった。

先のトランザクションの問題もあわせて考えるに、 iOS8 の IndexedDB では、ひとつのデータベースにオブジェクトストアはひとつしか作れない、と考えて問題ない(わけがない)のではないでしょうか。

以上、現在までに把握している iOS8 IndexedDB の注意点を列挙しました。最初に言及したとおり、 IndexedDB の価値はクロスブラウザで動くことにあるわけなので、早めの修正を期待したいですね。

もっとも、現実問題としてクライアントサイドで厳密なトランザクションが必要になることは稀なので、予めできないことがわかっていれば対処できなくはないでしょう。品質管理的にどうなのよという話はあるにせよ、使える場面では使っていくのが吉かなと思っています。

ところで Apple さん、 iOS9 では ServiceWorker をぜひ。

関連記事

この記事にコメントする

Recommendations
Books
「Closure Library」の入門書です。
詳しくはこちらの記事をどうぞ!
Categories
Recent Articles