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
沒有留言:
張貼留言