排他制御でGoogle Apps Scriptを安全に実行【GAS】

G Suiteの大きな特徴の1つとして、複数名同時に同一ドキュメントに対して作業が出来るコラボレーション機能です。ですが、例えばスプレッドシート上で大きなコピペをするスクリプトを複数名が同時に使った場合、ややこしい事になってしまいます(更に言えば連番を取って付け加える場合、番号がめちゃくちゃになります)。

そこで使用するのが排他制御。排他制御とは別の誰かが実行している場合には、そのスクリプトの実行をロックさせてしまう機能で、一方が実行中はもう一方はその実行を待たせる機能です。指定した秒数以上待たされた場合には、エラーが発生するので、通常try〜catch文も合わせて使用するのが定石です。

排他制御には3種類のタイプがあります。

今回使用するスプレッドシート

  • 排他制御サンプル1 - 3種類の排他制御コードが入っています(ライブラリ元でもある)
  • 排他制御サンプル2 - スクリプトロックをサンプル1からライブラリとして呼び出すコードだけが入ってる

※サンプルではcopypaste()というメインルーチンをそれぞれのルーチンから呼び出しています。

概要

LockServiceというクラスを使用するのですが、3種類のロックサービスがあります。必ず冒頭でロックを宣言して、その後指定秒数のロックを実施、最期に必ずロックをリリースするといった一連の手順が必要になっています(でなければタイムアウトするまでロックされっぱなしになります)。

指定秒数よりも前にスクリプトが終了した場合、待たされていた側は即時にスクリプトが実施されるようになります。逆に指定秒数以上待たされた場合にはエラーが発生しスクリプトの実行がキャンセルされます。

ロックは、DocumentLock、UserLock、ScriptLockがありそれぞれに特徴があります。また指定秒数のロックの実施ではtryLockとwaitLockの2つがあり、これもまた少し挙動が異なります。最期にreleaseLockでロックを開放するわけです。releaseLockを忘れたり実行されずにエラーで途中で止まると、次の人は数十秒待たされたり、また「同じスクリプトに対する lockservice の操作が多すぎます」といったエラーを招きますので注意。

図:地味な機能だけれど習得必須の機能です

tryLockとwaitLockの違い

指定秒数のロックを取得する際に使う2つのメソッドですが、両者はちょっと挙動が異なります。tryLockはロックを取得時に誰かがロックをしてて指定秒数超えるとfalseが返ってくる仕組みです。一方、waitLockはfalseが返ってくるのではなくエラーが発生する仕組みになっているので、try〜catchが必要です。

tryLockの使い方

//UIを取得する
var ui = SpreadsheetApp.getUi();

//ドキュメントロックを使用する
var lock = LockService.getDocumentLock();

//30秒間のロックを実施
if (lock.tryLock(30000)) {
   try {
      //ここに処理を記述する
   } catch(e) {
      //ここにエラー時の処理を記述する
   } finally {
      //最期に必ずロックを開放する
      lock.releaseLock();
   }
}else{
   //ロックが取得できずにfalseが返ってきたのでエラーメッセージを表示   
   ui.alert("処理がタイムアウトしました。");
}

※Lock取得に失敗したらfalseが返ってくるので、if文でロック取得を試みて失敗時にはelse文以下に処理が移り、メッセージをユーザに伝える処理を記述します。ロックが取得できても、メインルーチンでエラーが発生した時に備えて、try〜catch〜finally文でエラートラップの処理をきちんとしておいてあげます。

waitLockの使い方

//UIを取得する
var ui = SpreadsheetApp.getUi();
var msg = "";

//ドキュメントロックを使用する
var lock = LockService.getDocumentLock();

//30秒間のロックを取得
try {
   //ロックを実施する
   lock.waitLock(30000);
   //ここにメインルーチンを記述する
   
   //メッセージを格納
   msg = "完了したよ";

} catch (e) {
   //ロック取得できなかった時の処理等を記述する
   var checkword = "ロックのタイムアウト: 別のプロセスがロックを保持している時間が長すぎました。";

   //通常のエラーとロックエラーを区別する
   if(e.message == checkword){
      //ロックエラーの場合
      msg = "誰かまだ使ってるみたい";
   }else{
      //ソレ以外のエラーの場合
      msg = e.message;
   }    

} finally {
   //ロックを開放する
   lock.releaseLock();

   //メッセージを表示する
   ui.alert(msg);
}

