GAS高速化のススメ

本記事はニーリーアドベントカレンダーの23日目の記事 その1です。

はじめに

こんにちは。サクセスエンジニアリングチームの増田です。 今年の8月に入社して早4か月が経ちました。

入社エントリも公開していますので良ければ見ていってください note.nealle.com

最近週4でカレーばっか食ってます。 美味しいカレーの後がけスパイスやソースなどあればぜひ教えていただきたい...!

GASについて

みなさん普段から業務でGAS(Google Apps Script)利用されてますでしょうか。 GASは知っての通りセットアップ不要で使え、Googleサービス(Google Sheets、Gmail、Driveなど)への認証が標準で組み込まれている非常に便利なツールです。非エンジニアでも扱いやすく、業務効率化の手段として広く活用されています。

GASのデメリットと課題

そんな便利なGASですが多くの制限が存在します。 その中でも代表的なものが実行時間の制限だと思います。GASの実行制限は6分でそれを超えるとタイムアウトが発生してしまいます。 せっかく業務効率化のために実装したGASが実行時間を超えてしまってやり直す。その行為だけで数分の無駄な時間が生まれてしまいます。 ただGASは基本的に同期処理で処理が進んでいくため雑に実装をしてしまうと、意外とすぐにその制限を超えてしまうことに遭遇します。 そういった事態から逃れるためにもGASを高速化するためのアプローチを知っておくことは大きな武器となります。 日常の業務で頻繁に使用されるスクリプトであればあるほど、わずかな待機時間でも大きな違いが生まれます

今回は気をつけなければいけない基本的なアプローチと少し応用的なGAS高速化のための手法を共有します。

基本的なアプローチ

シートへの読み書きを可能な限り一括で行うようにする

GASで一番やりがちなアンチパターンは、単一行での読み書きをループで回すことです。 GASでスプレッドシート上のデータの読み書きをする際には、単一行ずつだと多くのリクエストが発生し、かつそれが同期的に処理されるため多くの時間を要します。 可能な限りまとめて読み込み、まとめて書き込むことが大切になります。

例えば、Google Sheetsでタスク管理を行っていて、「A列にあるタスク名が完了ならB列に『済』と書き込む」というスクリプトを書いたとします。

function inefficientExample() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const lastRow = sheet.getLastRow();
  
  const startTime = new Date(); // 計測開始
  
  for (let i = 2; i <= lastRow; i++) {
    const status = sheet.getRange(i, 1).getValue(); // A列のタスク名を取得
    if (status === "完了") {
      sheet.getRange(i, 2).setValue("済"); // B列に「済」を書き込む
    }
  }
  
  const endTime = new Date(); // 計測終了
  const duration = (endTime - startTime) / 1000; // 秒単位に変換
  Logger.log(`非効率な処理の実行時間: ${duration}秒`);
}

上記のように書くと対象の行が1000行ある際には読み書きを合わせて2000回のアクセスが発生する事になります。

function efficientExample() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const lastRow = sheet.getLastRow();

  const startTime = new Date(); // 計測開始
  
  // A列(タスク名)を一括で取得
  const taskNames = sheet.getRange(2, 1, lastRow - 1, 1).getValues();

  // B列用のデータを準備
  const results = taskNames.map(row => row[0] === "完了" ? ["済"] : [""]);

  // B列に一括で書き込み
  sheet.getRange(2, 2, results.length, 1).setValues(results);

  const endTime = new Date(); // 計測終了
  const duration = (endTime - startTime) / 1000; // 秒単位に変換
  Logger.log(`効率的な処理の実行時間: ${duration}秒`);
}

それぞれ1000行の完了行を用意した状態で実行時間の比較をした結果が以下の通りです

非効率な処理の実行時間: 217.872秒

効率的な処理の実行時間: 0.153秒

結構な違いが出ますね。このように少しの違いでも大きな違いになってしまうのがGASの恐ろしいところですね。

外部APIリクエストを並列する

例えば、複数の外部APIからデータを取得し、シートに記録する処理を行いたい場合を考えます。 以下のコードはAPIリクエストを順番に実行してしまう典型的な非効率な例です。

