Link and Motivation Developers' Blog

リンクアンドモチベーションの開発者ブログです

Google Apps Script で6分制限を超えるためのライブラリを作った

自己紹介

はじめまして。リンクアンドモチベーション プロダクトデザイン室 データユニットに所属している みく@Ryo Koizumi です。好きな食べ物はチョコレートです。

データユニットはヘルススコアを用いた既存事業の収益改善から、データを用いた新規事業の模索まで幅広く行うユニットです。チーム内でもデータサイエンティストをはじめPdMやデータエンジニアなどさまざまな役割のメンバーがおり、社内スタートアップという自己定義のもとに日々邁進しています。

その中でも自分はアプリケーション開発の経験が長いため、データサイエンティストが作成したモデルのアプリケーションへの組み込みや、DevOpsの整備、データ収集や可視化などデータエンジニアリング全般を担当しており、チームの生産性とケイパビリティを最大化するための活動を行っています。

今回は、データの収集において直面した Google Apps Script の制限の話と、その制限を回避するために開発した自作ライブラリのご紹介をさせていただきます。

(とても長いです)

今回のまとめ

  • Google Apps Script で6分を超えるバッチ実行処理を簡単に書くためのライブラリ(BackgroundRunnerApp)を作ったよ
  • カレンダーデータの大量ダウンロードなど、GASでデータ収集を行う際に利用してるよ

前提

  • Google Apps Script(以下GAS) では1つの処理に6分までの実行時間制限があり、長いバッチが実行できません。
  • 長時間に渡るバッチはAWSなど外部で回した方がいいのは承知の上で、実際に外部の仕組みに移行するには SpreadsheetApp などGAS専用の関数を用いているスクリプトを書き換える必要があり、6分制限だけでも回避出来れば、、、という気持ちになることが多々あります。
  • 今回はGASの仕組みだけで6分制限を回避する際の注意点を紹介し、さらにめんどくさい処理を隠蔽したライブラリを提供することで、6分制限を回避する処理を簡単に実装できるようにします。

今回のライブラリのユースケース

  • 今回のライブラリのユースケース逐次実行になります。
  • つまり、外部のDB(スプレッドシートなど)に巨大なリストがあり、それを少しずつ読み込んで所定の処理を行い、リストの終点に達したら終了するような処理を想定します。

実装物

BackgroundRunnerApp

注意

  • 社外向けであり、社内向けのバージョンとは異なります。
  • 突然公開を終了することがありますのでご了承ください。
  • このライブラリを用いたいかなる損害も、当社及び個人は責任を負わないものとします。

使い方(準備)

  1. Apps Script エディタの左からライブラリの追加ボタンを押す

  1. スクリプトIDに下記のIDを貼り付け、検索を押す

    1aFT6J1RlHtYIj1_O5jqz0iHInI3kOyIXZ1Xr1WeVXYCZgJ8X5on6MO9-

  1. バージョンは最新のものを選択し、IDはそのまま(BackgroundRunnerApp) にして追加を押す

使い方(実装)

基本

  • 例として Counter というバッチ処理を実装します。このバッチ処理は単純に逐次実行のたびにカウントアップしていき、閾値をオーバーしたときに終了します。
  • 継続実行させるトリガー関数と、それをキックするスターター関数の基本形は以下です。
function start() {
  const properties = PropertiesService.getScriptProperties();
  BackgroundRunnerApp.newBackgroundRunner('trigger', { count: 1 }, properties)
    .withName('Counter')
    .create();
}

function trigger() {
  const properties = PropertiesService.getScriptProperties();
  const runner = BackgroundRunnerApp.getActiveRunnerByName('Counter', properties);
  if (!runner) {
    return;
  }
  runner.run((args, { logger }) => {
    if (args.count > 10) {
      return undefined;
    }
    logger.info(`count to ${args.count}`);
    return { count: args.count + 1 };
  }); 
}
  • newBackgroundRunner
    • 第2引数はトリガー関数に渡される初期引数です。この場合はcountというカウンタを1からスタートさせることを意味しています。
    • withName でこのランナーに Counter という名前を付けており、後続の getActiveRunnerByName で(継続中のランナーを)取得できます。
  • getActiveRunnerByName
    • アクティブな(継続中である)ランナーを名前で取得します。
  • BackgroundRunner#run
    • 逐次処理を実行します。第一引数に現在のステートから引き出される引数を取ります。
    • イテレータは2通りの値を返すように実装します。
      • 次の処理に継続する場合は、次の逐次実行の引数を返します。この場合はカウントを1つ上げた引数を返します。
      • 処理を終了する場合は undefined を返します。
    • イテレータのインタフェースは以下になります。
