システム開発にあたってMySQLなどのデータベースを用いることが多いですが、スマホアプリ開発では端末内にいわゆるデータベースを保持できないので、通常はデータベース用のサーバーを用意する必要があります。
もちろん、データベース用サーバーが必須な場合(他ユーザーと連携するようなアプリの場合)も多いのですが、オフラインで動かせるアプリの場合は無理にデータベース用サーバーを用意しなくても良いと言えます。
通信が少なくなりますし、サーバーの容量や、情報漏洩についても心配が減ります。
ハイブリッドアプリは、ブラウザ内にデータを保存する機能を利用することが出来るので、CookieやwebStorageも利用できるのですが、複雑なデータになると扱いが大変です。
このような時に重宝するのが「indexedDB」です。
MySQLなどのRDBとは構想が違うので扱いに少し戸惑うこともあるかもしれませんが、基本的にRDBより柔軟に作られていますので、すぐに慣れると思います。
利用にあたっては、「Dexie.js」というライブラリと共に利用するのが使いやすいと思います。
このブログでは、「indexedDB + Dexie.js」の使い方、基本的な記述、簡単な動作例、便利なポイントを説明していきます。
動作例は、軽快に動きますので、ぜひ触ってみてください!
全体として、BRISKで作成したスマホアプリ「スターティングメンバーを決めよう!」を例に記載していきます。
参考サイト:
MozzilaのIndexedDB APIドキュメント
Dexie.js公式ドキュメント(英語)
目次
使い方
Dexie.jsの読み込み
indexedDB は HTML5 の機能なので特に何かを読み込まなくても使用できます。
Dexie.js を公式サイトからダウンロードして利用しましょう。ダウンロードページ
1 2 3 | <script src="js/dexie.min.js"></script> |
DB設定
最初にDBの設定をする必要があります。
もちろん複数作成できますが、基本的にスタンドアローンなシステムを作るわけですので、1つのシステムで作るDBは1つで十分かと思います。
1 2 3 | var db = new Dexie('my_db_name'); |
ストア設定
ストアというのは、RDBにおけるテーブルのようなイメージで、データの集合体です。
しかし、RDBのようにカラムが明確に決まっているイメージではないので、箱とユニークキーとインデックスだけ設定しておけば、項目が増えたデータを追加したりすることが出来ます。
例えば野球アプリでこのようなデータを扱いたいときには以下のように設定できます。
[試合情報]
試合ID | 試合名 |
---|---|
1 | 地区予選1回戦 |
2 | 地区予選2回戦 |
3 | 地区予選決勝 |
[選手情報]
選手ID | 選手名 | 背番号 | 守備可能位置 | 更新日時 |
---|---|---|---|---|
1 | 佐藤 | 310 | 投手 | 2020/7/21 9:51:00 |
2 | 鈴木 | 8 | 内野, 外野 | 2020/7/22 15:23:00 |
3 | 高橋 | 24 | 捕手, 内野 | 2020/7/22 15:23:00 |
[各試合の出場選手情報]
試合ID | 選手ID | 守備位置 |
---|---|---|
1 | 1 | 投手 |
1 | 2 | 遊撃 |
2 | 1 | 投手 |
2 | 3 | 捕手 |
1 2 3 4 5 6 7 8 | db.version(1).stores({ // ストア名: "キーをカンマ区切りで記入" games: "++game_id", members: "++member_id, *position, updated", entry_member: "&[game_id+member_id], game_id, member_id, position" }); |
この設定を見ると分かるように、データとして格納したい全ての項目を設定する必要はありません。
インデックスを貼りたい項目(検索やソートで後から利用したい項目)だけをここで設定します。
「++」のような記号をつけることにより項目に意味を追加することが出来ます。
++ | Auto incremented(自動連番)で、主キー |
---|---|
& | ユニークなキー |
* | 複数入力キー(カテゴリやタグのようなもので良く使う) |
[A+B] | 複合キー |
&[A+B] | ユニークな複合キー |
ここまででひとまずDBを使えるようになりました。
「version(1)」という記述がありますが、こちらは後で説明します。
基本的な記述
RDBで言うところのSQLコマンドと対応させて基本的な記述を説明していきます。
それぞれ、primaryKey を基に実行するやり方と、条件を基に実行するやり方があります。
取得 – SELECT
データの取得です。「toArray」を使って、返ってくるデータをオブジェクトとして受け取ることが出来ます。
1 2 3 4 5 6 7 8 | db.members .toArray() .then(function (records) { console.log(records); // 全データ }); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | db.members .where('member_id') .above(10) .toArray() .then(function (records) { console.log(records); // 選手IDが 10 より大きい選手データ }); db.members .where('position') .equals('内野') .toArray() .then(function (records) { console.log(records); // 守備可能位置に「内野」が含まれている選手データ }); // こういう書き方も出来ます。 db.entry_member .where({'game_id': 2, 'position': '捕手'}) .toArray() .then(function (records) { console.log(records); // 試合IDが「2」で守備位置が「捕手」の出場選手データ }); |
新規登録 – INSERT
追加登録です。
オートインクリメントの項目は特に指定しなくて大丈夫です。
1 2 3 4 5 6 7 8 9 10 11 | // add を使って1つのデータを追加できます。 db.members.add({member_name: '佐藤', member_number: 310, position: '投手', updated: getTimestamp()}); // 複数データを一度に登録するのは bulkAdd を使うとできます。 db.games.bulkAdd([ {game_name: '地区予選1回戦'}, {game_name: '地区予選2回戦'}, {game_name: '地区予選決勝'} ]); |
更新 – UPDATE
データを更新するときには、primaryKey を基に更新するのか、それ以外で更新するのか、で記載が異なります。
1 2 3 4 5 6 7 8 9 10 | // update を使って1つのデータを更新できます。 var target_id = 1; // primaryKey db.members.update(target_id, {member_name: '佐藤 貴之', updated: getTimestamp()}); // 検索した条件に対して更新することもできます。 db.entry_member .where({'position': '投手'}) .modify({'member_id': 4}); |
削除 – DELETE
こちらも更新と同じような感じで操作できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // delete を使って1つのデータを削除できます。 var target_id = 1; // primarKey db.members.delete(target_id); // bulkDelete を使って複数データを一度に削除できます。 var target_ids = [2,3]; // primarKey db.members.bulkDelete(target_ids); // 検索した条件に対して削除することもできます。 db.entry_member .where({'position': '投手'}) .delete(); |
並べ替え – ORDER BY
並べ替えは sortBy や reverse を利用して対応します。並べ替えたい項目はストア設定のところで指定しておく必要があります。
orderBy というコマンドも公式サイトを見るとあるのですが準備中のようです。
この sortBy なのですが、検索して絞り込んだ対象に対してしか並べ替えできません。
ストア全体に対して並べ替えが出来ない、ということになります。
1 2 3 4 5 6 7 | // NGな書き方 db.members .sortBy('updated') .then(function (records) { }); |
でも全体を並べ替えて表示したい、ということはありますよね。どうすれば良いかというと、全体を取得できるような条件で検索したものを並び替えればよい、ということになります。
例えば primarKey が 0 より大きい、という条件で検索したら全件取得できるので、sortBy を利用することができます。
1 2 3 4 5 6 7 8 9 10 | // OKな書き方 db.members .where('member_id') .above(0) .sortBy('updated') .then(function (records) { console.log(records); }); |
逆順にする reverse は、ストアに対しても検索結果に対してもどちらでも使えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // ストア逆順 db.members .reverse() .toArray() .then(function (records) { console.log(records); }); // 検索結果逆順 & sortByと組み合わせ db.members .where('member_id') .aboveOrEqual(1) .reverse() .sortBy('updated') .then(function (records) { console.log(records); }); |
数限定取得 – LIMIT
最初の1件だけを取得するのは first、最後の1件だけを取得するのは last、というコマンドで出来ます。これらは条件で絞り込んだときにだけ使えて、ストア全体には使えません。
件数指定は limit ですね。こちらはストア全体にも使えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | // 最初の1件だけ db.entry_member .where({'game_id': 2}) .first() .then(function (records) { console.log(records); }); // 最後の1件だけ db.entry_member .where({'game_id': 2}) .last() .then(function (records) { console.log(records); }); // 検索結果逆順 & sortByと組み合わせ db.members .limit(2) .toArray() .then(function (records) { console.log(records); }); |
簡単な動作例
上の方で定義したDBに対して追加、更新、削除の動作をするようにしました。
indexedDBなので、もちろん通信は発生しません。
初期状態に戻したい場合は、下にある「リセット」を押してください。
上記実装のソースも記載します。参考にしていただければ幸いです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | (function ($) { 'use strict'; var db; // 0埋め関数 var zeroPadding = function(num,length){ return ('0000000000' + num).slice(-length); }; // タイムスタンプ取得関数 var getTimestamp = function() { var now = new Date(); var y = now.getFullYear(); var m = now.getMonth() + 1; var d = now.getDate(); var w = now.getDay(); var h = now.getHours(); var mi = now.getMinutes(); var s = now.getSeconds(); var timestamp = y + '-' + zeroPadding(m,2) + '-' + zeroPadding(d,2) + ' ' + zeroPadding(h,2) + ':' + zeroPadding(mi,2) + ':' + zeroPadding(s,2); return timestamp; }; // DB設定 var init_dexie = function() { // 初期データ設定 db.games.bulkAdd([{game_name: '地区予選1回戦'},{game_name: '地区予選2回戦'},{game_name: '地区予選決勝'}]); db.members.add({member_name: '佐藤', member_number: 310, position: '投手', updated: getTimestamp()}); db.members.add({member_name: '鈴木', member_number: 8, position: ['内野','外野'], updated: getTimestamp()}); db.members.add({member_name: '高橋', member_number: 24, position: ['内野','捕手'], updated: getTimestamp()}); db.entry_member.bulkAdd([{game_id: 1, member_id: 1, position: '投手'},{game_id: 1, member_id: 2, position: '遊撃'},{game_id: 2, member_id: 1, position: '投手'},{game_id: 2, member_id: 3, position: '捕手'}]); }; // Dexie実行 var do_dexie = function() { db = new Dexie('my_db_name'); // ストア設定 db.version(1).stores({ // ストア名: "キーをカンマ区切りで記入" games: "++game_id", members: "++member_id, member_number, *position, updated", entry_member: "&[game_id+member_id], game_id, member_id, position" }); }; // 表示 var disp_dexie = function() { setTimeout( function() { db.games .toArray() .then(function (records) { var games_html = ''; games_html += '<div class="table01"><p class="table-title">[試合情報]</p><table class="nowrap"><tr><th>試合ID</th><th>試合名</th><th></th></tr>'; $.each( records, function(index,data) { games_html += '<tr><td class="game_id_td">' + data.game_id + '</td><td class="game_name_td">' + data.game_name + '</td><td><button class="update-select-btn">選択</button><button class="delete-btn">削除</button></td></tr>'; }); games_html += '<tr><td><input type="hidden" name="game_id"><span class="game_id"></span></td><td><input type="text" name="game_name"></td><td><button class="new-btn">新規</button><button class="update-btn">更新</button></td></tr>'; games_html += '</table></div>'; $('#my-db-games').html(games_html); }); db.members .toArray() .then(function (records) { var members_html = ''; members_html += '<div class="table01"><p class="table-title">[選手情報]</p><table class="nowrap"><tr><th>選手ID</th><th>選手名</th><th>背番号</th><th>守備可能位置</th><th>更新日時</th><th></th></tr>'; $.each( records, function(index,data) { if ( $.isArray(data.position) ) { var position_text = data.position.join(', '); } else { var position_text = data.position; } members_html += '<tr><td class="member_id_td">' + data.member_id + '</td><td class="member_name_td">' + data.member_name + '</td><td class="member_number_td">' + data.member_number + '</td><td class="position_td">' + position_text + '</td><td class="updated_td">' + data.updated + '</td><td><button class="update-select-btn">選択</button><button class="delete-btn">削除</button></td></tr>'; }); members_html += '<tr><td><input type="hidden" name="member_id"><span class="member_id"></span></td><td><input type="text" name="member_name"></td><td><input type="text" name="member_number"></td><td><input type="text" name="position"><div class="ex"></div></td><td class="input-updated-td"></td><td><button class="new-btn">新規</button><button class="update-btn">更新</button></td></tr>'; members_html += '</table></div>'; $('#my-db-members').html(members_html); }); db.entry_member .toArray() .then(function (records) { var entry_member_html = ''; entry_member_html += '<div class="table01"><p class="table-title">[各試合の出場選手情報]</p><table class="nowrap"><tr><th>試合ID</th><th>選手ID</th><th>守備位置</th><th></th></tr>'; $.each( records, function(index,data) { entry_member_html += '<tr><td class="game_id_td">' + data.game_id + '</td><td class="member_id_td">' + data.member_id + '</td><td class="position_td">' + data.position + '</tdclass><td><button class="update-select-btn">選択</button><button class="delete-btn">削除</button></td></tr>'; }); entry_member_html += '<tr><td><input type="text" name="game_id"></td><td><input type="text" name="member_id"></td><td><input type="text" name="position"></td><td><button class="new-btn">新規</button><button class="update-btn">更新</button></td></tr>'; entry_member_html += '</table></div>'; $('#my-db-entry_member').html(entry_member_html); }); }, 100); }; // 以下、処理 do_dexie(); // do if ( localStorage.getItem('dexie_init') != 1 ) { init_dexie(); localStorage.setItem('dexie_init', 1); } disp_dexie(); // リセット $('#js-reset-btn').on('click', function() { Dexie.delete('my_db_name'); // reset do_dexie(); init_dexie(); disp_dexie(); }); // 削除処理 $('#my-db-games').on('click', '.delete-btn', function() { var target_id = parseInt($(this).closest('tr').find('.game_id_td').text()); db.games.delete(target_id); disp_dexie(); }); $('#my-db-members').on('click', '.delete-btn', function() { var target_id = parseInt($(this).closest('tr').find('.member_id_td').text()); db.members.delete(target_id); disp_dexie(); }); $('#my-db-entry_member').on('click', '.delete-btn', function() { var target_game_id = parseInt($(this).closest('tr').find('.game_id_td').text()); var target_member_id = parseInt($(this).closest('tr').find('.member_id_td').text()); db.entry_member.where({'game_id': target_game_id, 'member_id': target_member_id}).delete(); disp_dexie(); }); // 新規処理 $('#my-db-games').on('click', '.new-btn', function() { var target_game_name = $(this).closest('tr').find('[name="game_name"]').val(); db.games.add({game_name: target_game_name}); disp_dexie(); }); $('#my-db-members').on('click', '.new-btn', function() { var target_member_name = $(this).closest('tr').find('[name="member_name"]').val(); var target_member_number = parseInt($(this).closest('tr').find('[name="member_number"]').val()); var tmp_position = $(this).closest('tr').find('[name="position"]').val(); if ( tmp_position.indexOf(',') >= 0 ) { var target_position = tmp_position.split(','); } else { var target_position = tmp_position; } db.members.add({member_name: target_member_name, member_number: target_member_number, position: target_position, updated: getTimestamp()}); disp_dexie(); }); $('#my-db-entry_member').on('click', '.new-btn', function() { var target_game_id = parseInt($(this).closest('tr').find('[name="game_id"]').val()); var target_member_id = parseInt($(this).closest('tr').find('[name="member_id"]').val()); var target_position = $(this).closest('tr').find('[name="position"]').val(); db.entry_member.add({game_id: target_game_id, member_id: target_member_id, position: target_position}); disp_dexie(); }); // 更新処理 $('#my-db-games').on('click', '.update-select-btn', function() { if ( $(this).hasClass('on') ) { $(this).removeClass('on'); $(this).closest('table').find('[name="game_id"]').val(''); $(this).closest('table').find('[name="game_id"]').next('.game_id').text(''); $(this).closest('table').find('[name="game_name"]').val(''); } else { $(this).closest('table').find('.on').removeClass('on'); $(this).addClass('on'); $(this).closest('table').find('[name="game_id"]').val($(this).closest('tr').find('.game_id_td').text()); $(this).closest('table').find('[name="game_id"]').next('.game_id').text($(this).closest('tr').find('.game_id_td').text()); $(this).closest('table').find('[name="game_name"]').val($(this).closest('tr').find('.game_name_td').text()); } }); $('#my-db-games').on('click', '.update-btn', function() { if ( $(this).closest('tr').find('[name="game_id"]').val() != '' ) { var target_game_id = parseInt($(this).closest('tr').find('[name="game_id"]').val()); var target_game_name = $(this).closest('tr').find('[name="game_name"]').val(); db.games.update(target_game_id, {game_name: target_game_name}); disp_dexie(); } }); $('#my-db-members').on('click', '.update-select-btn', function() { if ( $(this).hasClass('on') ) { $(this).removeClass('on'); $(this).closest('table').find('[name="member_id"]').val(''); $(this).closest('table').find('[name="member_id"]').next('.member_id').text(''); $(this).closest('table').find('[name="member_name"]').val(''); $(this).closest('table').find('[name="member_number"]').val(''); $(this).closest('table').find('[name="position"]').val(''); } else { $(this).closest('table').find('.on').removeClass('on'); $(this).addClass('on'); $(this).closest('table').find('[name="member_id"]').val($(this).closest('tr').find('.member_id_td').text()); $(this).closest('table').find('[name="member_id"]').next('.member_id').text($(this).closest('tr').find('.member_id_td').text()); $(this).closest('table').find('[name="member_name"]').val($(this).closest('tr').find('.member_name_td').text()); $(this).closest('table').find('[name="member_number"]').val($(this).closest('tr').find('.member_number_td').text()); $(this).closest('table').find('[name="position"]').val($(this).closest('tr').find('.position_td').text()); $(this).closest('table').find('.input-updated-td').text($(this).closest('tr').find('.updated_td').text()); } }); $('#my-db-members').on('click', '.update-btn', function() { if ( $(this).closest('tr').find('[name="member_id"]').val() != '' ) { var target_member_id = parseInt($(this).closest('tr').find('[name="member_id"]').val()); var target_member_name = $(this).closest('tr').find('[name="member_name"]').val(); var target_member_number = $(this).closest('tr').find('[name="member_number"]').val(); var tmp_position = $(this).closest('tr').find('[name="position"]').val(); if ( tmp_position.indexOf(',') >= 0 ) { var target_position = tmp_position.split(','); } else { var target_position = tmp_position; } db.members.update(target_member_id, {member_name: target_member_name, member_number: target_member_number, position: target_position, updated: getTimestamp()}); disp_dexie(); } }); $('#my-db-entry_member').on('click', '.update-select-btn', function() { if ( $(this).hasClass('on') ) { $(this).removeClass('on'); $(this).closest('table').find('[name="game_id"]').val(''); $(this).closest('table').find('[name="member_id"]').val(''); $(this).closest('table').find('[name="position"]').val(''); } else { $(this).closest('table').find('.on').removeClass('on'); $(this).addClass('on'); $(this).closest('table').find('[name="game_id"]').val($(this).closest('tr').find('.game_id_td').text()); $(this).closest('table').find('[name="member_id"]').val($(this).closest('tr').find('.member_id_td').text()); $(this).closest('table').find('[name="position"]').val($(this).closest('tr').find('.position_td').text()); } }); $('#my-db-entry_member').on('click', '.update-btn', function() { if ( $(this).closest('tr').find('[name="game_id"]').val() != '' && $(this).closest('tr').find('[name="member_id"]').val() != '' ) { var target_game_id = parseInt($(this).closest('tr').find('[name="game_id"]').val()); var target_member_id = parseInt($(this).closest('tr').find('[name="member_id"]').val()); var target_position = $(this).closest('tr').find('[name="position"]').val(); db.entry_member .where({game_id: target_game_id, member_id: target_member_id}) .modify( {position: target_position}); disp_dexie(); } }); })(jQuery); |
便利なポイント
indexedDB + Dexie.js でスマホアプリを開発していて、個人的に便利だなぁと感じた点や良かった点を挙げていきます。
・ドキュメントがしっかりしている
・後からのストア構成の変更
・保持データは追加自由
・JSON形式でデータ取得できるので、連携が簡単
ドキュメントがしっかりしている
Dexie.js のサイトはドキュメントが分かりやすく纏められています。Dexie.js リファレンス(英語)
この記事で取り上げた以外にも多くのコマンドがありますので、色々とやりたいことがある場合にはここで調べながら実装していけます。
後からのストア構成の変更
アプリをリリースしてから「背番号で並び替えをしたい」、という追加仕様があったとします。
並び替えをするには、ストア定義でインデックスを貼っていなければいけないのですが、初期設定では「背番号」には貼っていませんでした。
しかし、バージョン更新機能で後からストア構成の変更を行うことができます。
最初の定義で「version(1)」の記載をしましたが、ストア定義を更新するときに、これを利用します。
version(1) の記述を消さずに、version(2) の記述を追記する形です。
これにより、データの整合性を取りつつバージョンアップしてくれます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | db.version(1).stores({ // ストア名: "キーをカンマ区切りで記入" games: "++game_id", members: "++member_id, *position, updated", entry_member: "&[game_id+member_id], game_id, member_id, position" }); db.version(2).stores({ // ストア名: "キーをカンマ区切りで記入" games: "++game_id", members: "++member_id, member_number, *position, updated", // ← 変更のあったのはこの行だけだが全部記述する entry_member: "&[game_id+member_id], game_id, member_id, position" }); |
保持データは追加自由
「ストア設定」の部分でも少し記載しましたが、基本的に項目は定義しなくても追加自由です。
例えば、選手情報に「出身地」「身長」「体重」などのデータを入れたいだけなら、ストア定義のバージョンを変更しなくても大丈夫です。
ただ、それをキーに検索をしたりする(例えば東京都出身の選手一覧を表示する)となると、インデックスを貼る必要があるので、ストア定義をバージョンアップする必要があります。
JSON形式でデータ取得できるので、連携が簡単
toArray() コマンドを用いてJSON形式で取得できるので、例えば通信してPHPとの連携もやり易いのはポイントです。
また、データのエクスポート、インポートも簡単です。
JSON形式のデータを読み込めれば以下の記述で上書きインポートできます。
1 2 3 4 5 6 7 | db.members .clear() // リセットして .then(function (notes) { db.members.bulkAdd(allMembersData); // インポート }); |
まとめ
スマホアプリ内で使用できる indexedDB と、それを便利に簡単に利用できるようにするライブラリ Dexie.js について、使い方や動作例、便利な点などを纏めてみました。
indexedDB を使いやすくするライブラリはいくつもありますが、個人的には Dexie.js が一番とっつきやすく、便利に思えました。
外部サーバーと連携するようなアプリであっても、内部DBの仕組みとして indexedDB + Dexie.js の導入は開発をやり易くすると感じました。
BRISK でも自社サービスの一環としてスマホアプリをどんどんと開発していこうと思っていますので、技術の研鑽に励みたいものです。※自社サービスについての社長日記
南本貴之