Google Apps Script(GAS)で自作アプリを公開する

GASで簡単なWebアプリケーションを作って公開してみます!

作るもの

酒好きの友人と幹事当番制で月1で飲み会を開催していて、開催も20回を超えていろいろ美味しいお店も見つかったので、その飲み会の履歴を記録するページを作ってみようと思います。以下の様な機能を実装したい。

  • 飲み会履歴一覧機能
  • 飲み会履歴登録機能
  • 飲み会履歴編集機能

1つの飲み会履歴は以下のような属性を持つ想定。

  • 開催年
  • 開催月
  • 開催場所
  • 店名
  • URL
  • 参加者

検討事項

今回アプリを作成するに当たって検討が必要だった点は以下。

DBをどうするか

最初はスプレッドシートをDBに採用代わりにしようと思っていました。もともと飲み会の記録はスプレッドシートにメモ程度にとっていたので。

けど仮実装してみると、やはり機能が貧弱すぎる・・・。セルの範囲を処理のたびに指定するのがだるいし、DBみたいにSQLでソートや検索もできないし。

とはいえ、もともと手軽かつ無料でウェブアプリケーションが作れるというところに魅力を感じてGASを触ってみたので、Google Cloud SQLみたいな有料のサービスは使いたくない・・・。

どうしようと思っていたところで、Fusion Tablesというナイスなサービスを見つけました。

https://support.google.com/fusiontables/answer/2571232?hl=en

2017年7月現在試験運用ということですが、今回のアプリのユーザーは限られていますし、何より無料という点が非常に魅力的なので採用することを決定。

UIをどうするか

第三者に公開するということで、UIもある程度綺麗に作りたいところです。

調べてみたところCDNを介してBootStrapの利用が可能なようなので、BootStrapベースで作ることに決めました。

あとサーバー・UI間のデータのやりとりですが、以下の記事のサンプルを参考にしてMVVMライブラリであるknockout.jsを採用することにしました。

http://qiita.com/shibukawa/items/8fee715232e12f183698

実装

完成したものが以下になります(Fusion TablesはDEMO用に分けてあります)。 スマホから見るのを想定していて、PC用のCSSを作り込んでいないのでブラウザで見るとすごく拡大されると思います・・・。 デベロッパーツールとかでスマホ用の画面サイズにしてください・・・。

https://script.google.com/macros/s/AKfycbwBvUNboSuHQwFZ0PDuAUspfJZ5FkClStrMaZ5qIFx7hOjQPdq4/exec

Fusion Tables の作成・有効化

まずデータを格納するFusion Tablesを作ります。

oogleドライブにアクセスして 新規 > その他 > アプリを追加 を選択し、「fusion tables」で検索します。Google Apps Scriptが出てくるので追加し、ファイルを新規作成します。

f:id:uu64:20170706012549p:plain

そうするとNew Tableという新しいテーブルが表示されると思いますので、名前を適当に変えてカラムを追加しましょう。 メニューの Edit > Change columnsから列名の編集・追加が可能です。

f:id:uu64:20170706012704p:plain

Save ボタンを押して上記の操作を確定します。今回は、Year、Month、Location、Name、Member、Urlの6つのカラムを追加しました。

f:id:uu64:20170706012629p:plain

最後に、プロジェクトでFusion Tables APIを有効化する必要があります。 Google Apps Scriptのメニューのリソース > Googleの拡張サービスから Fusion Tables API を見つけ有効化します。

f:id:uu64:20170706012852p:plain

またGoogle API コンソールでも有効化する必要があります。ダイアログ下部のリンクからコンソールを開き、画面上部のAPIを有効にするをクリック、Fusion Table APIを検索して選択し、有効にするを選んでください。

f:id:uu64:20170706012832p:plain

これでFusion Tablesが利用可能になります。

サーバー側処理の実装

サーバー側処理(main.gs)の処理は以下になります。一番上の変数TABLE_IDには、作成したFusion TablesのURL末尾のdocidを指定してください。

例) https ://fusiontables.google.com/hogehoge?docid={ここの文字列}#rows:id=1

