GAS＋GoogleSpreadSheetでサーバレスアプリを作った話

# はじめに
昨年の夏頃に開発したWebアプリについてメモ程度に残しておきます。
国家試験の対策授業をやっていて、学生が用語を全く覚えていないことに気づいたので単語帳みたいなアプリがあるといいな～、なんて思ったので作ってみました。

# 目次
- GASとは
- アプリ概要
- 実装

# GASとは
GoogleAppScriptの各単語の頭文字を取ってGASです。Googleによって開発されたWebアプリ開発のためのスクリプトプラットフォームです。組んだプログラムをWebアプリとして公開することも出来ますし、APIとして公開することも出来ます。使用するにはGoogleアカウントが必要です。JavaScriptが出来るなら扱えるのではと思います。

# アプリ概要
単語帳をベースにした１問１答の簡単なWebアプリです。SpreadSheetをDBとして利用することでDBサーバも作らずに済みます。
<img width="300" alt="gassi.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605371/d0bbaebb-c23f-a066-73b0-b138762958a9.png">
ユーザが単語帳アプリのURLからアプリにアクセスするとGASが分野選択ページを表示します。ユーザは分野を選択することで一問一答の画面に遷移します。その際、GASは選択された分野のスプレッドシートにアクセスしランダムに単語を抽出する処理を行います。
<img width="300" alt="ss.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605371/61994d22-4a09-5ffe-6a3c-f337831b9f4e.png">
新規の単語を追加するときはスプレッドシートに直接書き込みます。正直、新しい画面を作るのが面倒だったんです。

# 実装
###１．分野選択画面
index.html

```HTML
<!DOCTYPE html>
<html lang="ja">
<head>
    <base target="_top">
    <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>
</head>
<body>
    <h1>一問一答</h1>
    <div class="tabs">
        <input id="fe" type="radio" name="tab_item" checked>
        <label class="tab_item" for="fe">FE</label>
        <input id="ap" type="radio" name="tab_item">
        <label class="tab_item" for="ap">AP</label>
        <input id="special" type="radio" name="tab_item">
        <label class="tab_item" for="special">SPECIAL</label>
        <div class="tab_content" id="fe_content">
            <div class="tab_content_description">
                <p class="c-txtsp">
                <ul id="nav">
                    基本情報
                    <li class="border_none">
                    <a href="スプレッドシートURL">分野</a></li>
                    <br>
                    coming soon
                    <li>s小分野</li>
                    <li>小分野</li>
                </ul>
            </div>
        </div>
        <div class="tab_content" id="ap_content">
            <div class="tab_content_description">
                <p class="c-txtsp">
                    <ul id="nav2">
                        応用情報
                        <li class="border_none">
                        <a href="スプレッドシートURL">分野</a></li>
                        <br>
                        coming soon
                        <li>小分野</li>
                        <li>小分野</li>
                    </ul>
            </div>
        </div>
        <div class="tab_content" id="special_content">
            <div class="tab_content_description">
                <p class="c-txtsp">
                    <ul id="nav3">
                        SPECIAL
                        <li class="border_none">
                        <a href="スプレッドシートURL">分野</a></li>
                    </ul>
            </div>
        </div>
        
    </div>
    <footer>
        <p>コピーライトやらなんやら</p>
    </footer>
</body>
</html>
```
js.html

```HTML
<script>
// ボタンがクリックされたとき呼び出されるハンドラ
    function onbtnclick(){

      // html読み取り
      var answer = document.getElementById("answer");
      var btn = document.getElementById("btn");

      if(answer.style.visibility == "hidden"){
        // 答えが表示されていないので、答えを表示しボタンを「次の問題」に
        btn.innerHTML = "次の問題";
        answer.style.visibility = "visible";
      } else {
        // 答えが表示されているので、問題・答えを取得して答えを非表示にしボタンを「答えを見る」に
        new_quiz();
        btn.innerHTML = "答えを見る";
//        answer.style.visibility = "hidden";
      }
    }

    // サーバ側スクリプトnew_quiz_sv()が実行成功したとき呼び出されるハンドラ
    function onSuccess (res){

      // html読み取り
      var quiz = document.getElementById("quiz");
      var answer = document.getElementById("answer");
      var no = document.getElementById("no");
      var answer = document.getElementById("answer");
      
      answer.style.visibility = "hidden";
      // 問題のセット
      quiz.innerHTML = res[0];
      // 回答のセット
      answer.innerHTML = res[1];
      answer.style.visibility = "hidden";
      //番号のセット
      no.innerHTML = res[2];

    }

    // サーバサイド関数を稼働させて、問題・答えを取得する関数
    function new_quiz() {
      google.script.run.withSuccessHandler(onSuccess).new_quiz_sv();
    }
</script>
```
problem.html

```HTML
<html>
  <head>
    <title>Quiz Web App</title>
    <?!= HtmlService.createHtmlOutputFromFile('js').getContent(); ?>
  </head>
  <body>
  <a href="https://script.google.com/macros/s/AKfycbyhQNEc3TMs7WcBurJ5niwvg9LPTkQ3bhwkB4itiMVGOd2mcY0/exec?page=index" style="font-size: 150%">メニュー画面へ</a>
    <table style="width: 100%;" cellspacing="100" cellpadding="0">
      <tbody>
      <td style="vertical-align: top;" align="left">
       <h2>NO.<span id="no" style="font-size: 230%" /></h2>
      </td>
        <tr>
          <td style="vertical-align: top;" align="left">
            <div id="quiz" style="font-size: 300%;"><br></div>
          </td>
        </tr>
        <tr>
          <td style="vertical-align: top;" align="left">
            <button type="button" id="btn" style="width: 100%; height: 200%; color: white; background: rgb(80, 184, 216) none repeat scroll 0% 0%; font-size: 300%;" onclick="onbtnclick()">答えを見る</button>
          </td>
        </tr>
        <tr>
          <td style="vertical-align: top;" align="left">
            <div id="answer" style="font-size: 300%;"></div>
          </td>
        </tr>
      </tbody>
    </table>
    <script>

    // 最初にHTMLが読み込まれたときに問題・答えを設定する
    new_quiz();

    </script>
  </body>
</html>
```
css.html

