HTML5のLocal Storageや、WebSQLといった技術が非推奨である(※)こともあり、推奨されているindexedDBを使用してみようという方も多いのではないでしょうか。
indexedDBの使い方をなんとなく掴みたい、アプリケーションのデータキャッシュ戦略の一部として取り入れたい、そんな人はぜひ参考にしてください。
IndexedDBの概要
indexedDBは、ほぼ全てのブラウザで利用できるAPIで、データの取り出しや保存を非同期で行います。
単なるKey-Valueストアの集まりであったLocal Storageより強力な機能を備えており、以下のような優れた点があります。
indexedDBの利点
- データを構造化して保存することができる
- 容量制限が大きい
- トランザクション・インデックスなどに対応している
indexedDBでは、データベースの中にオブジェクトストアという単位でデータを管理します。
技術的には正確ではない(※)ですが、感覚的にはオブジェクトストアはテーブルのようなものです。オブジェクトストアごとに異なる種類の(形の)オブジェクトを保存できます。
アプリケーションで扱う(大半の)構造化されたデータに対して、オブジェクトストアごとにデータの種類を分けられるindexed DBは使いやすく感じられるかと思います。
idbライブラリ
純粋なindexedDB APIは、コールバック関数を記述するタイプの非同期APIで、Promise
をサポートしていません。
idbライブラリはPromise
で処理を行えるようindexedDBをラップしてくれるライブラリです。
コールバックのみで記述するのはしんどいですので、本記事ではidb
ライブラリを使用したコード例を紹介します。
idb
はNodeモジュールに用意されています。npm install
またはyarn add idb
でインストールしましょう。
オブジェクトストアの作成
まず最初に、openDB
メソッドでデータベースアクセスし、オブジェクトストアを作成しましょう。idb
ライブラリでPromise
を使用できるようになっていますので、async/await
が使用できます。
{"code":"import { openDB } from 'idb';\nasync function createObjectStore() {\n \/\/ \"TodoAppData\" \u30d0\u30fc\u30b8\u30e7\u30f31\u306e\u30c7\u30fc\u30bf\u30d9\u30fc\u30b9\u304c\u3042\u3063\u305f\u3089\u958b\u304f\n const db = await openDB('TodoAppData', 1, {\n \/\/ \u7121\u304b\u3063\u305f\u3089upgrade()\u304c\u5b9f\u884c\u3055\u308c\u308b\n upgrade(db){\n \/\/ \u30c7\u30fc\u30bf\u30d9\u30fc\u30b9\u4e2d\u306b\"todos\"\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u30b9\u30c8\u30a2\u304c\u7121\u304b\u3063\u305f\u3089\u4f5c\u6210\u3059\u308b\n if (!db.objectStoreNames.contains('todos')){\n db.createObjectStore('todos', {keyPath: 'id'});\n }\n }\n })\n}\ncreateObjectStore();","language":"javascript","id":1,"filename":""}
openDB(DBName, Version, callback)
指定された名前・バージョンのデータベースがあったら開き、結果をPromise
を返す。
無かった時、コールバック関数が実行される。
openDB
メソッドでデータベースを開きます。初期化時はデータベース自体が無いので、第3引数のコールバック関数が実行されます。
upgrade
は、openDB
で開いたデータベース(db
)にアクセスできるメソッドです。upgrade
の内部で、todos
オブジェクトストアを検索し、無ければ作成します。
{"code":"\/\/ objectStoreNames\u306f\u3001\u30c7\u30fc\u30bf\u30d9\u30fc\u30b9\u4e2d\u306e\u5168\u3066\u306e\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u30b9\u30c8\u30a2\u306e\u540d\u524d\u3092\u4fdd\u6301\u3057\u3066\u3044\u308b\nif (!db.objectStoreNames.contains('todos')){\n db.createObjectStore('todos', {keyPath: 'id'});\n}","language":"javascript","id":1,"filename":""}
createObjectStore
メソッドでオブジェクトストアを生成できます。
createObjectStore(ObjectStoreName, { keyPath: primaryKey })
createObjectStore
実行時にkeyPath
に主キーを設定する。
主キーは、データを選択するための検索キーとなる
主キーは、データを選択する一意なものでなくてはなりません。複数のデータで重複するとindexedDBはエラーを返します。
上記の例では、todoリストの保存を想定して、(多くの場合、アプリ内部で自動的に生成される)todoリストのidを主キーに利用します。
データ操作
データベースとオブジェクトストアを作成したら、データを操作してみましょう。基本的なメソッドを順に紹介します。
データの取得 | get()
get
メソッドで、オブジェクトストア内のデータにアクセスできます。
以下は、todos
オブジェクトストア内に、idが1のtodoデータが保存されていた場合を想定したコード例です。
{"code":"import { openDB } from 'idb';\nasync function getTodoFromStore(todo_id) {\n const db = await openDB('TodoAppData', 1)\n \/\/ 'todo'\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u30b9\u30c8\u30a2\u306e\u4e2d\u304b\u3089\u30c7\u30fc\u30bf\u3092\u691c\u7d22\u3057\u3066\u8fd4\u3059\n const todo = await db.get('todos', todo_id);\n return todo;\n}\n\/\/ id\u304c1\u306etodo\u3092\u53d6\u5f97\nconsole.log(getTodoFromStore(1));\n\n\/\/ \u51fa\u529b\u4f8b\n\/\/ { id: 1, title: 'go shopping', description: 'buy milk and tea at supermarket.' }","language":"javascript","id":1,"filename":""}
db.get(ObjectStoreName, primaryKey)
get
メソッドで、オブジェクトストア名と主キーを指定してデータを取得する
データの追加 | add()
add
メソッドで、オブジェクトストアにデータを追加できます。
{"code":"import { openDB } from 'idb';\nasync function addTodoToStore(newTodo) {\n const db = await openDB('TodoAppData', 1);\n \/\/ 'todo'\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u30b9\u30c8\u30a2\u306b\u30c7\u30fc\u30bf\u3092\u8ffd\u52a0\u3059\u308b\n await db.add('todos', newTodo);\n}\n\/\/ \u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u3092\u751f\u6210\nconst newTodo = {\n id: 2,\n title: 'Shipment',\n description: 'ship products to New York'\n};\naddTodoToStore(newTodo);","language":"javascript","id":1,"filename":""}
db.add(ObjectStoreName, Object)
add
メソッドで、オブジェクトストア名を指定してデータを保存する
新規に追加しようとしているオブジェクトに主キーが無い場合、主キーが既存のデータと重複している場合にはエラーになってしまうので注意してください。
データの更新 | put()
put
メソッドで、オブジェクトストア内のデータを選択し更新できます。下記では、idが1のtodoが既にある状態で、todoの詳細を更新する例です。
{"code":"import { openDB } from 'idb';\nasync function getTodoFromStore(todo_id) { ... }\nasync function updateTodoInStore(update) {\n const db = await openDB('TodoAppData', 1);\n await db.put('todos', update);\n}\nconsole.log(getTodoFromStore(1));\n\/\/ \u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u3092\u6e21\u3057\u3066\u66f4\u65b0\u3059\u308b\nupdateTodoInStore({\n id: 1,\n title: 'go shopping',\n description: 'buy tomato at supermarket'\n});\nconsole.log(getTodoFromStore(1));\n\n\/\/ \u51fa\u529b\u4f8b\n\/\/ { id: 1, title: 'go shopping', description: 'buy milk and tea at supermarket.' }\n\/\/ { id: 1, title: 'go shopping', description: 'buy tomato at supermarket.' }","language":"javascript","id":1,"filename":""}
db.add(ObjectStoreName, NewObject)
put
メソッドで、オブジェクトストア内のデータを新しいオブジェクトで更新する
注意点として、indexedDBはput
メソッドでの更新に関して既存のオブジェクトの差分を検知せず、新しいオブジェクトで既存のオブジェクトを置き換えます。そのため、更新実行後も保持しておきたいプロパティについては、実際の値には変化がなくてもオブジェクトに指定してください。
{"code":"import { openDB } from 'idb';\nasync function getTodoFromStore(todo_id) { ... }\nasync function updateTodoInStore(update) {\n const db = await openDB('TodoAppData', 1);\n await db.put('todos', update);\n}\nconsole.log(getTodoFromStore(1));\n\/\/ title\u30d7\u30ed\u30d1\u30c6\u30a3\u3092\u6e21\u3055\u305a\u306b\u66f4\u65b0\u3059\u308b\nupdateTodoInStore({\n id: 1,\n description: 'buy tomato at supermarket'\n});\nconsole.log(getTodoFromStore(1));\n\/\/ \u51fa\u529b\u4f8b title\u304c\u6d88\u3048\u3066\u3057\u307e\u3046\n\/\/ { id: 1, title: 'go shopping', description: 'buy milk and tea at supermarket.' }\n\/\/ { id: 1, description: 'buy tomato at supermarket.' }","language":"javascript","id":1,"filename":""}
put
で指定しなかったプロパティは、更新後に消えてしまう
データの削除 | delete()
delete
メソッドで、オブジェクトストア内のデータを選択し削除できます。idが1のtodoデータがある状態から、そのデータを削除してみます。
{"code":"import { openDB } from 'idb';\nasync function getTodoFromStore(todo_id) { ... }\nasync function deleteTodoFromStore(todo_id) {\n const db = await openDB('TodoAppData', 1); \n await db.delete('todos', 1);\n}\nconsole.log(getTodoFromStore(1));\ndeleteTodoFromStore(1);\nconsole.log(getTodoFromStore(1));\n\n\/\/ \u51fa\u529b\u4f8b\n\/\/ { id: 1, title: 'go shopping', description: 'buy milk and tea at supermarket.' }\n\/\/ Delete todo.\n\/\/ \u30a8\u30e9\u30fc\u304c\u8d77\u304d\u308b","language":"javascript","id":1,"filename":""}
db.delete(ObjectStoreName, primaryKey)
delete
メソッドで、オブジェクトストア名と主キーを指定してデータを削除する
高度な機能 |トランザクション・インデックス
データ操作の仕方を押さえたら、実用上必要になるトランザクション・インデックス(用語説明)の設定の仕方を押さえましょう。
トランザクション
indexedDBでは、複数のデータ操作をまとめてトランザクションを設定することができます。
indexedDBでは、openDB
で返されるdb
オブジェクトにはtransaction
メソッドが用意されており、複数のデータ操作をまとめてトランザクションを設定する事ができます。
複数のデータをまとめてadd
するトランザクションの例を掲載します。
{"code":"import { openDB } from 'idb';\nasync function addMultipleDataToStore() {\n const db = await openDB('TodoAppData', 1);\n \/\/ \u30c8\u30e9\u30f3\u30b6\u30af\u30b7\u30e7\u30f3\u306e\u4f5c\u6210 \n const tx = db.transaction('todos', 'readwrite');\n \n await Promise.all{[\n tx.store.add({\n id: 1,\n title: 'Shopping',\n description: 'Buy milk at supermarket'\n }),\n tx.store.add({\n id: 2,\n title: 'Payment',\n description: ''\n }),\n tx.done\n ]}\n}\naddMultipleDataToStore();","language":"javascript","id":1,"filename":""}
db.transaction(ObjectStoreName, transactionMode)
transaction
メソッドで、オブジェクトストア名と、データアクセスのモードを指定してトランザクションを作成する。
モードは2つ。
readonly
: 読み取り専用モード。データの追加や更新はできない。
readwrite
:読み書きモード。
tx.done
は、トランザクション中の他の全ての処理が完了した時(Promise
が解決されたとき)に、解決するようなPromise
を返します。
要するに、非同期的に(順不同で)行われる複数の処理が間違いなく完了したことを保証してくれるものですね。トランザクションを使用する時には必ず利用しましょう。
tx.done
を含んだ処理全体を、Promise.all
でラップしています。Promise.all
はPromise
の配列を引数に取り、配列内の全てのPromise
が解決された時に解決するようなPromise
を返します。
このあたりの実装はindexedDBの仕様には関係がありませんので、Promise.allのドキュメントを参考になさってください。
インデックス
indexedDBにおいて、インデックスを作成すると以下の2つの操作が可能になります。
indexedDBのインデックスの利点
- 主キー以外での検索
- 保存されているデータに条件を設定した絞り込み検索
インデックスの作成
インデックスの作成は、オブジェクトストアを作成する際に同時に設定する必要があります。
以下では、todoリストのデータに各todoにかかる日数days
が設定されているとして、日数days
を検索に利用することを意図してインデックスを作成します。
{"code":"import { openDB } from 'idb';\nasync function createObjectStore() {\n \/\/ \"TodoAppData\" \u30d0\u30fc\u30b8\u30e7\u30f32\u306e\u30c7\u30fc\u30bf\u30d9\u30fc\u30b9\u3092\u958b\u304f\n const db = await openDB('TodoAppData', 2, {\n upgrade(db){\n if (!db.objectStoreNames.contains('todos')){\n const todosStore = db.createObjectStore('todos', {keyPath: 'id'});\n todosStore.createIndex('duration', 'days', { unique: false });\n }\n }\n });\n}\ncreateObjectStore();","language":"javascript","id":1,"filename":""}
ObjectStore.createIndex(IndexName, property, options)
createIndex
は、オブジェクトストアに使用できるメソッド。オブジェクトストア名・インデックスを作成するプロパティ名、オプションを設定する。
オプションは2つあるが、unique
だけとりあえず覚える。
unique
: true
は、インデックスを作成するプロパティの値が一意であることを保証する。false
は、複数のデータで値が重複していることを許容する。
multiEntry
: MDNドキュメント参照
todoの日数には重複がありますので、{unique: false}
で設定します。これで、日数指定の検索、日数での絞り込み検索などができるようになります。
次節から、インデックスを利用した検索の方法を記載します。
RDBでのインデックスと同様に、indexedDBでも不要なインデックスの作成はパフォーマンスの低下を招きます。
一般に一意なキーを用いた検索は十分に高速ですから、「主キーでは検索できないが頻繁に行われる検索・絞り込み」に利用しましょう。
インデックスの利用 | カーソル
インデックスを利用した検索のコード例の前に、カーソルの使用方法について確認します。
インデックスを利用した検索は、ほぼ全ての場合でカーソルとともに使用されます(カーソルが必要なわけ)。
カーソルは、データベース内部のデータ1つ1つを順に調べるために必要な、現在位置を保持するデータです。
手順としては、
- トランザクションを作成する:
db.transaction
- トランザクションの中で、カーソルを設定する:
tx.store.openCursor
- カーソルを使用してデータベースを走査する
になります。
次のコード例では、カーソルが自動的にデータベース内部のデータ1つ1つを走査し、現在位置を保持していることを確認することができます。
{"code":"async function scanDBWithCursor() {\n const db = await openDB('TodoAppData', 2);\n const tx = await db.transaction('todos', 'readonly');\n \n \/\/ \u30ab\u30fc\u30bd\u30eb\u3092\u4f5c\u6210\u3057\u3001\u30c7\u30fc\u30bf\u30d9\u30fc\u30b9\u306b\u30a2\u30af\u30bb\u30b9\n let cursor = await tx.store.openCursor();\n \n while (cursor) {\n \/\/ \u30ab\u30fc\u30bd\u30eb\u304c\u4fdd\u6301\u3057\u3066\u3044\u308b\u5185\u5bb9\u3092\u51fa\u529b\u3059\u308b\n console.log(cursor.key, cursor.value);\n \/\/ \u30ab\u30fc\u30bd\u30eb\u3092\u6b21\u306e\u4f4d\u7f6e\u306b\u79fb\u52d5\u3059\u308b\n cursor = await cursor.continue();\n }\n}\nscanDBWithCursor();","language":"javascript","id":1,"filename":""}
transaction.store.openCursor()
openCursor
メソッドで、オブジェクトストアにカーソルを設定する。オブジェクトストア自体の指定は、トランザクション作成時に指定する。
インデックスの利用 | 絞り込み検索
カーソルを作成して、データ1つ1つを走査することができるようになりました。それでは、todoの日数days
に対して、絞りこみ検索を行なってみます。
日数days
が、3日間以上5日間以下のtodoのみを選択して表示するコード例を先に見てみましょう。
{"code":"async function createObjectStore() {\n \/\/...\n \/\/ days\u306b\u5bfe\u3057\u3066duration\u30a4\u30f3\u30c7\u30c3\u30af\u30b9\u3092\u4f5c\u6210\u3057\u3066\u3044\u305f\n todosStore.createIndex('duration', 'days', { unique: false });\n \/\/...\n}\nasync function searchTodoWithDays (lowerBound, upperBound) {\n \/\/ \u7d5e\u308a\u8fbc\u307f\u7bc4\u56f2\u306f\u3001IDBKeyRange.bound()\u3092\u4f7f\u3046\n let range = IDBKeyRange.bound(lowerBound, upperBound);\n const db = await openDB('TodoAppData', 3);\n \/\/ \u30c8\u30e9\u30f3\u30b6\u30af\u30b7\u30e7\u30f3\u306e\u4f5c\u6210\n const tx = await db.transaction('todos', 'readonly');\n \n \/\/ duration\u30a4\u30f3\u30c7\u30c3\u30af\u30b9\u3092\u9078\u629e\n const index = tx.store.index('duration');\n \n \/\/ \u30a4\u30f3\u30c7\u30c3\u30af\u30b9\u3092\u30c8\u30e9\u30f3\u30b6\u30af\u30b7\u30e7\u30f3\u306b\u7d10\u4ed8\u3051\u308b\n let cursor = await index.openCursor(range);\n \n while (cursor) {\n console.log(cursor.key, cursor.value);\n cursor = await cursor.continue();\n }\n}\n\/\/ \u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u30b9\u30c8\u30a2\u3092\u4f5c\u6210\u3057\u3001\u540c\u6642\u306b\u30a4\u30f3\u30c7\u30c3\u30af\u30b9\u3092\u4f5c\u6210\ncreateObjectStore();\n\/\/ days\u304c3\u301c5\u65e5\u9593\u306e\u30c7\u30fc\u30bf\u306e\u307f\u3092\u8868\u793a\u3059\u308b\nsearchTodoWithDays(3, 5);","language":"javascript","id":1,"filename":""}
事前に作成されたインデックスがある場合に、絞り込み検索を行う手順は以下のようになります。
インデックスを利用した絞り込み検索の手順
- 絞り込み範囲を
IDBKeyRange.bound(下限、上限)
で定める
- トランザクションにインデックスを紐付ける。この時、絞り込み範囲も渡す
- 絞り込み範囲の中をカーソルで走査し処理を行う
インデックスとトランザクションを紐づけるのがわかりくいところですが、インデックスとはソートのようなものであることを思い出すとわかりやすくなります。
実際の動作としては、
- インデックスで定められた順(例えば日数
days
の昇順)にカーソルはデータを走査する。走査して、決められた絞り込み範囲にあるものだけを保持して返す
といった動作になります。
IDBKeyRange.bound()
ですが、上限下限はもちろんどちらか一方でも使用できます(IDBKeyRange)。
まとめ
- IndexedDBは、オブジェクトストアごとに分けてデータを保存できる
- idbライブラリで、データ操作は簡潔に書くことができる
- 高度な検索機能や、データ不整合を防ぐ仕組みが備わっている
以上になります。お疲れ様でした。
注釈
用語メモ:カーソルが必要なわけ
カーソルは、データベース内のデータ(RDBであればレコード)を1つ1つ順に参照する際に、今どこの位置にいるのかを(プログラムが認識するために)保存しているデータです。
一意なキーを利用した検索では(実は)内部のデータを見る必要はありません。ハッシュなどを利用しているからです。
値による絞り込みなど、一対一に結果が決まるとは限らない検索ではデータを走査(スキャン)する必要があるので、カーソルを使用するわけです。
戻る
用語メモ:トランザクション・インデックス
- トランザクション:複数のデータ操作を、意味のある処理(例:商品の購入処理、ユーザー認証処理)の単位にまとめる事。トランザクションにまとめられたデータ操作を順に実行して上手くいった時だけデータベースは更新され、途中でバグが発生した場合には一つも実行しない。
- インデックス:データの検索を高速化するために、予め指定しておいたキーでデータをソートしておく(正確には、ソートされた状態との紐付けを保存しておく)こと。
戻る
indexedDB ≠ RDB
indexedDBは構造化されたデータを適切に分類して保持するのに役立ちますが、複数のテーブルの関係性を利用するRDBとは全く異なります。
しかし、indexedDBの聞き慣れない「オブジェクトストア」の意味を掴むためだけであれば、RDBテーブルのメタファーがあっても良いのではと思います。
戻る