tryLockよりも少々処理の記述法がより高度になっています。自分は通常こちらを使っています。try〜catch〜finally文も一部ではなく全体にたいして掛けて、最期にreleaseLockで開放し成功時・エラー時のメッセージをユーザに表示するようにしています。タイムアウトなのか?メインルーチンのエラーなのか?を区別するために、checkwordにてタイムアウトかどうかを判定させています。

ソレ以外の場合はe.messageにてエラー内容をメッセージに加えています。

hasLockの使い方

tryLockとwaitLockの他にもhasLockと呼ばれる検証用のメソッドがあります。このメソッドは「ロックがされているかどうか?」を調べる為のメソッドで、tryLockなどの後で、hasLockでテストをし、例えばロックが取れているかどうか?を元に処理をさせることが出来ます。hasLockでfalseが返ってきた場合には、速攻でその場でスクリプトの実施をキャンセルさせる事が可能です。

//UIを取得する
var ui = SpreadsheetApp.getUi();

//ロックを取得する
var lock = LockService.getScriptLock();

//30秒間のロックを実施する
lock.tryLock(30000);

//ロックが取れているかどうかをチェック
if (!lock.hasLock()) {
   //メッセージを表示する
   ui.alert("誰かが実施中なので処理は中断されました。30秒くらい待ってちょんまげ");

   //処理をキャンセルする
   return;
}

ロックを使ってみる

ドキュメントロック

ドキュメントロックとは、同一ドキュメントに対するスクリプトの実施をロックする為のメソッドです。同一ドキュメントに対する処理がロックされるので、通常これを使う事が多いです。同一人物であろうとなかろうと区別しません。特にユニークIDの生成やそれに対するスプレッドシートへの書き込み処理では必須のロックです。

実施テストは、サンプル1のスプレッドシートにてメニューより「作業実行」⇒「ドキュメントロック」でテストが出来ます。2つ以上のアカウントで同じスクリプトを同時に実施してみると挙動がわかると思います。20秒間のロックを加えています。

var lock = LockService.getDocumentLock();

ユーザーロック

ユーザロックとは、同一ユーザによるスクリプトの実施をロックするためのメソッドです。よって、別の人が同時にスクリプトを実施出来てしまいますので注意が必要です。主に同一人物による二重起動をロックするために使います。あまりスプレッドシートへの書き込みなど他人とバッティングはしない処理に対して使用します。

実施テストは、サンプル1のスプレッドシートにてメニューより「作業実行」⇒「ユーザロック」でテストが出来ます。複数人物で同じスクリプトを実施すると挙動がわかります。同時に実行出来てしまう点で区別が付きます。

var lock = LockService.getUserLock();

スクリプトロック

スクリプトロックはちょっと特殊です。同一スクリプトの実施に対してロックを掛けるので、表面上はドキュメントロックと挙動がほぼ同じです。ですが、このロックの使い方はそうではありません。これは通常ライブラリ化したルーチンの中で使用し、同一スクリプトだけれど、別のドキュメントでそれぞれ参照し利用している場合に使います。

今回はサンプル1にスクリプトロックのメインルーチンを記述しライブラリ化、サンプル2にそのライブラリを呼び出して流用させています。サンプル1のシートで実行後サンプル2で実施しても、最初に呼び出した側の処理が終わるまで、サンプル2側の処理はスクリプトは待機させられます。主に、2つのシートでユニークなIDを降る時に両シートで重複しないように降るといったような処理で利用出来ます。

実施テストはサンプル1のスプレッドシートメニューより「作業実行」⇒「スクリプトロック」を実施。サンプル2で「作業実行」⇒「スクリプトロック」を同時に実施すると挙動がわかります。サンプル2はサンプル1の実施が終わるまで待機しています。

var lock = LockService.getScriptLock();

サンプル2側はメインルーチンまるごと存在せず呼び出しているだけです(lockmanとう名前でサンプル1のライブラリを追加してあります)。

