19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

🏖️ はじめに

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」ファイルの全てのファイルでスコープが共有される

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

main.gs
scope-test.gs

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

# main.gs
var foo=1

function myFunction() {
  console.log(foo)
}
# 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 という「ファイル名」として解釈されるということです。

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

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

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

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

# 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() という関数がメインルーチン側に存在しているはずです(なければ作ります)。

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

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で記述しています)。

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 してあります。

参考

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

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

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

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

この強制出力スクリプトレットタグ(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 というライブラリを使った簡単なコンバート用のスクリプトを書いています。これを利用すると、その辺りの依存に引っかかる事なくコンパイルができます。

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

npm i -D ts2gas@3.6.2

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

// 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> 内へ展開してみましょう。

<!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 は下記のような設定になっています。

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

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

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

#!/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

③の工程がちょっと難しいですが、参考リンク を読むとやっていることが詳しくわかります、興味のある方はそちらをどうぞ!

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

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

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

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

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

{
  "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 する。

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

# -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内はこんな感じで指定しておきます。

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点を考慮し、先程のデプロイスクリプトを改変し、オプション指定でどちらにもデプロイできるように書き換えてみます。

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

		#!/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 を作成します。

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で気持ちよく開発していきたいですね!✨

19
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?