1. やること
ローカル環境のWindows, Mac, Linuxなどの上で、
Go、C#、Java、Pythonのいずれかの言語で簡易サーバを作ります。
作ったローカル環境の簡易サーバで、
クラウドベースの各種チャットサービスやSNSなどのリアルタイムの通知をWebhookで受けます。
2. 必要なもの
インターネットにつながるWindows、Mac、Linuxなどが必要です。
中から外に繋がればいいので、Webhook用のpublicなURL(外からアクセスできるhttpsサーバなど)は必要ありません。
インターネット上のWebサイトが見れるような環境であればOKです。
ngrok
(後述)とプログラミング言語を利用するので、それらをサポートしている環境である必要はあります。
3. ngrokの準備と起動
かなり、ざっくりな説明をすると、
ngrok
は、インターネットに抜けられるローカルの環境(インターネット上のWebサイトが見れるような環境)で、
public URLへのリクエストを受けられるトンネリングサービスです。
TCPのトンネリングもできますが、今回はhttpのトンネリングに関してのみ触れます。
あえて日本語で読むと、「エングロック」になります。
こちらが公式サイトです:
https://ngrok.com/
無償版、有償版があります。
無償版では、1分間当たり40コネクション(40リクエストではありません)までですが、
ちょっとしたテストをする分には、充分だと思います。
有償版ではプランによって、できることが増えていきます。
3.1. ngrokのダウンロード
公式サイトの[DOWNLOAD]ページからダウンロードできます。
ダウンロードしたら、適当なディレクトリに解凍しましょう。
ngrokのアカウントを作らずに動作させた場合は、アプリ起動ごとに8時間のみ動作します。
起動しなおせば、再度8時間利用できますが、無償アカウントを作っておいた方が制限も緩和されるのでよいと思います。
公式サイトの[SING UP]からアカウントの作成ができます。
3.2. ngrokのアカウントに接続する
ngrokのアカウントを作った場合は、今後、ngrokを起動する場合にアカウントと関連付くようにします。
<YOUR_AUTH_TOKEN>
は、SING UP後やLOGIN後に取得できます。
LOGIN後のページトップに『Setup & Installation』が表示されて、
『(3) Connect your account』にまさに実行すべきコマンドがそのまま書かれています。
> .\ngrok authtoken <YOUR_AUTH_TOKEN>
$ ./ngrok authtoken <YOUR_AUTH_TOKEN>
このコマンドを実行すると、各OSのユーザのホームディレクトリ配下の以下の場所に(Ver 2.xの場合)、
{userhome}/.ngrok2/ngrok.yml
というファイルが出来上がって、そこに上記のアカウントのトークン情報などが保存されます。
ngrok.yml
には、その他さまざま設定を書くこともできますが、説明は省略します。
--config
オプションで、設定ファイルのパスも指定できるので、例えば、ngrokをdockerコンテナで実行する場合に、
ホスト側のディレクトリに設定ファイル置いて、マウントさせて設定参照などもできます。
3.3. ngrokを起動する
Webhook用のpublicなURLは、ngrok側が準備してくれます。
Webhook用にTLSを利用しない、http
を使うのは、通常はおろかな行為なので、https
だけ準備されるようにオプションを指定(--bind-tls=true
)します。
以下コマンドの、8080
は、ローカル環境のサーバのポート番号です。使っていない適切なポート番号を指定します。
この時点では、まだローカル側のサーバは作っていませんが、空いている適切なポートを指定しましょう。
> .\ngrok http 8080 --bind-tls=true
$ ./ngrok http 8080 --bind-tls=true
うまく行くと、以下のような出力が得られるはずです。
{random-id}
の部分は、実行するたびに変わります(有償版では、この部分を指定することもできます)。
Region
も指定できますが、今回はデフォルトでよいと思います。
ngrok by @inconshreveable (Ctrl+C to quit)
Session Status online
Account Your Name (Plan: Free)
Version 2.x.yz
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding https://{random-id}.ngrok.io -> http://localhost:8080
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
3.4. ここまでの手順でngrokがやってくれること
Forwarding https://{random-id}.ngrok.io -> http://localhost:8080
という出力が示すように、
ngrokサービスが、publicなURLであるhttps://{random-id}.ngrok.io
を準備してくれています。
このURLへの要求は、ローカル環境(ngrokコマンドを実行したマシン)のhttp://localhost:8080
にフォワードされます。
つまり、ローカルマシン上では、http://localhost:8080
(http://127.0.0.1:8080
)へのリクエストを受けられるようにアプリを実装すればよいです。
有償版ではプランによって、ローカルのhttpsサーバ(ローカル側がTLSあり)にフォワードすることもできますが、今回は、ローカル側はTLSなしのhttpサーバとします。
ざっくり図解すると、今回のオプション指定では、以下のような接続になっています。
矢印の方向は接続開始時の要求の方向を示しています。リクエストとレスポンスを処理するので、データのやりとりの観点では双方向です。
ローカルにあるngrokのアプリが、クラウドにあるngrokサービスとセキュアに常時接続しています。
ngrokのpublicなURLにリクエストがあると、この常時接続を通じて、ローカル側のngrokのアプリにリクエストが通知されて、
ローカル側のngrokのアプリが、ローカル側のサーバにリクエストを出します。
ngrokが準備したPublicなURLがhttpsの場合は、ここのTLSのサーバ証明書はngrok側のものになります。
この証明書チェーンは、標準的な環境では信頼済みのものであるので、これが非常に便利な場合もあります。
ngrokサービス提供元を信頼するという前提の下では、通信経路は暗号化されてTLSのレベルでデータが守られるということになると思います。
3.5. Web Interfaceも便利
Web Interface http://127.0.0.1:4040
と出力されていると思いますが、テストではこれは便利に使えます。
(オプションでWeb Interfaceを無効にすることもできます。)
http://127.0.0.1:4040
にアクセスすることで、ngrokを経由した通信内容を閲覧したりできます。
例えば、httpのリクエストとレスポンスのヘッダやBodyの中身を調べたりすることができます。
また、[Replay]機能が非常に便利で、ngrok経由で過去に行われたhttpなどのリクエストを、
再度ローカルのサーバに好きなタイミングで送信することができます。
リクエストを編集して送信することもできます。
テストでは結構便利で、Webhookの実際の通知を再度発生させることなく、以前の通知内容をちょっと変えて試すということもできます。
(本格的なテストを行うときは、ngrokで本機能を使うのではなく、より高度なテストの自動化をする場合が多いと思います。)
3.6. APIもあります
Web Interfaceを有効にしているとAPIも使えます。
APIの詳細はこちらのページ(https://ngrok.com/docs#client-api)に記載されています。
ngrokサービスが割り当ててくれるpublicなURLは、無償版ではランダムになりますが、
APIで何を割り当ててもらったかを取得することもできます。
Web Interfaceの
http://127.0.0.1:4040/api/tunnels
に、HTTP GETのリクエスト投げると、レスポンスで各種情報が得られます。
リクエスト時には、Accept
やContent-Type
を、application/json
にしておくと、JSON形式でレスポンス返ってきます。
例えば、以下のような感じです。
metrics
も興味深いですが、今回は中身省略して記載しています。
{
"tunnels": [
{
"name": "command_line",
"uri": "/api/tunnels/command_line",
"public_url": "https://{random-id}.ngrok.io",
"proto": "https",
"config": {
"addr": "http://localhost:8080",
"inspect": true
},
"metrics": {
}
}
],
"uri": "/api/tunnels"
}
今回はngrok起動コマンドで、明示的にhttps
のみ指定したので、"tunnels"
配列には、1つのオブジェクトのみ含まれます。
そのオブジェクトの"public_url"
から、ngrok側が割り当てたpublicなURLが取得できます。
"config"
オブジェクトの"addr"
からは、フォワード先のローカル側のURLが取得できます。
また、先ほどWeb Interfaceで説明したリクエストやレスポンスのヘッダやBodyの中身の時系列での情報取得や、
Replayを行うAPIもあります。
4. ここまでで、もうWebhookの通知は受けられます
ngrokサービス
が準備してくれた、public URLをWebhookの通知先として、各種チャットサービスやSNSなどのAPIサービス側に登録すれば、実際に動作するはずです。
この時点ではローカル側にまだサーバーがないので、正常なレスポンスを返すことができませんが、
Web Interface ( http://127.0.0.1:4040 )から、Webhookで通知されたリクエストのヘッダやBodyの中身は確認できます。
Forwarding https://{random-id}.ngrok.io -> http://localhost:8080
のように出力されている場合は、
https://{random-id}.ngrok.io/webhook
などを、Webhookの通知先のURLとして登録します。
{random-id}の部分は、ngrokコマンドを実行した環境によって、また無償版では(有償版で指定していない場合も)、起動ごとに変わります。
後ろにくっつけたパス部分の、/webhook
は好きなパスに指定できます。
/
とかでも良いですが、この後のコードでは、https://{random-id}.ngrok.io/webhook
のように、パスは/webhook
を指定したものとして記載します。
Webhookの通知先の登録方法は利用するクラウドサービスなどによって異なります。
開発者用のサイトから登録できるものや、APIで登録する場合などあります。
試してみたいサービスごとに登録方法は確認する必要があります。
サービスによっては、登録作業を行った瞬間に、Webhookの通知先にリクエストを出して、
適切なレスポンスを返さないと、登録に失敗する仕組みのものもあります。
こちらの場合でも、登録時のリクエスト内容は、上記のWeb Interfaceで確認できます。
また、ここで、Webブラウザなどで、同じマシンからでも、別のマシンからでも、
https://{random-id}.ngrok.io/webhook
にアクセスすると、
ngrokコマンドを実行しているコンソールや、Web Interfaceにも出力があるはずです。
HTTP GET以外でもいろいろ試してみたい場合は、Postman などを使って、とりあえず、動作を見ることはできると思います。
5. 各種言語で簡易サーバを実装する
今のままでは、ngrokがローカルにフォワードする先のサーバがないので、簡易サーバを作っていきます。
httpのリクエストに対して、どんなレスポンスを返すべきかは、サービスごとにことなるので、
今回は、とりあえず、200 OK
で、Bodyは以下のようなJSONを固定で返すことにします。
サービスによっては、204 No Content
で、Bodyなしで返せばよいものもあります。
とりあえず、レスポンスBodyに入れる内容(実際は利用するサービスによって適切なものを返す必要あり):
{
"status" : "OK"
}
この後、例として記載するいずれかのコードを実行した上で、ngrok
を実行して、
ngrokが準備したhttpsのURLを元にしたURL(https://{random-id}.ngrok.io/webhook
とか)を、
対象のクラウドサービス側にWebhook通知先として登録すると動作します。
例では、ngrokが準備したURLに、"/webhook"のパスが追加されている前提のコードになっています。
例では、サービスからの通知されたリクエストのBodyを表示して、固定のレスポンスを返しているだけですが、
利用するサービスに応じて、処理を少し追加すると、いろいろできると思います。
今回は、さくっと例を記載したいだけなので、処理に成功したかどうかのチェック等は省略しています。
煩雑になりすぎないように、必要最小限に近いくらいのコードになるようにしていますが、実際のアプリでは各種チェックが必要になります。
また、Webhookは、クラウドサービスの場合は、publicなURLで受ける場合が多いので、
偽装した通知が送られるような場合も想定しておく必要があると思いますが、その辺りの対策も今回の実装例には盛り込んではいません。
関連してですが、(いろんな意見あると思いますが)、個人的には、偽装通知する人のヒントになるような、
404 Not Found
とか405 Method Not Allowed
とかも返すべきでないと思っていますが、その辺も今回の実装例には盛り込んでいません。
不正な通知に対しては、「204 No Content
で成功で返す」、
「レスポンス自体返さず相手はレスポンス待ち状態にする(TCPのコネクションは切断しない)」、
「レスポンスは返さず、TCPのコネクションを切断する」などの対処があると思います。
ただし、コネクションを切断しないパターンは、
サーバ側の残りの接続数やスレッドプール数に悪影響がでやすい(リソースを枯渇させる攻撃が考えられる)ので、
一般的には実装が難しいです(TCP/Socketレベルで実装考えないといけなくなると思います)。
5.1. Goでの実装の例
標準の、net/http
パッケージを利用した例です。
※ 説明用にコメントは多めに書いています。
go1.13 + Windows 10 Pro 64bitで動作確認しています。
package main
import (
"fmt"
"io/ioutil"
"net/http"
)
// Webhookに通知が来た時に呼ばれるハンドラ。
func handleWebhook(w http.ResponseWriter, r *http.Request) {
// Webhook通知元の仕様にもよるが、HTTPのPOSTかGETかで通知が来る前提にして、
// それ以外は、"204 No Content"を返す。
if r.Method != http.MethodPost && r.Method != http.MethodGet {
w.WriteHeader(http.StatusNoContent)
return
}
// Bodyの内容を読み込んで表示するだけ。
body, _ := ioutil.ReadAll(r.Body)
fmt.Println("ReceivedData:", string(body))
////////////////////////////////////////////////////////////////
// 以下、レスポンスで何を返すべきかは通知元のサービス側の仕様にもよる。
// "204 No Content"を返せばいい場合は、以下にコメントアウトした1行だけでBody出力不要。
// w.WriteHeader(http.StatusNoContent)
// レスポンスヘッダで、Content-Type: application/jsonにする。
w.Header().Set("Content-Type", "application/json")
// レスポンスのBodyは決め打ち。
fmt.Fprint(w, "{\"status\" : \"OK\"}")
}
func main() {
// ローカルサーバの"/webhook"にリクエストが来た時に呼ばれるハンドラを登録。
http.HandleFunc("/webhook", handleWebhook)
// ローカルの8080番ポート(ngrok起動時のオプションで指定した番号)で待ち受け開始。
// ngrokが同じローカルマシンで動いているので、"127.0.0.1"だけで待ち受ければよい。
// (外部の環境からリクエストを受ける必要がない)
http.ListenAndServe("127.0.0.1:8080", nil)
}
5.2. C#での実装の例
.NET Core 2.0以降や、.NET Frameworkなどで利用できるSystem.Net.HttpListener
を利用した例です。
※ 説明用にコメントは多めに書いています。
.NET Core 2.0 + Windows 10 Pro 64bitで動作確認しています。
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
namespace WebhookListener
{
class Program
{
static void Main(string[] args)
{
// System.Net.HttpListenerを利用してサーバを実装します。
using (var listener = new HttpListener())
{
// ローカルの8080番ポート(ngrok起動時のオプションで指定した番号)で待ち受け開始。
// ngrokが同じローカルマシンで動いているので、"127.0.0.1"だけで待ち受ければよい。
// (外部の環境からリクエストを受ける必要がない)
// また、"/webhook"のパスも入れておく。Prefix指定時は、"/"で終わるようにしておく必要がある。
listener.Prefixes.Add("http://127.0.0.1:8080/webhook/");
// HttpListenerの待ち受けを開始します。
listener.Start();
// スレッドプール上で待ち受けるようにする。
// 今回は1スレッドだが、例えばループで64回Task作成すれば、64スレッドで待ち受けるようになる。
Task.Run(
async () =>
{
// HttpListenerが待ち受け中はループする。
while(listener.IsListening)
{
////////////////////////////////////////////////////////////////
// このサンプルの実装では、whileブロック内で例外が発生すると後続の待ち受けも中断されます。
// 実際には、適切に例外を処理する必要があります。
// どの例外をcatchすべきかは、whileブロック内での処理内容にもよります。
// catchしすぎると、意図せずループが続く場合もあるので注意が必要です。
// 基本的には、例外が発生しても待ち受けを継続したいような場合の例外は、whileブロックの中、
// 待ち受けを継続しても意味がないような例外は、whileブロックの外側で受けるように実装します。
// 先ほど登録したアドレス、ポート、パスに合致するリクエストが来ると、処理用のContextが取得できる。
var context = await listener.GetContextAsync();
// リクエストとレスポンス処理用のインスタンス取得。
var request = context.Request;
var response = context.Response;
try
{
if (request.HttpMethod != "POST" && request.HttpMethod != "GET")
{
// Webhook通知元の仕様にもよるが、HTTPのPOSTかGETかで通知が来る前提にして、
// それ以外は、"204 No Content"を返す。
response.StatusCode = 204;
}
else
{
// Bodyの内容を読み込んで表示するだけ。
if (request.HasEntityBody)
{
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
{
Console.WriteLine("RequestData: {0}", reader.ReadToEnd());
}
}
////////////////////////////////////////////////////////////////
// 以下、レスポンスで何を返すべきかは通知元のサービス側の仕様にもよる。
// "204 No Content"を返せばいい場合は、以下にコメントアウトした1行だけでBody出力不要。
// response.StatusCode = 204;
// レスポンスヘッダで、Content-Type: application/jsonにする。
response.ContentType = "application/json";
// レスポンスのBodyは決め打ちで書き込む。
using (var writer = new StreamWriter(response.OutputStream))
{
writer.Write("{\"status\" : \"OK\"}");
}
}
}
finally
{
// ResponseはClose()を呼ぶ必要があります。
response.Close();
}
}
});
// 何かキーを押したら終了させる。
Console.WriteLine("終了するには何かキーを押してください。");
Console.ReadKey(false);
}
}
}
}
5.3. Javaでの実装の例
Java 1.6以降利用可能なcom.sun.net.httpserver.HttpServer
を利用した例です。
パッケージは、com.sun.net
配下になっていますが、OpenJDKでも利用できます。
※ 説明用にコメントは多めに書いています。
OpenJDK11(AdoptOpenJDK) + Windows 10 Pro 64bitで動作確認しています。
package thrzn41.samples;
import java.io.IOException;
import java.net.InetSocketAddress;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
public class WebhookListener {
/**
* リクエストが来た時に処理するハンドラクラス。
*/
private static class WebhookHandler implements HttpHandler {
/**
* HttpServer.createContext()で登録したパスにHTTPリクエストがあると、このメソッドが呼ばれる。
*/
@Override
public void handle(HttpExchange exchange) throws IOException {
// Webhook通知元の仕様にもよるが、HTTPのPOSTかGETかで通知が来る前提にして、
// それ以外は、"204 No Content"を返す。
String method = exchange.getRequestMethod();
if( !method.equals("POST") && !method.equals("GET") ) {
exchange.sendResponseHeaders(204, -1);
return;
}
// Bodyの内容を読み込んで表示するだけ。
try(var input = exchange.getRequestBody()) {
// 本来は、ちゃんとリクエストヘッダのエンコーディングを見た方がいいですが、
// 今回は、"utf-8"である前提で変換しています。
String body = new String(input.readAllBytes(), "utf-8");
System.out.printf("RequestData: %s%n", body);
}
////////////////////////////////////////////////////////////////
// 以下、レスポンスで何を返すべきかは通知元のサービス側の仕様にもよる。
// "204 No Content"を返せばいい場合は、以下にコメントアウトした1行だけでBody出力不要。
// exchange.sendResponseHeaders(204, -1);
// レスポンスのBodyは決め打ちで書き込む。
var responseBytes = "{\"status\" : \"OK\"}".getBytes("utf-8");
try(var output = exchange.getResponseBody()) {
// レスポンスヘッダで、Content-Type: application/jsonにする。
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, responseBytes.length);
output.write(responseBytes);
}
}
}
public static void main(String[] args) {
try {
// ローカルの8080番ポート(ngrok起動時のオプションで指定した番号)で待ち受けます。
// ngrokが同じローカルマシンで動いているので、"127.0.0.1"だけで待ち受ければよい。
// (外部の環境からリクエストを受ける必要がない)
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 8080), -1);
// ローカルサーバの"/webhook"にリクエストが来た時に呼ばれるハンドラを登録。
server.createContext("/webhook", new WebhookHandler());
// 今回はデフォルトのExecutorを利用してHTTPリクエストを処理するように指定(null)。
server.setExecutor(null);
// HttpServerの待ち受けを開始します。
server.start();
System.out.println("終了するには何かキーを押してください。");
System.in.read();
// HttpServerの待ち受けを停止します。
server.stop(0);
} catch(IOException ioex) {
ioex.printStackTrace();
}
}
}
5.4. Pythonでの実装の例
標準のhttp.server
モジュールを使っても実装できますが、今回はflask
を使っちゃいます。
※ 説明用にコメントは多めに書いています。
flask入ってなかったら、以下でインストール(pip
使う場合の例)。
$ pip install flask
Python 3.6 + flask 1.1 + Windows 10 Pro 64bitで動作確認しています。
from flask import Flask, request
# 起動時の自分の名前からFlaskのインスタンス作成。
app = Flask(__name__)
# Webhookに通知が来た時に呼ばれるハンドラ。
# 以下の指定では、"/webhook"に対するHTTP POST, GET, PUT, DELETEリクエストの場合に呼ばれます。
# パスが違うと"404 Not Found", リストにないメソッドの場合は、"405 Method Not Allowed"が返ります。
@app.route("/webhook", methods=["POST", "GET", "PUT", "DELETE"])
def handle_webhook():
# Webhook通知元の仕様にもよるが、HTTPのPOSTかGETかで通知が来る前提にして、
# それ以外は、"204 No Content"を返す。
if request.method != 'POST' and request.method != 'GET':
# レスポンスのBodyとStatus Codeをタプルで返します(make_response()でタプル指定するのと同じ)。
return ('', 204)
# Bodyの内容を読み込んで表示するだけ。
print(request.get_data(as_text=True))
# ==============================================================
# 以下、レスポンスで何を返すべきかは通知元のサービス側の仕様にもよる。
# "204 No Content"を返せばいい場合は、以下にコメントアウトした1行だけでBody出力不要。
# return ('', 204)
# レスポンスのBodyは決め打ち。
# ディクショナリで返せば、jsonに変換(jsonify()を呼ぶのと同じ)して、Content-Typeヘッダも"application/json"にしてくれます。
# より厳密には、make_response()が呼ばれて、その中でjsonify()が呼ばれて、その中で、Content-Typeが"application/json"に設定される。
return { 'status' : 'OK' }
if __name__ == '__main__':
# ローカルの8080番ポート(ngrok起動時のオプションで指定した番号)で待ち受け開始。
# ngrokが同じローカルマシンで動いているので、"127.0.0.1"だけで待ち受ければよい。
# (外部の環境からリクエストを受ける必要がない)
app.run('127.0.0.1', 8080)