個人開発でストア関連の作業をシートに集めるようになって、しばらく経ちます。多言語の説明文、審査の提出履歴、段階公開の進み具合。どれも表で持つのが一番早いのですが、実際に手間だったのは表を作ることではありませんでした。
詰まるのは、いつも「誰の権限で、いつ、何回動くのか」の方でした。
自分だけが開くシートなら関数を1つ書けば済みます。ところが、時間になったら勝手に動いてほしい、外部の API を叩く回数には上限がある、途中で落ちても二重に処理してほしくない——このあたりが混ざり始めた途端に、シートの上では収まらなくなります。私自身、Apps Script に落とした処理のほとんどは、表を作りたかったからではなく、この実行の条件を書き留める場所が他になかったからでした。
8月26日、Sheets canvas が自然言語のプロンプトから読み書き可能な対話型アプリを組み立てられるようになりました。表を「入力フォームつきの小さな業務アプリ」として配る用途に、Gemini 側から手が伸びた形です。
そこで最初に浮かんだのは「内製した Apps Script を畳めるのか」でした。結論から言えば、畳めるのは入口までです。そしてその線を引く物差しは、機能の多さではなく実行境界にあります。
8月26日に増えた選択肢の輪郭
canvas でできるようになったのは、シートの内容を対話的に読み書きできる形へ変換することです。作成と編集にはユーザーごとの使用量上限が設定されています。
ここで見落としやすいのは、増えたのが「作る手段」であって「動かす条件」ではない点です。
観点 Sheets canvas Apps Script
作る手間 プロンプトで組み上がる コードを書く
起動の主体 開いた人の操作 トリガー・外部呼び出しも可
実行をまたぐ状態 持たない前提 PropertiesService 等で保持
権限の範囲 そのシートの文脈 マニフェストで宣言した範囲
回数の制約 ユーザーごとの使用量上限 スクリプト単位の各種割り当て
この表の上から2行目より下が、私が実行境界と呼んでいるものです。canvas が短くしてくれるのは1行目だけでした。
実行境界という物差し
実行境界とは、その処理が「誰の権限で」「何をきっかけに」「何回まで」「途中で落ちたらどうなるか」を規定している部分を指します。自分で名付けた言葉ですが、この4つを並べて見ると、移せる処理と移せない処理がきれいに分かれます。
たとえば、シートの数値を読んで整形して別の列へ書き戻すだけの関数には、実行境界がほとんどありません。開いた人が押したときに動き、失敗しても押し直せば済み、外部の割り当てを消費しません。こういう処理は canvas 側へ移して構いません。
一方、毎晩1時に動いて未処理行を順に外部へ投げ、途中で止まったら次回そこから再開する処理には、実行境界が濃く乗っています。時間主導型トリガーが起動の主体で、UrlFetch の日次上限を共有し、再開位置を実行をまたいで保持しています。この3つは、対話型アプリの中には置き場所がありません。
個人開発で書いてきた自動化のうち、後から手を入れる羽目になったのも常にこちら側でした。UrlFetch の日次上限を月末に溶かして止まる問題はUrlFetch 日次上限の予算ガバナー設計 に、時間主導型トリガーの本数が静かに尽きる問題は時間主導型トリガーをディスパッチャに束ねる方法 にまとめました。どちらも表の作り方とは無関係で、実行境界だけの話です。
手元の資産を棚卸しする
線を引く前に、何本のスクリプトが何のトリガーで動いているのかを一覧にします。頭の中では覚えているつもりでも、数年運用したプロジェクトでは大抵ずれています。
/**
* プロジェクトに登録されているトリガーを一覧化します。
* 実行境界のうち「起動の主体」を可視化するのが目的です。
* 権限の範囲は API から取得できないため、appsscript.json の
* oauthScopes を別途目視で突き合わせてください。
*/
function auditTriggers () {
const triggers = ScriptApp. getProjectTriggers ();
if (triggers. length === 0 ) {
Logger. log ( 'トリガーなし: このプロジェクトは手動起動のみです' );
return ;
}
triggers. forEach ( function ( t ) {
const handler = t. getHandlerFunction ();
const source = String (t. getTriggerSource ()); // CLOCK / SPREADSHEETS / FORMS
const event = String (t. getEventType ()); // CLOCK / ON_EDIT / ON_OPEN ...
const userDriven = (event === 'ON_EDIT' || event === 'ON_OPEN' );
Logger. log (
[handler, source, event, userDriven ? 'ユーザー起動' : '非ユーザー起動' ]. join ( ' | ' )
);
});
Logger. log ( '登録数: ' + triggers. length + ' / 上限は用途ごとに定められています' );
}
getTriggerSource() と getEventType() は文字列そのものではなく列挙型を返すため、比較する前に String() で包んでいます。ここを忘れると条件が常に偽になり、すべてがユーザー起動として集計されます。私は一度これで棚卸し結果を丸ごと信じ、移行対象を広く見積もりました。
「非ユーザー起動」と出た行が、canvas へは移らない処理です。この時点で候補はかなり絞られます。
移せるもの、分けるもの、残すもの
棚卸しの結果は、3つのどれかに落ち着きます。
判定 条件 扱い
移す 実行境界のシグナルがなく、シートの読み書きだけで完結する canvas 側へ移し、スクリプトは削除する
分ける 入口はシート操作だが、内部で外部呼び出しや状態保持を含む 入口を canvas、処理を Apps Script に残して接続する
残す 起動の主体がユーザーでない、または排他制御を必要とする 手を触れない
意外だったのは「分ける」が一番多かったことです。移行の議論は「移すか残すか」の二択で語られがちですが、実際の資産はたいてい入口と処理が1つの関数に同居しています。二択で考えると、入口だけ移せばよかったものまで残すことになります。
分けるべき処理を見つけたら、まず関数を入口側と処理側に割ってから移行の可否を判断します。私の場合は、この順序を先に決めておいたことで、同じ関数を二度見直さずに済みました。
判定を機械にやらせる
スクリプトが数本なら目視で足りますが、10本を超えたあたりから見落としが出ます。ソースの文字列から実行境界のシグナルを拾って分類する小さな関数を用意しました。
// classify.js — Apps Script のソース文字列から実行境界を検出して分類します
const BOUNDARY_SIGNALS = [
{ key: 'time_trigger' , re: /ScriptApp \. newTrigger | getProjectTriggers/ , why: '起動の主体がユーザー操作ではない' },
{ key: 'external_call' , re: /UrlFetchApp \. / , why: '日次 UrlFetch 上限を共有する' },
{ key: 'exclusive_lock' , re: /LockService \. / , why: '同時実行を1本に絞る必要がある' },
{ key: 'durable_state' , re: /PropertiesService \. | CacheService \. / , why: '実行をまたぐ状態を持つ' },
{ key: 'wider_scope' , re: /DriveApp \. | GmailApp \. | CalendarApp \. / , why: 'シート以外の権限を要求する' },
{ key: 'long_running' , re: /Utilities \. sleep | while \s * \( true \) / , why: '6分の実行上限に触れる' },
];
// 入口シグナル: シートの読み書きと整形だけで完結する処理を指します
const ENTRY_ONLY = /SpreadsheetApp \. | getRange \( | setValues \( | getValues \( / ;
function classifyScript ( name , source ) {
const hits = BOUNDARY_SIGNALS . filter ( function ( s ) { return s.re. test (source); });
const hasEntry = ENTRY_ONLY . test (source);
let verdict;
if (hits. length === 0 && hasEntry) verdict = 'move' ; // 入口ごと canvas へ
else if (hits. length > 0 && hasEntry) verdict = 'split' ; // 入口と処理を分ける
else verdict = 'keep' ; // Apps Script に残す
return {
name: name,
verdict: verdict,
reasons: hits. map ( function ( h ) { return h.key + ': ' + h.why; }),
};
}
module . exports = { classifyScript };
手元の3本を通した実行結果です。
FormatReport.gs -> move
TranslateRows.gs -> split
external_call: 日次 UrlFetch 上限を共有する
durable_state: 実行をまたぐ状態を持つ
NightlyDispatcher.gs -> keep
time_trigger: 起動の主体がユーザー操作ではない
external_call: 日次 UrlFetch 上限を共有する
exclusive_lock: 同時実行を1本に絞る必要がある
TranslateRows.gs が split で返ってきた点が、この分類器を用意した理由そのものです。目視のときは「外部を叩いているから残す」と即決していましたが、シートを読んで書き戻す入口部分は移せます。理由が2行並ぶことで、移せない根拠が外部呼び出しと状態保持の2つだけだと分かり、その2つを別関数へ追い出せば入口は手放せると判断できました。
判定を1回きりで捨てず、verdict と reasons を CSV でシートに書き戻しておくと、次に同じ悩みが来たときに考え直さずに済みます。
canvas を入口にしたときに残る接続点
split に分類した処理は、入口と処理をどこで繋ぐかを決める必要があります。ここで安易にシートの編集イベントへ寄せると、後で困ります。
/**
* canvas 側の入力を受けて、重い処理はキューに積むだけにします。
* 実際の外部呼び出しは時間主導型トリガーの drain() が行います。
* onEdit に処理本体を置かない理由は下に書きました。
*/
function enqueueFromSheet () {
const sheet = SpreadsheetApp. getActive (). getSheetByName ( 'queue' );
const rows = sheet. getRange ( 2 , 1 , Math. max (sheet. getLastRow () - 1 , 0 ), 2 ). getValues ();
const pending = rows
. filter ( function ( r ) { return r[ 0 ] && ! r[ 1 ]; }) // 未処理のみ
. map ( function ( r ) { return String (r[ 0 ]); });
if (pending. length === 0 ) return '積むものがありません' ;
const props = PropertiesService. getScriptProperties ();
const queue = JSON . parse (props. getProperty ( 'queue' ) || '[]' );
props. setProperty ( 'queue' , JSON . stringify (queue. concat (pending)));
return pending. length + ' 件を受け付けました' ;
}
onEdit に処理本体を置かない理由は、単純な実行時間の問題ではありません。onEdit はシートを編集した人の権限で走るため、共有相手が編集した瞬間に、その人の権限で外部呼び出しが試みられます。自分のシートでは通っていた処理が、共有した途端に相手側で失敗します。この経路は、canvas で入口を配りやすくなったぶん踏みやすくなります。回避策は単純で、入口の関数は受け付けるところまでにとどめ、外部を叩く処理は自分の権限で走るトリガー側へ寄せることです。
権限の主体をどこに置くかの設計は、Apps Script 自動化の権限を最小に保つ設計 で扱った話とそのまま地続きです。canvas は入口を配る手段が増えただけで、権限の考え方は変わっていません。
移す前に確かめる3つの取りこぼし
移行の判断そのものより、移した後に気づく落とし穴を先に潰しておきます。どれも一度踏むと、原因にたどり着くまでが長い種類のものです。
1. 権限の主体が入れ替わっていないか
自分だけが動かしていた処理を、対話型の入口として配ると、実行者が変わります。書き込み先のシートやフォルダに、その人の権限が届くとは限りません。回避するには、配る前に自分以外のアカウントで一度開いて、書き込みまで通ることを確かめます。
2. 使用量上限の当たる先が変わっていないか
canvas の作成と編集にはユーザーごとの使用量上限が設定されています。スクリプト単位の割り当てとは数え方が別なので、片方の余裕をもう片方の根拠にはできません。
3. シートの構造を壊す操作が入っていないか
対話的に列を足したり並べ替えたりできるのは便利ですが、列位置を固定で参照している既存スクリプトは、その瞬間に静かに壊れます。getRange('C2:C500') のような固定参照が残っている処理は、移行対象でなくても影響を受けます。移す前に、列名で引き直しておくことを推奨します。
3番目は、数千行を6分の実行上限内で処理する分割と冪等性の設計 で組んだような、行位置を前提にした処理に効いてきます。入口を対話的にするなら、位置ではなくヘッダー名で列を解決する形へ先に直しておきます。
次に手をつけるなら
auditTriggers() を一度走らせて、非ユーザー起動の行が何本あるかを数えるところからで十分です。その本数が、canvas へ移らない処理の下限になります。
新しい入口が用意されたときに真っ先に決めるべきなのは、何を移すかではなく、何を移さないと決めるかなのだと感じています。移さないものが確定すれば、残りは自然に片付いていきます。