2020年10月28日 星期三

WebRTC (1) - 穿透技術以及 go 的 WebSocket 資訊交換實作

WebRTC 已經行之有年,曾經在寫這篇文章的 5 年前在懵懵懂懂的情況下用 WebRTC 做出簡單的視訊應用,這篇文章主要是紀錄現今對於 WebRTC 基礎建設的研究,已及實驗步驟。

 

從頭開始實作,可以參考這篇文章,我在網頁端的程式碼也是使用他寫的範例去改的:

[WebRTC-介紹與實戰]


WebRTC 是經過 RFC 標準定義而來的,如果已經成功可以實作 WebRTC 的應用了話,可以回去讀讀規格: https://github.com/feixiao/rfc

在這個技術中,最需要額外 Study 的應該是 Video Streaming Codec。


從回憶的面向,簡單說幾件事:


  1. 防火牆穿透
    • TURN
    • STUN
    • ICE
    • UDP hole punching
  2. 角色
    • Offer
    • Answer
  3. 資訊交換狀態
    • IceCandidate (所選的 ICE Server ,讓對方也到同一個 ice 去建立溝通用的)
    • SDP Description (會話狀態)
  4. 資訊交換伺服器
    • WebSocket Signaling
    • HTTP Signaling
    • Furioos Signaling
  5. 開始 WebRTC 雙工串流
    • Video Encoder


編號本身就有實作的順序性,以下是假設在已知 WebRTC 大致上運作方式下,提供的回憶。

實作的 go 程式只負責廣播 SDP 資訊跟 ICE Candidate 資訊給其他者,初始發話是從第一個撥話的人開始傳,傳給其他人收到後,也會打開自己的串流,根據初始發話者的 ICE Candidate 一個個連接,然後把 SDP 廣播給其他人,也包含廣播回到初始發話者上。

以下範例是包含傳送電腦螢幕畫面以及攝影機、聲音出去的。


防火牆穿透技術: 採用 TRUN


在這篇文章中 [STUN, TURN, ICE介绍] 稍微比較過後,我直接就選擇 TURN,而且我會採用自己架設的方式進行,畢竟人人有一條自己的固定 IP 是很正常的事,連帶一起做的話,省去 Production 面的麻煩。


TURN Server 我是直接採用 pion/turn 的 repo ,記得放在 go/src/github/pion 底下(gopath),然後這個 repo 自帶就有 TRUN 範例,在 https://github.com/pion/turn/blob/master/examples/turn-server/tcp/main.go 這個位置。

在這個 main.go ,想要修改的幾個地方是:


package main
func main() {
        publicIP := flag.String("public-ip", "0.0.0.0", "IP Address that TURN can be contacted by.")
        port := flag.Int("port", 3478, "Listening port.")
        users := flag.String("users", "mac=123", "List of username and password (e.g. \"user=pass,user=pass\")")
        realm := flag.String("realm", "pion.ly", "Realm (defaults to \"pion.ly\")")
        flag.Parse()
...


我把 public-ip 換成本地 0.0.0.0, port 換成 3479,users 那邊是驗證的帳號密碼,有時候密碼是驗證的 key,不過基本的格式就是 username=credential ,credential(可以是你自己的密碼)。

在 gopath 的這個 go/src/github.com/pion/turn/exapmles/turn-server/tcp 目錄下,做 go get -u ./... 之後,直接 go build 或是 go run main.go 就可以打開 server。

如果想看有沒有連接進來,可以在 main.go 的 AuthHandler 加上 Println 看。


資訊交換伺服器: 1-1 撥號端與接通端


試想,開啟 LINE 單人撥號與群聊的差異,其實在這邊最重要的實作就是有撥號廣播的 Server,WebRTC 並不是什麼神奇的東西,他也是需要經過 Hole Punching 以及一台等待接聽的 Server 把雙方資訊交換, WebRTC 才能建立連接。

在這裡,我採用 WebSocket 的方式做兩端資訊交換,因此我是直接做一個 Go 的廣播伺服器,請參考: https://github.com/gorilla/websocket/tree/master/examples/chat

在這個廣播範例中,是不能直接拿來用的,因為這個範例在 client.go 中的 serveWs 中,可以很明顯地看到他使用了類似 Queue Worker 的機制在處理傳送的訊息:

go client.writePump() //處理廣播

go client.readPump()  //處理接收訊息


這會導致出現廣播連體嬰的問題,也就是,如果你在很短的時間內連續送出好幾個 part JSON,廣播時就會連在一起: {xxxx}\r\n{xxxx},而且這個方式本身就無法即時傳送,他是等待 tick 去跑廣播。

另外一點是, client.go 本身有限制 maxMessageSize,這會導致 PeerConnection 的 SDP (Session Description Protocol) 資訊太大的話傳不出去。

綜合以上幾點,我們要做一點修改:


Client.go:

// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"bytes"
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/gorilla/websocket"
)