function fetchApisInefficient() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const urls = [];
  for (let i = 1; i <= 20; i++) {
    urls.push(`https://reqres.in/api/users/${(i % 12) + 1}`); // ユーザーIDは1〜12の範囲でループ
  }

  const startTime = new Date(); // 計測開始

  const values = [];
  for (let i = 0; i < urls.length; i++) {
    const response = UrlFetchApp.fetch(urls[i]); // 順次リクエスト
    const data = JSON.parse(response.getContentText()).data;
    values.push([data.id, data.email, `${data.first_name} ${data.last_name}`]);
  }

  sheet.clear();
  sheet.getRange(1, 1, 1, 3).setValues([["ID", "Email", "Name"]]);
  sheet.getRange(2, 1, values.length, values[0].length).setValues(values);

  const endTime = new Date(); // 計測終了
  const duration = (endTime - startTime) / 1000; // 秒単位
  Logger.log(`順次APIリクエスト処理時間: ${duration}秒`);
}

GASでAPIのリクエストをする場合にはUrlFetchAppという標準ライブラリを利用するしか方法がありません。 上記の例ではUrlFetchApp.fetchというメソッドを使用して実行しています。

これを効率化するためにはUrlFetchApp.fetchAllを使うことで並列でリクエストを送って全ての結果が返却されることを待つことができます。

function fetchApisEfficient() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const urls = [];
  for (let i = 1; i <= 20; i++) {
    urls.push(`https://reqres.in/api/users/${(i % 12) + 1}`); // ユーザーIDは1〜12の範囲でループ
  }

  const startTime = new Date(); // 計測開始

  try {
    // APIリクエストを並列で実行
    const responses = UrlFetchApp.fetchAll(urls);
    const values = responses.map(response => {
      const data = JSON.parse(response.getContentText()).data;
      return [data.id, data.email, `${data.first_name} ${data.last_name}`];
    });

    sheet.clear();
    sheet.getRange(1, 1, 1, 3).setValues([["ID", "Email", "Name"]]);
    sheet.getRange(2, 1, values.length, values[0].length).setValues(values);

  } catch (e) {
    Logger.log(`APIリクエスト中にエラーが発生: ${e.message}`);
  }

  const endTime = new Date(); // 計測終了
  const duration = (endTime - startTime) / 1000; // 秒単位
  Logger.log(`並列APIリクエスト処理時間: ${duration}秒`);
}

それぞれの結果の違いが以下の通りです。

順次APIリクエスト処理時間: 3.034秒

並列APIリクエスト処理時間: 0.973秒

今回はAPIのサンプルとして使用したreqresというサービスのレスポンスが高速だったので大きな変化は出ませんでしたが、より一回ごとのリクエストが重たいほど大きな変化が出てきます。 また、fetchAllを使う際の注意点として接続先のサービスに影響がない範囲で並列数を決めてチャンクに分けて実装することが必要になります。そこを気をつけて実装しましょう。

応用的なアプローチ

キャッシュの活用

GASには一時的にデータを保存する機能、いわゆるキャッシュが存在します。 頻繁に呼び出すAPIデータは一時的にキャッシュに保存することで高速化をすることができます。 最大でのキャッシュ時間は6時間となるため、日次での実行のGASなどにはあまり効果的ではないですが6時間よりも頻繁な頻度で実行が予測されるスクリプトには効果的です。 ただ一番の制約としてキーごとに保存可能なサイズが100KB、キャッシュに保存できるサービスの上限は1000個となっています。 それらの制約を理解した上で適切なキャッシュ時間を設定した上でアプリケーションを構築することが重要です。

キャッシュを利用するには CacheServiceを使用します。 CacheServiceを通じて3つのキャッシュにアクセスすることができますが、今回はスクリプトのキャッシュであるScriptCacheを使った実装のサンプルを掲載します。その他のキャッシュの用途については公式ドキュメントを参照してください。