Fusion Tables操作のクエリは公式のドキュメントを見てもらえればわかりやすいと思います。 今回はknockout.jsで操作しやすいように、SQLのレスポンスを加工しています。

var TABLE_ID = "";
function doGet(e) {
  var template = HtmlService.createTemplateFromFile("Index.html");
  template.title = "飲み会 archive";
  template.data = getAllStores();
  return template.evaluate().setSandboxMode(HtmlService.SandboxMode.IFRAME);
}

/** レコードの全取得 **/
function getAllStores() {
  var sql = "SELECT ROWID, Year, Month, Location, Name, Member, Url FROM " + TABLE_ID +
        " ORDER BY Year DESC, Month DESC",
      res = FusionTables.Query.sql(sql);
  return JSON.stringify(toHashArray(res));
}

/** レコードの追加 **/
function addStore(year, month, location, name, member, url) {
  var sql = "INSERT INTO " + TABLE_ID + 
        " (Year, Month, Location, Name, Member, Url) VALUES ('" + 
          year + "','" + month + "','" + location + "','" + name + "','" + member + "','" + url + "')";
  FusionTables.Query.sql(sql);
}

/** レコードの更新 **/
function updateStore(year, month, location, name, member, url, index) {
  var sql = "UPDATE " + TABLE_ID + 
        " SET Year = '" + year + 
        "', Month = '" + month + 
        "', Location = '" + location + 
        "', Name = '" + name + 
        "', Member = '" + member + 
        "', Url = '" + url + 
        "' WHERE ROWID = '" + index + "'";
  FusionTables.Query.sql(sql);
}

/** knockoutJSでバインドできるようSQLの返り値をhash形式に変換する **/
function toHashArray(res) {
  var hashArray = [],
      rows = res.rows,
      columns = res.columns,
      hash;
  for (var i in rows) {
    hash = {};
    for (var j in columns) {
      hash[columns[j]] = rows[i][j];
    }
    hashArray.push(hash);
  }
  return hashArray;
}

フロント側処理の実装

HTMLとJavaScriptCSSは以下になります。

bootstrapはCDNで読み込んでいて、ボタンデザインなど一部を上書きしています。

工夫したのは飲み会履歴の追加と更新の処理です。 どちらも共通のモーダルを用いていますが、追加処理の場合は openInsertModal 関数、 更新処理の場合は openUpdateModal 関数をモーダル起動と同時に呼び出し、モーダルのタイトルやボタンラベル、入力フィールドの値を更新しています。またhiddenフィールドに、処理の内容に応じて “add” または “update” という値を設定しています。

サーバーにpostする時は postStore 関数を呼び出します。上記で設定したhiddenフィールドの値を読み、更新時はサーバーの updateStore 関数、新規追加時は addStore 関数を呼び出します。これらの関数呼び出し時に、self.storesを一度空にしていますが、これは飲み会履歴の一覧を非表示にしユーザーの操作を防ぐためです。self.storesを空にするだけでUIが非表示となるのは、knockout.jsで監視をしているからです(self.stores = ko.observableArray(JSON.parse(data));の部分、詳細はknockout.jsのドキュメントを参照してください)。

また、サーバー処理中は「処理中・・・」のメッセージを表示する様にしています。これは通常時は見えない(display: none)メッセージ用のdivのcssを更新し、”display: block”にすることで実現しています。

サーバー処理が成功すれば、受け取ったレスポンスを再度self.storesに設定します。するとknockout.jsにより自動でUIが更新され、ユーザーには処理後のデータが表示されることになります。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title><?= title ?></title>
  <?!= HtmlService.createHtmlOutputFromFile('Stylesheet').getContent(); ?>
