バックエンドではファイルなどのリソースのロック処理は当たり前のように行われていますが、これまでフロントエンドの世界ではほとんど気にすることもなかったのではないでしょうか。しかし、ウェブアプリケーションが複雑・高度化するに伴い、フロントエンドでもリソースのロックの必要性が高まっています。今回はブラウザーでリソースをロックする仕組みを提供する Web Locks API を解説します。
バックエンドでのリソースのロックとは
ここで言うリソースとは、ファイルやデータベースのレコードなど、複数のプロセスが同時にアクセスする可能性があるものを指します。バックエンドを例にリソースのロックを簡単に説明しましょう。
ページのアクセス数をファイルで管理しているとしましょう。一昔前に流行ったアクセスカウンタを想像してください。ページにアクセスがあると、ファイルを開き、カウンターを読み取り、1 を加えた数値をそのファイルに保存しなおします。
バックエンドでは数多くのアクセスが同時に発生します。この一連の処理を複数のプロセスが同時に行ってしまっては困ります。同時に書き込みアクセスが発生するとファイルが壊れる可能性も否めません。そのためリソースへのアクセスは 1 つのプロセスに限定するよう制御しなければいけません。これがリソースのロックです。
では、この例をフロントエンドに置き換えるとどうなるでしょうか。この場合のリソースとは Indexed Database API や File System Access API などのストレージが該当するでしょう。そしてリソースにアクセスするのはブラウザーのタブが該当します。複数のタブで同じページを開くことが可能です。さらに前述のストレージはオリジンで 1 つしか用意されません。ということは、同じリソースに複数のタブから同時にアクセスされる可能性があります。しかし、バックエンドと違い同時にアクセスされる可能性は限りなく低いと言えるでしょう。
フロントエンドでのリソースのロックの使いどころ
フロントエンドで求められるロックのユースケースはバックエンドとは少し異なります。リソースの排他制御を行いたいという点ではバックエンドもフロントエンドも同じですが、ロックする時間がフロントエンドの方が長くなる傾向にあります。
例として、Google Docs のようなドキュメント編集アプリケーションを想定しましょう。オフラインでも編集できるよう最新のドキュメントデータをまずはローカルのストレージに保存します。そして、ドキュメントの編集中はドキュメントデータをメモリーに読み込むのではないでしょうか。そして、定期的に、または、ユーザーが保存のアクションを取ったら、ローカルのストレージにドキュメントデータを保存します。場合によってはクラウドに保存します。
では、同じドキュメントを二つ以上のタブで開いたらどうなるでしょうか。それぞれのタブで中途半端に編集してしまったとします。その場合、ユーザーの意図しないほうが保存されてしまう場合も考えられます。
これを避ける方法として、一つのタブがドキュメントを開いたら、次のタブでは読み取り専用にするのが良いのかもしれません。場合によってはエラーを表示して複数のタブで同じドキュメントを開かないよう制限してしまうことも考えられるでしょう。
これらを実現するには、別のタブでドキュメントをすでに開いているかどうかを知る必要があります。Cross-document messaging や BroadcastChannel を使ってタブ同志でメッセージのやり取りを行って同時アクセスを避ける方法もあるでしょうが、ロックのために用意された仕組みではないため、使い勝手が良いとは言えません。そこで登場したのが Web Locks API です。
Web Locks API の使い方
次の例は、架空の非同期関数 doAsynchronousThing()
の処理が終わるまで、"my-document"
というリソース名をロックします。
navigator.locks.request('my-document', async (lock) => {
await doAsynchronousThing();
});
このコードをご覧の通り、Web Locks API はファイルやストレージといったリソースを直接ロックする仕組みではなく、リソース名を指定して、それがロック中であることを宣言しているだけです。
では、navigator.locks.request()
メソッドの詳細を見ていきましょう
navigator.locks.request() メソッド
navigator.locks.request()
メソッドの基本的な構文は次の通りです。
promise = navigator . locks . request(name, callback)
第一引数 name
にはリソース名を、第二引数 callback
には非同期の処理を実行する関数を指定します。
第一引数 name
のリソース名は文字列であれば何でも構いません。日本語でも大丈夫です。ただし、先頭が半角ハイフンであってはいけません。
第二引数 callback
には Promise
オブジェクトを返す関数を指定します。この非同期の関数の処理が開始されるとロックが開始されます。この間、他のタブでは第二引数の非同期関数の実行が待たされます。この処理が完了するとロックが解除されますが、もし他のタブで実行が待たされていれば、そのタブで再度ロックされ、そのタブでの関数の処理が開始されます。
navigator.locks.request()
メソッドには引数を 3 つ指定する構文も定義されています。
promise = navigator . locks . request(name, options, callback)
第二引数の options
は次のプロパティを持ったオブジェクトです。
プロパティ名 | 型 | 説明 | デフォルト値 |
---|---|---|---|
mode | String | ロックのモードを "shared" , "exclusive" のずれかで指定します。 | "exclusive" |
ifAvailable | Boolean | true をセットすると、同じリソース名がすでにロックされていたとしても、そのロックが解除されるのを待たずにコールバック関数 callback が実行されます。 | false |
steal | Boolean | true をセットすると、同じリソース名の他のロックを解除し、さらに、すでに待ち状態のリクエストもクリアした上で、自身を強制的に実行します。 | false |
signal | AbortSignal | AbortSignal オブジェクトをセットすることができます。セットすると、リクエストを中止できるようになります。 |
上記のオプションの詳細は後述します。
コールバックに引き渡される Lock オブジェクト
navigator.locks.request()
メソッドに指定するコールバック関数には、ロックの情報を格納した Lock
オブジェクトが引き渡されます。Lock
オブジェクトは次のプロパティを持ちます。
プロパティ名 | 型 | 説明 |
---|---|---|
mode | String | 該当のロックのモードがセットされます。値は "shared" , "exclusive" のずれかです。 |
name | String | 該当のロックのリソース名がセットされます。 |
navigator.locks.request('my-document', async (lock) => {
console.log(lock.mode); // "exclusive"
console.log(lock.name); // "my-document"
});
mode オプション
一般的にロックには排他ロックと共有ロックがあります。排他ロックは読み取りと書き込みをともにロックする方式で、共有ロックは書き込みだけをロックする方式です。排他ロックでリソースがロックされると、そのリソースは他から書き込みができないのはもちろんのこと、読み取りすらできません。一方、共有ロックでリソースがロックされると、リソースの書き込みはできませんが、読み取りは許されます。
この排他ロックや共有ロックはファイルなどリソースの実体があれば意味がありますが、Web Locks API ではリソースの実体は扱いませんので、あくまでも宣言にすぎません。しかし、コードの実行タイミングなどの挙動に違いが出てきますので、それを理解する必要があります。
次のサンプルはリソース名 "my-document"
を共有ロックします。
navigator.locks.request('my-document', { mode: 'shared' }, async (lock) => {
console.log(lock.mode); // "shared"
console.log(lock.name); // "my-document"
await doAsynchronousThing();
});
最初のタブがこの JavaScript コードを実行したらリソース名 "my-document"
は共有ロックされます。もし非同期関数 doAsynchronousThing()
の実行が終わる前に次のタブでこのコードが実行されると、次のタブの doAsynchronousThing()
は待たされずに実行されてしまいます。この挙動は Web Locks API を使わない場合と同じですので、ここまでの話だと Web Locks API の共有ロックの存在意義が無くなってしまいます。
実は、Web Locks API で共有ロックが一つでも有効になっている間、どのタブも該当のリソースを排他ロックすることができません。つまり、排他ロックをリクエストすると、他のタブの共有ロックがすべて解除されるまで待たされることになります。
なお、いくら Web Locks API で共有ロックをしたとしても、実際にストレージやファイルなどのリソースに書き込みができなくなるわけではありませんので注意してください。Web Locks API は、あくまでもリソース名ごとにロックの状態を管理しているだけにすぎません。
ifAvailable オプション
もし他のタブがリソースを排他ロックまたは共有ロックしていると、排他ロックをリクエストしても待たされてしまいます。場合によっては、待たずにあきらめてすぐにコールバック関数を実行してほしい時もあるでしょう。その場合に役に立つのが ifAvailable
オプションです。
navigator.locks.request('my-document', { ifAvailable : true }, async (lock) => {
console.log(lock); // すでにロック中なら null
});
ifAvailable
プロパティに true
をセットすると、もしすでに他のタグが該当のリソースを排他ロックしていたとしても、コールバック関数はすぐに実行されます。もちろん、排他ロックの権限が手に入ったわけではありません。そのため、あなた自身で排他ロックの権限が与えられているのかをチェックして処理を分ける必要があります。
もし排他ロックの権限が与えられないままにコールバック関数が呼び出されたとしたら、コールバック関数に引き渡される Lock
オブジェクト(変数 lock
)は null
になります。これによって、コールバック関数の中では、他のタブによって該当のリソースは排他ロック中であることが分かります。
steal オプション
他のタブが排他ロックの権限を保持していたとしても、強制的にその権限を横取りしたい場合は steal
オプションを使います。
navigator.locks.request('my-document', { steal : true }, async (lock) => {
await doAsynchronousThing();
});
排他ロックの権限を奪われたタブでは、navigator.locks.request()
で DOMException
例外が投げられます。Chrome なら次のようなメッセージがコンソールに出力されます。
Uncaught (in promise) DOMException: Lock broken by another request with the 'steal' option.
もし steal
オプションを有効にしたリクエストの可能性がある場合、権限を横取りされるタブでは、次のように例外をキャッチするコードを書くのが良いでしょう。
(async () => {
try {
await navigator.locks.request('my-document', async (lock) => {
await doAsynchronousThing();
});
} catch (error) {
if (error.name === 'AbortError') {
console.log('権限が奪われましたかもしれません。');
} else {
console.error(error);
}
}
})();
DOMException
例外が投げられるのはロック権限を待っている間です。そして例外を投げるのは navigator.locks.request()
自身ですので、async
のブロックの中で await
を使って navigator.locks.request()
を呼び出します。そして、try/catch
構文で DOMException
例外をキャッチするのが良いでしょう。
この DOMException
例外の Error
オブジェクト(変数 error
)の name
プロパティには "AbortError"
がセットされます。
DOMException
例外の Error
オブジェクト(変数 error
)の name
プロパティが "AbortError"
だとしても、それが権限を横取りされたかどうかについては断言できません。後述の signal
オプションで意図的に AbortError
を出せるからです。
Chrome なら error.message
に "Lock broken by another request with the 'steal' option."
がセットされます。しかしメッセージはブラウザーによっても異なる可能性がありますし、同じブラウザーでも将来にわたって同じとは限りませんので、それを判断基準にすることはお勧めできません。なお、各ブラウザーのエラーメッセージは次のようになります。
ブラウザー | エラーメッセージ |
---|---|
Chrome | Lock broken by another request with the 'steal' option. |
Edge | Lock broken by another request with the 'steal' option. |
Firefox | The lock request is aborted |
Safari | Lock was stolen by another request |
signal オプション
signal
プロパティに AbortSignal
オブジェクトをセットすることができます。AbortSignal
オブジェクトは WHATWG DOM Standard 仕様で規定されているオブジェクトのことで、AbortController
オブジェクトとともに非同期処理を中断する仕組みを提供します。ここでは AbortSignal
オブジェクトの詳細には触れませんが、簡単なサンプルコードを紹介します。
次のサンプルは、排他ロックの権限が 1 秒以内に取得できなければ処理を中断します。
const controller = new AbortController();
const timer = window.setTimeout(() => {
controller.abort();
}, 1000);
(async () => {
try {
await navigator.locks.request('my-document', { signal: controller.signal }, async (lock) => {
window.clearTimeout(timer);
await doAsynchronousThing();
});
} catch (error) {
console.log(error.name); // "AbortError"
}
})();
前述の steal オプションと同様に、処理が中止されると DOMException
例外が発生し、その Error
オブジェクト(変数 error
)の name
プロパティには "AbortError"
がセットされます。
ロックの状態を取得する navigator.locks.query() メソッド
navigator.locks.query()
メソッドは該当のオリジンにリクエストされているロックの情報を取得します。このメソッドは非同期のメソッドで Promise
オブジェクトを返します。そのため、async
ブロックの中で await
を使って呼び出します。
const state = await navigator.locks.query();
console.log(JSON.stringify(state, null, ' '));
このサンプルコードを実行すると、次のような結果が出力されます。
{
"held": [
{
"clientId": "9bf38f94-f84b-4315-8049-094c92fb2f51",
"mode": "exclusive",
"name": "my-document"
}
],
"pending": [
{
"clientId": "6169361a-d657-4a5d-a6dd-4eec6dbbb673",
"mode": "exclusive",
"name": "my-document"
}
]
}
"held"
にリストアップされているのは、すでに権限を取得したロックの情報です。そして、"pending"
にリストアップされているのは、権限取得を待っているロックのリクエストの情報です。なお、ロックの情報にある "clientId"
の値はロックのリクエスト元のタブを表す一意的なキーで、ブラウザーが割り振ったものです。
まとめ
Web Locks API は何かしらのリソースの実体を直接ロックする仕組みではなく、枠組みしか提供しませんので、少し分かりづらいかもしれませんね。しかし、このロックの仕組みを自作するのはかなり大変です。すでにすべてのメジャーブラウザーで Web Locks API はサポートされていますので、もしタブごとにロックの仕組みが必要になったら Web Locks API をうまく活用したいものです。
今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。