Help us understand the problem. What is going on with this article?

GoogleAppsScriptで社内ポータルサイトを作る

AWS Lambdaに代表されるサーバレスアーキテクチャ(FaaS)が熱い。開発屋としては筆者が今後もっとも楽しみな分野であり、対応フレームワークの動向に目が離せない。

無料で使える Google Apps Script(以降GAS)もサーバレスと表現して良いかと思う。
GASでちょっとしたWebアプリケーション(社内ポータルサイト)を書いてみたので、一部だが、コードを改変した上で晒してみる。

画面イメージ

掲示板と会議室予約状況をGASで実装する。
image.png

コードをざっくりと

GASだけでも頑張れば本格的なWebアプリが書けることを証明したいだけなので、コードの突っ込んだ解説は割愛する。ざっくりとスキームだけ感じ取ってもらえれば。

HTMLコード

全体の雛型レイアウトと個々のページのHTMLファイルを作る。
<?!= ... ?> がGASにおけるスクリプトレットタグだ。<?!= include('css'); ?> は main.gs で定義されたオリジナルの関数で、読み込んだファイルの内容を挿入するもの。

app.html

Laravelで例えるなら、Bladeテンプレートのマスターページレイアウトに相当するもの。
コード量が増えてきたら、ヘッダーとフッターを別ファイルにする等のリファクタリングを進めた方が良いだろう。

app.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <base target="_top">
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="author" itemprop="author" content="MindWood">
    <title>社内ポータル</title>
    <?!= include('css'); ?>
  </head>
  <body>
  <div id="wrapper">
    <!-- ナビゲーションバー -->
    <nav class="navbar navbar-expand-sm">
      <a class="navbar-brand" href="<?=ScriptApp.getService().getUrl() ?>">社内ポータル</a>
      <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav4" aria-controls="navbarNav4" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse justify-content-end">
        <ul class="navbar-nav">
          <li class="nav-item active">
            <a class="nav-link" href="#"><i class="fas fa-home mr-1"></i>トップ</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="https://foo" target="_blank"><i class="fas fa-file-import mr-1"></i>勤怠システム</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="https://bar" target="_blank"><i class="fas fa-yen-sign mr-1"></i>Web明細システム</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="<?=ScriptApp.getService().getUrl() ?>?p=doc"><i class="fas fa-download mr-1"></i>様式集ダウンロード</a>
          </li>
        </ul>
      </div>
      <!-- Google検索窓 -->
      <form method="get" action="http://www.google.co.jp/search">
        <input type="text" name="q" size="35" maxlength="255" value="">
        <input type="hidden" name="ie" value="UTF-8">
        <input type="hidden" name="oe" value="UTF-8">
        <input type="hidden" name="hl" value="ja">
        <input type="submit" name="btnG" value="Google 検索">
      </form>      
    </nav>
    <hr/>
    <!-- メイン画面 -->
    <div class="content">
      <?!= content ?>
    </div>
    <!-- フッター -->
    <footer class="footer hidden-print">
      <div class="container">
        <p><a href="https://mindwood.jp">Copyright &copy; 2020 by MindWood</a></p>
      </div>
    </footer>
    <?!= include('js'); ?>
  </div>
  </body>
</html>

top.html

トップ画面HTML。掲示板と会議室予約状況のViewになる。

