WebRTC 已經行之有年,曾經在寫這篇文章的 5 年前在懵懵懂懂的情況下用 WebRTC 做出簡單的視訊應用,這篇文章主要是紀錄現今對於 WebRTC 基礎建設的研究,已及實驗步驟。
從頭開始實作,可以參考這篇文章,我在網頁端的程式碼也是使用他寫的範例去改的:
WebRTC 是經過 RFC 標準定義而來的,如果已經成功可以實作 WebRTC 的應用了話,可以回去讀讀規格: https://github.com/feixiao/rfc
在這個技術中,最需要額外 Study 的應該是 Video Streaming Codec。
從回憶的面向,簡單說幾件事:
- 防火牆穿透
- TURN
- STUN
- ICE
- UDP hole punching
- 角色
- Offer
- Answer
- 資訊交換狀態
- IceCandidate (所選的 ICE Server ,讓對方也到同一個 ice 去建立溝通用的)
- SDP Description (會話狀態)
- 資訊交換伺服器
- WebSocket Signaling
- HTTP Signaling
- Furioos Signaling
- 開始 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
沒有留言:
張貼留言