はじめに
ここ最近、Chrome拡張機能を作成していましたが、多くの問題に直面しました。ようやく本格的な実装フェーズに入ったので、これまでの知見をまとめます。
1. 目標とする要件
APIを境界線として、フロントエンド開発とバックエンド開発を同時に進める場合、フロントエンドには2つの段階があります:
-
APIが未完成。とりあえずダミーデータを使用して、画面の調整やロジックの実行を行う。
-
APIが完成。ダミーデータを取り除き、バックエンドサービスが提供する実際のデータで調整する。
このとき、必ずダミーデータが存在することになります。それをアプリケーションコードの中に書き込み、API完成後にコメントアウトするのでしょうか? それとも独立したファイルとして切り出し、ページで読み込んで、完了後に script タグを削除するのでしょうか? あるいはローカルサーバーを立ててダミーデータを設定し、完了後にローカルAPIを本番APIに差し替えるのでしょうか?……どこに置くとしても、API完成後に既存のコードを修正する必要があり、修正があればミスが起こる可能性があります。
コードを一切変更せずに済む方法はないでしょうか?
あります。ダミーデータをChrome拡張機能の中に置けば、ダミーデータとアプリケーションコードを完全に切り離すことができます。
2. 実装プラン
通常、アプリケーションコードは DOM ready のタイミングでAPIをリクエストし、取得したデータを画面に表示します。もちろん、 DOM ready 前にリクエストが送信される可能性もあります。
そのため、すべてのアプリケーションコードが実行される前に、リクエストのインターセプト(横取り)ロジックとダミーデータをページに注入し、インターセプトしたリクエストに対して適切なダミーデータを割り当てる必要があります。
少なくともAjaxリクエストやJSONPのGETリクエストをインターセプトする必要がありそうです。インターセプト自体は問題ありませんが、重要なのはフィルタリングです。データ以外のリクエストはそのまま通し、データリクエストだけを止めてダミーデータを返す必要があります。 RESTful API を考慮すると、ダミーデータの構造は少し複雑になるかもしれません。
しかし、Hybrid Appにはいくつか特殊な点があります:
-
クライアント側が特定の機能を公開し、クライアントAPIの形式で内蔵ページから利用できるようにしている。
-
データリクエストはクライアント経由で転送され、クライアント側で暗号化されるなどのセキュリティ対策が施されている。
この場合、AjaxやJSONPを気にする必要はありません。データリクエストはクライアントAPIを通じて行われ、データもクライアントから「提供」されるからです。つまり、クライアントをエミュレートするだけで済みます。
リクエストのインターセプトすら考える必要はなく、直接クライアントになりすましてデータリクエスト用APIを提供するだけでよいのです(もちろん、他の機能用APIも提供してフルセットで構築すれば、デバッグにおいてクライアントアプリに依存することはほとんどなくなります)。
P.S. Chrome拡張機能には、ページのリクエストを処理するための "webRequest", "webRequestBlocking" などの権限が用意されています。詳細は 公式ドキュメント を参照してください。
プランが決まったら、詳細を検討します:
-
iOSは通常、サードパーティライブラリの
WebViewJavascriptBridgeを通じてネイティブAPIを提供します。 -
AndroidでAPIを提供するための最も簡単な方法は、JSにグローバルオブジェクトを公開することです。
WebViewJavascriptBridge の「ネイティブ側の準備完了(native ready)」通知は、 document 上でカスタムイベント WebViewJavascriptBridgeReady を発生させることで行われ、ページのJSはこのイベントをリッスンして「接続」を完了します。一方、Androidはよりシンプルで、注入されたグローバルオブジェクトにはいつでもアクセスでき、ページのJSが実行されるよりずっと前に「準備完了」しています。そのため「接続」は不要です。
つまり、クライアントを模倣する方法は、Androidの場合はページのすべてのJSが実行される前にカスタムグローバルオブジェクトをセットすることであり、iOSの場合は好きなタイミングで手動で WebViewJavascriptBridgeReady を発生させることです。したがって、AndroidではダミーAPIを同期的に注入する必要がありますが、iOSではどちらでも構いません。また、ダミーデータ自体を同期的に注入する必要はありません(ダミーデータは巨大になる可能性があるため、非同期注入にすることでページの初期表示を速くできます。ダミーデータが届くまでは、リクエストをキューに溜めておくことができます)。
3. 問題と解決策
1. content_scripts がローカルファイルに効かない
拡張機能の管理画面において、一部の拡張機能の下に「ファイルの URL へのアクセスを許可する」というチェックボックスがありますが、これはデフォルトでは表示されません。設定ファイル manifest.json に以下のような記述がある場合にのみ表示されます:
"content_scripts": [{
"matches": ["<all_urls>"]
}]
これにチェックを入れることで、 content_scripts でローカルファイルを処理できるようになります。
2. スクリプトを同期的に注入する方法は?
content script はページのDOMを操作できますが、JSの実行環境が異なるため、ページのJS変数を直接書き換えることはできません。ページのJSを変更するには、 script タグを注入するしかありません。手順は以下の2ステップです:
-
ページに注入するスクリプトを取得する。
-
scriptタグを作成してページに挿入する。
スクリプトの非同期注入は非常に簡単です:
var loadScript = function(filename) {
var s = document.createElement('script');
// 拡張機能のインストール後、mock.js の file:/// パスを取得する
//! manifest.json で web_accessible_resources を宣言する必要がある
s.src = chrome.extension.getURL(filename);
// head がまだ準備できていなければ、html を取得する
var doc = document.head || document.documentElement;
return doc.appendChild(s);
}
// テスト
loadScript('mock.js');
ページに script タグが挿入され、 src 属性が file:///xxx になります。スクリプトファイルは非同期で読み込まれ、読み込み完了後に実行されます。
しかし、このスクリプトが実行されるのは実際には DOM ready 以降であり、それより前のリクエストはダミーのクライアントが読み込み中であるため、漏れてしまいます。ページのすべてのJSが実行される前に mock ロジックの実行を完了させる必要があります。そうすることで、AndroidのUA環境下でもリクエストが漏れることを防げます。
そこで、スクリプトを同期的に注入する(注入後すぐに実行され、非同期読み込みを待たない)方法が必要になります。まずスクリプトを同期的に読み込み、それをインラインスクリプトとして挿入すればよいのです:
// 1. スクリプトの内容を同期的に取得
var readFileSync = function(filename, callback) {
// 同期的なスクリプト読み込み
var xhr = new XMLHttpRequest();
var scriptUrl = chrome.extension.getURL(filename);
//!!! 非同期を無効にする
xhr.open("GET", scriptUrl, false);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
callback(xhr.responseText);
}
};
xhr.send(null);
};
// 2. インラインスクリプトを挿入
var writeScriptSync = function(code) {
var s = document.createElement('script');
s.textContent = code;
var doc = document.head || document.documentElement;
return doc.appendChild(s);
};
// テスト
readFileSync('mock.js', writeScriptSync);
ポイントは xhr.open("GET", scriptUrl, false); と s.textContent = code; です。前者は同期Ajaxでファイル内容を読み取り、後者はコード文字列を script タグに書き込みます。
3. ツールバーのアイコンと拡張機能管理画面のアイコン
まともな拡張機能にはアイコンが必要です。 manifest.json の browser_action.default_icon と icons フィールドをそれぞれ設定する必要があります:
// ツールバーのアイコン
"browser_action": {
"default_icon": "icon/icon.png",
},
// 拡張機能管理画面のアイコン
"icons": {
"128": "icon/128.png",
"16": "icon/16.png",
"48": "icon/48.png"
}
4. データの読み書き
選択肢はいくつかあります:
-
window.localStorage: 同期的な読み書き。CORS制限や5MBの制限がある。 -
chrome.storage.local/sync: 非同期的な読み書き。5MBの制限があるが、権限を宣言すれば解除可能。 -
IndexedDB: 非同期的な読み書き。NoSQLデータベース。 -
WebSQL: 非同期的な読み書き。SQLiteデータベース。非標準。
非標準の WebSQL は除外し、 window.localStorage も大きな利点はありません。 chrome.storage.local/sync は少し強力ですが、読み書きの効率は IndexedDB に劣ります。そのため、 IndexedDB をお勧めします。
また、それぞれにメリットがあります:
-
window.localStorageは同期的なので、同期が求められる特定のシーンで便利です。 -
chrome.storage.localは最もシンプルな方法で、少量のデータの読み書きに適しています。 -
chrome.storage.syncは複数デバイス間での自動同期が可能で、非常に強力です。
実際の使用において、 chrome.storage.sync で set が失敗する( get で undefined が返る)問題が発生したため、一時的に local を使用して状態データを保存するようにしました。 unlimitedStorage 権限を宣言すれば5MBの制限を解除できますが、新たな制限も発生します:
Note: This permission applies only to Web SQL Database and application cache (see issue 58985). Also, it doesn't currently work with wildcard subdomains such as http://*.example.com.
P.S. WebSQL の標準化プロセスは頓挫しました。各ブラウザが SQLite に基づいて実装されていたため、標準化が SQLite の制約を受け、膠着状態に陥ったためです。詳細は Web SQL Database を参照してください。
5. eval の権限
拡張機能内ではデフォルトで外部ファイルからの内容を eval で実行することは許可されていません。例えば、拡張機能のインストール時に設定データを読み取って storage に書き込む際などに eval を使うとエラーになります。どうしても使いたい場合は、 manifest.json で CSP(コンテンツセキュリティポリシー)を修正する必要があります:
"content_security_policy": "script-src 'unsafe-eval'; object-src 'self'"
デフォルトの script-src 'self'; object-src 'self' では、安全でない eval は許可されません。
4. IndexedDB
ネイティブAPIは使い勝手が悪く、 objectStore 、 keyPath 、 transaction といった独特な概念を理解する必要があります。サードパーティライブラリの Dexie.js をDBヘルパーとして使用することをお勧めします。
APIデザインが非常に洗練されており、CRUD操作の例は以下の通りです:
// データベースとテーブルの作成
var db = new Dexie('db');
db.version(1).stores({
// ++ はオートインクリメント、& は一意制約
tb: '++id, &key, value, desc, other'
});
// 追加
db.tb.bulkAdd([{
key: 1,
value: '1'
}, {
key: 2.5,
value: '2.5'
}, {
key: 2,
value: '2'
}]);
// 削除
db.tb
.where('key')
.equals(2.5)
.toArray()
.then(function(res) {
console.log(res);
// 削除
db.tb.delete(res[0].id);
}, console.log.bind(console));
// 更新
db.tb.put({
key: 'getPoiInfo',
value: 'value'
});
// 検索
db.tb
.where('id')
.inAnyRange([[0, 100]])
.each(function(item) {
console.log(item);
});
// 検索 + 更新
db.tb
.where('key')
.equals(1)
.modify({value: 'value1'});
// カスタム全表検索
db.tb
.filter(item => /get.*/i.test(item.key))
.each(function(item) {
console.log(item.value);
});
5. スクリプト間の通信方法
拡張機能の開発には複数種類のスクリプトが存在し、それらの間で通信が必要です:
-
content script と「拡張機能専用のスクリプト」間の通信
-
injected script と content script 間の通信
-
「拡張機能専用のスクリプト」同士の通信
-
拡張機能と外部サーバー間の通信
今回注目するのは content script と「拡張機能専用のスクリプト」間の通信です。 content script 内でバックグラウンドページにダミーデータを要求する必要があるため、 runtime message を利用して実現できます:
// 通信コネクションの確立
var port = chrome.runtime.connect({name: "app"});
// content script から送信
port.postMessage({
action: 'KEYQUERY',
key: key
});
// バックグラウンドページ background.js で受信
chrome.runtime.onConnect.addListener(function(port) {
port.onMessage.addListener(function(msg) {
if (port.name != "app") return;
switch (msg.action) {
case 'KEYQUERY':
db.tb
.filter(item => item.key === msg.key)
.toArray()
.then(function(arr){
port.postMessage({
_action: msg.action,
data: arr
});
}, function() {
port.postMessage({
_action: msg.action,
data: []
});
});
}
}
}
上記はまず通信コネクションを確立してからメッセージを送信していますが、コネクションを確立せずに単発のメッセージを送ることもできます:
// 送信
chrome.runtime.sendMessage({greeting: "hello"}, function(response) {
console.log(response.farewell);
});
// 受信
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
console.log(sender.tab ?
"from a content script:" + sender.tab.url :
"from the extension");
if (request.greeting == "hello")
sendResponse({farewell: "goodbye"});
}
);
ただし、単発のメッセージは安定したコネクションが存在しないため、非同期でレスポンスを返すことができない点に注意が必要です。例:
// 受信
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
console.log(sender.tab ?
"from a content script:" + sender.tab.url :
"from the extension");
if (request.greeting == "hello")
// 非同期レスポンスを試みる
setTimeout(() => {
sendResponse({farewell: "goodbye"});
}, 1000);
//!!! ここで送信側のコールバックが即座に実行され、undefined が返される
// 1秒後の sendResponse は失われる
}
);
この場合、非同期の sendResponse は失われてしまうため、通信コネクションを確立する方法( connect )を採用することをお勧めします。
スクリプト通信方法の詳細は、 Chrome 拡張開発におけるスクリプトの実行メカニズムと通信方式 を参照してください。
コメントはまだありません