【GAS】処理時間の制限を回避する

JavaScriptGoogle Apps Script

こんにちは、しきゆらです。
急な出社が続いてあまり記事を書けていない今日この頃です。
今回は、以前投稿したGASでファイル一覧を取得するコードを載せましたが、実際の環境で処理回したら処理時間の制限に引っかかったので、回避するように直したのでその旨をメモしておきます。
前回の記事はこちら。

改めて、GASの制限については以下を参照いただければと思います。

Google サービスの割り当て  |  Apps Script  |  Google for Developers

上記を見ると、スクリプトの制限としては1回の処理で6分までとなっています。 今回は、ここを回避していきます。


回避の方針

処理時間の制限としては、処理時間が一定時間を超えたらそこでいったん処理を止める、というだけ。 ただし、それだけだと処理しきれなかった部分が出てくるので、そこもケアしてあげましょう。

ざっくり手順としては以下の通り。

  • 定期的に処理時間の確認し、一定時間を超えていたら処理を止める
  • 処理途中のデータを保存する
  • 処理を続きから実行するためのトリガーを定義する
  • 一時停止した場合に前回の処理から再開する

それぞれ見ていきましょう。

なお、スクリプトのよって内容が変わると思いますが、ここでの例として前回のファイル・フォルダの一覧を取得する、というものに対してコードを書いていきます。

ファイル・フォルダ一覧を取得するコードはこんな感じ。

function allFiles(){
  const folderId= "xxxxxxxxxx";
  const targetDir = DriveApp.getFolderById(folderId);
  const files = []; // ファイルオブジェクトを保持する配列
  const subFolders = [targetDir.getFolders()];

  for(let i = 0; i < subFolders.length; i++) {
    const subFolder = subFolders[i];
    while(subFolder.hasNext()) {
      const folder = subFolder.next();
      subFolders.push(folder.getFolders());
      folders.push(...allFilesBy(folder));
    }
  }
  return files;
}

function allFilesBy(folder) {
  const files = []; // 対象となるフォルダにあるファイルオブジェクトを保持する配列
  const fileIterator = folder.getFiles();
  while(fileIterator.hasNext()) {
    files.push(fileIterator.next());
  }
  return files;
}

処理時間を回避するコード

処理時間の確認

処理時間を取得する機能はなさそうなので、単純に実行タイミングでDateオブジェクトを生成しておき、定期的に現在時刻との差分を求める形で簡易的に確認します。

// 処理の開始時点の日付・時刻を取得する
const startDate = new Date();
...

後は、定期的に処理の中でstartDateと現在時刻との差分を求めて処理時間を計算しましょう。

Dateオブジェクトの差分だったり、Date.getTime()を使っても同じように取得できます。 なお、値はミリ秒なので注意が必要です。

...
// ミリ秒で計測する場合
const processingMilliSec = (new Date() - startDate);
// 秒に直す場合
const processingSec = (new Date() - startDate) / 1000;
...