function getApiDataWithCache() {
  const cache = CacheService.getScriptCache();
  const cacheKey = "user";
  const cachedData = cache.get(cacheKey);

  if (cachedData) {
    Logger.log("キャッシュからデータを取得しました");
    const data = JSON.parse(cachedData); // キャッシュデータを返す
    Logger.log(data)
    return data;
  }

  // キャッシュがない場合、APIリクエストを実行
  const url = "https://reqres.in/api/users?page=1";
  const response = UrlFetchApp.fetch(url);
  const data = JSON.parse(response.getContentText());

  // APIレスポンスをキャッシュに保存(有効期限: 5分)
  cache.put(cacheKey, JSON.stringify(data), 300);

  Logger.log("APIからデータを取得しました");
  Logger.log(data);
  return data; // APIデータを返す
}

このスクリプトを実行していただくと、初回はAPIからデータを取得して、2回目以降はキャッシュからデータを取得していることがわかると思います。 キャッシュサイズなど制約はありますが、データにリアルタイム性を求めない場合に有効なアプローチの一つです。

擬似的な非同期処理を実装する

GASにはトリガーと呼ばれる自動実行のための機能があります。 トリガーはGASのコンソールから手動で登録することもできますが、スクリプトを使って登録をすることができます。 そのトリガーと先ほど紹介したキャッシュを活用して、実行を分割する事によって擬似的な非同期処理を実装することができます。 なんだか難しそうと思うかもしれませんが、既に公開されている便利なライブラリのAsync.gsがあります。

コードの中身を見てみるとやっていることはシンプルで、キャッシュに実行する関数名と引数を保存して、すぐに実行されるトリガーを設定するといったものです。

// Triggers asynchronous execution of a function (with arguments as extra parameters)
Async.call = function(handlerName) {
  return Async.apply(handlerName, Array.prototype.slice.call(arguments, 1));
};

// Triggers asynchronous execution of a function (with arguments as an array)
Async.apply = function(handlerName, args) {
  var trigger = ScriptApp
  .newTrigger('Async_handler')
  .timeBased()
  .after(1)
  .create();
  // トリガーのuidをkeyとして関数実行のために必要なデータを保存 
  CacheService.getScriptCache().put(String(trigger.getUniqueId()), JSON.stringify({ handlerName: handlerName, args: args }));
  return { 
    triggerUid: trigger.getUniqueId(),
    source: String(trigger.getTriggerSource()), 
    eventType: String(trigger.getEventType()), 
    handlerName: handlerName,
    args: args
  };
};

トリガー実行後に呼び出されるハンドラーで保存したキャッシュから情報を取得して関数を実行して完了後にトリガーを削除するといった内容になっています。

とても便利に感じますが注意点は多くあります。 一つ目にトリガーはすぐに実行されるわけではなく30秒~1分程度ラグがあったりします。そのためケースによってはシンプルに処理をしたほうが速くなりますが、利用方法には注意が必要です。

二つ目にトリガーについて上限が20で設定されているので、そこまで多くのトリガーを作成することもできません。

最後に元のスクリプトで完了まで待つことができないという点があります。利用者に状況をわかりやすく伝えるために、非同期実行したスクリプト上で進捗状況をスプレッドシートに書き込んだり、ログを適切に出力するなどの工夫が必要になります。

こういった注意点はありますが、GASの6分の制約を凌ぐためのアプローチとしてとても有用です。 実際に社内で6分程度かかっていたスクリプトをこの手法を使って5分割して非同期実行するようにしたところ実行時間が2分程度まで抑えることができました。

最後に

GASは少しの違いで実行時間に影響があったり、実行時間の制限を超えるためのアプローチが色々と用意されていることが伝わったでしょうか。 生成AIの発展によってより一層誰でも書けるようになったGASですが、まだまだ社内に効率化につながる宝の山は多く眠っているのかもしれません。 これを機に一度日々業務で使用しているGASを見直してみるのはいかがでしょうか。

また、今回は触れませんでしたがGASの開発については rollup.js + claspでGit管理してTSでコードを書くのが個人的にはおすすめです!