ぼくのかんがえたさいきょうのGAS開発手法2023

前提

  • clasp の制約、Script API の考え方、Google Drive の考え方に素直に従う
  • その条件下である程度モダンな開発環境での開発を目指す
    • 可能ならコードは VCS で管理する
    • (pull-req など)ドキュメントベースで共同作業に向いた手法で開発を進める

特にカジュアルに始めやすい Google Apps Script は悪い意味での属人化まっしぐらになりやすい。これが長期間の業務に影響しないような、ワンショットのものなら別にそれでもよいが、これが誰かに引き継がなければいけないような状況が生まれると一気に地獄みが増してしまうので、そうなってしまう前により良い開発手法を考えておきたい。

考慮したこと

GAS は素朴に作ると Script 本体の構造がそれを利用する container (例えば Spreadsheet)のデータ構造などと密結合になってしまう。この状態のままコードやデータ構造の更新を行うと、一時的にデータと Script の対応が外れてしまったり、データの変更は不可逆なので Script の対応が完了するまで業務が止まってしまったりする。

それでも自分だけが使っているデータであり、自分だけが使っている Script なら「えいや」でやってしまえばよい。しかし複数人が使っている場合にはそうもいかない。

せっかく GAS 自体にバージョンの概念があっても、Script とデータが密結合にならざるを得ない、データは DBMS のように堅い schema を持っていない、十分に development 環境でテストできるわけではない、自動 migration でディレイをなくすこともできない、schema ( データ ) 側の rollback はできない(手動で頑張るしかない)といった課題を抱えている。

まとめると

  • Script とデータ構造は密結合しており、Script だけバージョンが打てるだけでは不十分

ということになる。本格的に GAS を活用していくにはこの課題を克服、少なくとも緩和する方法を考える必要がある。でないと「 ファーストリリースまでは順調にいってもリリース後どんどんつらくなる のは目に見えている」状態に陥りやすい。

諦めた妥協したこと

Continuous Deploy (完全自動化)は目指さない

  • GitHub-flow のような操作感ではリリースはできないが、そこは諦める
  • 逆に Script の runtime 側に deploy ( バージョン ) の概念があるので、これを利用する

GASのライブラリを利用した際にパフォーマンスが多少犠牲になるのは受け入れる

これについては、開発しやすさと複数環境の用意しやすさを優先する。

パフォーマンスペナルティについてはそれより Apps Script ネイティブの API call がちょっとでも増えれば如実に遅くなるわけで、そういう処理をいかに減らしていくかを考えた方が効果は大きいと判断した。

TypeScript は使わない

やりたければやってもよいが、V8 runtime でそのまま動くわけでもないので変換が必要になる。そうなると

  • Script Editor 上でのデバッグを考えて差異の大きすぎる変換は避けたい
  • source map は有効なのか?

など考えることが増えるが、前提のところでも触れたが、そもそもデータ側がふにゃふにゃであり、コードだけカチカチにしても意味がない。そこで JSDoc コメントでアノテーションを確保しつつ、TypeScript にはこだわらないこととした。

もしどうしてもやりたいなら

  • TypeScript を使う部分だけ独立性の高いライブラリとして切り出す
  • その際、Apps Script ネイティブな要素を混ぜない

を徹底するならアリだと思う。ただ本当に type の繊細なコードを扱わなければいけないのならGAS とは別な何かに切り出して API 呼び出しで応える形でもよいわけで、アプリケーション全体を考える際に何においても TypeScript がすべてに優先する、みたいな考え方はやめた方がよいと思う。

全体像 - アプリケーションとしてのライブラリ

以上のような前提を踏まえたうえで考えたのは以下のような方法である。

  • アプリケーション全体をGASライブラリにしてしまう
    • 適切にバージョン(デプロイ)を打つ
  • アプリケーションの実行は Container からライブラリを呼び出すことで行う
  • production 環境はアプリケーションとして stable なバージョンのライブラリに依存させる
  • staging 環境は Container を丸ごとコピーすることでデータ構造ごと変えることを許容しつつ、ライブラリの HEAD を呼び出す

