GoogleAppsScript
Webhook

Google Spreadsheet を簡易 Webサーバーとして動かして、手軽にWebHookを受け取る方法

外部のサービスからの通知を WebHookで受け取ってちょっとした処理をしたいことってたまにありませんか?

たとえば、アプリのクラッシュログを解析するFabricの Crashlytics には、新しいクラッシュが発生したり特定の閾値を超えると WebHookで通知してくれる、つまり特定の URL にPOSTを発行してくれる機能があります。

これを上手く使うと、下の図のように「新しいクラッシュが発生したら Backlogなどのチケット管理サービスに自動登録」といったことができそうです。

もちろんCrashlyticsから BacklogのAPIは直接叩けないので、このように WebHookを使ってこちらのサーバを叩いてもらい、そのサーバがあらためて Backlogの APIを使ってチケットを登録する、というよう感じになります。

以下は実際のClashlyticsの管理画面です。WebHook機能のスイッチをONにして、呼び出したいサーバのURLを設定するだけです。

ちなみに、このスクリーンショットをよく見ると、Slack, Jira, Redmineなどの有名どころのサービスは専用の連携機能があらかじめ用意されているのがわかります。

こういったサービスであれば中継サーバなどを用意しなくても簡単に接続できてしまいます。しかし、連携のサポートが無い Backlogなどと接続したい場合は、最初の図のように中継サーバを自前で用意して、プロトコル変換(APIの再呼び出し)をする必要があります。

中継サーバをどこに用意するか、それが問題だ

慣れた方なら「POSTされた JSONの形を変えて、別のURLの POSTを叩く」というのは技術的には大したことではないと思いますが、その プログラムをインターネット上の公開された場所に配備する というのは割と面倒だったりします。

実際、考えられる方法としては次のようなものがあります。

  • レンタルサーバーを借りて動かす
  • AWSの EC2を借りて動かす
  • Google App Engine でアプリを動かす
  • Amazon Lambda を使う

いずれにせよ 金がかかる というのがちょっとネックです。金額は大したことなくても、クレジットカード登録したりするのは心理的な障壁が高いですよね。業務上必要な場合、会社によってはなかなか稟議が通らなかったりという問題もあるでしょう。

なんとか無料でサーバーを動かせる環境はないものでしょうか?

Google Spreadsheet をサーバーにしてしまおう!

Google Spreadsheetは Googleアカウントを持っていれば誰でも使えるオンラインのEXCELのようなものです。みなさん一度は使ったことがあるのでは無いでしょうか?

この Google Spreadsheetにはスクリプト(EXCELマクロみたいなもの)を動かす仕組みがありプログラムでシートを操作することができるのですが、このスクリプトは思った以上にいろんなことができるものでして、なんと、Webサーバーとして外部からリクエストを受け取って処理する ことまでできてしまうのです。

やってみよう

簡単な例として、まずは GETリクエストに対してhogeという文字列を返すだけの簡単なサーバーを立てて見ましょう。

1. Google Spreadsheetに空のシートを作る

http://spreadsheet.google.com にアクセスして空のシートを作ってください。シート名はなんでもいいですが「WebHook Test 1」としました

2. スクリプトエディタを開く

script-editor-1.png

「ツール」-「スクリプト エディタ...」を選んでスクリプトエディタを開いてください。

3. doGet() 関数を記述

GETリクエストを処理する関数として、以下のような doGet() 関数を書きます。

スクリーンショット 2018-03-04 23.32.32.png

// GETリクエストに対する処理
function doGet(e) {
  var output = ContentService.createTextOutput("hoge");
  output.setMimeType(ContentService.MimeType.TEXT);
  return output;
}

細かい説明は省略しますが、これで hoge という文字列を持ったレスポンスを作ることができます。

4. Webアプリとして公開する

3で作ったサーバーを公開してみます。スクリプトを保存したら、「公開」メニューから「Webアプリケーションとして導入...」を選んでください。

スクリーンショット 2018-03-04 23.34.16.png

