ステップバイステップで試す！Apps Scriptとスプレッドシートで作る無料ウェブアプリ

この記事は[Google Apps Script Advent Calendar 2023](https://qiita.com/advent-calendar/2023/google-apps-script) 1日目の記事です。

![A whimsical and playful illustration in a flat, 2D style, featuring two anthropomorphic characters, 'Spreadsheet-kun' and 'Apps Script-kun', holding hands. 'Spreadsheet-kun' is represented as a cheerful character with elements of a spreadsheet, while 'Apps Script-kun' is depicted as a lively character with features symbolizing coding and script elements. The background is simple and pastel-colored, emphasizing the friendly and cooperative relationship between the two characters. This image is suitable for a blog post about web app development using Google Apps Script and spreadsheets, in a 16:9 aspect ratio.
](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/ed8d34d3-9cc2-ce5f-98da-e83dabb44e02.png)

Google Apps Scriptを使うと無料でウェブアプリを作成できます。無料のため速度やデータ量に制約がありますが[^1]、500名規模の社内システムには十分対応できます。

[^1]: Apps ScriptをはじめとするGoogleサービスでの制約はこちらにまとまっています。 https://developers.google.com/apps-script/guides/services/quotas?hl=ja

無料で作る鍵は、Google Sheets（Googleスプレッドシート）をデータベースとして利用することです。データの更新や削除は実装が難しいのですが、新規追加と読み込みはスプレッドシートでも安定して実行できます。

ヘッダー画像はApps Scriptくんとスプレッドシートくんが手をつないでいる様子です。二人をうまく連携させて、ウェブアプリを作ってみましょう。

# アクセスログを記録・表示するウェブアプリ

この記事で作るのは、ウェブアプリにアクセスした人のメールアドレスをスプレッドシートに記録して、Webブラウザーに表示する、という簡単なウェブアプリです。3つのステップに分けてApps Scriptで実装していきます。Step1でHello WorldとWebブラウザーに表示し、Step2でスプレッドシートに日付とアクセスした人のメールアドレスを記録し、Step3でアクセスログをWebブラウザーに表示します。最終的にはWebブラウザーには次のようにアクセスログを表示します。

- アクセスログ
  - 2023/12/01 09:00:01: user1@example.com
  - 2023/12/01 09:10:02: user2@example.com
  - ...

## 対象読者

この記事は、JavaScriptやApps Scriptの経験者を対象としています。開発にはGoogleアカウントとWebブラウザーが必要です。

関係する公式ドキュメントはこちらです[^2]。

https://developers.google.com/apps-script/guides/web?hl=ja

[^2]: 同じ階層（ユーザーインターフェース）にある、次のドキュメントも有益です。ウェブアプリがうまく動かなくて困ったときには目を通してみてください。(1) シングルページアプリケーション（SPA）のウェブアプリを作る時に必要になる[`google.script.run`の説明](https://developers.google.com/apps-script/guides/html/communication?hl=ja)。フロントエンドのJavaScriptから、バックエンドのApps Scriptで記述された関数を呼び出すのに使います。(2) マルチページアプリケーション（MPA）のウェブアプリを作る時に必要になる[`HtmlService`によるテンプレートエンジンの説明](https://developers.google.com/apps-script/guides/html/templates?hl=ja)。バックエンドのApps Scriptから使います。(3) ソースコードの整理術など[ベストプラクティスの紹介](https://developers.google.com/apps-script/guides/html/best-practices?hl=ja)。 

では早速開発していきましょう！

# Step1: 最も簡単なウェブアプリの作成 - Hello World

最初はHello Worldとだけ表示する簡単なウェブアプリを公開してみます。

```mermaid
sequenceDiagram
    Webブラウザー ->> Apps Script: HTTP GET / doGet()
    Apps Script -->> Webブラウザー: 200 OK (Index.html)

```

スプレッドシートの準備: 初めに、スプレッドシートを作成しましょう。このステップでは「Hello World」の表示には使用しませんが、後のステップで必要です。[Google Driveにアクセスし](https://drive.google.com/drive/my-drive)、新しいスプレッドシートを作成して、「アクセス履歴」と名付けます。

次に、スプレッドシートの上部メニューから「拡張機能 > Apps Script」を選んで、スクリプトエディターを表示します。スプレッドシートを開いたばかりだと、メニューからApps Scriptを選んでもエディターが別タブとして表示されないことがあります。その時は、もう一度、メニューから選び直してください。スクリプトエディターのプロジェクト名は「AccessLog」としておきます。

ここから、バックエンドのApps Scriptと、フロントエンドのHTML + JavaScriptを記述していきます。まずは、フロントエンドから始めましょう。スクリプトエディターの左ペイン「ファイル」でプラスアイコン（＋）を押して、HTMLを選択します。さらに、ファイル名に「Index」を入力します。ここで間違いやすいのですが「Index.html」ではなく、「Index」と入力します。もし「Index.html」と入力した場合は、ファイル名が「Index.html.html」となるので、リネームして「Index」とだけ入力します。

すると、スクリプトエディターにHTMLの雛形が表示されます。まずは単純に「Hello World」とだけ表示させてみましょう。下のコードをIndex.htmlに上書きして保存します。

```html:Index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
  </head>
  <body>
    Hello World 
  </body>
</html>
```

次にバックエンドの準備をします。WebブラウザーからHTTP GETでアクセスがあると、Index.htmlを返信するコードです。スクリプトエディターで「コード.gs」を選ぶと

```javascript:Code.gs
function myFunction() {
  
}
```

と記述されていますが、`function myFunction()`は削除して、下のコードで上書きして保存します。

```javascript:Code.gs
function doGet() {
  return HtmlService.createHtmlOutputFromFile('Index');
}
```

これでソースコードの準備は終了です。ここで書いたコードが正しいかどうか試してみましょう。デプロイ作業をするとウェブアプリにURLが与えられてWebブラウザーからアクセスできるようになります。ただ、デプロイ作業に関して注意事項があるので先に説明させてください。

## デプロイの注意事項

Apps Scriptのデプロイは多機能であり、注意が必要です。ここでは主要なポイントを紹介します。

### 「新しいデプロイ」は一度だけ。次からは「デプロイを管理」から

これからやる作業は「新しいデプロイ」から作業します。この作業でURLが入手できます。ただ、新しいデプロイをするたびに、URLは新しくなります。URLが変わってしまうと混乱の元になるため、新しいデプロイは一度だけにしましょう。次のデプロイは（新しいデプロイではなく）「デプロイを管理」を選択し、右上のエンピツアイコンを押して、バージョンを「新しいバージョン」に変更します。こうすることで、最初に入手したのと同じURLでデプロイをし直すことができます。

<img width="520" alt="デプロイを管理で新しいバージョンを指定" src="https://storage.googleapis.com/zenn-user-upload/6a19e05b516a-20231123.png"/>

### デプロイし直さなくてもテスト用URLで動作を確認できる

新しいデプロイを一度実行すると、URLを入手できます。このURLの末尾は`/exec`になっています。デプロイを実行した後にソースコードを変更しても、変更分は反映されません。

このURLは一般利用者用のURLです。開発者は別の便利なURLを使えます。「デプロイをテスト」を選択すると表示される、末尾が`/dev`のURLです。こちらのテスト用URLにアクセスすれば、最新のソースコードでの動作を（デプロイし直すことなく）試せます。

なお、テスト用URLにアクセスできるのは、Apps Scriptの編集権限を持っている開発者だけです。一般利用者向けには、デプロイし直す必要があります。デプロイを管理から新しいバージョンでデプロイをして、利用者が同じURLで最新の機能を使えるようにしてください。

### ウェブアプリの実行権限とアクセス権限

ウェブアプリをデプロイする時に「次のユーザーとして実行」と「アクセスできるユーザー」の2つを指定します。まずは次のユーザーとして実行から見ていきます。

- 次のユーザーとして実行
  - **自分**: 誰がウェブアプリにアクセスしたか関係なく、そのウェブアプリを開発者本人として実行します。
  - **ウェブアプリケーションにアクセスしているユーザー**: そのウェブアプリはウェブアプリにアクセスした人として実行します。

「次のユーザーとして実行」はウェブアプリが別のリソース、例えば、スプレッドシートにアクセスする時に影響が出ます。そのスプレッドシートに開発者だけがアクセスできる場合で、「ウェブアプリケーションにアクセスしているユーザー」に設定すると、開発者以外がウェブアプリにアクセスした時にリソースが使えずエラーになります。

- アクセスできるユーザー
  - **自分のみ**: 開発者だけがそのウェブアプリにアクセスできます。
  - **Googleアカウントを持つ全員**: ウェブアプリにアクセスするとGoogleアカウントによるログインが求められます。
  - **全員**: 誰でもそのウェブアプリにアクセスできます。
  
誰がそのウェブアプリにアクセスした人のメールアドレスは、Apps Scriptで`Session.getActiveUser().getEmail()`を呼び出すことで分かります（[Session.getActiveUser()](https://developers.google.com/apps-script/reference/base/session#getactiveuser)）。しかし、メールアドレスを取得するには次の条件を満たさなければいけません。

- ウェブアプリでメールアドレスを取得できる条件
  - 次のユーザーとして実行: **ウェブアプリケーションにアクセスしているユーザー**
  - アクセスできるユーザー: **Googleアカウントを持つ全員**
  - ウェブアプリから利用するリソース: **全員が編集権限を持つ**（ウェブアプリの利用者がそのリソースの編集権限を持つ）

なお、開発者本人がアクセスする場合に限っては、開発者本人のメールアドレスが取得できます。自分で動かすときにはうまく動くのですが、他の人に使ってもらうと動作しないことがよくあるので注意してください。

## 初めてのデプロイ

注意事項が長くなってしまってすみません。それではデプロイをしてみましょう。最初のデプロイなのでスクリプトエディターの右上「デプロイ」ボタンから「新しいデプロイ」を選びます。次のユーザーとして実行は「ウェブアプリケーションにアクセスしているユーザー」、アクセスできるユーザーは「Googleアカウントを持つ全員」にします。

<img width="520" alt="新しいデプロイからウェブアプリケーションにアクセスしているユーザーとして実行、Googleアカウントを持つ全員がアクセスするよう設定する画面" src="https://storage.googleapis.com/zenn-user-upload/54d4d3846d20-20231124.png" />

デプロイが完了すると、ウェブアプリのURLが表示されます。URLをクリックすると、Hello Worldと表示されるはずです。URLの末尾は`/exec`になっています。

<img width="520" alt="デプロイが成功してURLが表示されている画面" src="https://storage.googleapis.com/zenn-user-upload/88fb65d0c034-20231123.png" />

テスト用URLも確認しましょう。スクリプトエディターの右上「デプロイ」ボタンから「デプロイをテスト」を選びます。すると、テスト用URLが表示されます。テスト用URLの末尾は`/dev`になっています。こちらをクリックしても、先程と同様にHello Worldと表示されるはずです。

では、スクリプトエディターでソースコードを書き換えて、`/exec`と`/dev`のそれぞれのURLにアクセスしてみましょう。Hello World を、Hello World!!!に書き換えてみます。

```html:Index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
  </head>
  <body>
    Hello World!!!
  </body>
</html>
```

「デプロイを管理」で表示される末尾が`/exec`のURLにアクセスしても、Hello Worldのままですが、「デプロイをテスト」で表示される末尾が`/dev`のテスト用URLにアクセスすると、Hello World!!!が表示されます。テスト用URLなら、ソースコードを書き換えて保存するだけで（デプロイ作業をしなくても）ウェブアプリの動作が変わっていることが確認できます。

さて、ウェブアプリの動作スピードはいかがでしたか？URLにアクセスをしてから、少し時間がかかって、Hello Worldが表示されたと思います。HTMLを表示するだけの一番単純なウェブアプリでもこの動作スピードです。もっとキビキビ動くウェブアプリを作るのであれば、Apps Script以外の手段を使いましょう。

# Step2: スプレッドシートとの連携 - アクセスログ追記

誰がこのウェブアプリにアクセスしたかをスプレッドシートに追記していきます。

```mermaid
sequenceDiagram
    Webブラウザー ->> Apps Script: HTTP GET / doGet()
    Apps Script ->> Apps Script: メールアドレスを取得
    Apps Script ->> スプレッドシート: 日付とメールアドレスを追記
    Apps Script -->> Webブラウザー: 200 OK (Index.html)

```

## Sheet.appendRow()で追記

ウェブアプリからスプレッドシートの末尾に情報を追記するには、[Sheet.appendRow()](https://developers.google.com/apps-script/reference/spreadsheet/sheet#appendrowrowcontents)を使います。今回は、アクセスのあった時刻と、アクセスした人のメールアドレスを追記します。

まず、開発者以外のアクセスログも記録できるよう、他の人がスプレッドシートを編集できるよう権限を付与しておきます。スプレッドシート右上の「共有」ボタンから操作します。

<img width="520" alt="他の人がスプレッドシートを編集できるよう権限を付与する画面" src="https://storage.googleapis.com/zenn-user-upload/df43fe49418e-20231124.png" />

バックエンドのApps Scriptを次のように変更します。

```javascript:Code.gs
function doGet() {
  // スプレッドシートにアクセス時刻とアクセスした人のメールアドレスを追記
  const email = Session.getActiveUser().getEmail();
  const row = [ new Date(), email ];
  SpreadsheetApp.getActive().getSheets()[0].appendRow(row);

  return HtmlService.createHtmlOutputFromFile('Index');
}
```

スクリプトエディターで保存をしたら、テスト用URLにアクセスしてみます。すると、`Authorization is required to perform that action.`と表示されるかもしれません。これは、新しく保存したコードを実行するのに、スプレッドシートを操作する権限が新しく必要になったためです。

## ウェブアプリに与える権限を確認する

スクリプトエディター上で「実行」ボタンを押し、doGet()を実行します。すると、ウェブアプリが開発者の情報にアクセスして良いかを確認する長いプロセスが始まります。下図のような警告が出るので内容を良く確認し、先に進んでください。

<img width="520" alt="権限を確認する画面" src="https://storage.googleapis.com/zenn-user-upload/eb25b59b2952-20231123.png" />

<img width="520" alt="アカウントを選択する画面" src="https://storage.googleapis.com/zenn-user-upload/a56cde884f55-20231123.png" />

<img width="520" alt="非常に危険なことを警告する画面" src="https://storage.googleapis.com/zenn-user-upload/48243ffa8e0d-20231123.png" />

<img width="520" alt="非常に危険なことを警告する画面から先に進む" src="https://storage.googleapis.com/zenn-user-upload/033516e7b8b1-20231123.png" />

<img width="520" alt="スプレッドシートの操作権限を付与する画面" src="https://storage.googleapis.com/zenn-user-upload/5a254c8f142e-20231123.png" />

スクリプトエディターの実行ログには何も表示されず成功したはずです。スプレッドシートを見てみると、doGet()を実行した時刻と、自分のメールアドレスが表示されています。このログはウェブアプリを閲覧したときのログではなく、スクリプトエディターからdoGet()を実行したときのログです。

先程はエラーが出ていたテスト用URLにもう一度アクセスしてみましょう。テスト用URLにアクセスするたびに、スプレッドシートにアクセスした時刻と自分のメールアドレスが表示されるようになります。

友人に頼んで、友人のアクセスログがスプレッドシートに記録されるかを確認するのも良いでしょう。友人が初めてウェブアプリにアクセスするときは次の画面が表示され、ウェブアプリが友人の情報にアクセスしてもよいかを確認する長いプロセスが始まります。

<img width="520" alt="友人の情報にウェブアプリがアクセスしてよいかを確認する画面" src="https://storage.googleapis.com/zenn-user-upload/370fbd152366-20231124.png" />

## トラブルシューティング

スプレッドシートに日付だけ追記され、友人のメールアドレスが追記されない場合は、デプロイの設定が間違っているかもしれません。次のユーザーとして実行が「ウェブアプリケーションにアクセスしているユーザー」に、アクセスできるユーザーは「Googleアカウントを持つ全員」になっているかを確認してください。

友人がウェブアプリにアクセスした時に次のエラーが表示される場合は、スプレッドシートに編集権限を付与しているかを確認してください。`Exception: You do not have permission to access the requested document.`

# Step3: フロントエンドからの非同期なデータ読み込み - アクセスログ表示

最後に、アクセスログをウェブブラウザーに表示してみます。体感速度を向上させるために、ウェブブラウザーに「アクスログを取得中...」と表示してから、google.script.runを使ってバックエンドのfetchAccessLog()を呼び出してアクセスログを取得し、フロントエンドに表示します。この方法は公式ドキュメントの[ベストプラクティス](https://developers.google.com/apps-script/guides/html/best-practices?hl=ja#load_data_asynchronously_not_in_templates)でも紹介されています。

```mermaid
sequenceDiagram
    Webブラウザー ->> Apps Script: HTTP GET / doGet()
    Apps Script ->> Apps Script: メールアドレスを取得
    Apps Script ->> スプレッドシート: 日付とメールアドレスを追記
    Apps Script -->> Webブラウザー: 200 OK (Index.html)
    Webブラウザー ->> Apps Script: google.script.run() / fetchAccessLog()
    Apps Script ->> スプレッドシート: データを取得
    スプレッドシート -->> Apps Script: 日付とメールアドレス
    Apps Script -->> Webブラウザー: 日付とメールアドレス (JSON)

```

## Sheet.getDataRange().getValues()で取得

まずバックエンドにfetchAccessLog()を実装していきます。Apps Scriptでスプレッドシートの情報をまとめて取得するには、Sheet.getDataRange()と、Range.getValues()を組み合わせて使います。さらに、JSON形式に変換して、日付dateと、メールアドレスemailを渡すことにします。

先程のコードをリファクタリングして、getSheet_()を抽出した結果、バックエンドのコード.gsは次のようになります。Apps Scriptでは、`メソッド名_()`のようにメソッド名の後にアンダーバーを記述するとプライベートメソッドとして定義され、スクリプトエディター上で実行することも、google.script.runから呼び出すこともできなくなります。

```javascript:Code.gs
function getSheet_() {
  return SpreadsheetApp.getActive().getSheets()[0];
}

function doGet() {
  // スプレッドシートにアクセス時刻とアクセスした人のメールアドレスを追記
  const email = Session.getActiveUser().getEmail();
  const row = [ new Date(), email ];
  getSheet_().appendRow(row);

  return HtmlService.createHtmlOutputFromFile('Index');
}

function fetchAccessLog() {
  return getSheet_().getDataRange().getValues().map(row => {
    const [date, email] = row;
    // このままではgoogle.script.runで動作しない
    return { date, email };
  });
}

function testFethAccessLog() {
  console.log(fetchAccessLog());
}
```

スクリプトエディター上でtestFetchAccessLog()を実行すると、実行ログにJSON形式でアクセスログが正しく出力されます。なので大丈夫かと思いきや、このままではフロントエンドからgoogle.script.runを使ってfetchAccessLog()を呼び出しても正しく動作しません。その理由と対策は後ほど解説します。

次に、フロントエンドの実装に移りましょう。Index.htmlはフロントエンドのDOMツリーを構築すると、google.script.runを使ってバックエンドのfetchAccessLog()を呼び出します。

```html:Index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
  </head>
  <body>
    アクセスログ
    <ul id="access-log">
      <li>アクセスログ取得中...
    </ul>
  <script>
document.addEventListener('DOMContentLoaded', () => {
  google.script.run.withSuccessHandler((data) => {
    console.log(data);
    const li = [];
    for (let log of data) {
      li.push(`<li>${log.date}: ${log.email}`);
    }
    document.querySelector('#access-log').innerHTML = li.join('\n');
  }).withFailureHandler(error => alert(error)).fetchAccessLog();
});
  </script>
  </body>
</html>
```

ここまで実装できたら、テスト用URLにアクセスしてウェブアプリの動作を確認してみましょう。しかし、Webブラウザーの画面は「アクセスログ取得中...」ままです。Webブラウザーのデベロッパーツールでコンソールを確認すると、`Uncaught TypeError: data is not iterable`というエラーが表示されています。コンソールログにfetchAccessLog()の返り値も表示していますが`null`になっています。

## google.script.run経由で渡せないデータ型

実は、google.script.runには制限があり、Date型をフロントエンドに返すことができません。

[パラメーターと戻り値](https://developers.google.com/apps-script/guides/html/communication?hl=ja#parameters_and_return_values
):
> `form`のほかに、`Date`、`Function`、DOM要素、またはその他の禁止されたタイプ（オブジェクトまたは配列内の禁止タイプを含む）を渡そうとすると、リクエストは失敗します。循環参照を作成するオブジェクトも失敗し、配列内の未定義フィールドは null になります。

そこで、バックエンドのfetchAccessLog()を次のように修正します。[Utilities.formatDate()](https://developers.google.com/apps-script/reference/utilities/utilities#formatDate(Date,String,String))を使って、Date型を文字列に変換しています。なお時間に相当する`HH`は必ず大文字にしてください。間違えて小文字にすると（24時間ではなく）12時間表記となり変換ミスになります。

```javascript:Code.gs
function fetchAccessLog() {
  return getSheet_().getDataRange().getValues().map(row => {
    const [date, email] = row;
    // このままではgoogle.script.runで動作しない
    // return { date, email };
    return { 
      date: Utilities.formatDate(date, 'JST', 'yyyy/MM/dd HH:mm:ss'),
      email
    };
  });
}
```

では再びテスト用URLにアクセスしてウェブアプリの動作を確認してみましょう。今度は「アクセスログを取得中...」の文字列が、実際のアクセスログに書き換わったことが確認できるはずです。

# まとめ

この記事では次のことをご紹介しました。

- Apps Scriptを使用して、基本的なウェブアプリを作成する。
  - バックエンドのApps Scriptで`doGet()`を実装して、フロントエンドのHTMLを返却する。
  - フロントエンドのHTMLで`google.script.run`を使い、バックエンドのApps Scriptを呼び出してデータを連携する。
- Apps Scriptで作成したウェブアプリをデプロイし、実際にアクセス可能な状態にする。
  - 新しいデプロイは1回だけ。その後はデプロイを管理から新しいバージョンでデプロイすることで、URLを維持する。
  - 末尾が`/dev`のテスト用URLを使えば、デプロイし直さなくてもウェブアプリの動作を確認できる。
  - ウェブアプリ利用者のメールアドレスを取得する条件は「ウェブアプリケーションにアクセスしているユーザー」「Googleアカウントを持つ全員」「リソースは全員が編集権限を持つ」
- ウェブアプリからGoogleスプレッドシートにデータを追加し、それを読み取る。
  - 追記には`Sheet.appendRow()`を使う。
  - 読み取りには`Sheet.getDataRange().getValues()`を使う。 
- フロントエンドからバックエンドを呼び出し、データを取得して表示する。
  - google.script.runでDate型は取得できない。`Utilities.formatDate()`で文字列に変換する。

スプレッドシートをデータベース代わりにしてデータの追記と読み取りができるウェブアプリを作ってみました。ここで紹介した仕組みをベースにして、より複雑なウェブアプリを作ることができます。ぜひご活用ください。