GASの落とし穴と、claspで "WebApp" をモダンで快適に開発するために色々やった知見まとめ

# 🏖️ はじめに

GAS(Google Apps Script)では、フロントエンドを含むWebアプリケーションを作成することができるのですが、巷にはちゃんとその開発についてまとまった情報が少なく、きちんとしたものを開発しようとすると、意外とその特殊な仕様のせいで快適に開発するのが難しかったりします。

また、claspという、CLIでGASをTypeScriptにより開発/デプロイ管理できるツールが存在しますが、こちらもフロントエンド開発の快適性を担保するようにはできておらず、色々と工夫が必要となります。

今回は、これらの課題を整理し、それぞれどのように解決していくかを自身の経験や工夫に基づいて解説していきたいと思います。

GAS初心者でもなるべくわかりやすいように記載しているつもりですが、分かりづらいところや、それ間違ってるよ！とか、もっと簡単に解決できるよ！等の指摘がありましたらコメントお願いします🙇‍♂️

※ GASの設定でChrome V8ランタイムを有効にした状態での開発を前提としています。

# ⛳ この記事のゴール

下記に挙げる課題を一つずつ解決し、快適かつ無理なくWebアプリ開発ができるようになることです。

# 🤔 GAS(clasp)でのWebApp開発における様々な課題とWill

- GAS上では拡張子を `.gs`と`.html`しか認めておらず、そのままではjsやCSSをhtml側から外部参照できない。
- かといって、例えばCSSのファイル名を `hoge.css.html` としてしまうと開発時のシンタックスハイライトやlintがおかしなことになったりして、純粋なcssとして扱うのが難しくなる(jsも同じことが起こる)。
- フロントエンド側のjavascriptは、claspのサポート対象外？なのか、TypeScriptのコンパイルに対応していない。せっかくclasp使って開発しているのでできればこちらもTypeScriptで書いて、デプロイ時にコンパイルできるようにしたい
- コンパイル前のコード等、デプロイに含めたくないファイルを除外したい
- きれいなディレクトリ構成でコードを管理したい
- GAS上でのファイルの読み込み順を制御したい
- 開発環境と本番環境を分けて管理し、気軽に開発環境にデプロイしてテストできるようにしたい
- 上記環境ごとのデプロイを簡単に、かつ間違えずにできるようにしたい
- コードをGitHub等で安全に管理したい

# 🗒️ GASのファイルの扱いについての基礎知識

GAS上では、かなり特殊なファイル管理がされている為、この特性を把握しておかないと開発を初めて間もないうちから無事死亡できます。

## GASで扱える拡張子は「.gs」と「.html」だけ

この2つ以外のファイルは実質作れません。実際にGASのWebエディタ上で見てみるとわかるのですが、ファイルの新規作成が「スクリプト」と「HTML」のどちらかしか選べず、そこにCSSなどの入り込む余地はありません。

唯一 `appscript.json` だけは例外みたいです。(プロジェクト設定のところで `「appsscript.json」マニフェスト ファイルをエディタで表示する` のチェックが入ってると見えるようになります )

これらを解決する為に、基本的にはスクリプト認定されない `.html` を基準にしてCSS等の外部ファイルを作成することになります。

## GASでは「.gs」ファイルの全てのファイルでスコープが共有される

これは例を見たほうが早いかもしれません。下記のようなファイル構成だとします。

```tsx
main.gs
scope-test.gs
```

各実装は下記の通りとします。

```tsx
# main.gs
var foo=1

function myFunction() {
  console.log(foo)
}
```

```tsx
# scope-test.gs
var foo=2
```

この状態で、main.gsの `myFunction()`を実行すると、 `foo` の値は `2` となってしまいます。

これは、GASでは実行前にすべてのファイルを1つにつなぎ合わせてから実行する為、グローバルスコープが先に処理された後で `myFunction()` が呼び出されてしまった為です。そのため、グローバルスコープでの名前の付け方には気を使う必要があります。