const (
	// Time allowed to write a message to the peer.
	writeWait = 10 * time.Second

	// Time allowed to read the next pong message from the peer.
	pongWait = 60 * time.Second

	// Send pings to peer with this period. Must be less than pongWait.
	pingPeriod = (pongWait * 9) / 10

	// **修改為 512 的 600 倍大小
	maxMessageSize = 512 * 600
)

var (
	newline = []byte{'\n'}
	space   = []byte{' '}
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	//需要做 CORS Domain 給外部的服務作為連接使用
	CheckOrigin: func(r *http.Request) bool { return true },
}

// Client is a middleman between the websocket connection and the hub.
type Client struct {
	hub *Hub

	// The websocket connection.
	conn *websocket.Conn

	// Buffered channel of outbound messages.
	send chan []byte
}

// readPump pumps messages from the websocket connection to the hub.
//
// The application runs readPump in a per-connection goroutine. The application
// ensures that there is at most one reader on a connection by executing all
// reads from this goroutine.
func (c *Client) readPump() {
	defer func() {
		c.hub.unregister <- c
		c.conn.Close()
	}()
	c.conn.SetReadLimit(maxMessageSize)
	c.conn.SetReadDeadline(time.Now().Add(pongWait))
	c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
	for {
		_, message, err := c.conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
				log.Printf("error: %v", err)
			}
			break
		}
		message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
		c.hub.broadcast <- message
		//向不是自己以外的人發送自己的訊息
		//fmt.Println(c.hub.clients)
		//fmt.Println(c)
		//解決訊息連體嬰
		for v, _ := range c.hub.clients {
			//fmt.Println(v == c)
			if v != c {
				w, _ := v.conn.NextWriter(websocket.TextMessage)
				fmt.Println(string(message))
				w.Write(message)
				if err := w.Close(); err != nil {
					return
				}
			}
		}
	}
}

// serveWs handles websocket requests from the peer.
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println(err)
		return
	}
	client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
	client.hub.register <- client

	// Allow collection of memory referenced by the caller by doing all work in
	// new goroutines.
	go client.readPump()
}

hub.go 不變,然後這是 main.go:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/gorilla/mux"
)

func main() {
	router := mux.NewRouter()
	router.PathPrefix("/").Handler(http.FileServer(http.Dir("./")))

	hub := newHub()
	go hub.run()
	http.HandleFunc("/boradcast", func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("ds")
		serveWs(hub, w, r)
	})
	go http.ListenAndServe(":8899", nil)

	fmt.Println("TEST")
	log.Fatal((&http.Server{
		Handler:      router,
		Addr:         ":8080",
		WriteTimeout: 15 * time.Second,
		ReadTimeout:  15 * time.Second,
	}).ListenAndServe())
}

修改後,直接使用 go run main.go client.go hub.go 執行。