この方式は上で考慮したことにもある通り、データと Script が不一致にならない、なっても困らない、そのうえでコードとしては管理を一本化するための工夫であり、ここでは Container のデータを例としているが、例えば Webhook のようなものを作ったとしても考え方は同じで、production の entrypoint と staging の entrypoint を「ライブラリを呼び出す Script を複数持つ」ことで増やしつつ、本体は同一のものであり、VCS と clasp で管理できるようになる。

基本的にこの「アプリケーションとしてのライブラリ」は Google Workspace 内のメンバーは誰でも閲覧可能にしておくのでよいと思う。(書き換えはさすがに絞った方がよい)

※ 「アプリケーションとしてのライブラリ」は Drive API 時代と違って standalone ライブラリである必要はない。Documents の container-bound にしておくと使い方などのドキュメントを書きやすくてよいかもしれない。

Spreadsheetのcustomfunction作りたいだけなんだけど?

残念ながら customfunction はライブラリ側からは提供できないので、素通しの function を用意しつつライブラリ上の function を呼び直すようなものを用意する。

もっとも、ごくごく単純な function までこういう形で管理しなければいけないわけではないと思う。

/**
 * @customfunction
 * @param {string} param
 * @returns {object}
 */
function customFunction (param) {
  return AwesomeLibrary.realFunction(param)
}

開発環境

前提

ユニットテストが必要なほどの複雑さがないなら、別に VCS + clasp にこだわる必要はない。

上にも示した通り、GAS 自体にバージョンの考え方があるので、安定バージョンをデプロイし、staging 環境相当の container からは開発バージョン(HEAD)を参照する方法を採用してさえあれば、データと Script の不整合で業務が止まってしまうような事態は基本的に避けられるはずである。

npmなどの依存があり、ユニットテストを基本としたい場合

基本的には clasp + gas-local + rollupで恐らく最も手軽なlocalでのGoogle Apps Script開発 (2020-11-12) | あーありがち のまま。

  • export / import をやめ、他のファイルの function を無造作にコールする形で書く(GAS runtime 上ではこれで普通に動作する)
  • npm package を利用したい場合、rollup などで export / import 不要な形で動作する IIFE に変換する
    • ESLint を利用している場合は恐らくほぼ怒られ対象なので適切に ignore する
  • テストは gas-local を利用することで他のファイルの function をそのまま呼べるように
  • 依存する GAS ライブラリがあった場合、そのコードを git submodule などで手元に持ってきて cp して同じ場所に納める
    • clasp push 時には無駄になるので .claspignore で除外する

2020年に gas-local について書いた際には GAS ライブラリに依存するコードのことは考えていなかったが、実際にライブラリベースでの開発を行うことになるとどんどん GAS ライブラリが増えていくことになるので、「GAS ライブラリを手元で呼べないよう」と困ることになった。

2023年夏時点では git submodule くらいしか解決方法を思いついていないが、もしもっとよい方法がありそうなら教えてもらえると嬉しい。

気をつける点

関数はステートレスなのでどこかに情報を保存する必要がある

これは そもそも ではあるんだけど、「アプリケーション本体をライブラリに分離」という考え方を適用しようとすると踏み抜きやすくなるので、改めて。

例えば何らかの UI を加えてその UI から特別な処理を走らせたいとする。

container-bound 側を

function onOpen () {
  AwesomeClient.register(<token>)
}

として、アプリケーションとしてのライブラリ側に

function register (token) {

  ..
  
  addUi()
}

function addUi () {
  const ui = SpreadsheetApp.getUi()
  ui.createMenu('Awesome')
    .addItem('execute', '<Library>.Excute')
    .addToUi()
}

function Execute () {
  // この中で最初に渡ってきた token を受け取る方法がない
  
}

こんな感じのコードになる。