//スクリプトロック
function scriptlock(){
  //ライブラリにあるreccopy2を実行させる
  lockman.reccopy2();
}

一応コピペのルーチン

今回は排他制御の為のエントリーですので、コピペするルーチンはおまけです。ここで注目すべきは同時実施やスクリプトロック実行時のIDの値の付き方です。このルーチンは全てのスクリプトロックサンプルスクリプトで呼び出していて、ユニークIDも共用させています。故に、IDの付き方に差が付きます。

//コピペをするルーチン
function copypaste(){
    //現在のuidの値を取得する
    var Properties = PropertiesService.getScriptProperties();
    var uid = Properties.getProperty("uid");

    //スプレッドシートのデータを取得する
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var editsheet = ss.getSheetByName("書込先");
    var database = ss.getSheetByName("データベース").getRange("A2:E").getValues();

    //databaseのレコード数とカラム数をlengthとして取る
    var length = database.length;
    var clength = database[0].length + 1;

    //書込先シートをとりあえず全クリア
    var clear = editsheet.getRange("A2:F").clearContent();
    
    //書込用配列の準備と格納・書込
    for(var i = 0;i<length;i++){
      //配列を用意
      var array = [];
      
      //uidの生成
      uid = Number(uid) + 1
      var cnt = 0;
      
      //配列にuidとデータをpush
      for(var j = 0;j<clength;j++){
        if(j == 0){
          //最初だけuidを入れる
          array.push(uid);
        }else{
        
          //databaseのレコード値を格納してゆく
          array.push(database[i][cnt]);
          
          //カウンターを回す
          cnt = cnt + 1;
        }
      }
      
      //シートに書込
      editsheet.appendRow(array);
      
    }
    
    //uidをプロパティに書き戻す
    Properties.setProperty("uid",uid);
}
try~catch文でロック中であるケースを取得する

waitLockの場合、try~catch文を使用していますが、このままだと、普通のエラーが発生しても、catch文が作動してしまいます。waitLockのロック時間を超過してエラーになったケースと区別するには都合が悪いです。この場合、catch文が教えてくれるメッセージを取得し、その値が含まれているかをチェックするようなコードを記述すると良いです。具体的には、

try{
 
 
}catch(e){
   var checkword = "ロックのタイムアウト: 別のプロセスがロックを保持している時間が長すぎました。";
 
  
   //通常のエラーとロックエラーを区別する
    if(e.message == checkword){
    //ロックエラーの場合
      flag = 1;
    }else{
    //ソレ以外のエラーの場合
      
    }
 
}
 
//Flagが1だったらロックエラーなのでアラートを出す
if(flag == 1){
  ui.alert("誰かまだ使ってるみたい");  
}

といったように、e.messageの配列の中に「ロックのタイムアウト: 別のプロセスがロックを保持している時間が長すぎました。」という文言が含まれていたら、ロックエラーですので、その旨のメッセージを出し、ソレ以外でエラーになった場合には、そのためのメッセージを出すといったように、エラー別に処理を実装出来ます。ただし、このcatch内で続けてBrowser.msgBoxやui.alertなどで表示させようとすると、Unexpected exception upon serialization continuationというエラーが出て止まってしまいますので、使えません。対策として、別にflagを用意しておき、ロックエラーなら1として、try〜catch文の外側でflag判定をさせて、ダイアログを出せば回避出来ます。

ソースコード

//メニューの表示
function onOpen() {
    var ui = SpreadsheetApp.getUi();
    ui.createMenu('▶作業実行')
    .addItem('レコードコピー', 'reccopy')
    .addToUi();
}
 
