Node.gs： Node.js スタイルでの Google apps script 開発手法

Google apps script のオリジナル・オンライン開発環境は、小さなプログラムをちゃちゃっと書いては捨てて動かすのにはとても便利ですが、ある程度大きなソフトウェアを開発・運用するとなるとそのままでは難しくなります。

特に Google apps（今は G-Suite ですね）を利用した業務システムの開発・運用をしている人は、業務システムは継続改善がつきものですし、苦労されているのではないかなあ・・・と思います（私もです！）。

そこで本稿では、Google apps scriptアプリケーションを Node.js のように複数モジュールで構成し、テストし、構成管理しながら開発する一連の手法を紹介します。

> このフローを支えるツールもいくつか公開済みなのですが、実際にはまだ完全でない部分もあります。そういう意味では本来この記事はまだ早いのかもしれません。ただ業務の片手間でやっていてちっとも進まないため、まずはアイデアとしてだけでもシェアしたいと思います。

以降の説明の便宜上、この手法をNode.gsと呼ぶことにします。

#要更新項目（Known bugs）
- ~~久しぶりに過去のプロジェクトを引っ張りだして grunt 実行したところ、wgetが通らなくなっていました。<br/>
doget()へ自身のアカウントでアクセスするよう、cookieファイルを食わせていたのですが、その方法が通用しなくなっている模様。なんらかの仕様変更があったかもしれません。原因を調査して対策を講じるか、ダメなら doget ではなく Web API経由に切り替える必要があるかもしれません。なにかご存じの方いらしたら、教えてください m(_ _)m [ 2018/02/07 ]~~<br/>
`wget` の代わりに、`w3m -dump` を使用することで解決しました。 [ 2018/04/06 ]

