Java+Netty+WebRTC语音视频屏幕共享聊天室设计实践
Posted 殷长庆
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java+Netty+WebRTC语音视频屏幕共享聊天室设计实践相关的知识,希望对你有一定的参考价值。
背景
本文使用webtrc实现了一个简单的语音视频聊天室、支持多人音视频聊天、屏幕共享。
环境配置
音视频功能需要在有Https协议的域名下才能获取到设备信息,
测试环境搭建Https服务参考Windows下Nginx配置SSL实现Https访问(包含openssl证书生成)_殷长庆的博客-CSDN博客
正式环境可以申请一个免费的证书
复杂网络环境下需要自己搭建turnserver,网络上搜索大多是使用coturn来搭建turn服务
turn默认监听端口3478,可以使用webrtc.github.io 测试服务是否可用
本文在局域网内测试,不必要部署turn,使用的谷歌的stun:stun.l.google.com:19302
webrtc参考文章
实现
服务端
服务端使用netty构建一个websocket服务,用来完成为音视频传递ICE信息等工作。
maven配置
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.luck.cc</groupId>
<artifactId>cc-im</artifactId>
<version>1.0-SNAPSHOT</version>
<name>cc-im</name>
<url>http://maven.apache.org</url>
<properties>
<java.home>$env.JAVA_HOME</java.home>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.74.Final</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.5.7</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.luck.im.ServerStart</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
JAVA代码
聊天室服务
package com.luck.im;
import java.util.List;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioserverSocketChannel;
import io.netty.handler.codec.MessageToMessageCodec;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
public class ChatSocket
private static EventLoopGroup bossGroup = new NioEventLoopGroup();
private static EventLoopGroup workerGroup = new NioEventLoopGroup();
private static ChannelFuture channelFuture;
/**
* 启动服务代理
*
* @throws Exception
*/
public static void startServer() throws Exception
try
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>()
@Override
public void initChannel(SocketChannel ch) throws Exception
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(
new WebSocketServerProtocolHandler("/myim", null, true, Integer.MAX_VALUE, false));
pipeline.addLast(new MessageToMessageCodec<TextWebSocketFrame, String>()
@Override
protected void decode(ChannelHandlerContext ctx, TextWebSocketFrame frame,
List<Object> list) throws Exception
list.add(frame.text());
@Override
protected void encode(ChannelHandlerContext ctx, String msg, List<Object> list)
throws Exception
list.add(new TextWebSocketFrame(msg));
);
pipeline.addLast(new ChatHandler());
);
channelFuture = b.bind(8321).sync();
channelFuture.channel().closeFuture().sync();
finally
shutdown();
// 服务器已关闭
public static void shutdown()
if (channelFuture != null)
channelFuture.channel().close().syncUninterruptibly();
if ((bossGroup != null) && (!bossGroup.isShutdown()))
bossGroup.shutdownGracefully();
if ((workerGroup != null) && (!workerGroup.isShutdown()))
workerGroup.shutdownGracefully();
聊天室业务
package com.luck.im;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.AttributeKey;
import io.netty.util.internal.StringUtil;
public class ChatHandler extends SimpleChannelInboundHandler<String>
/** 用户集合 */
private static Map<String, Channel> umap = new ConcurrentHashMap<>();
/** channel绑定自己的用户ID */
public static final AttributeKey<String> UID = AttributeKey.newInstance("uid");
@Override
public void channelRead0(ChannelHandlerContext ctx, String msg)
JSONObject parseObj = JSONUtil.parseObj(msg);
Integer type = parseObj.getInt("t");
String uid = parseObj.getStr("uid");
String tid = parseObj.getStr("tid");
switch (type)
case 0:
// 心跳
break;
case 1:
// 用户加入聊天室
umap.put(uid, ctx.channel());
ctx.channel().attr(UID).set(uid);
umap.forEach((x, y) ->
if (!x.equals(uid))
JSONObject json = new JSONObject();
json.set("t", 2);
json.set("uid", uid);
json.set("type", "join");
y.writeAndFlush(json.toString());
);
break;
case 2:
Channel uc = umap.get(tid);
if (null != uc)
uc.writeAndFlush(msg);
break;
case 9:
// 用户退出聊天室
umap.remove(uid);
leave(ctx, uid);
ctx.close();
break;
default:
break;
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception
String uid = ctx.channel().attr(UID).get();
if (StringUtil.isNullOrEmpty(uid))
super.channelInactive(ctx);
return;
ctx.channel().attr(UID).set(null);
umap.remove(uid);
leave(ctx, uid);
super.channelInactive(ctx);
/**
* 用户退出
*
* @param ctx
* @param uid
*/
private void leave(ChannelHandlerContext ctx, String uid)
umap.forEach((x, y) ->
if (!x.equals(uid))
// 把数据转到用户服务
JSONObject json = new JSONObject();
json.set("t", 9);
json.set("uid", uid);
y.writeAndFlush(json.toString());
);
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception
cause.printStackTrace();
ctx.close();
启动类
package com.luck.im;
public class ServerStart
public static void main(String[] args) throws Exception
// 启动聊天室
ChatSocket.startServer();
前端
网页主要使用了adapter-latest.js,下载地址webrtc.github.io
github访问不了可以用webrtc/adapter-latest.js-Javascript文档类资源-CSDN文库
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>聊天室</title>
<style>videowidth:100px;height:100px</style>
</head>
<body>
<video id="localVideo" autoplay playsinline></video>
<video id="screenVideo" autoplay playsinline></video>
<div id="videos"></div>
<div id="screenVideos"></div>
<div>
<button onclick="startScreen()">开启屏幕共享</button>
<button onclick="closeScreen()">关闭屏幕共享</button>
<button onclick="startVideo()">开启视频</button>
<button onclick="closeVideo()">关闭视频</button>
<button onclick="startAudio()">开启语音</button>
<button onclick="closeAudio()">关闭语音</button>
<button onclick="leave()">退出</button>
</div>
</body>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script>
function getUid(id)
return id?id:uid;
// 开启屏幕共享
function startScreen(id)
id=getUid(id);
if(id!=uid)
sendMsg(id,type:'startScreen')
return false;
if(!screenVideo.srcObject)
let options = audio: false, video: true;
navigator.mediaDevices.getDisplayMedia(options)
.then(stream =>
screenVideo.srcObject = stream;
for(let i in remotes)
onmessage(uid:i,t:2,type:'s_join');
stream.getVideoTracks()[0].addEventListener('ended', () =>
closeScreen();
);
)
function selfCloseScreen(ot)
screenVideo.srcObject.getVideoTracks()[0].stop()
for(let i in remotes)
sendMsg(i,type:'closeScreen',ot:ot)
screenVideo.srcObject=null;
// 关闭屏幕共享
function closeScreen(id,ot)
id=getUid(id);
ot=(ot?ot:1);
if(id!=uid)
if(ot==1&&remotes[id].screenVideo)
remotes[id].screenVideo.srcObject=null;
else
sendMsg(id,type:'closeScreen',ot:2)
return false;
if(screenVideo.srcObject&&ot==1)
selfCloseScreen(ot)
// 开启视频
function startVideo(id)
id=getUid(id);
if(id!=uid)
sendMsg(id,type:'startVideo')
return false;
let v = localVideo.srcObject.getVideoTracks();
if(v&&v.length>0&&!v[0].enabled)
v[0].enabled=true;
// 关闭视频
function closeVideo(id)
id=getUid(id);
if(id!=uid)
sendMsg(id,type:'closeVideo')
return false;
let v = localVideo.srcObject.getVideoTracks();
if(v&&v.length>0&&v[0].enabled)
v[0].enabled=false;
// 开启语音
function startAudio(id)
id=getUid(id);
if(id!=uid)
sendMsg(id,type:'startAudio')
return false;
let v = localVideo.srcObject.getAudioTracks();
if(v&&v.length>0&&!v[0].enabled)
v[0].enabled=true;
// 关闭语音
function closeAudio(id)
id=getUid(id);
if(id!=uid)
sendMsg(id,type:'closeAudio')
return false;
let v = localVideo.srcObject.getAudioTracks();
if(v&&v.length>0&&v[0].enabled)
v[0].enabled=false;
// 存储通信方信息
const remotes =
// 本地视频预览
const localVideo = document.querySelector('#localVideo')
// 视频列表区域
const videos = document.querySelector('#videos')
// 同屏视频预览
const screenVideo = document.querySelector('#screenVideo')
// 同屏视频列表区域
const screenVideos = document.querySelector('#screenVideos')
// 用户ID
var uid = new Date().getTime() + '';
var ws = new WebSocket('wss://127.0.0.1/myim');
ws.onopen = function()
heartBeat();
onopen();
// 心跳保持ws连接
function heartBeat()
setInterval(()=>
ws.send(JSON.stringify( t: 0 ))
,3000);
function onopen()
navigator.mediaDevices
.getUserMedia(
audio: true, // 本地测试防止回声
video: true
)
.then(stream =>
localVideo.srcObject = stream;
ws.send(JSON.stringify( t: 1, uid: uid ));
ws.onmessage = function(event)
onmessage(JSON.parse(event.data));
)
async function onmessage(message)
if(message.t==9)
onleave(message.uid);
if(message.t==2&&message.type)
switch (message.type)
case 'join':
// 有新的人加入就重新设置会话,重新与新加入的人建立新会话
createRTC(message.uid,localVideo.srcObject,1)
const pc = remotes[message.uid].pc
const offer = await pc.createOffer()
pc.setLocalDescription(offer)
sendMsg(message.uid, type: 'offer', offer )
if(screenVideo.srcObject)
onmessage(uid:message.uid,t:2,type:'s_join');
break
case 'offer':
createRTC(message.uid,localVideo.srcObject,1)
const pc = remotes[message.uid].pc
pc.setRemoteDescription(new RTCSessionDescription(message.offer))
const answer = await pc.createAnswer()
pc.setLocalDescription(answer)
sendMsg(message.uid, type: 'answer', answer )
break
case 'answer':
const pc = remotes[message.uid].pc
pc.setRemoteDescription(new RTCSessionDescription(message.answer))
break
case 'candidate':
const pc = remotes[message.uid].pc
pc.addIceCandidate(new RTCIceCandidate(message.candidate))
break
case 's_join':
createRTC(message.uid,screenVideo.srcObject,2)
const pc = remotes[message.uid].s_pc
const offer = await pc.createOffer()
pc.setLocalDescription(offer)
sendMsg(message.uid, type: 's_offer', offer )
break
case 's_offer':
createRTC(message.uid,screenVideo.srcObject,2)
const pc = remotes[message.uid].s_pc
pc.setRemoteDescription(new RTCSessionDescription(message.offer))
const answer = await pc.createAnswer()
pc.setLocalDescription(answer)
sendMsg(message.uid, type: 's_answer', answer )
break
case 's_answer':
const pc = remotes[message.uid].s_pc
pc.setRemoteDescription(new RTCSessionDescription(message.answer))
break
case 's_candidate':
const pc = remotes[message.uid].s_pc
pc.addIceCandidate(new RTCIceCandidate(message.candidate))
break
case 'startScreen':
startScreen()
break
case 'closeScreen':
const ot = message.ot
if(ot==1)
closeScreen(message.uid,1)
else
closeScreen(uid,1)
break
case 'startVideo':
startVideo()
break
case 'closeVideo':
closeVideo()
break
case 'startAudio':
startAudio()
break
case 'closeAudio':
closeAudio()
break
default:
console.log(message)
break
function removeScreenVideo(id)
if(remotes[id].s_pc)
remotes[id].s_pc.close()
if(remotes[id].screenVideo)
screenVideos.removeChild(remotes[id].screenVideo)
function onleave(id)
if (remotes[id])
remotes[id].pc.close()
videos.removeChild(remotes[id].video)
removeScreenVideo(id)
delete remotes[id]
function leave()
ws.send(JSON.stringify( t: 9, uid: uid ));
// socket发送消息
function sendMsg(tid, msg)
msg.t = 2;
msg.tid=tid;
msg.uid=uid;
ws.send(JSON.stringify(msg))
// 创建RTC对象,一个RTC对象只能与一个远端连接
function createRTC(id,stream,type)
const pc = new RTCPeerConnection(
iceServers: [
urls: 'stun:stun.l.google.com:19302'
]
)
// 获取本地网络信息,并发送给通信方
pc.addEventListener('icecandidate', event =>
if (event.candidate)
// 发送自身的网络信息到通信方
sendMsg(id,
type: (type==1?'candidate':'s_candidate'),
candidate:
sdpMLineIndex: event.candidate.sdpMLineIndex,
sdpMid: event.candidate.sdpMid,
candidate: event.candidate.candidate
)
)
// 有远程视频流时,显示远程视频流
pc.addEventListener('track', event =>
if(type==1)
if(!remotes[id].video)
const video = createVideo()
videos.append(video)
remotes[id].video=video
remotes[id].video.srcObject = event.streams[0]
else
if(!remotes[id].screenVideo)
const video = createVideo()
screenVideos.append(video)
remotes[id].screenVideo=video
remotes[id].screenVideo.srcObject = event.streams[0]
)
// 添加本地视频流到会话中
if(stream)
stream.getTracks().forEach(track => pc.addTrack(track, stream))
if(!remotes[id])remotes[id]=
if(type==1)
remotes[id].pc=pc
else
remotes[id].s_pc=pc
function createVideo()
const video = document.createElement('video')
video.setAttribute('autoplay', true)
video.setAttribute('playsinline', true)
return video
</script>
</html>
nginx配置
上面的index.html文件放到D盘根目录下了,然后配置一下websocket
server
listen 443 ssl;
server_name mytest.com;
ssl_certificate lee/lee.crt;
ssl_certificate_key lee/lee.key;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location /
root d:/;
index index.html index.htm index.php;
location /myim
proxy_pass http://127.0.0.1:8321/myim;
运行
java启动
java -jar cc-im.jar
网页访问
https://127.0.0.1/index.html
基于WebRTC开源框架的实时视频聊天项目,搭建私人实时通信服务
本项目是一个 Web 应用,使用安全且可扩展的 WebRTC 构建,提供视频和音频通信、文件共享、屏幕共享、白板和实时 P2P 和群聊消息的实时通信。WebRTC 是一个开源框架,可在 Web 和本机应用程序中实现音频、视频和数据的实时通信。 它使用户能够通过视频和音频会议、网络研讨会、播客等与他人交流。
尽管任何 WebRTC 应用程序都使用 4 种类型的服务器:
- 应用程序服务器- 托管 Connect Web 应用程序!
- 信令服务器- WebRTC 信令是指建立、控制和终止通信会话的过程。为了让两个端点开始相互交谈,必须交换三种类型的信息: 会话控制信息确定何时初始化、关闭和修改通信会话。
- NAT Traversal Server (STUN & TURN) - WebRTC 在连接同一本地网络中的浏览器时表现出色。但是,一旦您开始到达您的网络之外——例如,进入企业防火墙——您将需要更多的火力。如果不使用 STUN(用于 NAT 的会话遍历实用程序)或 TURN(使用围绕 NAT 的中继的遍历)协议,防火墙配置不会让 WebRTC 进入。这就是您需要 STUN/TURN 服务器的原因。
- 媒体服务器- 媒体服务器派上用场,因为它有助于减少客户端需要发送的流的数量,通常为一个,甚至可以减少客户端需要接收的流的数量,具体取决于媒体服务器的功能。
但是目前 Connect 默认提供免费信令服务器,因此 Application Server 和 NAT Traversal Server 两台服务器即可。
一、环境依赖
由于应用程序是使用 Laravel 7 构建的,因此它需要具备 Laravel 7 的所有先决条件。 Laravel 的依赖项之一是Composer。以下是脚本的核心依赖项:
PHP >= 7.4
MySQL >= 8.0
Apache / Nginx
Node.js、npm、Composer
此外,还有一些文件和文件夹需要脚本的“写”权限。
.env 文件位于根文件夹中
storage/framework 文件夹及其子文件夹
storage/logs 文件夹及其子文件夹
bootstrap/cache 文件夹及其子文件夹
resources/lang文件夹及其子文件夹
二、基本部署
以宝塔环境为例,首先宝塔面板直接安装: PM2管理器
以及对应版本的数据库、php。安装完成后重启服务器以生效:
# 1.安装宝塔
yum install -y wget && wget -O install.sh http://download.bt.cn/install/install.sh && sh install.sh
# 2.配置宝塔
(a) 安装Fileinfo扩展 # 如果Fileinfo拓展安装失败,就是内存太小,添加swap
(b) 禁用函数: proc_open、symlink、putenv、pcntl_signal、passthru
(c) 创建站点
其中值得注意的是,宝塔面板默认安装MySQL 8.0要求2核4G内存,低配服务器可以通过如下方式安装MySQL 8.0:
wget http://download.bt.cn/install/0/mysql.sh;
bash mysql.sh install 8.0
这样低内存服务器在宝塔面板安装Mysql8.0就实现了!
请注意:如果你已经安装了数据库,上面的命令会卸载删除当前数据库及数据,请提前备份,一定要提前备份!
# 3.安装Composer
curl -sS https://getcomposer.org/installer | php
mv composer.phar /usr/local/bin/composer
# 4.编辑环境变量:修改.env文件数据库连接内容
# 5.创建数据库、创建站点、申请ssl证书、进入域名目录拉取项目
git clone https://github.com/DXJian/P2PChat.git -b master
# 6.创建上传目录软链接
php artisan storage:link
期间报错按照错误提示安装PHP拓展以及解除禁用函数即可,如:
# 7.设置storage目录权限为777
chmod -R 0777 storage
# 8.修改网站运行目录public ,取消防跨站攻击
# 9.添加网站伪静态如下:
location /
try_files $uri $uri/ /index.php$is_args$query_string;
三、线上部署
- 第一步检测环境是否合格,如若不合格按照不合格的选项整改即可。
- 第二步连接数据库
- 第三步创建管理员账户
- 第四步要求填入访问代码,随意输入即可。
项目上线后,进入设置(config)页面,(任意)填入Pusher Credential
选项中的信息。
四、建立通信
instant meeting
——start a meeting
——Live Class
之后的报错不需要理会,instant meeting
——meeting history
此时可以看到之前创建的代码为444的聊天室
五、新用户加入
新用户通过注册页面注册成功后,需要管理员激活新用户。
六、项目展示
激动人心的实时通信:
其他可以辅助参考的信息:
- codecanyon-27525559-connect-live-class-meeting-webinar-online-training-web-conference
- https://kodemintserviceshelp.freshdesk.com/support/solutions/81000097241
- https://www.youtube.com/watch?v=QY_VdHvBsSI
至此,本文也就进入尾声了。希望本文能够起到抛砖引玉之效,也欢迎大家的批评交流。
如果您有任何疑问或者好的建议,期待你的留言、评论与关注!
以上是关于Java+Netty+WebRTC语音视频屏幕共享聊天室设计实践的主要内容,如果未能解决你的问题,请参考以下文章
WebRTC音视频之iOS屏幕共享画面静止时,如何传递视频数据
带有 socket.io 的 WebRTC/nodejs 中的屏幕共享问题
如何将多个 WebRTC 媒体流(屏幕捕获 + 网络摄像头)混合/组合成一个流?