</head>
<body>
  <nav class="navbar navbar-fixed-top">
    <div class="container-fluid">
      <div class="navbar-header">
        <div class="navbar-brand"><?= title ?></div>
      </div>
    </div>
  </nav>
  <div class="container-fluid mainpanel">
    <div class="row">
      <div class="col-xs-12">
        <div class="tool">
          <button type="button" class="btn btn-success btn-lg btn-circle" data-toggle="modal" data-target="#storeModal" data-bind="click: openInsertModal">
            <i class="fa fa-plus fa-2x" aria-hidden="true"></i>
          </button>
        </div>
        <div class="panel panel-default" id="load-message">
          <div class="panel-body"></div>
        </div>
        <div data-bind="foreach: stores">
          <div class="panel panel-default">
            <div class="panel-body">
              <div class="edit-btn-div">
                <button type="button" class="btn btn-primary btn-lg btn-circle" data-toggle="modal" data-target="#storeModal" data-bind="click: $parent.openUpdateModal">
                  <i class="fa fa-pencil fa-2x" aria-hidden="true"></i>
                </button>
              </div>
              <div>
                <div><span data-bind="text: Year"></span><span data-bind="text: Month"></span></div>
                <div>開催場所: <span data-bind="text: Location"></span></div>
                <div>店名: <span data-bind="text: Name"></span></div>
              </div>
              <div>url: <a data-bind="attr: {href: Url}" target="_blank"><span data-bind="text: Url"></span></a></div>
              <div>参加者: <span data-bind="text: Member"></span></div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
  
  <div class="modal fade" id="storeModal" tabindex="-1">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h4 class="modal-title"></h4>
        </div>
        <div class="modal-body">
          <div><span class="require">*</span>: 入力必須</div><br>
          <form>
            <div class="form-group">
              <input type="hidden" id="mode">
              <input type="hidden" id="index">
              <label for="year-input"><span class="require">*</span></label>
              <select class="form-control" id="year-select">
                <option>2015</option>
                <option>2016</option>
                <option selected="selected">2017</option>
                <option>2018</option>
                <option>2019</option>
                <option>2020</option>
              </select>
            </div>
            <div class="form-group">
              <label for="month-input"><span class="require">*</span></label>
              <select class="form-control" id="month-select">
                <option selected="selected">1</option>
                <option>2</option>
                <option>3</option>
                <option>4</option>
                <option>5</option>
                <option>6</option>
                <option>7</option>
                <option>8</option>
                <option>9</option>
                <option>10</option>
                <option>11</option>
                <option>12</option>
              </select>
            </div>
            <div class="form-group">
              <label for="location-input">開催場所</label>
              <input class="form-control" id="location-input"/>
            </div>
            <div class="form-group">
              <label for="name-input">店名 <span class="require">*</span></label>
              <input class="form-control" id="name-input"/>
            </div>
            <div class="form-group">
              <label for="url-input">url</label>
              <input class="form-control" id="url-input"/>
            </div>
            <div class="form-group">
              <label for="member-input">参加者</label>
              <input class="form-control" id="member-input"/>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-primary btn-lg modal-btn" data-bind="click: postStore"></button>
                <button type="button" class="btn btn-default btn-lg" data-dismiss="modal">キャンセル</button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
  <script>
    var data = <?= data ?>;
  </script>
  <?!= HtmlService.createHtmlOutputFromFile('JavaScript').getContent(); ?>