すると以下のようなダイアログが出て来ます。

スクリーンショット 2018-03-04 23.35.39.png

  • プログラムの説明は適当に Initial version とかに
  • 「アプリケーションにアクセスできるユーザー」は「全員(匿名ユーザーを含む)」に

変更して「導入」を押してください。

すると、スクリプトに対して権限を与えるための認証ダイアログが出て来ますので、指示をよく読んで許可を与えてください。

成功すると以下のような画面が表示され、いま作ったWebアプリのURLが表示されます。

スクリーンショット 2018-03-04 23.43.02.png

このURLはこれから何度も使いますので、利用しやすい場所にコピーしておいてください。

5. GETアクセスしてみる

公開されたURLに対して GETアクセスしてみてください。

> curl -L https://script.google.com/macros/s/AKfycbyW0...(あなたのURL)
hoge

hogeと帰ってきたら成功です。

なお、Googleの公式ドキュメントにもある通り、このスクリプトは最初に 302 Moved Temporarily が返ってきますので、curlの -L オプションをつけてリダイレクトに追従させる必要がありますのでご注意ください。

6. プログラムを修正してみる

うまく動いたら今度は hogeの代わりにfugaと表示させてみましょう。スクリプトの該当文字列を変更すれば良いだけなのですが、プログラムを修正した場合は再度公開しなおす必要があるので注意してください。

スクリーンショット 2018-03-04 23.53.22.png

再公開する際の注意点ですが、更新する際は「スクリプトバージョン」を「新規作成」にして、新しいバージョンを生成するようにしてください。

というのも、スクリプトの内容は版管理されているため、ここで 1とか2などのバージョン番号のまま更新しても昔のバージョンが公開されるだけなのです。そのため、最新のスクリプトを動かしたい場合は新しいバージョンを作成する必要があります。

POSTにも対応してみよう

GETの処理はうまくいきましたでしょうか。こんな感じで無料でサーバーが立てられてしまうのは面白いですよね。

さて、それでは実際に WebHookの処理をさせて見たいところですが、WebHookの多くはGETではなくPOSTを使うケースが多いと思います。すでにお気づきかと思いますが doGet() ではなく doPost() を使うことでPOSTリクエストに応答することもできます。

具体的な例として、POSTリクエストを受け付けて、リクエストの内容をスプレッドシートに追記するスクリプト を作って見ます。

1. doPost()関数の用意

まずは、以下のようにスクリプトを修正し、doPost()関数を追加してください。

// GETリクエストに対する処理
function doGet(e) {
  var output = ContentService.createTextOutput("hoge");
  output.setMimeType(ContentService.MimeType.TEXT);
  return output;
}

// POSTリクエストに対する処理
function doPost(e) {
  // スプレッドシートオブジェクトを取得
  var ss = SpreadsheetApp.getActive()
  var sheet = ss.getActiveSheet();

  // 日付、postDataオブジェクトを追記
  sheet.appendRow([new Date(), JSON.stringify(e.postData)]);
}

修正が終わったら、新しいバージョンでスクリプトを公開してください。

2. POSTリクエストを発行してみる

新しいスクリプトが公開されたら curl で POSTリクエストを発行してみましょう。

> curl -L -X POST -d "{\"key\":0}" -sS https://script.google.com/macros/s/AKfycbyW0...(あなたのURL)