//レコードコピールーチン
function reccopy(){
  //LockServiceオブジェクトを生成
  var lock = LockService.getPublicLock();  
  var ui = SpreadsheetApp.getUi();
  
  try{
    //30秒間のロックを取得する
    lock.waitLock(30000);
    
    //現在のuidの値を取得する
    var Properties = PropertiesService.getScriptProperties();
    var uid = Properties.getProperty("uid");
  
    //スプレッドシートのデータを取得する
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var editsheet = ss.getSheetByName("書込先");
    var database = ss.getSheetByName("データベース").getRange("A2:E").getValues();
    
    //databaseのレコード数とカラム数をlengthとして取る
    var length = database.length;
    var clength = database[0].length + 1;
 
    //書込先シートをとりあえず全クリア
    var clear = editsheet.getRange("A2:F").clearContent();
    
    //書込用配列の準備と格納・書込
    for(var i = 0;i<length;i++){
      //配列を用意
      var array = [];
      
      //uidの生成
      uid = Number(uid) + 1
      var cnt = 0;
      
      //配列にuidとデータをpush
      for(var j = 0;j<clength;j++){
        if(j == 0){
          //最初だけuidを入れる
          array.push(uid);
        }else{
        
          //databaseのレコード値を格納してゆく
          array.push(database[i][cnt]);
          
          //カウンターを回す
          cnt = cnt + 1;
        }
      }
      
      //シートに書込
      editsheet.appendRow(array);
    }
    
    //uidをプロパティに書き戻す
    Properties.setProperty("uid",uid);
 
  }catch(e){
    //時間内にロックが解除されなかったら、実行する
    
  
  }finally{
    //処理が終わったらロックを解除する
    lock.releaseLock();
    ui.alert("データのコピーが完了しました");
  }
}

ポイント

  • LockServiceは性質上、try~catch構文と常にペアで使用します。Lockを取得しようとして失敗した場合のエラー処理を必ずコードに記述しましょう。
  • ウェブアプリケーションを作った場合に、HTML側からのリクエストに対してLockServiceのエラーが出た場合、そのままではアプリケーションには伝わらないので、この場合、エラー処理には必ずreturnでエラー内容を返して上げましょう。ウェブアプリケーション側からは、google.script.run.withSuccessHandlerにて呼び出し、コールバックで受け取る側の関数でそのエラー内容をダイアログにでも出力するのがベストです。
  • ロックさせておく時間の設定がキモです。長過ぎる場合、他の方の処理は待ってる状態です。短すぎると、ロックが解除されるので、データのバッティング等が発生してしまいます。
  • 重めの処理を行う場合、ロックさせておく時間以上に処理が掛かる可能性もあります。それを想定して、ロックが継続してるかをチェックして継続していない場合には再びロックを取得するようなルーチンを入れておくと良いでしょう。
  • ロック中、他の方のリクエストは指定時間分ウェイトの状態になります。指定時間を超過してしまった場合、try~catchによってエラー処理に移行させられます。通常のLockの場合、指定時間内にロックが解除されると、他の方のリクエストの処理が始まります
  • ロックを取得してから、処理の途中でBrowser.msgboxなどのメッセージボックスを表示させると、そこでロックが解除されてしまいますので、注意が必要です。

関連リンク

排他制御でGoogle Apps Scriptを安全に実行【GAS】” に対して5件のコメントがあります。

  1. aaaa より:

    //30秒間のロックを実施

    これはドキュメントに対して30秒間ロックするのか、それともロックを取得するために最大30秒間待つのかわかりにくいです。

    1. akanemaru2017 より:

      ロックは30秒間、初めに取得した人が30秒間ロックさせるものです。他の人は最大30秒間待ちます。それが排他制御です。
      ドキュメントに掛けるのかどうかは、取得するLockserviceの内容によります。

      また、tryなのかwaitなのかで30秒を経過した場合のユーザへの返し方はことなってきます。

  2. Chihiro Fukazawa より:

    いつも参考にさせてもらってます!古い記事にコメント失礼します!もしかしてGASあるあるで仕様が変わったかもしれませんが、例外をスローするのはwaitLockだけで、tryLockは返り値で失敗状態を返すだけのようです!
    https://developers.google.com/apps-script/reference/lock/lock#tryLock(Integer)

  3. あき より:

    大変勉強になります。
    有益な記事をありがとうございます、参考にさせていただきます。

    1. akanemaru2017 より:

      いえいえ。排他制御は結構使うシーンが多いので、ご活用ください。
      特に申請フォームのような不特定多数が同時に利用するシーンでは必須ですね。

Chihiro Fukazawa へ返信する コメントをキャンセル

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)