</body>
</html>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
<script src="https://ajax.aspnetcdn.com/ajax/knockout/knockout-3.1.0.js"></script>
<script type="text/javascript">
  function storeVM() {
    var self = this;   
    self.stores = ko.observableArray(JSON.parse(data)); 
    self.openUpdateModal = function(store) {
      $(".modal-title").text("お店の更新");
      $(".modal-btn").text("更新");
      $("#mode").val("update");
      $("#index").val(store.rowid);
      $("#year-select").val(store.Year);
      $("#month-select").val(store.Month);
      $("#location-input").val(store.Location);
      $("#name-input").val(store.Name);
      $("#member-input").val(store.Member);
      $("#url-input").val(store.Url);
    };
    self.openInsertModal = function() {
      $(".modal-title").text("お店の追加");
      $(".modal-btn").text("追加");
      $("#mode").val("add");
      $("#index").val("");
      $("#year-select").val("");
      $("#month-select").val("");
      $("#location-input").val("");
      $("#name-input").val("");
      $("#member-input").val("");
      $("#url-input").val("");
    };
    self.postStore = function() {
      var mode = $("#mode").val(),
          index = $("#index").val(),
          year = $("#year-select").val(),
          month = $("#month-select").val(),
          location = $("#location-input").val(),
          name = $("#name-input").val(),
          member = $("#member-input").val(),
          url = $("#url-input").val();
      if (!year || !month || !name) {
        return false;
      }
      self.stores("");
      self.modalClose();
      $("#load-message").css("display", "block");
      $("#load-message .panel-body").text("処理中・・・");
      if (mode === "add") {
        google.script.run
          .withSuccessHandler(self.onPostSuccess)
          .addStore(year, month, location, name, member, url);
      }
      if (mode === "update") {
        google.script.run
          .withSuccessHandler(self.onPostSuccess) 
          .updateStore(year, month, location, name, member, url, index);
      }
    };
    self.onPostSuccess = function() {
      google.script.run.withSuccessHandler(function(data) {
        self.stores(JSON.parse(data));
        $("#load-message").css("display", "none");
      }).getAllStores();
    }
    self.onPostFailure = function() {
      $("#load-message .panel-body").text("処理に失敗しました。ページを再読み込みしてください。");
    }
    self.loadingView = function(flag) {
      $('#loading-view').remove();
      if(!flag) return;
      $('<div id="loading-view" />').appendTo('body');
    }
    self.modalClose = function() {
      $('body').removeClass('modal-open');
      $('.modal-backdrop').remove(); 
      $('#storeModal').modal('hide'); 
    }
  }
  $(document).ready(function () {
    ko.applyBindings(new storeVM());
  });
</script>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
<script src="https://use.fontawesome.com/a8b1ccabdb.js"></script>
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
  <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
  <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
<style>
body,.navbar-brand, .modal-title, .form-control, .modal-footer .btn {
  font-size: xx-large;
}
body {
  padding-top: 50px;
}
.navbar-brand { 
   font-weight: bold;
   color: #FFFFFF;
}
.navbar {
  padding-top: 30px;
  padding-bottom: 30px;
  padding-left: 5px;
  background-color: #000000;
}
.mainpanel {
  padding: 80px 15px;
}
.edit-btn-div {
  float: right;
}
.tool {
  padding-bottom: 10px;
  padding-right: 15px;
  text-align: right;
}
.year, .month, .location, .name, .member, th {
  text-align: center;
  vertical-align: middle !important;
}
.fa {
  margin: 10px;
}
.btn-circle {
  width: 84px;
  height: 84px;
  border-radius: 50px;
}
.btn-column {
  width: 46px;
}
.form-control {
  height: 60px;
}
.modal-dialog {
  width: 90%;
}
.modal-body {
  padding: 20px;
}
.modal-body-footer {
  text-align: right;
}
#load-message {
  display: none;
}
.require {
  color: red;
}</style>

感想

手軽に無料でWebサービスが作れるのはすごく魅力的です。がしかし、作り込もうとすればするほど物足りない点は出てきます。具体的には以下。

  • 基本画面遷移ができないので、アプリケーションで扱うモデル(今回はStoreのみ)が増えるときつい
  • 利用者数が多く、DBへのアクセスが多いのであれば、諦めてGoogle Cloud SQLなどを使うべき
  • CDN経由でないと他のライブラリが利用できない。最近流行りのフレームワークとか使おうと思っても使えない。
  • エディタが使いづらい(全角半角の見分けがつかないとかいろいろ)

要するに本格的にやりたくなったら諦めてお金を出すしかないと思います。 今回やってていろいろ機能追加したいなーと思ったので、そのうちS3, lambda, api gatewayとかのサーバーレス構成でAWSに乗せかえるかも。意外とお金かからないみたいだし。

否定的なことばかり書きましたが、ユーザー数が限られてて、すでにスプレッドシートとかで管理しているコンテンツは手軽にWebに公開できて便利だと思う。内輪のイベントのタイムテーブルの共有とか?せっかく勉強したので何か良い活用方法を探してゆきたい。