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 上で再現する

という方向で頑張ってみた。

これはこれでよかったが、この辺りのツールチェインは残念ながら流行り廃りが激しい。

bundler選びをやめて考え方を変えてみよう

じゃあ今改めてやるとして、しかし Browserify はさすがにもう厳しいかなと思い、じゃあ Webpack にするのかなぁ、Webpack でも Parcel でも GAS 化するところには実は必ず fossamagna 氏がいて、どれでも実現はできそうではある。

ただいずれにせよこの 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 と同じ動作を実現できる

ということが分かった。残る課題は

  1. GAS 固有のオブジェクトは Node.js 上では動作しないので stub/mock する必要がある
  2. 外部の依存パッケージは依然として import ( require ) が必要なのでは?
  3. 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として持ち込む

rollup.js

もう一度改めて。

このアプローチは考え方を逆にする。

ここまでで 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 アプリを作るのはやめた方がよさそう。

サンプルリポジトリ

wtnabe/example-clasp-gaslocal-rollup-typescript

About

例によって個人のなんちゃらです