Go で Heroku の WebSocket を使用する
この記事の英語版に更新があります。ご覧の翻訳には含まれていない変更点があるかもしれません。
最終更新日 2022年01月26日(水)
このチュートリアルでは、Heroku にデプロイされた WebSocket を使用する Go アプリケーションについて説明します。
デモアプリケーションのサンプルコードは GitHub で入手できます。編集や機能強化を歓迎します。単にリポジトリをフォークし、変更を加えて、プルリクエストを送信してください。
前提条件
- Go、Git、Godep、および Heroku クライアント (「Heroku スターターガイド (Go))」で説明)。
- Heroku ユーザーアカウント。 無料ですぐにサインアップできます。
WebSocket アプリの作成
このサンプルアプリケーションは、Go で WebSocket を使用する単純な例を提供します。サンプルアプリを入手し、読みながらコードを追っていくことができます。
サンプルアプリの入手
$ go get github.com/heroku-examples/go-websocket-chat-demo/...
$ cd $GOPATH/src/github.com/heroku-examples/go-websocket-chat-demo
機能
このサンプルアプリケーションは、バックエンドへの WebSocket を開く単純なチャットアプリケーションです。ブラウザからチャットメッセージが送信されると常に、そのメッセージはサーバーに送信された後、Redis チャネルに公開されます。別の goroutine が同じ Redis チャネルを購読しており、受信したメッセージを開かれているすべての WebSocket 接続にブロードキャストします。
この例では、次のように重要なものがいくつかあります。
- 完全な WebSocket 実装を提供する Gorilla WebSocket ライブラリ。
- 構造化されたプラグ可能なログ記録を提供する Logrus ライブラリ。
- Redis へのインターフェースを提供する redigo ライブラリ。
- サーバーへの WebSocket 接続を開き、サーバーから受信された WebSocket メッセージに応答する、ブラウザ上の JavaScript。
では、バックエンドとフロントエンドの両方をさらに詳細に見ていきましょう。
バックエンド
Gorilla の WebSocket ライブラリを使用すると、標準の http.Handler
とほぼ同様の WebSocket ハンドラを作成できます。この場合は、メッセージの送受信を処理する handleWebsocket
という名前の 1 つのエンドポイントを作成します。受信リクエストを WebSocket 接続にアップグレードするには、websocket.Upgrader
を使用する必要があります。このエンドポイントは、チャットサービスへの新しいメッセージの送信とそのサービスからのメッセージの受信の両方に使用されます。受信メッセージは、Go バイトスライスとして ws.ReadMessage()
経由で受信されます。これらのメッセージを受け取り、検証してから Redis サブスクリプションチャネルに挿入するため、接続されているすべてのサーバーが更新を受信できます。
// handleWebsocket connection.
func handleWebsocket(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
m := "Unable to upgrade to websockets"
log.WithField("err", err).Println(m)
http.Error(w, m, http.StatusBadRequest)
return
}
id := rr.register(ws)
for {
mt, data, err := ws.ReadMessage()
l := log.WithFields(logrus.Fields{"mt": mt, "data": data, "err": err})
if err != nil {
if err == io.EOF {
l.Info("Websocket closed!")
} else {
l.Error("Error reading websocket message")
}
break
}
switch mt {
case websocket.TextMessage:
msg, err := validateMessage(data)
if err != nil {
l.WithFields(logrus.Fields{"msg": msg, "err": err}).Error("Invalid Message")
break
}
rw.publish(data)
default:
l.Warning("Unknown Message!")
}
}
rr.deRegister(id)
ws.WriteMessage(websocket.CloseMessage, []byte{})
}
Redis から受信されたメッセージは、redisReceiver
の run()
関数によってすべての WebSocket 接続にブロードキャストされます。
func (rr *redisReceiver) run() error {
l := log.WithField("channel", Channel)
conn := rr.pool.Get()
defer conn.Close()
psc := redis.PubSubConn{Conn: conn}
psc.Subscribe(Channel)
for {
switch v := psc.Receive().(type) {
case redis.Message:
l.WithField("message", string(v.Data)).Info("Redis Message Received")
if _, err := validateMessage(v.Data); err != nil {
l.WithField("err", err).Error("Error unmarshalling message from Redis")
continue
}
rr.broadcast(v.Data)
case redis.Subscription:
l.WithFields(logrus.Fields{
"kind": v.Kind,
"count": v.Count,
}).Println("Redis Subscription Received")
case error:
return errors.Wrap(v, "Error while subscribed to Redis channel")
default:
l.WithField("v", v).Info("Unknown Redis receive during subscription")
}
}
}
このアーキテクチャモデルにより、このアプリケーションは必要なだけ多くの dyno で実行することができ、すべてのユーザーが更新を送受信できます。
main()
関数では、redisReceiver
と redisWritter
を作成し、それらを run()
で実行して Redis 対話を処理します。/ws
で handleWebsocket
ハンドラを登録します。public
ディレクトリは、public/
からの http.FileServer
によって処理されます。起動時に使用できない可能性があり、またメンテナンスの一部として再起動される可能性がある Redis サーバーの可用性は特に注意して処理します。
func main() {
port := os.Getenv("PORT")
if port == "" {
log.WithField("PORT", port).Fatal("$PORT must be set")
}
redisURL := os.Getenv("REDIS_URL")
redisPool, err := redis.NewRedisPoolFromURL(redisURL)
if err != nil {
log.WithField("url", redisURL).Fatal("Unable to create Redis pool")
}
rr = newRedisReceiver(redisPool)
rw = newRedisWriter(redisPool)
go func() {
for {
waited, err := redis.WaitForAvailability(redisURL, waitTimeout, rr.wait)
if !waited || err != nil {
log.WithFields(logrus.Fields{"waitTimeout": waitTimeout, "err": err}).Fatal("Redis not available by timeout!")
}
rr.broadcast(availableMessage)
err = rr.run()
if err == nil {
break
}
log.Error(err)
}
}()
go func() {
for {
waited, err := redis.WaitForAvailability(redisURL, waitTimeout, nil)
if !waited || err != nil {
log.WithFields(logrus.Fields{"waitTimeout": waitTimeout, "err": err}).Fatal("Redis not available by timeout!")
}
err = rw.run()
if err == nil {
break
}
log.Error(err)
}
}()
http.Handle("/", http.FileServer(http.Dir("./public")))
http.HandleFunc("/ws", handleWebsocket)
log.Println(http.ListenAndServe(":"+port, nil))
}
フロントエンド
この記事の 2 番目の部分は、サーバーとの WebSocket 接続を開くためのクライアント側の設定です。
インデックスページでは、CSS の Bootstrap と jQuery を使用します。すべての静的アセットを public
フォルダーに保存します。
主な WebSocket 対話は、メインページによってロードされる public/js/application.js
で実行されます。これがサーバーへの WebSocket 接続を開きます。
ブラウザ内の切断されたすべての接続を自動的に再接続する reconnecting-websocket を使用します。
WebSocket が開いていると、ブラウザはメッセージを受信するため、これを処理する関数を定義します。
box.onmessage = function(message) {
var data = JSON.parse(message.data);
$("#chat-text").append("<div class='panel panel-default'><div class='panel-heading'>" + $('<span/>').text(data.handle).html() + "</div><div class='panel-body'>" + $('<span/>').text(data.text).html() + "</div></div>");
$("#chat-text").stop().animate({
scrollTop: $('#chat-text')[0].scrollHeight
}, 800);
};
使用するメッセージは、handle
(ユーザーのハンドル) と text
(ユーザーのメッセージ) の 2 つのキーを含む JSON レスポンスです。メッセージは受信されると、JSON で解析された後、新しいエントリとしてページに挿入されます。
event.preventDefault()
を使用してフォームが実際に POST に送信されないようにすることにより、入力フォームでの送信ボタンの動作を上書きします。代わりに、フォームからこの値を取得し、それを JSON メッセージとして WebSocket 経由でサーバーに送信します。
$("#input-form").on("submit", function(event) {
event.preventDefault();
var handle = $("#input-handle")[0].value;
var text = $("#input-text")[0].value;
box.send(JSON.stringify({ handle: handle, text: text }));
$("#input-text")[0].value = "";
});
ローカルでの実行
このサンプルアプリケーションには、web
プロセスを宣言する Procfile がすでに含まれています。
web: go-websocket-chat-demo
実行可能ファイルをコンパイルして $GOPATH/bin
にインストールする必要があります。
$ go install -v
Heroku CLI には、ローカルでのアプリの実行を支援する Heroku Local コマンドが付属していますが、どの $PORT
と $REDIS_URL
にローカルに接続するかを認識している必要があります。
$ cp .env.local .env
必要に応じて .env
ファイルを変更します。
Web アプリをローカルで起動してください。
$ heroku local
[OKAY] Loaded ENV .env File as KEY=VALUE Format
## Deploy
It’s time to deploy your app to Heroku. Create a Heroku app to deploy to:
```term
$ heroku create
Creating pure-river-3626... done, stack is heroku-18
Buildpack set. Next release on pure-river-3626 will use heroku/go.
https://pure-river-3626.herokuapp.com/ | https://git.heroku.com/pure-river-3626.git
Redis アドオンを追加します。
$ heroku addons:create heroku-redis
Creating flowing-subtly-2327... done, (free)
Adding flowing-subtly-2327 to pure-river-3626... done
Setting HEROKU_REDIS_CHARCOAL_URL and restarting pure-river-3626... done, v15
Database has been created and will be available shortly
Use `heroku addons:docs heroku-redis` to view documentation.
git push
でコードをデプロイします。
$ git push heroku master
Counting objects: 624, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (342/342), done.
Writing objects: 100% (624/624), 808.36 KiB | 0 bytes/s, done.
Total 624 (delta 231), reused 611 (delta 225)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Go app detected
remote: -----> Checking vendor/vendor.json file.
remote: -----> Using go1.7
remote: -----> Installing govendor v1.0.3... done
remote: -----> Fetching any unsaved dependencies (govendor sync)
remote: -----> Running: go install -v -tags heroku .
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/Sirupsen/logrus
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/garyburd/redigo/internal
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/garyburd/redigo/redis
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/gorilla/websocket
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/heroku/x/redis
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/pkg/errors
remote: github.com/heroku-examples/go-websocket-chat-demo/vendor/github.com/satori/go.uuid
remote: github.com/heroku-examples/go-websocket-chat-demo
remote: -----> Discovering process types
remote: Procfile declares types -> web
remote:
remote: -----> Compressing...
remote: Done: 2.4M
remote: -----> Launching...
remote: Released v5
remote: https://go-websocket-chat-demo.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
To https://git.heroku.com/go-websocket-chat-demo.git
* [new branch] master -> master
以上で、Web アプリが Heroku で稼働するようになりました。
次のステップ
これでアプリをデプロイし、基本事項を理解できたので、アプリをもう少し堅牢になるように拡張したいと思います。これには、公開する前にやっておきたいことのすべては含まれていませんが、正しい判断を行うという方向性は間違っていません。
セキュリティ
これは単なるデモアプリケーションであるため、さまざまな攻撃に脆弱である可能性があります。WebSocket アプリケーションのセキュリティ保護に関するより一般的なガイドラインについては、「WebSockets Security」(WebSocket のセキュリティ) を参照してください。