type BackgroundRunnerArguments = {
  [key in string]: string | number | undefined | BackgroundRunnerArguments;
};

type BackgroundRunnerIterator<Arguments extends BackgroundRunnerArguments> = (
  args: Arguments,
  option: Readonly<BackgroundRunnerOption>
) => Arguments | undefined;

interface BackgroundRunnerOption {
  runnerId: RunnerId;
  userId: string;
  runnerName: string | undefined;
  runnerStatus: BackgroundRunnerStatus;
  functionName: string;
  startTime: string;
  maxDurationSeconds: number | undefined;
  continuesOnMaxDurationExceeded: boolean;
  logger: Logger;
}

function run(iterator: BackgroundRunnerIterator<Arguments>): void;

IDでハンドリングする

  • スクリプトトリガーと同じように発行した ID でのハンドリングも出来ます。この場合、IDを別途スクリプトプロパティなどに保存する必要があります。
function start() {
  const properties = PropertiesService.getScriptProperties();
  const { runnerId } = BackgroundRunnerApp.newBackgroundRunner('trigger', { count: 1 }, properties).create();
  properties.setProperty('runnerId', runnerId);
}

function trigger() {
  const properties = PropertiesService.getScriptProperties();
  const runnerId = properties.getProperty('runnerId');
  const runner = BackgroundRunnerApp.getRunnerById(runnerId, properties);
  if (!runner) {
    return;
  }
  runner.run((args, { logger }) => {
    if (args.count > 10) {
      return undefined;
    }
    logger.info(`count to ${args.count}`);
    return { count: args.count + 1 };
  }); 
}

処理内容をslack通知させる

  • withSlackLogger(webhookUrl: string) で Slack Incoming Webhook で処理内容を通知することが出来ます。
function start() {
  const properties = PropertiesService.getScriptProperties();
  BackgroundRunnerApp.newBackgroundRunner('trigger', { count: 1 }, properties)
    .withName('Counter')
    .withSlackLogger('<webhook url>')
    .create();
}

function trigger() {
  const properties = PropertiesService.getScriptProperties();
  const runner = BackgroundRunnerApp.getActiveRunnerByName('Counter', properties);
  if (!runner) {
    return;
  }
  runner.run((args, { logger }) => {
    if (args.count > 10) {
      return undefined;
    }
    logger.info(`count to ${args.count}`); // -> Slack通知される
    return { count: args.count + 1 };
  }); 
}

ストップ、レジュームする

  • ランナーを中断したり、中断したランナーをレジュームできます。
  • getActiveRunnerByName は継続中のランナーしか取得しないため、レジュームを行うには IDでハンドリングする必要があります。
function start() {
  const properties = PropertiesService.getScriptProperties();
  const { runnerId } = BackgroundRunnerApp.newBackgroundRunner(
    'trigger',
    { count: 1 },
    properties
  ).create();
  properties.setProperty('runnerId', runnerId);
}

function stop() {
  const properties = PropertiesService.getScriptProperties();
  const runnerId = properties.getProperty('runnerId');
  const runner = BackgroundRunnerApp.getRunnerById(runnerId, properties);
  runner?.stop();
}

function resume() {
  const properties = PropertiesService.getScriptProperties();
  const runnerId = properties.getProperty('runnerId');
  const runner = BackgroundRunnerApp.getRunnerById(runnerId, properties);
  runner?.resume();
}

エラーフック・完了フック

  • エラー時、完了時にフックが出来ます。
  • onError のみ、第1引数がエラーオブジェクトになります。
function trigger() {
  const properties = PropertiesService.getScriptProperties();
  const runner = BackgroundRunnerApp.getActiveRunnerByName('Counter', properties);
  if (!runner) {
    return;
  }

  runner.onFinished((args, { logger }) => {
    logger.info('finished.');
  });

  runner.onError((error, args, { logger }) => {
    logger.error(error.message);
  });

  runner.run((args, { logger }) => {
    if (args.count > 10) {
      return undefined;
    }
    logger.info(`count to ${args.count}`);
    return { count: args.count + 1 };
  }); 
}