再把 HMTL 檔案放在同目錄就好。

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://webrtc.github.io/adapter/adapter-latest.js" type="application/javascript"></script>
</head>
<body>
    <h1> Self View </h1>
    <video id="selfView" width="320" height="240" autoplay muted></video>
    <br/>
    <button id="call">Call</button>
    <h1> Remote View </h1>
    <video id="remoteView" width="320" height="240" autoplay muted></video>

    <video id="vframe"></video>
    <script>

        let ws = new WebSocket('ws://192.168.50.152:8899/boradcast')
        const configuration = {
            iceServers: [
                //{urls: "stun:23.21.150.121"},
                //{urls: "stun:stun.l.google.com:19302"},
                {
                    urls: "turn:192.168.50.152:3478?transport=tcp", 
                    username: "mac",
                    credential:"123",
                }  // TURN Server address
            ]
        };

        var pc;
        //isCaller 決定誰是撥號的人 offer 或是接電話的人 answer
        // start(true) 的情形,是按按鈕啟動的
        // start(false) 的情形,是 ws.onmessage 收到有人在撥號,表示自己應該是接聽的人去啟動的
        function start(isCaller) {
            pc = new RTCPeerConnection(configuration);

            // pc 選到的候選人,就把這個 ice 候選人傳給對方
            // send any ice candidates to the other peer
            pc.onicecandidate = function (evt) {
                ws.send(btoa(JSON.stringify({"candidate": evt.candidate})));
            };

            // ontrack 是收到串流之後,把 remoteView(video) 串流物件指向 streams data
            // once remote stream arrives, show it in the remote video element
            pc.ontrack = function (evt) {
                console.log("add remote stream");
                console.log(evt);
                remoteView.srcObject = evt.streams[0];
                console.log("收到遠端串流")
            };

            //得到 desc 後,傳給對方
            function gotDescription(desc) {
                pc.setLocalDescription(desc);
                ws.send(btoa(JSON.stringify({"sdp": desc})));
            }

            //接電話的傳自己的螢幕錄影
            function _startScreenCapture() {
                if (navigator.getDisplayMedia) {
                    return navigator.getDisplayMedia({video: true});
                } else if (navigator.mediaDevices.getDisplayMedia) {
                    return navigator.mediaDevices.getDisplayMedia({video: true});
                } else {
                    return navigator.mediaDevices.getUserMedia({video: {mediaSource: 'screen'}});
                }
            }
            
            if (isCaller){
                //撥電話的傳自己的視訊鏡頭跟音訊
                navigator.mediaDevices.getUserMedia({"audio": true, "video": true}).then((stream) => {
                    console.log("start streaming");
                    console.log(stream);
                    selfView.srcObject = stream;
                    pc.addStream(stream);

                    //建立 offer 得到 desc 後,傳給對方
                    pc.createOffer().then((desc)=>gotDescription(desc));
                });
            }else{
                //接電話的傳自己的螢幕錄影
                _startScreenCapture().then((stream) => {
                    console.log("start streaming");
                    console.log(stream);
                    selfView.srcObject = stream;
                    pc.addStream(stream);

                    //建立 answer 得到 desc 後,傳給對方
                    pc.createAnswer().then((desc)=> gotDescription(desc));
                })
            }

        }

        //按鈕按下去
        call.addEventListener('click', ()=> {
            console.log('webrtc start');
            start(true);
        });


        ws.onopen = () => {
            console.log('open connection')
        }

        ws.onclose = () => {
            console.log('close connection')
            ws = new WebSocket('ws://192.168.50.152:8899/boradcast')
            console.log('reconnect')
        }


        ws.onmessage = (event) => {
            console.log('event-data',event.data)
            const data = JSON.parse(atob(event.data))

            //如果 pc 沒有東西,而接到通知,表示自己是接聽電話的人
            //建立一個 pc 為 answer 的連接
            if (!pc)
                start(false);

            //收到 SDP Descriptiom 資訊,放到建立對方的連接會話描述中
            if (data.sdp)
                pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
            //收到 ICE 候選人名單,加入候選人,到時候會一個一個看在哪個候選人 ice
            else if (data.candidate)
                pc.addIceCandidate(new RTCIceCandidate(data.candidate));
        };

    </script>
</body>
</html>


完成修改後,用同一個網址在兩個瀏覽器分頁中就可以進行撥電話溝通的功能。


Reference:

https://www.html5rocks.com/en/tutorials/webrtc/infrastructure/
https://medium.com/@pohsiu0709/webrtc-%E7%9A%84%E9%9D%88%E9%AD%82-stun-turn-server-c652ce76725c
http://sj82516-blog.logdown.com/posts/1207821
https://stackoverflow.com/questions/34982250/how-to-establish-peer-connection-in-web-app-using-coturn-stun-turn-server
https://stackoverflow.com/questions/47274120/how-to-play-audio-stream-chunks-recorded-with-webrtc
https://codepen.io/OnyxJoy/pen/QWWdQpv
https://medium.com/ducktypd/serving-static-files-with-golang-or-gorilla-mux-b6bf8fa2e5e
https://developers.google.com/web/updates/2012/12/Screensharing-with-WebRTC
https://github.com/feixiao/rfc


沒有留言:

張貼留言

© Mac Taylor, 歡迎自由轉貼。
Background Email Pattern by Toby Elliott
Since 2014