Google Apps Scriptで6分の壁(タイムアウト)を突破する - 番外編【GAS】
以前、Google Apps Scriptの高速化関係のネタの1つとして、スクリプトトリガーを使った6分というタイムアウトの壁を突破する手法を紹介しました。前回の手法は、GAS側で制御して、スクリプトトリガーを駆使した手法だったのですが、トリガー発動の間隔が1分以上必要である点と、途中から開始する為に、スクリプトプロパティに終了済みのレコードの位置などを記録する必要がありました。
今回の手法はGAS側で制御せず、HTML側で制御して、時間カウント等をせずに継続的にGASの命令を何度も呼び出すことで実現する手法です。
目次
今回利用するスプレッドシート等
- ファイルを一気にコピーする - Google Spreadsheet
- 個人情報テストジェネレータ
- BatchRequest Library - (1HLv6tWz0oXFOJHerBTP8HsNmhpRqssijJatC92bv9Ym6HSN69_UuzcDk)
スプレッドシートを指定の場所に、スプレッドシートに記載された内容に基づいてコピーをするだけのサンプルです。自分の計測した結果としては、6分フルに使って100個のコピーが限界(1個当たり、3.6秒でコピーしてる計算)。
これを今回の手法ではタイムアウトをするような時間で送るのではなく、10個単位で送って(合計36秒)✕ 10回処理をする事で、結果的には6分の壁を超えて処理をするというのが狙いです。サンプルのデータはテストジェネレータで生成した架空のデータです(IDと名前の2つの列のみ生成)
また、今回はDrive API v3を使ったバッチリクエストでファイルをコピーするライブラリを利用しています。手動のメソッドで回すよりも30%程度高速にコピーが可能です。
今回の手法のあらまし
今回のスクリプトの中心となるのは、GAS側ではなくHTML側のJavaScript。よって、HTML Seriviceでダイアログやサイドバーから実行する必要があります。サイドバーで表示させて、その中のJavaScriptでデータの処理をコントロールさせます。HTML側では実行するにあたっての、タイムアウト制限は無いため、全体の処理を分割し、何回も小分けでGASに処理をさせる為、事実上GASのタイムアウトを回避しています。
プログラムの流れ
プログラム全体の流れとしては以下のような感じになります。
- GAS側から処理に必要なデータの塊を取得する(スプレッドシート等から)
- HTML側で受け取ったら、1レコード当たりの処理時間を調べておき、そこから逆算した1回あたりに処理で6分の壁に引っかからない量を算出する
- 2.に基づいてデータの配列から必要分だけを切り出し
- 制御する関数を用意し、データと回数を渡して処理を開始
- GAS側から処理が返ってきたら、再帰的に次のデータの塊を渡して、回数分終わるまで何度も実行する(Node.jsのasync.eachSeriesのような感じ)
- 全ての処理が完了したら、再帰処理を抜けて、終了処理を実行して完了
一つのファイルをコピーする処理をおおよそ100個コピーするのに6分まるまる掛かりますが、これを10個単位で送って10回回せば良いという考えです。1回当たりの処理は6分を超えないので規制に引っかからないわけです。またトータルの処理時間もトリガーを使った手法と異なり、1回1回の間隔は非常に短いので、結果的にはGASで制御するよりも早く処理が可能です。
注意点
- この手法はPromiseを利用するため、V8 Runtimeは有効化する必要があります。
- ファイルコピーは1回あたり10個までで今回は制御しています。
- 6分を超えて動作するため、作業中のメッセージボックスを出すようにしています。
-
return new Promiseの中で、google.script.run.withSuccessHandlerで呼び出した場合、なぜかGAS側からのreturnが受け取れずnullとなるため、その関数は別途制御する関数の外側で呼び出してcallbackするようにしています
Drive API V3の制限
利用してるライブラリは、Drive API V3なのでGASで使えるV2ではないAPIを使っています。リクエストはUrlfetchAppを使ってると思われるので、制限値的にはUrlfetchAppのほうを注意すべきかと思います。
Drive API V3としての制限値は
- アップロード : 750GB/1日/ユーザ
- アップロード最大サイズ:5TBまで
- APIリクエスト上限:10億回/1日
- APIリクエスト上限(100秒):2万回/100秒
- APIリクエスト上限(100秒且つユーザ単位):2万回/100秒/ユーザ
ダウンロードの実行に関しては資料がありません。
UrlfetchApp単体では、10秒間に3回以上のリクエストをすると429エラーが出やすくなります。そこで、UrlfetchApp.fetchAllを使ってまとめてリクエストを送る場合はそれが緩和されているので、後述の503エラーが出てるのはこの影響だと思われます。
ソースコード
GAS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
//ファイルをコピーする関数 function copydocs(arr){ let ui = SpreadsheetApp.getUi(); //作業中ダイアログを表示 var html = HtmlService.createHtmlOutputFromFile('spinner').setWidth(300).setHeight(150); ui.showModalDialog(html, '作業中'); //batchでデータをコピー var ret = batchman(arr,targetfolder) //値を返す return ret; } //ファイルコピーを実行する処理 async function batchman(arr,targetfolder){ //このファイルのIDを取得 let spread = SpreadsheetApp.getActiveSpreadsheet().getId(); //エンドポイント let endpoint = "https://www.googleapis.com/drive/v3/files/" + spread + "/copy" //ファイル名の一覧を生成 let titlearr = []; for(let i = 0;i<arr.length;i++){ //レコードを一個取り出す let rec = arr[i]; //ファイル名につけるタイトルを取得(名前欄を利用) let title = { title:rec[1] } //タイトルを追加 titlearr.push(title); } //バッチリクエストを構築(名前+日付でファイル名を指定) var requests = titlearr.map((title) => ({ method: "POST", endpoint: endpoint, requestBody: { parents: [targetfolder], name : title.title + "_" + getDate() }, })); //バッチリクエスト実行 var res = BatchRequest.EDo({ batchPath: "batch/drive/v3", requests: requests, }); //レスポンスデータからfileidの塊を取得 var arrayman = []; for(let j = 0;j<res.length;j++){ //レコードを一個取り出す let recman = res[j]; //IDだけを取り出す let fileid = recman.id; if(fileid == null){ messageman("503Error : 生成したドキュメントのファイルIDが取得出来ませんでした"); return; } //対応するレコードを取得 let rec = arr[j]; //ファイルIDと対応する対象のレコードを付けて返す arrayman.push([fileid,rec]) } //配列を返す return arrayman; } |
- 単純に送られてきたレコードをまとめてリクエストしてるだけで、タイムアウト制御はしていません。
- copydocsでHTML側から受け取って、batchmanへ渡しています。
- targetfolderはグローバル変数で、出力先のフォルダIDを指定しています。サンプルを使う場合は必ず入れておきましょう。
- 名前と日付をファイル名として指定し、requestを複数構築し、BatchRequest.EDoで一括で送り込んでいます。
- ファイルのBlobデータを取得したいのであれば、endpointのURLに「?alt=media」を加えて単純にGETでリクエストすれば、例えばtextやSVGなどのファイルの中身も取得可能です。
- レスポンスに配列で生成したファイルIDが入ってるので、これを取り出し配列でHTML側へ返しています。レスポンス内容は以下の通り
12345[ { kind: 'drive#file',id: 'ファイルIDがここに入ってる',name: 'test_2022-09-08',mimeType: 'application/vnd.google-apps.spreadsheet' }]
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
//コピー開始 function execcopy(){ //差込データが選択されてるかどうかチェック if(listdata.length === 0){ google.script.run.messageman("差込データが読み込みされていません"); return; } //小分けにしておくる回数を計算(10レコード単位) let loopman = Math.ceil(listdata.length / 10) //同期的にGASを実行する let result = eachSeries(loopman,listdata); } //6分のタイムアウトを回避する為に指定件数毎に処理を送る関数 function eachSeries(counter,arr) { var index = 0; //次の処理を実行する関数 function next() { if (index <= counter) { //カウンターを回す index++; //10件分切り出す let temparray = arr.splice(0,10); //GASを実行する console.log(index + "回目の処理") return execinsert(temparray,arr).then(next); }else{ google.script.run.messageman("処理が完了しました。"); return; } } return Promise.resolve().then(next); } //GASを同期的に実行する為の仕組み function execinsert(arr){ return new Promise((resolve, reject) => { gasexec(arr,function(result){ //ファイルIDと対応するファイル名を取得できる console.log(result); //5秒間のウェイトを入れて次を実行する為に返す setTimeout(()=>{ resolve(); }, 5000) }) }); } //ファイルコピーGAS実行本体 function gasexec(arr,callback){ google.script.run.withSuccessHandler(function(ret){ callback(ret); }).copydocs(arr); } |
- execcopyで予め取得しておいたデータから、loopmanで10レコード単位で割った時のリクエスト回数を算出して、eachSeriesへ送っています。
- eachSeriesでは、受け取った全データから10レコード単位で切り出して、execinsertへ送っています。
- execinsertではPromiseで制御を入れており、このままだとGAS側からのレスポンスデータがnullとなってしまうため、さらにgasexecへと渡しています。次の処理までの間に5秒間のウェイトを入れてあります。
- gasexecがGAS側のcopydocsへと処理を渡し、受け取ったレスポンスをcallbackでexecinsertへ返しています。
- 指定のリクエスト回数分を回し終わったら、eachSeriesでは終了処理へ移動してメッセージを表示して完了する。
- この一連の作業がタイムアウトの制御をする為の仕組みで、1回あたりのリクエストでは6分に至らないので、問題が起きずに6分以上の処理が行なえます。
注意点
今回利用してるBatchRequestsライブラリですが、自分が検証した限りでは、20個分以上のリクエストを一気に送り込むと、503エラーとして、Transient failureが返ってきて、そのレコードだけコピーされません。
特に制限の中に記載は無いものの、1度に送るのは10個分程度に抑えておいたほうが良いと思います。速度的には分けてもそこまで大きな差が出ないので無理して、大きな塊でエラーを起こすよりは確実に処理をする手法を選びましょう。
なお、Drive API V3の制限値はかなり緩いので、他の制限値はそこまで気にする必要は無いと思います。このライブラリ自体、後ろでUrlfetchAppを使ってると思うので、そちらの制限のほうを気にしましょう。
関連リンク
- ES6の約束-async.eachのようなもの?
- Handle Google Drive interruptions #159
- How to make a Drive API batch request with UrlFetchApp in Google Apps Script
- Batch Requests for Drive API using Google Apps Script
- Is there a way to replace text by a styled text using google doc API?
- Google Drive APIを利用する上で注意すべきポイント
- Google Drive API v3 - Convert doc to pdf
- [Google Apps Script] スクリプトの並列実行によって実行時間制限をクリアする方法