🦜

Go標準でブラウザにイベントストリーミングする

2023/03/29に公開1

WebSocketのツラミ

  • 中継サービスの対応がないと切れる
  • ルーターによっては長時間アクセスがないと切れる
  • 切れたら繋ぎなおすのはクライアントの実装次第
  • セキュアにつなぐためにはサーバーもクライアントも新バージョンのサポートが必要
  • 接続数が膨れず、安定して接続を維持するのには結構ノウハウが求められる
  • 単純に切れたら即繋ぐでは中継やサーバーに問題が発生することもある

そこでEventSourceですよ

  • メジャーブラウザでサポート・互換性も高い
  • プロトコル仕様がただのHTTPロングポール+アルファ
  • なのでほとんどの接続経路で中継トラブルが少ない
  • JSのEventSource実装がセッション維持を頑張ってくれる
  • サーバーから切断されたら再接続をしようとする
  • 特にGoなら標準機能でさっくりサーバーが書ける

クライアント実装

let es = new EventSource("/sse");
es.addEventListener("message", function (ev) {
  if (ev.data.length > 0) {
    let event = JSON.parse(ev.data);
    // eventを使った処理
  }
});

基本のコード

type Event struct {
	// 任意のフィールドセット
}

var (
	subscribe   = make(chan chan<- Params, 1)
	unsubscribe = make(chan chan<- Params, 1)
)

func sse(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	ch := make(chan Event, 64)
	subscribe <- ch
	log.Printf("connect from: %v", r.RemoteAddr)
	defer func() {
		unsubscribe <- ch
		log.Printf("disconnect from: %v", r.RemoteAddr)
	}()
	timeout := time.NewTicker(30 * time.Second)
	for {
		select {
		case <-r.Context().Done():
			return
		case v := <-ch:
			timeout.Reset(30 * time.Second)
			b, _ := json.Marshal(v)
			fmt.Fprintf(w, "data: %s\n\n", string(b))
		case <-timeout.C:
			fmt.Fprintf(w, "data: \n\n")
		}
		if f, ok := w.(http.Flusher); ok {
			f.Flush()
		}
	}
}

func do(publish chan<- Event) {
	for {
		// publishに投げ込む任意の処理
	}
}

func main() {
	publish := make(chan Event, 64)
	go func () {
		m := map[chan<- Event]struct{}{}
		for {
			select {
			case v := <-subscribe:
				m[v] = struct{}{}
			case v := <-unsubscribe:
				delete(m, v)
				close(v)
			case v := <-publish:
				for c := range m {
					c <- v
				}
			}
		}
	}
	http.Handle("/sse", http.HandlerFunc(sse))
	go do(publish)
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal(err)
	}
}

あとは「publish」にEvent値を投げ込めば接続済みのクライアントに通知されます。

まとめ

  • EventSourceは自動的に5秒おきに再接続してくれるので実装がシンプル
  • Go側(バックエンド)もややこしいライブラリが無くてもシンプルに実装できちゃう
  • Go側の再起動後、クライアントは勝手に再接続してくれる
  • 現実的な実装にするには各種goroutineの中断用にcontext.Contextを渡すようにしましょう

Discussion

NoboNoboNoboNobo

ちなみに「data:」の前に「event: any-name」を加えてやると、addEventListener用の任意のイベント名としてブラウザJSにイベントを投げることができます。