使用netty 实现webrtc 协议
第一步 获取媒体信息
在针对 Web 进行开发时,WebRTC 标准提供了用于访问连接到计算机或智能手机的摄像头和麦克风的 API。这些设备通常称为“媒体设备”,可通过实现 MediaDevices
接口的 navigator.mediaDevices
对象使用 JavaScript 进行访问。最常见的用法是通过函数 getUserMedia()
,该函数会返回一个针对匹配的媒体设备解析为 MediaStream
的 promise。
<video id="localVideo" autoplay></video>
const localVideo = document.getElementById("localVideo");
const constraints = {
//获取图像
'video': true,
//获取音频
'audio': true
}
navigator.mediaDevices.getUserMedia(constraints)
.then(stream => {
console.log('Got MediaStream:');
//将获取到的媒体流和 localVideo 进行绑定显示
localVideo.srcObject = localStream;
})
.catch(error => {
console.error('Error accessing media devices.', error);
});
这样就可以在网页中看到摄像头捕获的内容了。
注意 navigator.mediaDevices.getUserMedia 这个可以设置很多属性,这里只是获取摄像头和麦克风,有兴趣可以去官网看看 https://webrtc.org/getting-started/media-capture-and-constraints?hl=zh-cn
第二步 建立对等连接
媒体协商
一对一通信中,发起方发送的 SDP 称为Offer
(提议),接收方发送的 SDP 称为Answer
(应答)。
每端保持两个描述:描述本身的本地描述LocalDescription
,描述呼叫的远端的远程描述RemoteDescription
。
当通信双方 RTCPeerConnection 对象创建完成后,就可以进行媒体协商了,大致过程如下:
webrtc 的连接建立过程大概如下
- 发起方创建 offer ,保存为本地描述,发送给信令服务器,由信令服务器转发给接收方
- 接收方接收 offer ,保存远端描述,创建 answer ,发送给信令服务器,由信令服务器转发给发起方
- 发起方接收 answer,保存为远端描述。
- 整个媒体协商过程处理完毕。这样设置了本地和远程会话说明之后,他们就会了解远程对等方的功能。这并不意味着对等设备之间的连接已准备就绪。还需要收集 ice 候选项,相互交换后才能成功建立连接
这里就是使用 Netty 来与浏览器建立 websocket 连接作为信令服务器。
后端完整代码:
主要就是实现 websocket 连接,和处理转发 offer,answer 和 ice 候选项
使用 WebSocketServerProtocolHandler 可以很容易实现 websocket 的建立主要就是转发内容
用户需要先与服务器建立连接,并绑定信息,服务器将用户信息与channel保存下来,然后使用这些信息来转发 offer answer ice等内容,其实这里netty作为信令服务器就只需要对消息进行转发就行了。主要还是前端代码。
public class Test {
public static void main(String[] args) throws InterruptedException {
NioEventLoopGroup boss = new NioEventLoopGroup(2);
NioEventLoopGroup worker = new NioEventLoopGroup();
try{
ChannelFuture sync = new ServerBootstrap()
.channel(NioServerSocketChannel.class)
.group(boss, worker)
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new WebSocketServerProtocolHandler("/webrtc"));
pipeline.addLast(new WebRTCHandler());
}
}).bind(new InetSocketAddress("localhost",8080)).sync();
ChannelFuture channelFuture = sync.channel().closeFuture().sync();
}finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
}
}
}
//主要是 WebRTCHandler
class WebRTCHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
private static ConcurrentMap<String,Channel> channelConcurrentMap = new ConcurrentHashMap<>();
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) {
// 处理WebSocket消息
if (frame instanceof TextWebSocketFrame) {
// 处理文本消息
TextWebSocketFrame textFrame = (TextWebSocketFrame) frame;
String text = textFrame.text();
JSONObject jsonObject = JSON.parseObject(text);
//处理offer
if (jsonObject.get("type").equals("offer")){
System.out.println("处理 offer");
String remote = (String) jsonObject.get("remote");
extracted(text, remote);
}
//处理answer
else if (jsonObject.get("type").equals("answer")){
String remote = (String) jsonObject.get("remote");
extracted(text, remote);
}
else if (jsonObject.get("type").equals("bind")){
channelConcurrentMap.put((String) jsonObject.get("id"),ctx.channel());
ctx.channel().writeAndFlush(JSON.toJSONString(new JSONObject().put("data","成功")));
} else if (jsonObject.get("type").equals("send")){
ctx.channel().writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(new JSONObject().put("data","ok"))));
}else if (jsonObject.get("type").equals("iceCandidate")){
System.out.println("处理 answer");
String remote = (String) jsonObject.get("remote");
extracted(text, remote);
}
}
}
private void extracted(String text, String remote) {
//将offer 发送给对方
Channel channel = channelConcurrentMap.get(remote);
channel.writeAndFlush(new TextWebSocketFrame(text));
}
}
前端:
注意,这里需要双方都点击 bind 按钮,也就是与netty建立连接后,先点击发起方的 start 按钮,再点击 接收方的 start 按钮,才能正常运行
发起方:
这里将各个部分的代码分开展示,后面接收端的完整代码
html 代码
<video id="localVideo" autoplay></video>
<video id="remoteVideo" autoplay></video>
<button id="startButton">Start</button>
<button id="stopButton">Stop</button>
<button id="bind">bind</button>
获取网页元素,定义常量
const localVideo = document.getElementById("localVideo");
const remoteVideo = document.getElementById("remoteVideo");
const startButton = document.getElementById("startButton");
const stopButton = document.getElementById("stopButton");
const bindButton = document.getElementById("bind");
const sendButton = document.getElementById("sendButton");
let localStream;
let remoteStream;
startButton.addEventListener("click", start);
stopButton.addEventListener("click", stop);
bindButton.addEventListener("click", bind);
let socket;
先建立websocket连接,并发送绑定消息,主要 webrtc 协议并不是只能依靠 websocket 协议,只是这里使用 socket 协议实现的。后面发送消息的时候,都会带上对方的 id 服务器用于选择用户发送。
function bind() {
socket = new WebSocket("ws://localhost:8080/webrtc");
socket.onopen = () => {
//发送一个绑定消息,后端服务器用于区分channel
console.log("bind 1");
socket.send(JSON.stringify({ type: "bind", id: "1" }));
};
socket.onmessage = async (event) => {
//接收offer创建answer并发送
console.log("接收到消息" + event);
const data = JSON.parse(event.data);
console.log("data " + data);
};
}
开始进行 webrtc 连接,下面这些 addEventListener 都是用于监听事件都写在前面,他们只有在特定的条件下才会触发,并不影响执行顺序,还是双方交换 offer,answer,ice 后才会建立连接
async function start() {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
//将捕捉到的流,与本地 video 进行绑定,就可以看到本地画面了
localVideo.srcObject = localStream;
const configuration = {
};
const peerConnection = new RTCPeerConnection(configuration);
// 要收集 ICE 候选对象,只需为 icecandidate 事件添加监听器即可。针对该监听器发出的 RTCPeerConnectionIceEvent 将包 含 candidate 属性,该属性表示应发送到远程对等端的新候选音频
peerConnection.addEventListener("icecandidate", (event) => {
console.log('触发 ' + event)
if (event.candidate) {
//如果收集到了候选项就发送给对方
console.log("candidate" + event.candidate);
socket.send(
JSON.stringify({
type: "iceCandidate",
iceCandidate: event.candidate,
remote: "2",
})
);
} else {
/* 在此次协商中,没有更多的候选了 */
console.log("没有候选项");
}
});
//监听是否连接成功 connectionstatechange 事件
peerConnection.addEventListener('connectionstatechange', event => {
if (peerConnection.connectionState === 'connected') {
console.log("连接已建立!")
}
});
peerConnection.addEventListener("icecandidateerror", (event) => {
console.log('触发err ' + event)
});
//监听 track 事件,RTCTrackEvent 包含一个 MediaStream 对象数组,这些对象与对等项的相应本地数据流具有相同的 MediaStream.id 值,说白了也就是对方的音频流信息
peerConnection.addEventListener('track', async (event) => {
const [remoteStream] = event.streams;
//将接收到的流信息在 video 上面绑定进行显示
remoteVideo.srcObject = remoteStream;
});
//将本地流添加到 peerConnection 会开始收集 Ice候选项
localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));
//创建offer
const offer = await peerConnection.createOffer();
//保存自己的 本地说明
await peerConnection.setLocalDescription(offer);
//发送offer给服务器
socket.send(
JSON.stringify({ type: "offer", offer: offer, remote: "2" })
);
console.log("发送" + offer);
socket.onmessage = async (event) => {
//接收offer创建answer并发送
console.log("接收到消息" + event);
const data = JSON.parse(event.data);
console.log("state " + peerConnection.connectionState)
console.log("data " + data);
if (data.type === "answer") {
console.log("answer " + data.answer.sdp);
const remoteDesc = new RTCSessionDescription(data.answer);
//保存远端描述
await peerConnection.setRemoteDescription(remoteDesc);
//双方相互交换描述后,获取 iec 候选, 进行交换, 等ice候选项交换完后连接就建立了
} else if (data.type === "iceCandidate") {
// 处理ICE候选
await peerConnection.addIceCandidate(data.iceCandidate);
}
};
}
接收端
<!DOCTYPE html>
<html>
<head>
<title>WebRTC Video Chat</title>
</head>
<body>
<video id="localVideo" autoplay></video>
<video id="remoteVideo" autoplay></video>
<button id="startButton">Start</button>
<button id="stopButton">Stop</button>
<button id="bind"">bind</button>
<script>
const localVideo = document.getElementById("localVideo");
const remoteVideo = document.getElementById("remoteVideo");
const startButton = document.getElementById("startButton");
const stopButton = document.getElementById("stopButton");
const bindButton = document.getElementById("bind");
let localStream;
let remoteStream;
startButton.addEventListener("click", start);
stopButton.addEventListener("click", stop);
bindButton.addEventListener("click", bind);
var socket;
let offer;
function bind(){
socket = new WebSocket("ws://localhost:8080/webrtc");
socket.onopen = () => {
//发送一个绑定消息,后端服务器用于区分channel
console.log("bind 2")
socket.send(JSON.stringify({ type: "bind", id: "2" }));
};
socket.onmessage = async (event) => {
//接收offer创建answer并发送
const data = JSON.parse(event.data);
if (data.type === "offer") {
console.log("offer:"+data.offer.sdp)
offer = data.offer;
}else if (data.type === "candidate") {
// 处理ICE候选
console.log("data.iceCandidate "+data.iceCandidate)
await peerConnection.addIceCandidate(data.iceCandidate);
}
};
}
async function start() {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
localVideo.srcObject = localStream;
const configuration = {
};
const peerConnection = new RTCPeerConnection(configuration);
peerConnection.addEventListener('icecandidate', event => {
if (event.candidate) {
console.log(event.candidate)
//发送自己的
socket.send(JSON.stringify({ type: "iceCandidate", iceCandidate: event.candidate, remote:"1" }));
}
});
peerConnection.addEventListener('track', async (event) => {
const [remoteStream] = event.streams;
remoteVideo.srcObject = remoteStream;
});
//将本地流添加到 peerConnection
localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));
// 处理offer,创建answer
//保存远端 信息
peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
//创建answer
const answer = await peerConnection.createAnswer();
console.log("answer "+answer)
//保存本地信息
await peerConnection.setLocalDescription(answer);
//返回answer发送
socket.send(JSON.stringify({ type: "answer", answer: answer, remote:"1" }));
}
function stop() {
// 停止本地视频流
localStream.getTracks().forEach((track) => track.stop());
}
</script>
</body>
</html>
运行效果
参考文章:https://zhuanlan.zhihu.com/p/662875601
webrtc 网站:https://webrtc.org/?hl=zh-cn