継続実行時間を設定し、実行時間を超えた際の処理をフックする

  • setMaxDurationSecondsで最大継続時間を設定できます。
  • BackgroundRunner#onMaxDurationExceeded で最大継続時間を超えた際のフックを指定します。
    • 下記の例では最大継続時間を超えた際、開始時間の1日後に再度継続実行するためのトリガーを設定します。
function start() {
  const properties = PropertiesService.getScriptProperties();
  properties.setProperty('args', JSON.stringify({ count: 1 });
  createNewRunner();
}

function createNewRunner() {
  const properties = PropertiesService.getScriptProperties();
  const args = JSON.parse(properties.getProperty('args'));
  BackgroundRunnerApp.newBackgroundRunner('trigger', args, properties)
    .setMaxDurationSeconds(60 * 60 * 3)
    .withName('Counter')
    .create();
}

function trigger() {
  const properties = PropertiesService.getScriptProperties();
  const runner = BackgroundRunnerApp.getActiveRunnerByName('Counter', properties);
  if (!runner) {
    return;
  }

  runner.onMaxDurationExceeded((args, { startTime }) => {
    const tomorrow = new Date(startTime);
    tomorrow.setDate(tomorrow.getDate() + 1);
    properties.setProperty('args', JSON.stringify(args));
    ScriptApp.newTrigger('createNewRunner').timeBased().at(tomorrow).create();
  });

  runner.run((args, { logger }) => {
    if (args.count > 10) {
      return undefined;
    }
    logger.info(`count to ${args.count}`);
    return { count: args.count + 1 };
  }); 
}

実装詳細

ここからは実装の詳細になります。

GASで6分制限を回避するためには

GASの6分制限の回避方法として大きく知られているものは以下の2つです。

  1. 実行終了前にスクリプトトリガーを設定して継続する方法
  2. Script API を利用して外部からGASを叩ける状態にした上で、Webブラウザ側のスクリプトから叩く方法

実行終了前にスクリプトトリガーを設定して継続する方法

  • GASにはスクリプトトリガーという機能があり、これを用いることでcronのようなスケジュール実行やバックグラウンド実行が設定できます。
  • スクリプトトリガーはコード上でも設定できるため、処理が終わる前に次の処理をスクリプトトリガーを設定することで、再帰的な継続実行が実現できます。
  • 注意点としてはスクリプトトリガーに指定する関数は引数を取ることはできないため、通常のように引数を渡して再帰処理を行うことができず、どこまで処理を行ったかを記録するステートを外部に持つ必要があります。通常はスクリプトプロパティを用いることが多いです。
  • あとはトリガーの設置はスクリプト毎に1ユーザー20個までで、実行が完了した後も残り続ける(カウントされ続ける)ため、実行完了後に消去していく必要があります。また総実行時間に対するクォータ(6時間/日まで)も存在します。

Script API を利用して外部から叩ける状態にした上でWebブラウザ側のスクリプトから叩く方法

  • Script API を用いると GAS 上の関数を API を用いて叩けるようになります。外側からAPIを叩いても6分の実行制限は適用されますが、逐次実行する仕組みはGASに依存しなくても良くなります。
  • さらに、GASの関数を叩くためのHTMLをHtmlServiceで公開することによって仕組みがGASだけで完結します。Script API が有効になっていれば特に認証などは要らず、ホストコンテナ(スプレッドシートなど)のonMenuなどから起動することも可能であり、手元のブラウザで実行することになるため jQuery などのライブラリも利用可能になります。
  • 注意点としては UI を利用するためトリガーからの起動や自動起動が出来ません。

ライブラリの設計における注意

今回は、自動起動も可能なバッチ実行を実現するため、上記の1. の方法を利用することにします。したがってその手法を取る際に考慮しなければいけない点をライブラリ内で隠蔽し、利用者側に出来る限り内部実装を意識させないのが設計上のキモになります。

1.の設計上の注意点を以下にまとめました。

引数を持てない

  • 前述の通り、処理全体のバックエンドとしてはスクリプトトリガーを利用しますが、スクリプトトリガーに設定する関数は以下の制約を持ちます。
    • パブリックな関数である
    • イベントオブジェクト以外の引数を持てない
  • パブリックな関数であるという制約がありますが、GASの関数実行は全てステートレスであることに留意する必要があります。ステートレスかつ引数を持てないため、逐次的なバッチ処理を行うには外部にステートを保存し、関数の実行毎にステートを引き出した上で、次の処理に継続する前に外部に新しいステートを記録していくという処理が必要になります。
  • 例えば大量の名簿リストからカレンダーイベントをダウンロードするバッチを考えた時に、トータルの処理時間が6分以内に終わるならリストの頭から終わりまでを一気に実行すれば良いため、外部にステートを持つ必要はありません。しかし処理時間が6分で終わらない場合、どこまで処理を実行したかを一時ステートに保存し、次回の実行時にそれを引き出せないといけなくなります。

スクリプトトリガーの問題

  • スクリプトトリガーに保存できるのは1プロジェクトにつきユーザー毎に20個までになります。これは前述のように実行終了したトリガーも含むため、実行終了したトリガーは適宜消去していく必要があります。
  • 他のトリガーに干渉しないためには、トリガー起動時に発行されるトリガーIDを指定して削除する必要がありますが、そのためにはトリガーIDを外部のステートに保持しておく必要があります。
  • 逐次実行の場合、トリガーはワンショットで発行する形になるため、継続処理の基本は ステートを引き出す→前のトリガーを削除する→処理を実行する→新しいトリガーを発行する→新しいトリガーのIDをステートに保存する になります。

スクリプトプロパティ固有の問題

  • ステートを保存する場所として GAS からアクセスしやすいのはスクリプトプロパティもしくはスプレッドシートですが、ライブラリ単体で完結させるにはスクリプトプロパティに保存するのがベターになるかなと思います。
  • スクリプトプロパティは単純な key-value 型のストアなため、ステートはシリアライズして保存することになります。そのため保存できるステートはstring, numberなどのプリミティブな値になります。
    • Rails を利用している人なら sidekiq の制限をイメージすると分かりやすいと思います。
  • また注意点として、スクリプトプロパティは非共有リソースのため、ライブラリ側で生成した PropertiesService はライブラリ側のスクリプトプロパティに保存されます。これはライブラリの挙動としては問題がある形になります(ホスト側プロジェクトのステートは「ホスト側の」スクリプトプロパティに保存してほしい)。回避策としてはホスト側で PropertyService を生成し、ライブラリ側のメソッドを叩くときは基本的にその値を渡すことでホスト側のスクリプトプロパティを利用するようになります。

ライブラリ側からはホストの関数を直接呼べない

  • 外部からステートを引き出す処理の隠蔽やエラーハンドリングを行うため、トリガー関数をライブラリ側に設定したいですが、トリガーに設定したライブラリ側の関数からホスト側の関数を呼び出すことが出来ません。
  • 従って逐次実行におけるメイン処理はホスト側のパブリック関数で書く必要があり、基本的にはホスト側トリガー関数の内側でステートの引き出しと保存、終了処理、エラーハンドリングなどを書く必要が生じます。
  • ステートの保存や引き出しなどの処理を意識させないようにするため、これらの処理を「ランナーオブジェクト」と呼ばれるものに隠蔽することで全体の処理を隠蔽する方法を提示します。

また注意しておかなければいけない問題は以下になります。

トータル実行時間のクォータ

  • 1アカウントにつき1日6時間の実行制限があり、それを超えるとトリガー実行が失敗することがあります。
  • 回避するには継続時間をステートに保持し、予め定めた継続時間を超えた場合は継続実行を行わずに、ステートを保持して待機状態にする必要があります。
  • 1日経てばクォータはリセットされることを考えたら、本当に長い処理を実行する際は、継続時間を超えた時に中断した上でバッチ開始時間の1日後に再開するトリガーを設定し、自動で復帰出来るような処理があるといいかなと思います。

まとめ

  • ここまでお付き合いいただきありがとうございます。
  • リンクアンドモチベーションではライブラリで業務を楽にしたいデータエンジニアを始め、様々な職種を募集しています。興味がありましたらカジュアル面談もありますのでお気軽にお声がけください!