```css
<style>

h1 {
    color: #55e6f0;/*文字色*/
    text-align: center;
    border-top: solid 3px #55e6f0;/*上線*/
    border-bottom: solid 3px #55e6f0;/*下線*/
  }

/*タブ切り替え全体のスタイル*/
.tabs {
    margin-top: 50px;
    padding-bottom: 10px;
    background-color: #fff;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
    margin: 0 auto;}
  
  /*タブのスタイル*/
  .tab_item {
    width: calc(100%/3);
    height: 50px;
    border-bottom: 3px solid #5ab4bd;
    background-color: #d9d9d9;
    line-height: 50px;
    font-size: 32px;
    text-align: center;
    color: #565656;
    display: block;
    float: left;
    text-align: center;
    font-weight: bold;
    transition: all 0.2s ease;
  }
  .tab_item:hover {
    opacity: 0.75;
  }
  
  /*ラジオボタンを全て消す*/
  input[name="tab_item"] {
    display: none;
  }
  
  /*タブ切り替えの中身のスタイル*/
  .tab_content {
    display: none;
    clear: both;
    overflow-y: scroll;
  }
  
  
  /*選択されているタブのコンテンツのみを表示*/
  #fe:checked ~ #fe_content,
  #ap:checked ~ #ap_content,
  #special:checked ~ #special_content,
  #design:checked ~ #design_content {
    display: block;
  }

  
  /*選択されているタブのスタイルを変える*/
  .tabs input:checked + .tab_item {
    background-color: #5ab4bd;
    color: #fff;
  }

  #nav,#nav2,#nav3 {
      list-style-type: none;
  }

  #nav li,#nav2 li,#nav3 li {
      padding: 5px;
      color: black;
      border-top: dotted 1px #cecccd;
  }

  footer {
      width: 100%;
      text-align: center;
      position: fixed;
  }
</style>
```

### ２．スクリプト
main.gs

```JavaScript
var cnt = 0;

function doGet(e) {
  Logger.log(e);
  const page = e.parameter["page"];
  //画面遷移の分岐
  if(page == null || page == 'index'){
    Logger.log("ページ : " + page);
    app = HtmlService.createTemplateFromFile('index').evaluate();
    app.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
   //　スマホ対応用
//    .addMetaTag('viewport', 'width=device-width, initial-scale=1')
    return app;
  }else if(page == 'problem'){
    const app = HtmlService.createTemplateFromFile('problem').evaluate();
    //グローバル変数の代わりにPropertiesServiceを使う
    PropertiesService.getScriptProperties().setProperty("sheet_name",e.parameter["janre"]);
    PropertiesService.getUserProperties().setProperty("no", "0");
    if(e.parameter["exam"] == 'FE'){
      PropertiesService.getScriptProperties().setProperty("spread_sheet","https://docs.google.com/spreadsheets/d/1zK_mWuB8U_jdC9_tqKf40CrbTLTcEqGeVqlwbZPIN7Y/edit#gid=0");
      Logger.log("ページ : " + page + " 資格 : " + e.parameter["exam"]);
    }else if(e.parameter["exam"] == 'AP'){
      PropertiesService.getScriptProperties().setProperty("spread_sheet","https://docs.google.com/spreadsheets/d/1Oq9bvzkhbJjDIbsZmjuRsxxdgxKoyCJrFb-9hwxpcts/edit#gid=0");
      Logger.log("ページ : " + page + " 資格 : " + e.parameter["exam"]);
    }else{
      PropertiesService.getScriptProperties().setProperty("spread_sheet","https://docs.google.com/spreadsheets/d/1PqL5g4pldyeGsFm-cGxIzjpog3N2CXaFQcyVwolP9gU/edit#gid=0");
      Logger.log("ページ : " + page + " 資格 : " + e.parameter["exam"]);
    }
    app.setTitle(e.parameter["exam"] + "一問一答-" + e.parameter["janre"] + "-").setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
    return app;
  }
}

function new_quiz_sv(){
  // スプレッドシート処理
  var no = PropertiesService.getUserProperties().getProperty("no");
  no = String(Number(no) + 1);
  PropertiesService.getUserProperties().setProperty("no", no);
  const spread_sheet = PropertiesService.getScriptProperties().getProperty("spread_sheet");
  const sheet_name = PropertiesService.getScriptProperties().getProperty("sheet_name");
  Logger.log("スプレッドシートURL : " + spread_sheet);
  Logger.log("シート名 : " + sheet_name);
  const sheet = SpreadsheetApp.openByUrl(spread_sheet).getSheetByName(sheet_name);
  const max_row = sheet.getLastRow() - 1;
  
  var r = Math.floor(Math.random() * max_row) + 2; 

  // 問題文、回答文、出題回数の取得
  var text_quiz   = sheet.getRange("C" + r).getValue().replace(/\n/g,"<br/>");
  var text_answer = sheet.getRange("D" + r).getValue();
  
  return [text_quiz, text_answer, no];
}
```

# まとめ
短期間で作ったにしてはいいできのものが出来ました。ただ、利便性を考えると....
現在はReactとNode.jsを使ってニューバージョンの単語帳アプリ開発に励んでいます！