2020-11-11 [長年日記]
_ Google Apps Scriptを取り巻く環境のおさらい
claspを利用した開発がそれなりにまともに動くと言ってよい状況になりそうなので、前提の情報をまとめておく。
これまでのApps Scriptの開発はパッチワークだった
以前 Google Apps Script + Node.jsで簡単なツールを作ってみた - あーありがち(2015-10-31) や Google Apps Script開発をもうちょっとモダンにしてみる - あーありがち(2017-05-27) で取り上げた際には以下のような状況だった。
- 実行は Script Editor 上で手作業で行うかそれを実現する Web API を publish しておく
- ログは Script Editor 上で表示するか BetterLog - Google Apps Script Examples などを利用して Spreadsheet 上で確認する
- Drive API を利用して Standalone Script を local <-> remote ( Google ) で同期が可能
- いわゆる local 開発が可能と言われていた部分はここ。この local 開発の成果物を VCS で管理できるという話
- テストの自動化 は 1 と 3 を組み合わせて local 開発しつつ remote でそのまま動かす Home | QUnitGS2 のようなアプローチか、リンク先で挙げたように stub/mock しまくるかのいずれか
- 4 をどこまで頑張るかは置いておくとして GitHub ポチーで merge してリリースすることはできる
- GAS側のバージョン(Herokuのdeployment IDのようなもの)管理は手動
なんとか、あれとこれを組み合わせればできんことはないですよ、というレベル。しょっちゅうやっているならともかく、たまにしかやらないとまぁ忘れてしまうし、手間が掛かる割にそこまでよくなっていないようにも見える。
結局、手元でやりたいことの多くは API アクセスではなくて純粋なロジックの部分だけだったりするのでそこには Node.js 流の開発手法を持ち込みたいけど、いざ実行して確認するためには GAS 固有の手法もちゃんと頭に入れた状態で行う必要があって、やってることはそんなに複雑でもないはずなんだけど妙に頭のリソースを使うものになってしまっていた。
※ また Google Apps Script開発にstaging環境を用意してContinuous Deployment - あーありがち(2017-06-19) で説明したように、production と staging などの情報をいわゆる普通の Web ホスティングのように環境変数で設定するようなことはそんなに簡単じゃないので、逆に CI までその情報を引っ張ってきて CI 上でどうにか頑張って判別して前処理を行うなどの涙ぐましい努力が必要だった。この部分は Cloud Build を利用することで多少マシにはなる。また Cloud Build を前提にするのであれば、環境変数しか利用手段のなかった秘密情報の部分については Secret Manager を利用することでかなり管理しやすくなる。これは Apps Script 周りの話ではないが、Google のインフラ全体が改善されたことで得られたメリットなので補足として挙げておく。参考は あーありがち - GCPのサービスアカウントの鍵を作って持ち出す必要がなくなった
Apps Script APIが登場した
2018年頃の話だったかな。
- 3 new tools to help improve your Apps Script development and management experience | Google Cloud Blog
- google/clasp: 🔗 Command Line Apps Script Projects
これからは clasp やでぇ、試してみたでぇという話で溢れかえった。
実際にはツールとしての clasp 以前の話として API が追加されていて、そっちが重要になってくる。大きく変わった部分は以下の二つ。
- Apps Script API が登場し、Apps Script Dashboard というもので Apps Script の情報を俯瞰できるようになった
- Apps Script Project というリソースを整理し、これを GCP Project と紐づけることで他のアプリと同じように Stackdriver で実行の状況を管理できるようになった
Apps Script API | Google Developers
これまでは Apps Script の情報を取得するのに Drive API しか使えなかったため、Apps Script だけを抜き出した画面などは存在しなかったが、Apps Script API の登場でだいぶ管理しやすくなった。
また GCP Project と紐づけることができるようになったことで、これまで Script Editor 上でしか確認できなかったログの情報、エラーの情報を他の GCP Project と同じように Stackdrive で管理できるようになった。これは非常に大きい。やっとまともにログを扱えるようになったと言っていい。
加えて script の run が API で可能になった。つまり Web API の publish という無理やりな技は不要になった。
そしてclasp
この Apps Script API を利用する CLI として clasp が登場した。これまでバラバラだった
- script の読み書きとそれに必要な credentials の取得
- script の実行
- script の GAS 上でのバージョン管理
- project の deploy ( add-on として配布できるようにするとか )
などがすべて手元の CLI から可能になっており、開発体験は非常に良好になったと言える。
加えて clasp push の際には grant/ts2gas: A function that transpiles TypeScript to Google Apps Script. を利用した TypeScript から Apps Script への変換も自動で行われるようになったので、clasp を利用した Apps Script の開発はモダンな書き味での読み書きを提供してくれる。
※ ついでになってしまうが 2020年に V8 runtime が登場したことにより、Script Editor 上でもそのままモダンな書き味で Apps Script を読み書きできるようになった。Script Editor 自体のシンタックスハイライトなどの対応レベルはまだ改善の余地があって、それは2021年に行われるようだが、ts2gas が ES3 に変換を行うことで Script Editor 上では非常に古めかしく刺々しいコードになってしまうという問題はいずれ解消されるかもしれない。(個人的には ts2gas を使わない方法にしてしまう方がよいと思っているのであまり関係ないが。)
2020-11-12 [長年日記]
_ clasp + gas-local + rollupで恐らく最も手軽なlocalでのGoogle Apps Script開発
気づいていなかったことに気づいちゃったので、よりシンプルなセットアップにしようと頑張った成果が出たので、共有。
というか
絶対にwebpackの設定はやりたくない。絶対にだ。
という気持ちがほとんど。そしてそれは(もちろん内容によるけど)
考え方を逆転させれば叶うぞ
という話。
これまでを整理
まずは前回日記で書いた時点までに分かっている GAS 開発に関する制約や考えたこと、行なったことをまとめるところから始める。
当たり前の開発を行いたい
当たり前の考え方として以下が挙げられると思う。
- 1 ファイルにすべての function を並べるような開発は見通しが悪いのでやりたくない
- すべてのコードをゼロから書くのではなく適切に npm などに依存したい
- テストコードを書き、パーツごとにテストを行いたい
- 手元のコードのバージョンを管理したい
Apps Script上のコードの制限
一方で GAS ならではの制限としては以下のようなものがある。
- CommonJS や AMD などの方法ではコードの共有を行えない
- 実行する function は global に定義されている必要がある
- GAS 固有の「ライブラリ」はあるが、これを npm で管理することはできない
以前採用した解決方法
以前 GAS 開発をモダンにしたいと思った際には情報としてすでに多く出回っていた
require を GAS 上で再現する
という方向で頑張ってみた。
- Google Apps Script + Node.jsで簡単なツールを作ってみた - あーありがち(2015-10-31)
- Google Apps Script開発をもうちょっとモダンにしてみる - あーありがち(2017-05-27)
これはこれでよかったが、この辺りのツールチェインは残念ながら流行り廃りが激しい。
bundler選びをやめて考え方を変えてみよう
じゃあ今改めてやるとして、しかし Browserify はさすがにもう厳しいかなと思い、じゃあ Webpack にするのかなぁ、Webpack でも Parcel でも GAS 化するところには実は必ず fossamagna 氏がいて、どれでも実現はできそうではある。
- fossamagna/gasify: Browserify plugin for Google Apps Script
- fossamagna/gas-webpack-plugin: Webpack plugin for Google Apps Script
- fossamagna/gas-entry-generator
ただいずれにせよこの bundler の設定自体をやりたくないんだよなぁと思っていたところ、一つ新しく気が付いたことがある。それは
Apps Scriptはブラウザ上で実行されているJavaScriptのように他のファイルの内容にアクセスできる
というもので、実は Script Editor 上で異なるタブで開いているコードで定義されている内容は import / export なしにアクセスできるのだ。ということは
Node.js 上で import / export なしに他のファイルの内容にアクセスすることができれば問題解決では?
となる。いや、言ってることが矛盾してるように聞こえるんだけど、条件を限定さえしてしまえば実現できることが分かった。それが
mzagorny/gas-local: Execute and test your google app scripts locally in node.js
だ。
実は上に挙げた Google Apps Script開発をもうちょっとモダンにしてみる - あーありがち(2017-05-27) の中でも先の fossamagna 氏の資料には触れていて、その
Apps Scriptによるより高度な開発プロセス/More Advanced Development Process with Apps Script // Speaker Deck
中ですでに gas-local には触れられているのだが、この時は正直 gas-local の意味がよく分かっていなかった。今回調べていて改めて分かったことは、gas-local を利用して
const gas = require('gas-local') const app = gas.require(<コードの置かれているディレクトリ>, { globalObject }) app.myFunction()
と呼んであげることで、直接 export / import ( require ) していない function を実行することができる。しかもその中で他のコードに書かれている class などに依存していてもまったく問題なく動く。 のだ。これすごくない?
書き方としては
- gas-local で読み込むファイルの置かれているディレクトリを指定してまとめて読み込むことで GAS の実行環境を作る
- できた実行環境上の function を call する
という形になっている。
ということはこのコードさえ書けばあとは Node.js の普通のテスティングフレームワークでテストを実行できるわけだ。
gas-local利用上の注意点ふたつ
注意点の一つは上のコードに書いてある globalObject である。実は gas-local はこれを書いているバージョン 1.3.1 の時点で Node.js の VM という機能を利用していて、VM に共有するオブジェクトを渡してあげないといけないのだ。分かる人はこれだけ書けば十分話が伝わると思う。
要するに例えば
gas.require('./src', { console })
として console オブジェクトを渡してあげないと console を利用した log の出力を gas.require を呼んだ側と共有できないので、どんなに console.log() を書こうが、標準出力には何も表示されない。残念ながらここは GAS の知識ではなく Node.js の知識が必要になってくる。
もう一つは、外からアクセス可能なのは function のみということである。これは GAS の制限と同じと考えれば分かりやすい。例えば
class Klass { }
というクラスが定義されていても上のサンプルコードで言う app.myFunction() と同じ要領で app.Klass と書いても Klass にはアクセスできない。
この解決方法は簡単で、function が必要なら function を作ればよい。
class Klass { constructor(..) { .. } } createKlass(..) { return new Klass(..) }
この createKlass には外から自由にアクセスすることができる。これで Klass クラスのテストを自在に行うことができる。
gas-localを利用したGASのlocal開発
ここまでをおさらい。
- gas-local を利用することで export / import なしに他のファイルの内容にアクセスできる GAS と同じ動作を実現できる
ということが分かった。残る課題は
- GAS 固有のオブジェクトは Node.js 上では動作しないので stub/mock する必要がある
- 外部の依存パッケージは依然として import ( require ) が必要なのでは?
- TypeScript と仲良くしたい
になる。
stub/mockはglobalについてはご自由に
1 については gas-local 自体が mock の機能を持っており、これで解決するのが早そうだ。この mock は default で Logger などが定義されており、また独自の mock の追加も普通にオブジェクトを組み立てていくだけなので簡単にできる。
一方でこの gas-local の用意してくれる mock は単に GAS のコードをなんとか動かせるようにするためには使えるが、結局ただの global object なので、TDD 好きとしては完全にスペック不足と言わざるを得ない。
そこで毎度おなじみの sinon で以下のように自分で定義してもよい。
const gas = require('gas-local') const sinon = require('sinon') const Logger = { log: () => {} } sinon.stub(Logger, 'log').callsFake((message) => console.log(message)) const app = gas.require('./src', { Logger, console }) app.myFunction()
ここでは事前に stub 済みのオブジェクトを渡しているが、share されているので app 初期化後に sinon 側で手を加えてもよい。stub だけだと恩恵が薄いが、sinon なのでちゃんと mock もできる。
外部の依存パッケージはrollupでiifeとして持ち込む
もう一度改めて。
このアプローチは考え方を逆にする。
ここまでで gas-local を利用することで local でも import / export しないで外部のコードを利用することはできた。次にやるのは
build 時に bundle するのではなく、必要な依存パッケージは事前に準備する
になる。
依存パッケージは通常 CommonJS や ES Modules として提供されているが、これをこのまま持ち込んでも結局「import / export しない」を実現できない。しかし
rollupでiifeとしてコピーすれば自分で書いた素のコードと同じ扱いにできる
のだ。
例えば Ruby の Object#dig のような lodash.get というパッケージがあるが、これを利用する場合、以下のように rollup.config.js を書いて rollup -c すればよい。
import commonjs from '@rollup/plugin-commonjs' export default { input: './node_modules/lodash.get/index.js', output: { file: 'src/dig.js', format: 'iife', name: 'dig' }, plugins: [ commonjs() ] }
これで .src/dig.js に dig という名前で必要な function が置かれる。できあがった dig.js をそのまま git add してやる。
複数のパッケージを持ち込みたければ
export default [ {}, {} ]
のようにして複数の設定を書けばよい。
※ ただし、そのままでは独自の名前空間を持ちかつそれに対して plugin システムを持つようなものをうまく動作させるのは難しそうだ。何しろ名前自体は global でも実体としては IIFE として閉じているので。その場合は plugin まで解決し終わった class や function を export するような、rollup を利用した独自のスクリプトを書いてあげる形で解決する必要がありそうだ。
IIFEとは
IIFE - MDN Web Docs Glossary: Definitions of Web-related terms | MDN
Immediately Invoked Function Expression の略で、以下のようなコードのことを言う。jQuery 時代によく見た、あまりよい印象を持てないコードだ。
(function () { statements })();
ただ rollup で生成されたコードは以下のようになるので、これで GAS の機能を利用して global にアクセスできる function を持ち込むことができる。
var dig = (function () { 'use strict'; .. function get(object, path, defaultValue) { var result = object == null ? undefined : baseGet(object, path); return result === undefined ? defaultValue : result; } var lodash_get = get; return lodash_get; }());
TypeScriptと仲良くするには
残念ながら gas-local は TypeScript には対応していない。つまり
const gas = require('gas-local') const app = gas.require('./src')
としても src の中身が TypeScript だったら require してくれないのだ。そこで test を実行する前に一度 tsc でコンパイルしてあげる必要がある。設定は clasp の利用している ts2gas のデフォルトの設定を参考にすればよい。ドキュメントには
{ isolatedModules: true, module: "None", noLib: true, noResolve: true, }
のように書かれているがこれは嘘で、コードの中を見ると
{ experimentalDecorators: true, noImplicitUseStrict: true, target: ScriptTarget.ES3, .. emitDeclarationOnly: false, module: "None" }
しか書かれていなくて、下二つは必ず適用されるということになっている。もろもろ考え合わせると以下のような感じにしておくと快適に開発できそう。
{ "compilerOptions": { "target": "es2015", "lib": ["es2015"], "allowJs": true, "module": "None", "outDir": "./dist/", "types": [ "google-apps-script" ], "typeRoots": [ "./node_modules/@types" ] }, "include": [ "./src/**/*" ] }
src/ に TypeScript を置いて dist/ にコンパイル済みの JavaScript が生成される設定だ。で、先ほどの例で言うと gas-local では
gas.require('./dist')
としてコンパイル済みの JavaScript を読み込むようにすればテストを行うことができる。
もしかしたら必要ないかもしれないが、このコンパイル済みのコードを Google 上でも利用したい場合は .clasp.json に保存されている rootDir も dist に設定しておく。
また、@types/google-apps-script を利用できるようにすると Node.js 向けの Type 定義と合わない、そもそも runtime は Node.js ではないなどの問題が出るので、lib と types で絞っておくのがよいようだ。
まとめ
Webpack などの asset bundler を利用しなくても GAS の local 開発をモダンに行うことはできる。
- GAS の仕様に合わせて import / export を使わずにコードを書いて、テスト時には gas-local と組み合わせるとよい
- gas-local の生成する VM では function しか呼び出せないので、class を定義した場合はインスタンス生成用の function も定義すること
- Google 上ではなく local での動作になるので Google の runtime 上にしか存在しないオブジェクトは stub/mock してあげる必要アリ
- 依存パッケージは rollup などを利用して iife として持ち込むとよい
- gas-local は TypeScript に対応していないので TypeScript を利用したい場合はテスト実行前に自前で tsc で変換する。ディレクトリ丸ごと扱わないといけないので ts-node ではダメ。
以上をいい具合に package.json に定義してやると、結構快適に開発できる。
ここで「rollup で iife にしてリポジトリに持ち込むとかダサくね?」とか「もっと普通に Node.js の流儀で書けるべき」とか「Webpack の設定くらい書けて当然でしょ」みたいな考え方もあるかもしれない。そう考える人はそうすればよいのだが、自分はこのやり方の方がよいと考えている。判断のポイントは
どちらの API の方が寿命が長いか?
である。
Webpack などの bundler は恐らくまだまだ変化する。一方で GAS の API は今回の大型アップデートである V8 runtime の追加が行われても基本的には何も変わらなかった。gas-local の開発も3年前で止まっているが、なんら問題なく利用できる。
rollup は利用しているがこれは後発の bundler の割にかなり安定した開発になっており、今回の目的に利用している API も恐らく変わることはほぼないだろう。変えるメリットがないはずだ。
以上を踏まえて、あえて Webpack などの利用を避けていくのがよい選択なのではないかと考えている。
※ もちろん HTML Service を利用した Web アプリ開発に GAS を利用するのであれば Webpack の利用はよい選択肢になると思うが、最近自分はそもそも GAS を Web アプリとして使うこと自体に否定的な考えを持っている。Workspace add-ons が登場し、そこで利用できる Card Service が登場した。Card Service はデスクトップ向け Web UI にもモバイルアプリにも適用することができる。(残念ながらモバイル向けの Web UI には適用できない。)Google の考える未来の体験としては この Card Service を通じて HTTP で何らかのサービスと通信しながら機能を果たすものになるのだろう。Web アプリは GAE で作って Cloud IAP で制限を掛ける方がよほど素直に作ることができるので、今後は無理に GAS で Web アプリを作るのはやめた方がよさそう。