claspでTypeScriptを利用して安全に `import` 等の仕組みを利用できる為、ちゃんとクラス化しておけばこのような問題は自然に回避できることが多いのですが、それでもたまに問題になることはあるかもしれません。

また、この仕様のせいで下記の問題も発生します。

## GASでのファイル名とファイルの優先順位について

せっかく開発するならきれいなディレクトリ/ファイル構成で開発したいものです。

しかしGASには基本的に「ディレクトリ」という概念は存在せず、あくまでそのパスも含めて「ファイル名」としているようです。

つまり、 `./foo/bar/baz.gs` ファイルは `foo/bar/baz.gs` という「ファイル名」として解釈されるということです。

これの何が問題かというと、例えばローカル環境では下記のようなファイル構成だったとします。

```tsx
src/frontend/index.html
src/frontend/front.ts
src/backend/main.ts
src/config.ts
```

ここで `main.ts` が上の階層にある `config.ts` を `import` により参照することは普通に可能です。

しかし、これをそのままclaspでコンパイルしてGASにpushした場合、GAS上ではなるべくアルファベット順で読み込もうとする為、以下の順序でファイルが読み込まれてしまいます。

```tsx
# GASではディレクトリの概念が無いのですべてファイル名とみなす
backend/main.gs
config.gs
frontend/front.gs
frontend/index.html
```

claspはTypeScriptをGASに変換する際に、ファイルが1つにまとめられるという仕様を利用し、import文はすべて **なかったことにします** 。

そのため、 `main.ts` 内で `config.ts` が本来exportしてるはずの `Config` オブジェクトが参照できず、参照しようとした行でエラーとなってしまいます。

またこれはクラスとサブクラスの関係でも同じようなことが起きます。

これを回避する為、GAS上ではファイルを任意の順番に並べ替える機能がWebエディタ上にありますが、同じことをclaspでするにはどうしたらいいのかを後述します。

# 🎨 GASでのCSSの書き方と外部化の仕方

何もしないと、上記 `.html` しか対応できないので、CSSを書く場合にはHTML内に直接 `<style>` タグを使って書いていくしかないですが、長くなってくるとhtml, css双方の視認性が悪くなりがちで、分割したくなったり、シンタックスハイライトや補完を効かせたりもしたくなってきます。

そこで、ファイルとしては分割しつつ、上手くHTML内にインポートする仕組みを作ります。

ここでは最終的に `frontend/style.css` というファイルをインポートできるようにしますが、まずは上記の仕様の関係上 `frontend/style.css.html` というファイル名にしておきましょう(CSSの中身は何でも良いです)。

## インポート用のコードをgs側に書く

まず基本的に、WebApp用のhtmlを読み込ませて表示させる為には、 `doGet()` という関数がメインルーチン側に存在しているはずです(なければ作ります)。

先程のようにフロントエンド用にディレクトリを切っていた場合、こんな感じになっていると思います。

```tsx
function doGet() {
    const htmlOutput = HtmlService.createTemplateFromFile("frontend/index").evaluate();
    htmlOutput.setTitle("Page Title");
    return htmlOutput;
}
```

この `HtmlService.createTemplateFromFile("frontend/index")` で `frontend/index.html`ファイルを読み込み、 `HtmlTemplate` オブジェクトにしています(引数での拡張子は省略すること)。

その後 `.evaluate()` でHTML内のスクリプトレットタグ( `<? 〜 ?>`とか )を処理して、htmlとして出力できる形式である `HtmlOutput` オブジェクトに変換し、それを `return` することで、ブラウザからこの `frontend/index.html`ファイルを見れるようにしています。

重要なのは、この最終的な形である `HtmlOutput` というオブジェクトで、これをreturnすることで、ブラウザ上にhtmlとして表示できるというわけです。

これを利用して以下のような関数を、 `doGet()` のすぐ下に定義します(claspを利用している為、TypeScriptで記述しています)。

```tsx
function include(filename: string) {
  const script = HtmlService.createHtmlOutputFromFile(filename)
    .getContent()
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">");
  return script;
}
```

これは、ファイル名を受け取ることで、そのファイルを読み込み、HTMLとしてサニタイズして返すだけの関数です。

