因為產品中要加入網頁中網絡會議的功能,這幾天都在倒騰 WebRTC,現在分享下工作成果。
話說 WebRTC
Real Time Communication 簡稱 RTC,是谷歌若干年前收購的一項技術,后來把這項技術應用到瀏覽器中並開源出來,而且搞了一套標准提交給W3C,稱為WebRTC,官方地址是:http://www.webrtc.org/。WebRTC要求瀏覽器內置實時傳輸音視頻的功能,並提供一致的API供JS使用。目前實現這套標准的瀏覽器有:Chrome、FireFox、Opera。微軟雖然也在對WebRTC標准的制定做貢獻,但仍然沒有在任何版本的IE中支持WebRTC,所以,對於IE瀏覽器,不得不安裝Chrome Frame插件來支持WebRTC;對於Safari瀏覽器,可以使用WebRtc4all這個插件,地址是:https://code.google.com/p/webrtc4all/。
WebRTC基礎
- MediaStream 用於獲取本地的 音視頻流。不同的瀏覽器名稱不一樣,但參數一樣,谷歌和Opera是navigator.webkitGetUserMedia,火狐是 navigator.mozGetUserMedia。
- RTCPeerConnection:和 getUserMedia 一樣 谷歌和火狐分別會有webkit、moz前綴。這個對象主要用於兩個瀏覽器之間建立連接以及傳輸音視頻流。
- RTCDataChannel 用於兩個瀏覽器之間傳輸自定義的數據,用這個對象可以實現互發消息,而不用經過服務端的中轉。
WebRTC的實現是建立瀏覽器之間的直接連接,而不需要其他服務器的中轉,即P2P,這就要求彼此之間需要知道對方的外網地址。但大多數計算機都位於NAT之后,只有少部分主機擁有外網地址,這就要求一種方式可以穿透NAT,STUN和TURN就是這樣的技術。對於STUN和TURN的詳細介紹,可以查看這里(http://www.h3c.com.cn/MiniSite/Technology_Circle/Net_Reptile/The_Five/Home/Catalog/201206/747038_97665_0.htm)。
WebRTC會使用默認的或程序指定的SUTN服務器,獲取指向當前主機的外網地址和端口。谷歌瀏覽器默認的是谷歌域名下的一個STUN,國內可能不大穩定,於是我找到了這個 stunserver.org/ ,連接速度比較快,據說當年飛信就是使用的這個,應該比較可靠。如果信不過第三方的STUN服務,也可以自己搭建一台,搭建過程也挺簡單。
P2P的建立過程需要依賴服務端中轉外網IP及端口、音視頻設備配置信息,所以服務端需要使用可以雙工通訊的手段,比如WebSocket,來實現信令的中轉,稱之為信令服務器。
WebRTC會話的建立詳解
會話的建立主要有兩個過程:網絡信息的交換、音視頻設備信息的交換。以下以 lilei 要和 Lucy 開視頻為例描述這兩個過程。
網絡信息的交換:
- lilei首先創建了一個RTCPeerConnection對象,這個對象會自動的去向STUN服務器詢問自己的外網IP和端口。然后lilei把自己的網絡信息經過信令服務器中轉后,發送給lucy。
- lucy接收到lilei的網絡信息之后,也創建了一個RTCPeerConnection對象,並把lilei發過來的信息通過addIceCandidate添加到對象中。
- lucy把自己的網絡信息經過信令服務器的中轉后,發送給lilei。
- lilei接收到信息后,通過RTCPeerConnection對象的addIceCandidate方法保存lucy的網絡信息。
音視頻設備信息的交換:
- lilei通過RTCPeerConnection對象的createOffer方法,獲取本地的音視頻編碼分辨率等信息,通過setLocalDescription添加到RTCPeerConnection中,並把這些信息經過信令服務器中轉后發送給lucy。
- lucy接收到lilei發過來的信息后,使用RTCPeerConnection對象的setRemoteDescription方法保存。然后通過createAnswer方法獲取自己的音視頻信息並以同樣的手段發送給lilei。
- lilei接收到lucy的信 息,調用setRemoteDescription方法保存。
以上兩個過程可以是並發的,並無先后順序,但必須得等到兩個過程都完成后,P2P的連接才真正的建立。一旦連接建立,lilei和lucy就可以直接發送音視頻流,而不需要中轉。WebRTC在獲取本地網絡信息的時候,會先嘗試STUN,如果失敗,則會使用TURN。
WebRTC + Asp.net Web API 實現視頻聊天室
首先使用WebSocket實現信令服務器部分,在此需要用到微軟開發的用於實現WebSocket的dll (http://www.nuget.org/packages/Microsoft.WebSockets/),以及Json.net。

public class Session : WebSocketHandler { private static WebSocketCollection sessions = new WebSocketCollection(); public String UserId { get; set; } public override void OnOpen() { this.UserId = Guid.NewGuid().ToString("N"); var message = new { type = SignalMessageType.Conect, userId = this.UserId }; sessions.Broadcast(Json.Encode(message)); sessions.Add(this); } public override void OnMessage(string msg) { var obj = Json.Decode(msg); var messageType = (SignalMessageType)obj.type; switch (messageType) { case SignalMessageType.Offer: case SignalMessageType.Answer: case SignalMessageType.IceCandidate: var session = sessions.Cast<Session>().FirstOrDefault(n => n.UserId == obj.userId); var message = new { type = messageType, userId = this.UserId, description = obj.description }; session.Send(Json.Encode(message)); break; } } } public enum SignalMessageType { Conect, DisConnect, Offer, Answer, IceCandidate }
WebAPI控制器需要引用命名空間“Microsoft.Web.WebSockets;”代碼如下:

public class SignalServerController : ApiController { [HttpGet] public HttpResponseMessage Connect() { var session = new WebRTCDemo.Session(); HttpContext.Current.AcceptWebSocketRequest(session); return new HttpResponseMessage(HttpStatusCode.SwitchingProtocols); } }

var RtcConnect = function (_userId, _webSocketHelper) { var config = { iceServers: [{ url: 'stun:stunserver.org' }] }; var peerConnection = null; var userId = _userId; var webSocketHelper = _webSocketHelper; var createVideo = function (stream) { var src = window.webkitURL.createObjectURL(stream); var video = $("<video />").attr("src", src); var container = $("<div />").addClass("videoContainer").append(video).appendTo($("body")); video[0].play(); return container; }; var init = function () { window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection; peerConnection = window.RTCPeerConnection(config); peerConnection.addEventListener('addstream', function (event) { createVideo(event.stream); }); peerConnection.addEventListener('icecandidate', function (event) { var description = JSON.stringify(event.candidate); var message = JSON.stringify({ type: 4, userId: userId, description: description }); webSocketHelper.send(message); }); navigator.getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; var localStream = navigator.getMedia({ video: true, audio: true }, getUserMediaSuccess, getUserMediaFail); peerConnection.addStream(localStream); }; this.connect = function () { peerConnection.createOffer(function (offer) { peerConnection.setLocalDescription(offer); var description = JSON.stringify(offer); var message = JSON.stringify({ type: 2, userId: userId, description: description }); webSocketHelper.send(message); }); }; this.acceptOffer = function (offer) { peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); peerConnection.createAnswer(function (answer) { peerConnection.setLocalDescription(answer); var description = JSON.stringify(answer); var message = JSON.stringify({ type: 3, userId: userId, description: description }); webSocketHelper.send(message); }); }; this.acceptAnswer = function (answer) { peerConnection.setRemoteDescription(new RTCSessionDescription(answer)); }; this.addIceCandidate = function (candidate) { peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); }; init(); }; var WebSocketHelper = function (callback) { var ws = null; var url = "ws://" + document.location.host + "/api/Signal/Connect"; var init = function () { ws = new WebSocket(url); ws.onmessage = onmessage; ws.onerror = onerror; ws.onopen = onopen; }; var onmessage = function (message) { callback(JSON.parse(message.data)); }; this.send = function (data) { ws.send(data); }; init(); }; $(function() { var rtcConnects = {}; var webSocketHelper = new WebSocketHelper(function (message) { var rtcConnect = getOrCreateRtcConnect(message.userId); switch (message.type) { case 0: //Conect rtcConnect.connect(); break; case 2: //Offer rtcConnect.acceptOffer(JSON.parse(message.description)); break; case 3: //Answer rtcConnect.acceptAnswer(JSON.parse(message.description)); break; case 4: //IceCandidate rtcConnect.addIceCandidate(JSON.parse(message.description)); break; default: break; } }); var init = function() { navigator.getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; var stream = navigator.getMedia({ video: true, audio: true }, function() { var src = window.webkitURL.createObjectURL(stream); var video = $("<video />").attr("src", src); $("<div />").addClass("videoContainer").append(video).appendTo($("body")); video[0].play(); }, function (error) { console.error(error); }); }; var getOrCreateRtcConnect = function (userId) { var rtcConnect = rtcConnects[userId]; if (typeof (rtcConnect) == 'undefined') { rtcConnect = new rtcConnect(userId, webSocketHelper); rtcConnects[userId] = rtcConnect; } return rtcConnect; }; init(); });

<html> <head> <style> .videoContainer { float: left; padding: 10px 0 10px 10px; width: 210px; margin: 5px; } .videoContainer > video { width: 200px; height: 150px; margin-top: 5px; } </style> </head> <body> </body> </html>
其他
如果想部署自己專用的STUN服務器,這里(http://www.stunprotocol.org/)有STUN服務器的完整開源實現,原生是運行在Linux上的,但也提供了cgwin下編譯的windwos版本。如何編譯、運行等在它的github主頁上說的比較清楚:https://github.com/jselbie/stunserver。
如果覺得自己寫那一坨js比較繁瑣,這里(http://www.rtcmulticonnection.org/)有一個封裝庫,簡單了解了一下,功能挺強大的。