雑に関数にしておきます。 一定時間(ここでは300秒(= 5分)が経過したら処理を止めるようにしています。

function hoge() {
  // 処理の開始時点の日付・時刻を取得する
  const startDate = new Date();
  // いろいろな処理
  if (limitCheck(startDate)) return ;
}

function limitCheck(startDate: Date): boolean => {
  const processingSec = (new Date().getTime() - startDate.getTime()) / 1000;

  return processingSec >= 300; // 処理時間が300秒より大きい場合はtrueを返す
}

これにて、一定時間が経過した時点で処理を止める機能は完了です。

処理途中のデータを保存する

処理を止めるだけでは、次に実行したときに再開することはできません。 処理を止めたときに、次回実行時に処理途中から再開できるように

前述の通り、前回のファイル・フォルダ一覧を取得するコードを例として書いていきます。 前回の記事はこちら。

データを保持する先は、以前に紹介したPropertiesServiceを使います。 PropertiesServiceに関しては、過去記事を書いているのでこちらもご覧ください。

ファイル・フォルダの一覧を取得する場合、保持しなければいけないのは主に以下の3点ですね。

  • 取得したファイル・フォルダのリスト
  • 処理途中のファイル・フォルダイテレータ
  • 参照予定のフォルダリスト

この3点の中で、最初の「取得したファイル・フォルダのリスト」については、おそらく取得した後で最終的にはスプレッドシートにまとめたりするはずなので、あえてPropertiesServiceに置いておかなくてもよいかもしれません。

一方で、「処理途中のファイル・フォルダイテレータ」や「参照予定のフォルダリスト」は処理途中で終了しなければいけない場合は保持しておかなければ続きから再開ができません。
ということで、この2点を保持する形を作っていきます。

イメージ図を置いておきます。
赤フォルダ配下にあるフォルダを取得するとき、getFolders()で青フォルダたちFolderIteratorとして取得できます。

青フォルダたちをFolderIteratorで取得している間に下図の線の位置で時間切れとなった場合、残り2つのフォルダは次回に回さないといけません。
合わせて、青フォルダ1の配下にある紫フォルダたちを取得するFolderIteratorも取得済みなので、こいつらも次回処理するときに見る必要がありますよね。

ということで上記3点のうち、青フォルダのFolderIteratorが「処理途中のファイル・フォルダイテレータ」、紫フォルダのFolderIteratorが「参照予定のフォルダリスト」となります。
では、それぞれの保持の仕方を見ていきます。

イテレータの保持

FolderIteratorFileIteratorのどちらもgetContinuationToken()メソッドが定義されており、イテレータ処理で時間がかかる場合に途中から再開することができるようになっています。 再開するには、DriveAppクラスに定義されているcontinueFolderIteratorcontinueFileIteratorにトークンを渡せばよい。

// トークンの取得
const iteratorToken = fileIterator.getContinuationToken();

// イテレータの再開
const resumeIterator = DriveApp.continueFileIterator(iteratorToken);

このトークンをPropertiesServiceなどで保持しておけば再開できますね。

参照予定のフォルダリストの保持

前述の通り、フォルダやファイル一覧についてはPropertiesServiceを使って保持します。 なお、参照予定のフォルダ一覧はFolderIteratorが複数ある形なので、これらも上記の通りトークンに変換して保持します。

const subFolders = []; // 参照予定のFolderIteratorリストを保持する配列
...

// スクリプトプロパティを取得
const scriptProperty = PropertiesService.getScriptProperties();
// 参照予定のFolderIteratorをトークンに変換する
const subFolderTokens = subFolders.map(subFolder => subFolder.getContinuationToken());
// 参照予定のリストをJSONに変換して保持
scriptProperty.setProperty("folders", JSON.stringify(subFolderTokens));

処理途中のイテレータも同じくgetContinuationTokenメソッドでトークンを取得できるので 取得しつつsubFoldersの先頭に置いておけば再開できそうですね。

ここまでで、一時停止のためのデータ保持が完了です。

処理の続きを実行するトリガーを定義する

GASにはトリガーという機能があり、特定の時間やタイミングなどになったら処理を始める、ということを指定できます。 トリガークラスについてはこの辺を参照ください。

トリガーの作成はScriptAppのnewTrigger()で作成できます。

トリガー作成時に、トリガー起動時に呼び出す関数名を文字列で指定します。 ここでは、トリガーとして1分後に起動するトリガーを作成します。

ScriptApp.newTrigger(functionName).timeBased().after(1000 * 60).create();

newTrigger()が返してくるのはTriggerBuilderクラスになっています。 詳しくはこちら。

timeBased()はトリガーの種類として時間を基準として動くタイプとして指定しています。 after()でトリガーが起動する時間をミリ秒で指定しています。 create()で指定したトリガーを作っているだけ。見ればわかる感じですね。

一時停止した場合に前回の処理から再開する

前回の処理がある場合は、前回の処理から再開してあげる必要があります。

先ほどPropertiesServiceに保持したデータですが、ここから取得してできるかどうかで判別したり、関数の引数の有無で判別したり、方法はいくつかありそうですがここではPropertiesServiceにデータがあるかどうかで判別してみます。

// スクリプトプロパティを取得
const scriptProperty = PropertiesService.getScriptProperties();
// 前回の処理からの続きとなるデータを取得
const resumeData = scriptProperty.getProperty("folders");

if(resumeData === null) {
  // 前回のデータがないので、ターゲットとなるフォルダを取得
} else {
  // 前回のデータがあるので、再開する
}

再開する場合は、トークンを使ってイテレータの続きを取得します。 上記サンプルのelse部分は以下のような感じになります。

const tokenJson = JSON.parse(resumeData);
const subFolders = tokenJson.map(token => DriveApp.continueFolderIterator(token));

あとは、このトークンから復元したイテレータをひとつづつ取り出して処理を進めればよいですね。

コードの全体

まとめてコードを載せると以下のような感じ。

// 処理時間の制限を超えたかどうかのフラグ
let limitFlag = false;

function allFiles(){
  const functionName = "allFiles";
  const startDate = new Date();
  let subFolders = []; // 配下にあるサブフォルダを保持する配列
  let files = [];

  // 中断データを取得する
  const resume = getResume(functionName);
  if (resume === null) {
    // 中断データがない場合は、初期フォルダから処理を開始する
    const folderId= "xxxxxxxxxx";
    const targetDir = DriveApp.getFolderById(folderId);
    subFolders.push(targetDir.getFolders());
    files.push(...Array.from(allFilesBy(targetDir, startDate)));
  } else {
    // 中断データがある場合は、トークンからFolderIteratorに変換する
    const tokensJson = JSON.parse(resume);
    subFolders = tokensJson.map(token => DriveApp.continueFolderIterator(token));
  }

  for(let i = 0; i < subFolders.length; i++) {
    const subFolder = subFolders[i];
    while(subFolder.hasNext()) {
      const folder = subFolder.next();
      subFolders.push(folder.getFolders());
      files.push(...allFilesBy(folder));
      if(limitFlag || checkLimit(startDate)) break;
    }

    // 一定の処理時間を超えた場合、処理途中のFolderIteratorをトークンに変換して保存する
    if(limitFlag || checkLimit(startDate)){
      const tokens = [subFolder.getContinuationToken()];
      const subFolderTokens = subFolders.map(subFolder => subFolder.getContinuationToken());
      setResume(functionName, JSON.stringify(tokens.concat(subFolderTokens)));
			break;
    }
  }

  // 今回処理した結果を返す
  return files;
}

function allFilesBy(folder) {
  const files = []; // 対象となるフォルダにあるファイルオブジェクトを保持する配列
  const fileIterator = folder.getFiles();
  while(fileIterator.hasNext()) {
    files.push(fileIterator.next());
  }
  return files;
}

function checkLimit(startDate) {
  const processingSec = (new Date().getTime() - startDate.getTime()) / 1000;
  limitFlag = processingSec >= 300
  return limitFlag;
}

// 中断データを取得する
function getResume(functionName) {
  const scriptProperty = PropertiesService.getScriptProperties();
  const properties = scriptProperty.getProperty(functionName);
  scriptProperty.deleteProperty(functionName);

  return properties;
}

// 中断データを保持し、トリガーを設定する
function setResume(functionName, data) {
  const scriptProperty = PropertiesService.getScriptProperties();
  scriptProperty.setProperty(functionName, data);

  setTrigger(functionName);
}

// トリガーを設定する
function setTrigger(functionName) {
  let triggers = ScriptApp.getProjectTriggers();
  for(let trigger of triggers) {
    if(trigger.getHandlerFunction() === functionName){
      ScriptApp.deleteTrigger(trigger);
    }
  }
  ScriptApp.newTrigger(functionName).timeBased().after(1000 * 60).create();
}

これで、処理時間の制限に引っかかるような長時間の処理が必要な場合、これを回避して処理させることができるようになりました。 処理時間の制限があるのでGASを使うのはきびしいな、というような場合に参考にしていただければ幸いです。

まとめ

今回は、GASの処理時間の制限を回避するため、以下の項目を組み合わせて回避してみました。

  • 処理時間の計測
  • 処理途中のデータをPropertiesServiceへ保存
  • 処理再開のためのトリガー設定
  • 前回の処理途中からの再開

私の場合は、とあるフォルダ配下にあるファイルたちのオーナー一覧を取得してほしい、というお題が来たので上記のようなコードを書いていました。 RubyなどからAPI経由でデータ取得するよりも、GAS上で書く方がシンプルでしたが、ざっと書いたところ処理時間の制限に阻まれたのでリファレンス等を読みつつ変更した結果が今回の時期の内容になります。

GASでコードを書いた方がシンプルだが、処理にどの程度時間がかかるかわからない というような場合でもGASを使って処理させることができるようになるので、参考になればうれしいです。

今回は、ここまで。

おわり