ここで重要なのは、 `HtmlService.createHtmlOutputFromFile(filename)` という関数で、これを用いることで、ファイルパス(=ファイル名)からファイルを読み込み、その内容を先程の `HtmlOutput` オブジェクトとして扱うことができるようになる点です。

このオブジェクトに対して `getContent()`を行うことで、単なるStringとして返却することができるので、これをそのままフロントへ返すようなコードを書くことで、動的に外部jsやCSSをインポートすることが可能となるわけです。

ただし、`HtmlOutput` オブジェクトとなった時点で `<` や `>` がエスケープされてしまうらしく、これはreturnする前にこちら側で置換しておく必要があります。よってこの時一緒にそれらを `replace` してあります。

### 参考

- [Class HtmlTemplate | Apps Script | Google Developers](https://developers.google.com/apps-script/reference/html/html-template)
- [Class HtmlOutput | Apps Script | Google Developers](https://developers.google.com/apps-script/reference/html/html-output)

## HTML側からインポートする為の関数を呼び出す

上記ができたら、あとはHTML側で必要な箇所にてファイル名を渡してあげれば良いことになります。

 `index.html` 内で次のような**強制出力スクリプトレットタグ( `<?!= 〜 ?>`形式)**を用いて、先程作成した `include` 関数を用いて、CSSを `<style> </style>` 内へ展開してみましょう。

```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <style>
      <?!= include('frontend/style.css'); ?>
    </style>
  </head>
〜 以下略 〜
```

この強制出力スクリプトレットタグ([Force-printing scriptlets](https://developers.google.com/apps-script/guides/html/templates#force-printing_scriptlets))を用いることで、それ以上のエスケープ処理をすること無く強制的にinclude関数から返却された文字列を出力することができます💡

この時、include関数に渡すファイル名は、 `.html` を省略するようにします( `createHtmlOutputFromFile` 関数がそういう仕様の為)。

## .css.htmlを.cssとして編集できるようにする

せっかく外部化できても、 `.css.html` という拡張子のままでは扱いづらいことこの上ないので、ローカル編集時は `.css` でも、GASにpushする時だけ `.css.html` に直してpushができれば良いことになります。

これについては後述の「deploy用スクリプト」の項でやってしまおうと思います。なので、実際に上記でインポートできる仕組みがわかったら、一旦これは `frontnend/style.css` に直してしまって構いません。

# 🧑‍💻 フロントエンド用のtsコードの書き方と個別コンパイルの仕方

上記CSSと同じように、フロントエンド用のjavascriptもTypeScriptとして普通に書きたいわけですが、claspはpushする時にその処理の一環としてtsコードをコンパイルしてgsファイルにするという手法を取るおかげで、コンパイルしたあとにフロントエンドから読めるファイル形式( `.html`)に拡張子をリネームしたりすることはできないようになっています。

そこで、デプロイ直前にフロントエンドのコードは事前にコンパイルを通してあげて、拡張子を `.html` に変えてあげる必要があるので、こちらも後述のデプロイスクリプト内で行うのですが、大抵はバックエンド側からのレスポンスの型情報など、他のコードと結びついていたりするため、単体でこのファイルのみをコンパイルするのが難しいことが多いです。

そのため、僕はclaspが内部で利用している [ts2gas](https://github.com/grant/ts2gas) というライブラリを使った簡単なコンバート用のスクリプトを書いています。これを利用すると、その辺りの依存に引っかかる事なくコンパイルができます。

まずはこの [ts2gas](https://github.com/grant/ts2gas) のインストールなのですが、現時点(2021-11-19)だと最新版では問題があり、単体でインストールすると上手くいかない為、バージョンを固定してインストールしています。

```tsx
npm i -D ts2gas@3.6.2
```

その後、下記のようなスクリプトを書いて、プロジェクトルートに置きます。

```tsx
// front-ts-converter.ts

import * as ts2gas from 'ts2gas';
import * as fs from 'fs';

const PATH = './src/frontend';

function transpile() {
  const fileList = fs.readdirSync(PATH);
  fileList.forEach(f => {
    if (f.slice(-3) !== '.ts') { return; }
    console.log(ts2gas(fs.readFileSync(`${PATH}/${f}`, 'utf8')));
  });
}

transpile();
```

ファイルとしてフロントエンドのコード(`.ts`)を読み込ませたあと、それらに対してts2gasを通して標準出力に返します。これでコンパイル済みコードが標準出力に出るので、デプロイスクリプト内でこれらをリダイレクトして1つのファイルにしたものを `.html` に拡張子を変更して所定のディレクトリ内に配置してあげれば良いことになります(もちろんこのスクリプト内からデプロイ用のファイルを書き出してしまっても良いと思います)。

## HTML側からJavaScriptをインポート

CSSの際と同様に、**強制出力スクリプトレットタグ( `<?!= 〜 ?>`形式)**を用いて、先程作成した `include` 関数を用いて、JavaScriptをページの最下部にある `<script> </script>` 内へ展開してみましょう。

```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <style>
      <?!= include('frontend/style.css'); ?>
    </style>
  </head>
  <body>
    〜 以下略 〜
    <script>
      <?!= include('front/front.js'); ?>
    </script>
  </body>
</html>
```

これでフロントエンド用のTypeScriptも無事JavaScriptとして利用することができるようになりました！👏

# 🏌️ deploy用のスクリプト

ここまでの工程で、各種フロントエンド用のコードを外部化する為の準備は整いました。

ということで、実際にこれらのファイル名などを置換した結果をデプロイできるように、デプロイ用のスクリプトを作ります。

と、その前に今回の `.clasp.json` は下記のような設定になっています。

```json
{
  "scriptId":"YOUR_SCRIPT_ID",
  "rootDir":"./dist"
}
```

`rootDir` に `./dist` を指定している為、 `./src` 配下のスクリプトがコンパイルされた際には、こちらの `./dist` ディレクトリに結果が格納され、このディレクトリ配下のファイルがデプロイされることとなります。

それを踏まえた上で、下記のようなデプロイ用のスクリプトを作成しました(とりあえず今回は環境を考えずにシンプルにHEADに対してpushするだけのものです)。

```bash
#!/bin/bash
set -eu

# ① 実行前のクリーンアップと、appscript.jsonの配置
rm -rf ./dist/.*.ts ./dist/*
cp -rf ./src/* ./dist/
cp appsscript.json ./dist

# ② コンバートスクリプトを実行し、 ./dist 内の所定のパスへ .html の拡張子でまとめて配置。
npx ts-node front-ts-converter.ts > ./dist/frontend/front.js.html

# ③ findでかかったCSSファイルを./dist/frontend 内に移動し、ファイル名末尾に .html を付与する
# 参考: https://neos21.net/blog/2021/06/02-01.html
find ./src/frontend/*.css -type f -maxdepth 1 \
  | sed "p;s/\(.*\)src\(\/.*\)$/\1dist\2.html/" \
  | xargs -n2 cp
rm ./dist/frontend/*.ts || :

npx clasp push
```

③の工程がちょっと難しいですが、[参考リンク](https://neos21.net/blog/2021/06/02-01.html) を読むとやっていることが詳しくわかります、興味のある方はそちらをどうぞ！

そして実際のデプロイ時には `./deploy.sh` を叩くようにすればOKとなります。

これで、フロントエンドのファイルをcss/tsのままで開発し、それらを自然にGAS上で扱えるようになりました！👏

# 📊 ファイル名とファイルの優先順位について with clasp

さて、無事デプロイが出来るようにはなったのですが、前述したように意外とハマるのがこのファイルの読み込み優先順位です。依存性のあるファイルや、親クラスのファイルなどを先に読み込ませておかないと実行時に失敗してしまいます。

しかしこれについては、 `.clasp.json` に下記の設定( `filePushOrder` )を追記することで解決することができます。

```json
{
  "scriptId":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "rootDir":"./dist",
  "filePushOrder": [
    "dist/frontend/C.ts",
    "dist/frontend/B.ts",
    "dist/backend/A.ts"
  ]
}
```

このように記載することで、全てのファイルよりも優先的に `filePushOrder` オプション内に書かれたものから順番にpushすることができ、これによりGAS内での読み込み順をこちらで意図した通りに制御することができます。

# 🌎 dev環境とprod環境とで分ける

上記デプロイスクリプトは、ひとまずHEADへのpushのみを行い、あくまで最新バージョンを更新するという形になっていますが、実際、これを社内や外部に公開したいとなった場合には、しっかりとリリースバージョンを定義して、ユーザのアクセス先は原則としてそちらの方に向けてあげたいところです。

### GASのデプロイ管理の仕組み

GASではデプロイバージョンを管理することができ、特定の `deployment ID` に対して、バージョンを個別に指定することができます。

GASのWebAppのURLは、この `deployment ID` に対して紐付いているため、今後Production用として管理したい`deployment ID`を決めて、そのURLをユーザに公開するようにすることで

1. 単純にclasp pushする時はHEAD Deploymentが更新されていき、そこでテストする
2. リリースする時は production 用の`deployment ID`を指定して `clasp deploy` する。

具体的には下記のコマンドでデプロイすると良い。

```bash
# -i ... deployment IDを指定
# -d ... Descriptionを指定、実質ほぼこれがDeployment名となるので付けておくと便利
clasp deploy -i $DEPLOYMENT_ID -d Production
```

### 環境によって挙動を変える(env.tsを作成)

ローカル環境や開発環境(HEAD)だと動いてほしくないけど、Production環境でのみ動いて欲しいコードだったり、環境に応じて必要なCredentialsを書き換えたり選んだりすることはよくあると思います。

一応GASにはプロパティサービスという環境変数に似たような仕組みはあるのですが、それを利用するにしても、今動いてるコードが何環境として動いているのかという情報は必要になると思います。

そこでこちらも、 `src/env.ts` のようなtsファイルを用意しておき(`.gitignore`指定しておき、雛形だけを `src/env.ts.example` の用に管理しておくと便利)、デプロイスクリプトでpush前にこのenv.tsの中をsedで置換してからpushするような仕組みを作ることで実現できます。

env.ts内はこんな感じで指定しておきます。

```tsx
const Env = {
   isLocal: typeof process !== "undefined" && process.argv[2] === "local",
   isProd: false, // deploy時に自動で変更されるのでここはfalse固定でOK
};

export default Env;
```

あとは各ソースコード上で、この `env.ts` をimportすることで、 `Env.isProd` の状態によって、挙動を変えることができるようになります。

また、僕の場合は加えて `isLocal` という変数も用意しておき、これをコマンドライン引数が渡された時のみ `true` になるような実装にしておくことで、ローカル環境での実行かどうかも判別できるようにしてあります。こうすることでいちいちデプロイしなくてもGASの機能を使わないようなコードだけを実際に実行したりテストしたりと、色々な恩恵を受けることができます。

### デプロイスクリプトを更新して開発/Production両対応にする

それでは上記2点を考慮し、先程のデプロイスクリプトを改変し、オプション指定でどちらにもデプロイできるように書き換えてみます。

※ わかりやすくする為、追記した箇所以外をインデントしてます。

```bash
		#!/bin/bash
		set -eu
		
# 引数を初期化(デフォルトでは`dev`となるが、`prod`と明示的に渡すことでProduction環境へデプロイする)
DEPLOYMENT_TARGET=${1:-'dev'}

# 本番環境のdeploymentId
GAS_PROD_DEPLOYMENT_ID='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

		# ① 実行前のクリーンアップと、appscript.jsonの配置
		rm -rf ./dist/.*.ts ./dist/*
		cp -rf ./src/* ./dist/
		cp appsscript.json ./dist
		
		# ② コンバートスクリプトを実行し、 ./dist 内の所定のパスへ .html の拡張子で配置。
		npx ts-node front-ts-converter.ts > ./dist/frontend/front.js.html
		
		# ③ findでかかったCSSファイルを./dist/frontend 内に移動し、ファイル名末尾に .html を付与する
		# 参考: https://neos21.net/blog/2021/06/02-01.html
		find ./src/frontend/*.css -type f -maxdepth 1 \
		  | sed "p;s/\(.*\)src\(\/.*\)$/\1dist\2.html/" \
		  | xargs -n2 cp
		rm ./dist/frontend/*.ts || :

# デプロイ先環境が本番の場合のみ
# push前に env.ts 内のフラグを置換して切り替える
if [ $DEPLOYMENT_TARGET = 'prod' ]; then
  # ./dist/env.ts のisProdフラグ値をtrueに更新する
  perl -pi -e 's/(isProd: ).*/$1true,/' dist/env.ts
fi
	
		npx clasp push
		
# デプロイ先環境が本番の場合のみ clasp deploy する
# ※ デプロイ先が dev の場合(つまりGAS上のHead Deploymentsにあたる場合)、
#    clasp push のみ実行し、 clasp deploy は行わない。
if [ $DEPLOYMENT_TARGET = 'prod' ]; then
  npx clasp deploy -i $GAS_PROD_DEPLOYMENT_ID -d Production
fi
```

こうすることで、実行時に `./deploy.sh`  とすれば開発環境に、 `./deploy.sh prod` と実行することでProduction環境へ実行することが可能になりました。

# 🧑‍🍳 ややこしいのでシンプルに使えるようmake化する

さて色々仕組みを作ってはきたのですが、これらをチーム開発したりしていると、このようなローカルルール的な使い方をいちいち覚えてもらうのは大変です。

そこで便利な我らがmakeです。

下記のような簡単な `Makefile` を作成します。

```makefile
init:
	npm i
	cat src/env.ts || cp src/env.ts.example src/env.ts
	npm install -g @google/clasp
	clasp login

deploy:
	./deploy.sh

prod-deploy:
	./deploy.sh prod
	@make open-prod

open-code:
	@clasp open

open-dev:
	@clasp open --webapp --deploymentId $$(clasp deployments | grep @HEAD | perl -pe 's/^- ([^ ]+).*/$$1/g')

open-prod:
	@clasp open --webapp --deploymentId $$(clasp deployments | grep Production | perl -pe 's/^- ([^ ]+).*/$$1/g')
```

実際に叩くコマンドを例に、上記を一つずつ解説していきます。

- `make init`
    - `git clone` して最初に叩いてもらうコマンドです。単に `make` としても動きます。
    - npmのインストールや、 `env.ts` がなかった場合には、 `env.ts.example` からコピーしてくれたりといった、GASをclaspで開発するために必要な工程をやってくれるものです。
- `make deploy`
    - `deploy.sh` を実行し、開発環境(HEAD)にコードをpushします。
- `make prod-deploy`
    - `./deploy.sh prod` を実行し、Production環境へのデプロイを行います。コマンドを分けることで誤爆しにくくなります。
    - また、この時後処理で `make open-prod` も呼ぶようにしている為、Production環境にデプロイしたあとは必ずブラウザで本番環境のページが開きます。本番にデプロイした際には挙動確認をしましょうという意図もあったりします。
- `make open-code` , `make open-dev` , `make open-prod`
    - それぞれWebブラウザからコードを開く、開発環境のWebページを開く、本番環境のWebページを開く　となっています。
    - `clasp deploy` の際に `-d` オプションで説明(`Production`)を付けているため、 `clasp deployments` で動的に一覧から正しいものを抽出できるようになっています。

`Makefile` を作っておくことで、このプロジェクトの `README.md` なんかがずっと楽に作れるようになります(上記を説明すればだいたいオッケーなので)。

# 🏁 最後に

なんかできるだけ詳しく書いてるうちに長くなってしまいましたが、この仕組みを一度作っておくと、別プロジェクトでも同じような方法で使いまわせたりするので、GASのWebAppをよく開発する身としては個人的には気に入ってます。

めんどくさいGASの仕様を意識することなくTypeScriptで気持ちよく開発していきたいですね！✨