Google Apps Scriptでスプレッドシートに1行追加する場合、appendRow()メソッドを使用するのがメジャーな方法かと思います。
私は、「appendRowって、同時に実行しても大丈夫なんだろうか・・・?」という不安はありつつも、「まあ、Google側でうまくやってくれてるっしょ〜👍」と思ってました。
しかし、勤怠管理(打刻)アプリの開発を始めて、やっぱり不安になったので検証してみました。
結果、ほぼ同時に実行すると、普通に競合(上書き)が発生することがわかりました😇
この記事では、その検証方法と、対処方法を紹介します。
appendRow 同時実行の検証
よくありそうなケースを想定し、HtmlServiceのWebアプリから、同時に多数の行追加リクエストを発生させてみました。
コードはこんな感じです。
function doGet() {
return HtmlService.createHtmlOutputFromFile('index');
}
function doAppendRow(num) {
const sheet = SpreadsheetApp.getActiveSheet();
sheet.appendRow([num]);
}
<!DOCTYPE html>
<html>
<head>
<base target="_top">
</head>
<body>
<button onclick="appendRows()">実行</button>
<script>
function appendRows() {
let currentNum = 1;
for (let i = 0; i < 30; i++) {
const num = currentNum;
currentNum += 1;
google.script.run.withSuccessHandler((result) => {
console.log(`Success: ${num}`);
}).withFailureHandler((error) => {
console.log(`Error: ${num}`);
}).doAppendRow(num);
}
}
</script>
</body>
</html>
Webアプリの実行ボタンを押すと、GAS側のdoAppendRow関数がほぼ同時に30回実行されます。
実行結果はconsole.logでブラウザのコンソールに出力します。
ということで、実行ボタンを押してみました。
Webアプリ側ではすべて成功しているように見えますね。(当然ですが・・・)
しかし、書き込み先のスプレッドシートを見てみると・・・
4行しか書き込まれてない😂
実行中にスプレッドシートを見るとわかりますが、appendRow()はちゃんと30回実行されています。
ただし、最終行がちゃんと取得できておらず、上書きを繰り返してしまっている感じです。
・・・ということで、検証の結果、同じシートに対するappendRow()が同時に実行された場合は、競合が発生しデータが意図せず上書きされる可能性があるということがわかりました。
対処法
このままでは、打刻管理アプリなんて到底作れませんね。(ほぼ同じ時間帯に皆が打刻するので・・・)
ということで、対処法を調べて検証してみましたので紹介します。
Google Apps Scriptには、LockServiceというものがあり、それを使えば、スクリプトの一部分を同時実行不可にすることができ、この問題を解決することができます。
最低限のコードとしてはこんな感じでしょうか。
function doAppendRow(num) {
const sheet = SpreadsheetApp.getActiveSheet();
// スクリプトロックを取得する(この例では10秒間トライする)
const lock = LockService.getScriptLock();
if (lock.tryLock(10 * 1000)) {
// 指定時間内にロックが取得できた場合は処理を実行
sheet.appendRow([num]);
// ロックを解除
lock.releaseLock();
} else {
// 指定時間内にロックが取得できなかった場合はエラーを返す
throw new Error("ロック取得タイムアウト");
}
}
tryLock(ミリ秒)メソッドは、
- 同時実行されないようにスクリプトにロックをかけることを試みて、
- ロックをかけることができたら、trueを返す
- もし既にロックされていたら、ロックが解除されるのを待つ
- 指定した時間(ミリ秒)待ってもロックが解除されなかったらfalseを返す
・・・というイメージです。
実際に業務で使う場合は、ロックに関係ないエラーも考慮しなければいけないでしょうから、try〜catch〜finallyを使用して、下記コードのようにすると良いかと思います。
function doAppendRow(num) {
const sheet = SpreadsheetApp.getActiveSheet();
// スクリプトロックを取得する(この例では10秒間トライする)
const lock = LockService.getScriptLock();
if (lock.tryLock(10 * 1000)) {
// 指定時間内にロックが取得できた場合は処理を実行
try {
sheet.appendRow([num]);
} catch(error) {
// ロック取得以外のエラーが発生した場合
throw new Error(error.message);
} finally {
// ロックを解除
lock.releaseLock();
}
} else {
// 指定時間内にロックが取得できなかった場合はエラーを返す
throw new Error("ロック取得タイムアウト");
}
}
なお、公式ドキュメントにもあるとおり、Google Apps Scriptのロックには、DocumentLock, ScriptLock, UserLockの3種類のロックが用意されています。
詳細は割愛しますが、今回のようにWebアプリで使用するケースではScriptLockが適切かと思います。
上記のコードに変えて実行してみると・・・
30回の処理のうち、2回はエラーとなり、コンソールにエラーが出力できました。
スプレッドシートにも、28行分データが書き込まれていました。
実際のアプリでは、console.logでエラーを出力するだけでなく、alertやその他の方法でユーザーに通知を出すとよいですね。
(今回はロック取得を試みる時間を10秒にしたため、2件はエラーとなりましたが、時間を増やせばエラーなく完了します。しかし、Google Apps Scriptには実行時間の制限もありますし、長すぎる待機時間はユーザーにストレスを与えかねないため、適切な時間を設定してください。)
コメント