<!DOCTYPE html><html><head><link rel="shortcut icon" href="//ssl.gstatic.com/docs/script/images/favicon.ico"><title>エラー</title><style type="text/css">body {background-color: #fff; margin: 0; padding: 0;}.errorMessage {font-family: Arial,sans-serif; font-size: 12pt; font-weight: bold; line-height: 150%; padding-top: 25px;}</style></head><body style="margin:20px"><div><img alt="Google Apps Script" src="//ssl.gstatic.com/docs/script/images/logo.png"></div><div style="text-align:center;font-family:monospace;margin:50px auto 0;max-width:600px">スクリプトが完了しましたが、何も返されませんでした。</div></body></html>

GETの時同様、リダイレクトに追従するために -L オプションが必要ですのでご注意ください。
また、今回の doPost() ではレスポンスボディに何も入れていないので、Googleが生成したHTMLが返却されています。
ドキュメント上は doPost()の場合も doGet()同様にレスポンスを設定できるようなのですが、なぜかレスポンスコードが 404 になってしまう問題があって困っており、暫定的に何も返さないようにしています

3. スプレッドシートに追記されていることを確認

POSTの呼び出しがうまくっていれば、以下のようにシートにアクセス内容が記録されていきます。Google Spreadsheetなので、画面をリロードしなくてもリアルタイムで追記されていきます。

スクリーンショット 2018-03-05 10.27.22.png

また、curlに -H オプションをつけてContent-Typeを変更すると、記録される typeの値も変わるので試してみてください。

> curl -L -X POST -d "{key:0}" -sS -H 'Content-Type: application/json' https://script.google.com/macros/s/AKfycbyW0...(あなたのURL)

なお、今回は、シートにデータを追記する際、以下のように配列で「日付」と「POSTデータ」を与えています。

sheet.appendRow([new Date(), JSON.stringify(e.postData)])

このようにすると配列の並び順通りにシートに追記されるため、A列には日付、B列にはPOSTデータ(をJSON文字列化したもの)が入っているわけです。

ここをうまく工夫すればもうすこし実用的なデータ収集装置にすることができます。

リクエスト内容をパースし、もう少し実用的なスクリプトにしてみよう

スクリプトでPOSTリクエストを受け付けられたところで、今度はもう少し実用的な処理をして見たいと思います。

スクリーンショット 2018-03-05 10.29.11.png

この例では1行目にヘッダ行を用意して値を列挙しておくと、そのヘッダの内容に合った値を下のセルに追記するようになっています。

今回、ヘッダの意味は以下のようにしました。

  • date
    • アクセスされた日付
  • mimeType
    • Content-Typeヘッダの値
  • key1
    • リスエストのJSONの key1 というキーに対応する値
  • key2
    • リスエストのJSONの key2 というキーに対応する値

これを実現するスクリプトは以下のようになります。

// POSTリクエストに対する処理
function doPost(e) {
  // JSONをパース
  if (e == null || e.postData == null || e.postData.contents == null) {
    return;
  }
  var requestJSON = e.postData.contents;
  var requestObj = JSON.parse(requestJSON);

  //  
  // 結果をスプレッドシートに追記
  //

  var ss = SpreadsheetApp.getActive()
  var sheet = ss.getActiveSheet();

  // ヘッダ行を取得
  var headers = sheet.getRange(1,1,1,sheet.getLastColumn()).getValues()[0];

  // ヘッダに対応するデータを取得
  var values = [];
  for (i in headers){
    var header = headers[i];
    var val = "";
    switch(header) {
      case "date":
        val = new Date();
        break;
      case "mimeType":
        val = e.postData.type;
        break;
      default:
        val = requestObj[header];
        break;
    }
    values.push(val);
  }

  // 行を追加
  sheet.appendRow(values);
}

このスクリプトを公開したら、以下のように curlコマンドで叩いて見てください。

> curl -L -X POST -d "{\"key1\":0,\"key2\":1}" https://script.google.com/macros/s/AKfycbyW0bqFQdv9zsFx...(あなたのURL)

すると、key1というヘッダの下に0が、key2というヘッダの下に1が追記されたと思います。
なお、このスクリプトはヘッダの位置に依存していませんので、列を入れ替えても正しく動作します。また、key3,key4などの列を追加しても大丈夫です。

doPost()のテスト方法

なお、doPost()をテストする際、毎回公開して curlコマンドを叩くというのは効率が悪いため、以下のようなスクリプトを用意して手動で実行しましょう。

function doPostTest() {
  var e = new Object();
  var postData = new Object();
  postData.type = "application/json";
  postData.contents = "{\"key1\":100, \"key2\":\"文字列\"}";
  e.postData = postData;

  doPost(e);
}

関数を用意したら以下のようにプルダウンメニューから関数を選択し、実行ボタンを押すと動作が確認できます。

script-debug.png

他のAPIを呼び出す

さて、これで WebHookのリクエストを受け付けるところまではできましたので、あとは情報を加工して別のサービスのAPIを呼び出せば中継サーバーとして機能するようになります。

スクリプトからPOSTリクエストを発行するためには UrlFetchApp.fetch()関数を使います。せっかくなのでWebHook Test 2というスプレッドシートを新たに作り、そちらの doPost() から先ほど作成した WebHook Test 1を呼び出すようにしてみましょう。

WebHook Test 2 の doPost()

// POSTリクエストに対する処理
function doPost(e) {
  // JSONをパース
  if (e == null || e.postData == null || e.postData.contents == null) {
    return;
  }
  var requestJSON = e.postData.contents;
  var requestObj = JSON.parse(requestJSON);

  date = new Date();
  mimeType = e.type;

  // 他のサービスのAPIを呼び出す
  var payload = {
    "key1": "1234566",
    "key2": 12345
  };
  var options = {
    "method" : "post",
    "payload" : payload
  };
  var url = "https://script.google.com/macros/s/AKfycbyW0bqFQdv9zsFx...(WebHook Test 1のURL)";
  var r = UrlFetchApp.fetch(url, options);
  var result = JSON.stringify(r);

  //  
  // 結果をスプレッドシートに追記
  //

  var ss = SpreadsheetApp.getActive()
  var sheet = ss.getActiveSheet();

  // ヘッダ行を取得
  var headers = sheet.getRange(1,1,1,sheet.getLastColumn()).getValues()[0];

  // ヘッダに対応するデータを取得
  var values = [];
  for (i in headers){
    var header = headers[i];
    var val = "";
    switch(header) {
      case "date":
        val = new Date();
        break;
      case "mimeType":
        val = e.postData.type;
        break;
      case "result":
        val = result;
        break;
      default:
        val = requestObj[header];
        break;
    }
    values.push(val);
  }

  // 行を追加
  sheet.appendRow(values);
}

呼び出した結果

このように、WebHook Test 2に対して POSTリクエストを発行すると、WebHook Test 1にもリクエストが発行されているのが確認できます。

JSONでPOSTする

上記の実行結果をよく見ると、payloadのデータがJSONではなく、x-www-form-urlencoded形式になってしまっています。

これをJSON形式でPOSTしたい場合は以下のようにしてください。

  // 他のサービスのAPIを呼び出す
  var payload = {
    "key1": "1234566",
    "key2": 12345
  };
  var options = {
    "method" : "post",
    "contentType": "application/json",
    "payload" : JSON.stringify(payload)
  };
  var url = "https://script.google.com/macros/s/AKfycbyW0bqFQdv9zsFx...(WebHook Test 1のURL)";
  var r = UrlFetchApp.fetch(url, options);
  var result = JSON.stringify(r);

注意点

性能や保証など

この手法に対しては Googleも性能面の保証などはしていませんので、毎秒何十回もアクセスがあるような本気のサービスに使うことはできません。また、サービスがダウンしていることもあるかもしれませんし、たとえ呼び出し回数が少なかったとしても取りこぼしが許されないようなシビアなサービスに使うこともできませんのでご注意ください。

「1日に数回よびだされるかどうか」くらいの頻度で「参考情報だから取りこぼしてもオッケー」というようなゆるい温度感での使用をお勧めします。

Google Spreadsheet以外でもOK!

今回はログの収集に便利なので Google Spreadsheetを使用していますが、Google Docsを使ったり、そもそも G Suiteアプリを使わずにスクリプト単体で配備することもできるようです(やったことはない)。きになる方は文末の参考情報から公式資料を読んでチャレンジしてみてください。

まとめ

POSTリクエストを受けることも、そこから別のPOSTリクエストを発行することもできましたので、あとは接続したいWebHookに合わせて好きなように料理しちゃいましょう!

参考情報