💼

Google Apps Script向け簡易job-queueを作った

2021/08/01に公開1

Motivation

個人的にいくつかのSlackボットをGoogle Apps Script(以下GAS)で実装していますが、3秒ルールの壁があってイベント処理がタイムアウトしてしまうという課題がありました。
Slackの公式ドキュメントには3秒以内にレスポンスできない処理は、一旦レスポンスして非同期処理でイベントのペイロードにあるレスポンスURLに書き込めとありました。
GAS内で非同期処理を行うには、トリガー処理を組み合わせして実現できそうでした。

https://developers.google.com/apps-script/guides/triggers

使えるトリガーはいくつか種類がありますが、今回の用途では Time-driven が一番使えそうだと判断しました。

GASのTriggerについてspikeしてみた

まず軽くドキュメントをみて気になったのは1スクリプトにつき1ユーザーが20トリガーまでという制約が目につきました。

https://developers.google.com/apps-script/guides/services/quotas#current_limitations

実際にトリガーを使って非同期処理をさせてみましたが、イベント発火したトリガーは残り続けてしまい、すぐに制約に引っかかってしましました。
また非同期処理実行時のパラメータの受け渡しも、GASの機能としてはありませんでした。
スプレッドシートにデータを書き込んでそれをイベントとして非同期処理させる例も見かけましたが、単純に非同期処理する為だけにスプレッドシートを用意するというのも大げさだし、外部要因が増えることになるのでコントローラブルではないなと見送りました。

どうやったか?

  • 非同期処理のパラメータも含めてジョブとしてステータス管理をさせるようにした
    ジョブのデータのストレージとしてスクリプトキャッシュを利用しました
  • ジョブの登録と実行処理を関数化して、キュー実行できるようにした
    ジョブの登録時にトリガーを作成し、トリガー実行された関数からキューをフェッチして処理を行うようなインターフェースを定義しました。
    アプリケーション側からはトリガーを意識せずにジョブキューの様に振る舞うようにしました。
    内部的にはスクリプトロックを使った排他制御だったり、ジョブのconsume時にジョブのステータス管理をしてコールバック関数が完了したら次回以降にゴミとなったトリガーを回収するようにさせました。

いくつかのbotで作成した関数を組み込んで、動作検証&リファクタリングを行っていきました。
またとあるbotではジョブを時間指定させたいニーズがあったので、作成した関数を拡張して遅延実行もできるようにしました。

ライブラリ化へ

リファクタリングを経て安定化もしてきていい感じになってきたのですが、作成していたbotが片手では足りなくなってきたタイミングぐらいから関数の管理が辛くなってきました。
botの作成時期がバラバラで、途中でlinter入れたりjestを使い始めたりとで、作成した関数の微妙な差異も出てきてたりし始めていました。
GASのライブラリは、 OAuth2 など幾つか利用した実績はあったのですが、作成するのは初めてだったので色々苦労をしました。

ライブラリ化で変更した点

  • ランタイムをV8向けに作成していたのをwebpackでトランスパイルしてRhinoベースにダウングレード
    ライブラリ自体がV8向けで作成していると利用するアプリもV8にしないといけないみたいだったので手を入れました。せっかくライブラリ化するならサポートできる範囲が広い方が良いと考えました。
    ただ単純に書き直すのも癪だったので、GASでnpmライブラリを動かす検証をする際にwebpackを使っていたので、使いまわしてみました。
  • 主要な機能をグローバル関数で定義
    元々はクラスのみで機能提供していましたが、ライブラリ化に伴いグローバル関数として定義しないといけなかったので必要な機能のみを関数として切り出しました。
  • JSDoc追加
    I/F定義をJSDocでまとめました。実際のライブラリのコード上はトランスパイルされているので型情報は失われていますが、記載しておくとGASのエディタ上のコード補完時に戻り値の型等が表示されるようになります。
    editor
  • 型定義ファイルを用意
    最近はclaspを使ってTypescriptで記述することが殆どだと思うので、型定義ファイルも用意しました。
    型を使って記述したいという人は npm install --save-dev github:k2tzumi/apps-script-jobqueue して使ってみてください。

使い方

セットアップ