top.html
<div class="container-fluid">
  <div class="row">
    <div class="col-8">
      <h1>掲示板</h1>
      <div class="row">
      <div class="col-2">
        <div class="alert alert-secondary" role="alert">
          <i class="fas fa-building mr-2"></i>全社<br/>
          <i class="fas fa-arrow-circle-right ml-3 mr-2"></i>開発部<br/>
          <i class="fas fa-arrow-circle-right ml-3 mr-2"></i>営業部<br/>
          <i class="fas fa-arrow-circle-right ml-3 mr-2"></i>総務部<br/>
          <i class="fas fa-arrow-circle-right ml-3 mr-2"></i>販売部
        </div>
        <form method="GET" action="<?=ScriptApp.getService().getUrl() ?>">
          <button type="submit" class="btn btn-primary" name="p" value="post"><i class="fas fa-edit mr-2"></i>掲示板に投稿</button>
        </form>
      </div>
      <div class="col-10">
      <div class="table-responsive table-clickable">
        <table class="table table-bordered table-striped table-hover table-sm">
          <thead>
            <tr>
              <th><i class="fas fa-comment mr-2"></i>タイトル</th>
              <th><i class="fas fa-building mr-2"></i>部署</th>
              <th><i class="fas fa-user mr-2"></i>投稿者</th>
              <th><i class="fas fa-pen-alt mr-2"></i>更新日</th>
            </tr>
          </thead>
          <tbody>
          <form method="POST" action="<?=ScriptApp.getService().getUrl() ?>" id="view">
          <?!=hideVal('p', 'view') ?>
          <? for(var row in boards) { var data = boards[row]; ?>
            <tr data-row="<?= row ?>" data-form_id="view">
              <td><?=data[0] ?></td>
              <td><?=data[1] ?></td>
              <td><?=data[3] ?></td>
              <td><?=Utilities.formatDate(new Date(data[4]),'GMT-7', 'yyyy年M月d日 H:mm') ?></td>
            </tr>
          <? } ?>
          </form>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</div>

CSSコード

GASはCSSファイルを置けないので、cssを埋め込んだHTMLファイルを作る。
CSSフレームワークにBootstrap4、WebアイコンフォントにFontAwesomeを使うだけでも、なんかプロっぽいデザインになる。

css.html
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.1/css/all.css" integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous">

<style>
.content {
    padding: 0 12px 20px 12px;
}
.content > .container,
.content > .container-fluid {
    background: #fff;
    min-height: 70vh;
}
h1 {
    margin: 12px 0 9px;
    padding: 6px 9px;
    font-size: 27px;
    color: #555;
    background: #eee;
    border-left: solid 9px #d22027;
    border-bottom: 1px solid #ccc;
}
footer {
    margin-top: auto;
    background-color: #333;
    height: 20px;
    bottom: 0;
    text-align: center;
}
footer p {
    margin: 0;
    font-size: 12px;
    line-height: 20px;
}
footer a {
    color: #666;
}
footer a:hover {
    color: #993;
}
</style>

JavaScriptコード

GASはJavaScriptファイルを置けないので、JavaScriptを埋め込んだHTMLファイルを作る。
JavaScriptフレームワークには、筆者が昔から馴染んでいるという理由だけでjQueryを採用。サーバレスはシングルページアプリケーション(SPA)と相性が良さそうな気がするので、実際にはVueReactの方が向いていると思う。

js.html
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js" integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>
<script>
$(function(){
  $('tr[data-row]', '.table-clickable').on('click', function(){
    var row = $(this).data('row');
    var form = $('#' + $(this).data('form_id'));
    $('<input>').attr({
      'type': 'hidden',
      'name': 'row',
      'value': row
    }).appendTo(form);
    form.submit();
  });
});
</script>

GASコード

最後にGAS本体。
doGet()がGETリクエストで最初に呼び出される関数。URLパラメータを取得し、ページ遷移を疑似的に実装している。
doPost()は掲示板の投稿時に呼び出され、投稿文をGoogleスプレッドシート(SpreadsheetApp)に保存する。
会議室予約状況は、組織が運用しているGoogleカレンダー(CalendarApp)から取得している。

main.gs
var spreadSheetApp = SpreadsheetApp.openById('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX');
var boardSheet = spreadSheetApp.getSheetByName('board');
var html_app = HtmlService.createTemplateFromFile('app');
var lastRow;