しかし、上の Execute() に直接 token を与える方法はない。

  • UI から function を call する際に引数を渡せない
  • そもそも onOpen() trigger の function の実行と、実際のユーザーの操作、あるいは例えば Spreadsheet の customfunction の call だったとしても、それらは完全にステートレスで別なもの

完全に文脈の異なる別な function の実行でステートレスであり、最初に渡した何かに期待することはできない。そこで

プロパティ サービス  |  Apps Script  |  Google for Developers

などを使って、どうにか最初に与えた token を保存しておく必要がある。

UIの絡むコードを書く方法

Google Apps Script の UI の記述方法はお世辞にもよい方法とは言えないと個人的に考えている。特に UI から何らかの function を呼び出す部分が厳しい。

Google Workspace のカスタム メニュー  |  Apps Script  |  Google for Developers

例えば Spreadsheet に何らかのカスタメニューを追加しようとなった場合、以下のようなコードになるが、

function onOpen () {
  const ui = SpreadsheetApp.getUi();
  // Or DocumentApp or FormApp.
  ui.createMenu('Custom Menu')
    .addItem('First item', 'menuItem1')
    .addToUi();
}

この addItem() の部分で呼び出す関数を指定する際、

Class Menu  |  Apps Script  |  Google for Developers

にある通り、

  • 関数は文字列で名前を指定するしかない
  • 関数に引数を与えることはできない
  • ライブラリ内の関数を呼ぶ場合、その名前を ライブラリ名.関数名 にする必要があるが、実際に組み込むライブラリの名前は組み込み時に決定し、コード内から知る方法がない

といった制約がある。

このエントリではアプリケーションをライブラリにしようと言っているので、ここで難しくなってしまうが、以下のように書くとなんとかなる。

entrypoint ( container-bound etc )

function onOpen () {
  Library.register('Library', *params)
}

Library ( アプリケーション本体 )

function register (libName, *params) {
  addUi(libName)
  ...
}

function addUi (libName) {
  const ui = SpreadsheetApp.getUi();
  // Or DocumentApp or FormApp.
  ui.createMenu('Custom Menu')
    .addItem('First item', `${libName}.menuItem1`)
    .addToUi();
}

やっていることは

  • アプリケーションとしてのライブラリに、自身がどのようなライブラリ名で設定されているのかを教えてやる
  • ライブラリ内で UI を組み立てる際にこの教えてもらったライブラリ名を使って function 名を組み立てる

になる。もちろん UI 部分がすごくシンプルならライブラリを呼び出す側で組み立ててもよいのだが、staging 環境にもこのコードはコピペしなければいけないので、可能な限り避けたい。

補足

claspの制約

  • clasp はプロジェクト内の Script と development 環境の情報を .clasp.json に、認証情報を ~/.clasprc.json に固定で持ってしまう
  • CI/CD から環境(Script ID)を指定して deploy しようにも Script API は GCP のサービスアカウントでは動作せず、clasp での push を development 環境から無理に引き剥がそうとするのはコストとリスクが大きい

Script API がサービスアカウントで利用できないという情報は非常に検索しにくいが、しっかり公式のドキュメントに記載がある。

Introduction  |  Apps Script  |  Google for Developers

Apps Script API はサービス アカウントでは動作しません。

また2021年に結論リポジトリができている。

GitHub - ericanastas/deploy-google-app-script-action

結局のところ clasp で利用する認証情報は Google Workspace のアカウントで取得しなければならず、その更新を手動でやるか CI/CD サービスにアカウントの生の認証情報を渡すか、Google KMS みたいなものを利用して自動化するか、という話になってしまうので、負うリスクが大きく、解決のコストが大きいので、全体的にナシとした方が現実的だと思う。

※ Drive API で管理していた時代でも「固定で持つ」という制約は同じだが、サービスアカウントが利用できたこともあり、まだ工夫の余地はあった。妙に難解にはなってしまうが。

Google Apps Script開発にstaging環境を用意してContinuous Deployment (2017-06-19) | あーありがち

More