GASをイケイケな環境で開発したい！claspを使ってみる

# TL; DR

- [clasp](https://github.com/google/clasp) + [esbuild](https://esbuild.github.io/) で、GASのアプリケーションを...
    - バージョン管理できる！(GitHubでコード管理)
    - 型安全にできる！（TypeScript使用）
    - ユニットテストできる！（Jest使用）
- ~~が、つらみも感じたのでご利用は計画的に~~

作ったもの: [SwitchBot 温湿度計プラス](www.amazon.co.jp/dp/B09PYKJ6CS)で取得した温度、湿度をスプレッドシートに記録

https://github.com/Syuparn/meter-recorder

# はじめに

Google Apps Script (GAS) を使うと、ちょっとした処理を手軽に自動化できます。エディタがWeb上にあるので、サクッと書いてすぐに動かせます。

https://workspace.google.co.jp/intl/ja/products/apps-script/

そのままでも使いやすいのですが、もっと**イケイケな開発環境を作りたい**！（欲張り）

- ローカルで、好きなエディタで開発したい！
- Gitで管理したい/GitHubに公開したい！
- TypeScriptで型安全にしたい！
- ディレクトリ構成を整理して、クラスを疎結合にしたい！
- ユニットテストを書きたい！

というわけで、本記事ではGASプロジェクトで上記の願いを叶えるためにやったことを紹介します。

（TypeScriptもGASも初心者なので、「こうした方が良いよ」等ありましたらコメントいただけるとありがたいです！）

# バージョン

|名前|バージョン|
|-|-|
|Node.js|v16.16.0|
|npm|8.11.0|
|TypeScript|4.7.4|
|clasp|2.4.1|
|Jest|28.1.4|
|esbuild|0.14.49|
|esbuild-gas-plugin|0.4.0|
|talkback|3.0.1|

# 作ったもの

まずは今回作ったものです。

https://github.com/Syuparn/meter-recorder

[SwitchBot温湿度計プラス](www.amazon.co.jp/dp/B09PYKJ6CS)のデータを定期取得し、スプレッドシートに記録しています（導入手順は上記Readme参照）。

![meter_recorder.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/f13ba93d-7c1f-ee09-bc2d-121bee4a526f.png)

（~~買った次の週にプライムデーでセールになった :sob: 良い子のみんなは事前に確認しようね！~~）


実装には、以下の記事を参考にさせていただきました。~~当記事はn番煎じです~~

https://qiita.com/TMUS_SH/items/9197c2c78faab5fd6164

温湿度計自体の解説は上記記事がとても分かりやすかったので、本記事では割愛します。

# claspを使ったローカル開発環境の構築

「はじめに」で上げた種々の~~煩悩~~希望は、claspを使うことで解決しました。

https://github.com/google/clasp

claspは、GASのソースコードをローカル環境で開発できるツールです。GASのプロジェクトをnpmで開発できるので、Git管理もユニットテストもし放題です。

コードを変更したら

```bash
$ clasp push
```

するだけで、gsファイルにトランスパイルしてデプロイしてくれます(TypeScriptも対応)。

参考にさせていただいた記事

https://dev.classmethod.jp/articles/ts2gas/

https://dev.classmethod.jp/articles/vscode-clasp-setting/


また、`.clasp.json` ファイルにpush先のスクリプトIDが記載されているので、スクリプトIDを差し替えるだけで別環境にもデプロイ可能です。

（逆にいうと、スクリプトIDが分かると第三者にデプロイされてしまうので、`.clasp.json` は `.gitignore` に入れておくことをおススメします）

# ディレクトリ構成を整理して、クラスを疎結合にする

続いて、ディレクトリ構成です。変更に強いアプリケーションにするため、~~オーバーキル気味に~~ DDD + クリーンアーキテクチャで設計しました[^1]。

```:ディレクトリ構成
- src
  - domain
  - usecase
  - adapter
  - infrastructure
  - di
  - config
```

しかし、**GASはimport文を認識できません**。gsファイルに変換される際にコメントアウトされてしまいます。

![import.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/47f02f1d-6853-f25a-f316-6d434372a710.png)


公式では3通りの解決策が紹介されており、今回は一番ソースコードの変更が少なくて済む **ビルドして１ファイルにまとめる** 方法を採用しました。

https://github.com/google/clasp/blob/master/docs/typescript.md#modules-exports-and-imports

## ビルドして1ファイルにまとめる

ビルドツールには esbuild を使用しています。爆速で感動 :rocket:

https://esbuild.github.io/

全ソースコードをビルドし、`./dist/main.js` にまとめます。`clasp push` ではこの1ファイルだけデプロイすることで、importができない問題を解消しています。

:warning: デプロイ対象が変わったので、`appscript.json` も `./dist` 配下に移動してください

## ハマった点 1: ハンドラ関数が呼べなくなってしまう

GASでは、gsファイルのトップレベルで定義されている関数を実行できます。
しかし、**esbuildの生成物は即時関数に包まれてしまうので、関数が見えなくなってしまいます**。そこで、ビルドにesbuild-gas-pluginを使って関数が見えるようにしました。

https://github.com/mahaker/esbuild-gas-plugin

### esbuild-gas-pluginの使い方

まず、ビルドスクリプトを `build.js` に記載します。

```js:build.js
const { GasPlugin } = require('esbuild-gas-plugin');

require('esbuild').build({
  entryPoints: ['src/main.ts'],
  bundle: true,
  outfile: 'dist/main.js',
  plugins: [GasPlugin]
}).catch(() => process.exit(1))
```

```json:package.json
{
  "scripts": {
    "build": "node build.js"
  },
  // ...
}
```

次に、呼び出したい関数を `global` のプロパティに持たせます。

```ts:src/main.ts
// exportしていても、ビルド後は即時関数に包まれるので見えなくなってしまう
export function handler() {
  // ...
}

// そこで、handler関数をglobalに持たせて参照可能にする
declare let global: any;
global.handler = handler;
```

ビルドすると、`handler` がトップレベルに定義され呼び出し可能になります。

```ts:dist/main.js
// ビルド済みファイル

var global = this;
// コードは全て即時関数に包まれるので参照不可
(() => {
  // もちろん、本来handlerも参照不可能
  function handler() {
    // ...
  }

  // だが、globalに代入した（上記）のでthis.handlerとしてトップレベルに見えるようになった！
  global.handler = handler;
})();
```

参考にさせていただいた記事

https://tech.smarthr.jp/entry/2022/07/01/132343

## ハマった点 2: DIコンテナが動かない

[tsyringe](https://github.com/microsoft/tsyringe) を使う予定でしたが、GAS上でうまく動作しなかったのでやむなく手書きしました。

明示的に呼び出すと、DIコンテナクラスのimportが汚くなり、実装クラスもexportしないといけないのが辛いです...([ソースコード](https://github.com/Syuparn/meter-recorder/blob/main/src/di/container.ts))

とはいえ、モジュールを多用すると生成ファイルが肥大化してデバッグしづらくなるので手書きでちょうどよかったかもしれません。

# テスト作成

テストにはJestを使用しています。

https://jestjs.io/ja/

**ローカルでTDDできる**ので心安らかに開発できます。~~デプロイしてぶっつけ本番は調査がつらい~~
クリーンアーキテクチャで各クラスは抽象に依存しているので、ダミークラスに差し替えればユニットテストも簡単に作れます。

VSCodeを使っているなら、vscode-jestのインストールもしておきましょう。**ファイル保存のたびにテストが流れてくれます**！

https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest

## SwitchBot APIリクエストのユニットテスト

コーナーケースも検証できるよう、実際のHTTPリクエスト、レスポンスを使用しています。とはいえ、テストのたびに本家のAPIを叩くのは迷惑なので、talkbackのプロキシサーバーを利用しています。

https://www.npmjs.com/package/talkback

APIリクエストの際にtalkbackを経由すると、リクエスト、レスポンスを記録してファイル（テープ）に保存します。2回目以降は、talkbackが**記録したテープをもとにレスポンスを返し、本家にはリクエストが飛びません**。
結果、APIに負荷をかけず、かつネットワークの安定性に左右されずユニットテストが実行できます。

詳細は以下の記事をご覧ください。

https://qiita.com/Syuparn/items/85788096b57c5528daff

## ハマった点1: APIリクエスト
### node-fetchとJestの相性

Node.jsでは fetch APIが使えなかった[^2]ので、[node-fetch](https://www.npmjs.com/package/node-fetch)を導入しました。

しかし、最新の3系とJestの相性が悪くimportができませんでした。
```
 SyntaxError: Cannot use import statement outside a module

    > 1 | import fetch from 'node-fetch';
        | ^
      2 |
      3 | test('dummy', () => {
      4 |   fetch('httpbin.org/ip').then((res) => {
```

2系にダウングレードしたところ上手く動作しました。
モジュールの形式が違うのが原因のようですが、私のTypeScript力が足りず対処できませんでした...

https://stackoverflow.com/questions/69383514/node-fetch-3-0-0-and-jest-gives-syntaxerror-cannot-use-import-statement-outside

### そもそもGASではfetch APIが使えない

さらに根本的な問題として、GASでは `fetch` ではなく `UrlFetchApp.fetch` を使う必要がありました。一方 `UrlFetchApp.fetch` はローカルで動作しないので

- テスト： `fetch`
- 本番： `UrlFetchApp.fetch`

を差し替えて使っています。

微妙にシグネチャも違うので、`Fetcher` というインターフェースでラップして、`UrlFetchApp.fetch`の腐敗防止層を設けています。（かなり無理やりな実装）

https://github.com/Syuparn/meter-recorder/blob/main/src/infrastructure/fetcher.ts

TypeScriptでは関数の戻り値が共変なので救われました...[^3]

## ハマった点2: スプレッドシート処理のテストができない

スプレッドシートに出力する実装は、GASのクラスなのでローカルではテストできません。しかしモック化するとテストしたい対象自体なくなってしまうので、やむなくテストなしで実装しました。

テストがないので、処理をなるべく減らしています。

スプレッドシートは1ファイルのみ（スクリプトプロパティでIDを決め打ち指定）で、実行のたびに行を追記しています。

https://github.com/Syuparn/meter-recorder/blob/main/src/adapter/presenter.ts

スプレッドシートには1000万セルまで作成可能なので、5分に一度実行（3列あるので3セル追記）しても**あふれるのに30年かかります**。シートを分ける必要は特になさそうです。

https://forest.watch.impress.co.jp/docs/news/1395480.html#:~:text=Web%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9-,%E3%80%8CGoogle%20%E3%82%B9%E3%83%97%E3%83%AC%E3%83%83%E3%83%89%E3%82%B7%E3%83%BC%E3%83%88%E3%80%8D%E3%81%8C%E3%82%BB%E3%83%AB%E6%95%B0%E3%81%AE%E4%B8%8A%E9%99%90%E3%82%92500,%E3%81%8B%E3%82%891%2C000%E4%B8%87%E3%81%AB%E6%8B%A1%E5%A4%A7&text=Google%E3%81%AF3%E6%9C%8814,%E3%81%9F%E3%81%93%E3%81%A8%E3%82%92%E7%99%BA%E8%A1%A8%E3%81%97%E3%81%9F%E3%80%82

# おわりに

以上、claspを使ってGASの開発環境を整える方法の紹介でした。

管理しやすくなった部分もある一方、JavaScriptとGASの実装差分に躓いて消化不良の部分もありました...（スプレッドシート追記のユニットテストが書けない等）

いつかJavaScript/TypeScript力を上げてリベンジしたいです。

ここまでお読みいただきありがとうございました！

[^1]: ドメインモデルは改善の余地がありそうです...（温度、湿度を、タイムスタンプをIDとする集約[Environment](https://github.com/Syuparn/meter-recorder/blob/main/src/domain/environment.ts#L1)にまとめました（[公式サイト](https://us.switch-bot.com/products/switchbot-meter)の `SwitchBot Meter monitors the environment` から採用）が、値オブジェクトの方が適切な気もしています）
[^2]: v16系現在。v18系から使えるようになる？
[^3]: GoやJavaのように不変だったら、fetch APIもラップして戻り値を変換する必要がありました。