#0. Overview
開発手法の概要を以下の図に示します。
![開発フロー概要](https://docs.google.com/drawings/d/1CSTuNNiseZ3C6isC9Jv3REP78h1dmTwNp2pJ6HDRbJY/pub?w=640)

特徴は以下のとおりです。

1. ローカル開発<br/>
   ローカル環境で開発し、gas-managerなどのツールを用いて Google Drive へアップロードする。これによりGitなどによるバージョン管理が行える。
1. module system<br/>
   Node.js のモジュールシステムを採用し`require()`でモジュールのインポートを行う。npmで公開されたライブラリの一部はそのまま利用できる。
1. ローカル環境でのユニットテスト<br/>
   ローカル環境でユニットテストを行う。ターゲット環境上のライブラリ（`SpreadsheetApp`など）に対してはmockを用意する。
1. ターゲット環境でのユニットテスト<br/>
   ターゲット環境上で、ローカル環境でのユニットテストと同一コードのまま再テストする。
1. Mock<br/>
   mock 利用により、ローカル環境上で機能テストを行う。
1. CI対応<br/>
   利用ツール類を Headless にすることで、CI環境の構築に対応する。

以下、上記を順に説明します。

#1. ローカル開発
##1.1. Upload
Node.gsでは、Node.jsスタイルで開発した複数ファイルから構成されるプロジェクトをリンクして１ファイルにまとめ、Google Driveへアップロードします。

ローカルでリンクしたスクリプトは、[`gas-manager`](https://www.npmjs.com/package/gas-manager) もしくは [`node-google-apps-script`](https://www.npmjs.com/package/node-google-apps-script) でアップロードすることができます。

qiitaだと、以下の紹介記事が見つかりました。
[gas-managerを使ってGASのソースコードをローカルで管理する](http://qiita.com/zaki-yama/items/b2d137c2539e20c10138)
[GoogleAppsScriptローカル開発用の公式CLI(node-google-apps-script)がついに登場したので試してみる](http://qiita.com/zaki-yama/items/9a301542137febd8876c)

##1.2. Container bound script
Google apps scriptでは、上記のツールを使ってもアップロードできるのはスクリプト単独で動作する Standalone script のみで、スプレッドシートなどに付随する Container bound script をアップロードすることはできません。

そこで、スプレッドシートに付随するスクリプトをブリッジとし、アプリケーション本体をライブラリとして実装します。ライブラリは Standalone script ですから、上記のツールでアップロードすることが可能です。

以下の記事で、少し詳しく書いています。
[Container boundなGASプロジェクトをローカル環境で開発する方法](http://qiita.com/tyskdm/items/ae5afaa93db2baf4463e)

#2. module system
##2.1 module system
Google apps scriptには、モジュールの概念がなく、モジュール単位でのスコープ分離や再利用ができません。ライブラリ機構はありますが、大規模アプリケーションのモジュール分割に向くものではありません。

そこで Node.gs では（名前の通り）Node.js のモジュールシステムを利用できるようにしました。つまり、ソースファイル単位でモジュールを構成し、`require()` でモジュールのインポートを行います。その複数ファイルをツールでリンクし単一ファイルに変換することで、Google apps script 環境上で動作可能にします。

これを実現するために、[node-module-linker](https://www.npmjs.com/package/node-module-linker) をつくりました。npmで公開しています。

他にも、Browserify + gasify という選択肢もあります（というより、こちらのほうがずっとメジャーだと思います）が、私は使ったことがなく、以降のパートで node-module-linker を利用して実現していることを同様に実現できるかどうかはわかりません。

Browserify + gasify については、gasify作者さんによる以下の記事がありました。
[Google Apps Scriptでrequire()してみる](http://qiita.com/fossamagna/items/7c65e249e1e5ecad51ff)

node-module-linker については [npm](https://www.npmjs.com/package/node-module-linker) を見てください。いくつか違いがあります。例えば：

- リンク方法の違い（Browserifyはリンク対象を自動探索、node-module-linker はコマンドラインオプションや package.jsonなどで指定）
- スコープの扱いの違い（GASではエントリー関数がグローバル名前スコープに静的に存在する必要があるため、node-module-linkerはメインモジュールをグローバルスコープに展開する）
- require関数がグローバルオブジェクトを返すことができる（`require('SpreadsheetApp');` が、グローバルオブジェクト
 `SpreadsheetApp` を返す。Node.js上でのユニットテストしやすいようにするための仕様。）

そのうち記事を書くかもしれません。

##2.2. Node.js global objects
node-module-linker でも Browserify + gasify でも、npm に公開されたパッケージのうち環境依存のないものなら動作させることが可能です。

しかし、Node.js が持つグローバルオブジェクトが存在しないと動作しないものもあります。それらを Google apps script 環境上で動作させることはできません。

依存対象のうち小さなものは Google apps script 環境上に代替オブジェクトを用意してやることが可能なはずです。 Browserify ではすでに蓄積があるようです。

node-module-linker 向けには、未だ実験コードのレベルではありますが [codegs-core](https://www.npmjs.com/package/codegs-core) と [gas-console](https://www.npmjs.com/package/gas-console)を作成して使っています。

###codegs-core
Node.js version 0.10.26 の時代のままですが（現在との差分があるのかどうかも未確認ですが）、`global`（の一部のメンバー）, `assert`, `path`, `util` が使えるようになります。

- 以前 node-module-linker を codegs という名前で公開していたために、このような名前になっています。

###gas-console
Node.js で文字入出力を利用するためのオブジェクトが console ですが、文字出力のみ Google apps script環境から利用できるようにしました。

別のブラウザウィンドウで gas-console を開き、そこへ文字出力を行います。こちらも実験コードのレベルで、最大文字数1000文字などの制約があります。


#3. ローカル環境でのユニットテスト
##3.1. ツールと課題
Node.gs は Node.js のモジュールシステムを利用しているので、ローカルでのユニットテストツールに困ることはないと思います。
個人的には、ターゲット環境でのユニットテストと同一のテストを実行する、という要件から [rajah](https://www.npmjs.com/package/rajah) を作り、利用しています（後述）。

課題としては Google apps script が持つライブラリオブジェクト（たとえば SpreadsheetApp）の利用があります。

ユニットテスト時に、グローバル空間にモックオブジェクトを置けば解消できますが、グローバル空間を汚染するのはよい方法ではないでしょう（ゴミが残れば次のテストへ影響を及ぼす可能性がある）。

なんらかの対応が必要になります。

##3.2. node-module-linker の場合
この課題のために、node-module-linker ではグローバルオブジェクトを `require()` 経由で取得できるようにしています。

たとえば SpreadsheetApp を利用するコードを記述する際に、いきなり `var ss = Spreadsheetapp.open();` とせず、SpreadsheetApp オブジェクトを `var ss = require('SpreadsheetApp').open();` のように明示的に取得するようコーディングルールを定めておきます。

ターゲット環境上では無意味なコードになりますが、ローカル環境でのテストに際しては、モックを利用しやすくなります。


#4. ターゲット環境でのユニットテスト
##4.1. ツール
GASのターゲット環境上で動作させようとすると、利用可能なユニットテストツールはだいぶ減ってきます。
[Qunit](https://github.com/simula-innovation/qunit/tree/gas/gas) あたりが有名なのでしょうか（参考： [GoogleAppsScriptでユニットテスト](http://qiita.com/yooo_gooo/items/07c9513a87f6633e40c1)）

##4.2. rajah

いくつかの理由から、独自にツールをつくりました。
ユニットテストフレームワークの [Jasmine](https://jasmine.github.io/) を利用するためのツールで、[rajah](https://www.npmjs.com/package/rajah) です。

rajah をつくった主な理由は

1. xUnit系よりも、Jsmine を使いたかった。
2. Node.js 環境上でテストした後、同一のコードをターゲット環境上で動作させる機能が欲しかった。
3. 最終的に、Headlessでテストの自動実行をしたかった。

です。

１は単に好みの問題ですが、2はもう少しだけ深刻です。Node.js と Google apps script の Javascript エンジンは、同一ではありません。ときどき挙動が異なります。

たとえば正規表現オブジェクトとしてundefinedを指定した（ 例： `"ABCD".match();` ）場合、Node.js ではマッチしますが、GAS ではマッチしません（nullを返す）。

そのためユニットテストの信頼性を向上するために、完全に同一のテストコードをローカル環境とターゲット環境の両方で実行したいと考えました。

rajah は、ローカル環境上で動作するシンプルな Jasmine spec runner であると同時に、内部的に node-module-linker を呼び出してターゲット上で動作するテストコードを出力することができます。

アップロード先はユニットテスト専用にプロジェクトを用意し、そのプロジェクトを予めWebサービスとして公開しておきます。このプロジェクトへテストコードをアップロードし WebサービスのURLを叩くと、`doGet(e)` 関数経由でテストの実行および結果の表示を行います。Headless で実行＆結果確認を行いたい場合、`w3m -dump`コマンドなどを使うことで実現可能です。

rajah の使用方法などの情報は今のところ [Readme](https://www.npmjs.com/package/rajah) にしか情報がないので、そのうち記事を書くかもしれません。

#5. Mock
本章の内容は、まだ実験中です。
以下の内容が実現できるように node-module-linker や rajah を設計してはいますが、実使用での検証が取れている状態には至っていません。

##5.1. mockery
Jasmine は Spy機能を持っているため rajah を使用した通常のユニットテストでは mock のための専用ツールは不要です。

しかし、他のユニットテストフレームワークを使う場合、あるいは関数単体テストではなく機能テストを行いたい場合に、mock を使いたくなるケースが生じます。

node-module-linker がリンク時に埋め込む `require()` は、Node.js のそれと外部仕様だけでなく内部関数レベルでもある程度同じ挙動をします。そのため、mockery など require の内部関数を上書きして機能するツールも、ローカル環境はもちろん、ターゲット環境でも動作可能です。

- ターゲット環境での mockery 利用についてはまだ検証が不十分で、充分な確認が取れているわけではありません。

##5.2. ライブラリオブジェクト
mockery などの module 差し替え機能によって、`require("SpreadsheetApp");` で取得するオブジェクト全体を差し替えることができます。

機能が部分的過ぎるので公開していませんが、 .json ファイルをスプレッドシートファイルに見立ててアクセスするモジュールをつくりました。これを mockery でテスト環境に注入することにより、ローカル環境での機能テストに利用しています。

#6. CI対応
Node.gs で採用しているツールはすべて Headless での実行が可能です。
実際に Grunt を利用して、ターゲット環境でのユニットテストまでを実行・結果確認を行っていますが、一箇所工夫が必要です。

ターゲット環境でのユニットテストでは、結果をWebページとして取得します。 このWebページを解釈して結果を取得するコードが必要です。
今のところ専用ツール化しておらず、解釈のためのコードは Gruntfile に直書きしています。

いずれ共通コードにまとめ、ツール化したいと考えています。

#7. まとめ
Google apps scriptアプリケーションを Node.js スタイルで開発する手法を、かなり簡単ですが、紹介しました。

この手法を使うことでローカルで版歴管理を行いながら継続的開発・運用を行うことができるようになり、継続的な開発もだいぶ楽になるのではないかと思います。

とはいえ「ひとり情シス」なので（しかも本業ではないボランティアなので）、開発工数も知恵も限られています。

みなさんの意見やアイデアもシェアしてもらえたら、あるいはこの手法をみなさんなりに拡張してもらえたら、とても嬉しいです。