ライブラリをスクリプトに追加するには、GASのコードエディターで次のように操作します。

  1. メニューの "リソース > ライブラリ... "をクリックします。
    "Add a library "テキストボックスに、スクリプトID 11cz2CGI2m3W1_JS7PwnxL2_6hkvtj47ynFuxKDDAAUwh3jP04sYnigg8 を入力し、"追加 "ボタンをクリックします。
  2. ドロップダウンボックスでバージョンを選択します(最新バージョンを選択してください)。
  3. "保存 "ボタンをクリックします。

Identifierはそのまま JobBroker でOKです。
後述のサンプルコードも JobBroker の前提で記載します

サンプルコード

ライブラリを使って非同期処理を実行するには、以下のようなステップで記述します

  1. ジョブを登録する (enqueue)
const asyncFunction = (): void = {
    // 詳細はあとで後述します
};

const parameter = { foo: "bar" };
JobBroker.enqueueAsyncJob(asyncFunction, parameter);

JobBroker.enqueueAsyncJob で非同期処理用の関数オブジェクトを、パラメータも含めて登録します。
内部的には、即時実行のトリガーが生成されます。

  1. ジョブの実行 (consume job)
const asyncFunction = (): void => {
  JobBroker.consumeAsyncJob((parameter: Record<string, any>) => {
    console.info(JSON.stringify(parameter));
  }, "asyncFunction");
};

ジョブ登録時に指定された関数はトリガーを介して実行されます。
JobBroker#consumeAsyncJob にはジョブを消費するコールバック関数と、トリガーされた関数の名前を指定します。
指定したコールバック関数は、非同期処理の本体となります。
コールバック関数は、ジョブ登録時に指定したパラメータを受け取ることができます。
内部的には、コールバック処理が完了するとトリガーは削除されます。

遅延実行についても呼び出す関数名が違うだけでほぼ同じ感じで使えると思います。

サンプルコードは こちら
サンプルコードより情報量がありませんが、JSDocも ご参考 まで

やってみて

  • すべてV8ベースで統一できたほうが楽
    どれだけニーズがあるかわからないし、いずれサポート外になると思われる環境向けにwebpackの環境まで用意するのはヘビーでした。
    互換性の問題で一部使えなくなった関数 [1] も出てきたりしました。
    あと後述する型定義も考えるとどういう形が望ましいか?まだ自分の中でうまく咀嚼できない状態です。
  • グローバル関数故のモジュール化で悩む
    クラスライブラリを提供するという感じではないので、どう必要な機能を切り出すのかについて悩みました。
    またアプリケーションの呼び出し側で書き味が変わらないように型定義ファイルを用意したりしましたが、今回の記述方法で正解だったのか?まだ手探りな感じです。
  • testではライブラリ部分をすべてモックにしないといけない
    良い側面でもありますが、テストもいい感じに書けるようにするには前述のモジュールの切り方だったりが重要になります。jest周りの整備も含めてまだまだノウハウ貯めないといけないのが課題だと感じています。

これは所感とかではないのですが。思わぬ副産物として

  • WebアプリケーションでGoogle Cloud Platform(GCP)を利用しなくても非同期でログ出力するとApps Script ダッシュボードから確認ができる

というのがありました。
サクッと始めれるのがGASの良い所だと感じていますが、WebアプリケーションだとStackdriver Loggingが表示されないという仕様がありました。
それがこちらのテクニックを使うことでWebアプリケーションでもデバックが楽になりました。

やってないこと

  • リトライ処理の実装
  • ジョブのタイムアウト処理の実装

ジョブステータスの管理上は、上記のリトライやタイムアウトは意識していましたが、如何せん簡易的な実装になっているので対応はしていません。
一般的なjob-queueのシステムと違ってワーカーがいるわけではなく、triggerでイベントが発生した場合にジョブを捌いているだけなので定期的にステータスを監視することができないのでやっていません。

さいごに

GASでSlack botを作成するというニッチなニーズを満たす為に作成したライブラリですが、GAS自体で実行時間が6分を超える場合に処理が強制終了する制約があるのでまあまあ使えるのではないでしょうか。
ライブラリの公開I/Fの定義の部分など、まだ手探りな部分もありますので、是非ご意見等を頂けるとありがたいです。

脚注
  1. Function.callerは使えなかったです ↩︎

Discussion