//  HTTP GET request endpoint
function doGet(e) {
    var page = e.parameter.p;
    lastRow = boardSheet.getLastRow();

    switch (page) {
    default:
    case 'top':     // トップ画面
        html_app.content = topPage();
        break;
    case 'post':    // 掲示板の新規投稿画面
        html_app.content = HtmlService.createTemplateFromFile('post').evaluate().getContent();
        break;
    case 'xxxxx':
        break;
    }
    return html_app.evaluate();
}

//  HTTP POST request endpoint
function doPost(e) {
    var page = e.parameter.p;

    lastRow = boardSheet.getLastRow();

    switch (page) {
    case 'confirm':    // 投稿内容確認画面
        var title = e.parameter.title;
        var sec   = e.parameter.sec;
        var owner = e.parameter.owner;
        var body  = e.parameter.body;

        var html_confirm = HtmlService.createTemplateFromFile('confirm');
        html_confirm.title = title;
        html_confirm.sec   = sec;
        html_confirm.owner = owner;
        html_confirm.body  = body;
        html_app.content = html_confirm.evaluate().getContent();
        break;
    case 'post':       // 投稿を実行し、トップ画面に戻る
        var title = e.parameter.title;
        var sec   = e.parameter.sec;
        var owner = e.parameter.owner;
        var body  = e.parameter.body;

        upTime = Utilities.formatDate(new Date(), 'JST', 'yyyy/MM/dd H:mm:ss');

        var values = [
            [title, sec, body, owner, upTime],
        ];
        lastRow ++;
        boardSheet.getRange(lastRow, 1, 1, 5).setValues(values);
        html_app.content = topPage();
        break;
    case 'view':    // 投稿内容表示画面
        var row = parseInt(e.parameter.row);
        var values = boardSheet.getDataRange().getValues();
        var html_view = HtmlService.createTemplateFromFile('view');
        html_view.title = values[1 + row][0];
        html_view.sec   = values[1 + row][1];
        html_view.body  = values[1 + row][2];
        html_view.owner = values[1 + row][3];

        html_app.content = html_view.evaluate().getContent();
        break;
    default:
        return 'XXXXX';
    }
    return html_app.evaluate();
}

//  トップページ用
function topPage() {
  var html_top = HtmlService.createTemplateFromFile('top');

  //  掲示板
  html_top.boards = boardSheet.getRange(2, 1, lastRow - 1, 5).getValues();

  //  会議室予約
  html_top.events = null;
  var cal = CalendarApp.getCalendarById('XXXXXXXXXXXXXX@resource.calendar.google.com');
  if (cal != null) {
    var startDate = new Date();
    var endDate = new Date(startDate);
    for (var i = 0; i < 7; i++) {  // 1週間
      endDate.setDate(endDate.getDate() + 1);
    }
    html_top.events = cal.getEvents(startDate, endDate);
  }
  return html_top.evaluate().getContent();
}

//  ファイル内容を差し込む汎用関数
function include(filename) {
    return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

//  改行コードをbrタグに変換する汎用関数
function br(str) {
    return str.replace(/[\n]/g, '<br/>');
}

//  hiddenパラメータ挿入
function hideVal(name, val) {
    return '<input type="hidden" name="' + name + '" value="' + val + '">';
}

アクセス許可

Googleに初回ログイン時、アカウントごとに、スプレッドシートやカレンダーにアクセスすることの許可を求める画面になる。
image.png

mindwood
大手SIer(プライムコントラクタ)に20年以上勤続し、某メガバンクのSEを下流から上流までひと通り経験。退社後は個人事務所を開業し、中堅企業の部内SEの他、いくつかのベンチャー企業のシステム開発にも携わっている。アルゴリズムを考えるのが何よりも好きで、ボケ防止も兼ねて競技プログラミングに参加したい勢。生涯現役プログラマーを目指す。座右の銘は「継続は力なり」
https://mindwood.jp
alieaters
Alibaba Cloudを上手に使うためのノウハウの共有を目的としたコミュニティ
https://www.alieaters.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away