本文使用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信息等工作。
<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>
聊天室服务
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>video{width: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>
上面的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 很好奇,就使用rubyonrails自动化单元测试而言,你们正在做什么?您是否创建了一个脚本来在cron中运行rake作业并将结果邮寄给您?git中的预提交Hook?只是手动调用?我完全理解测试,但想知道在错误发生之前捕获错误的最佳实践是什么。让我们理所当然地认为测试本身是完美无缺的,并且可以正常工作。下一步是什么以确保他们在正确的时间将可能有害的结果传达给您? 最佳答案 不确定您到底想听什么,但是有几个级别的自动代码库控制:在处理某项功能时,您可以使用类似autotest的内容获得关于哪些有效,哪些无效的即时反馈。要确保您的提
我正在编写一个gem,我必须在其中fork两个启动两个webrick服务器的进程。我想通过基类的类方法启动这个服务器,因为应该只有这两个服务器在运行,而不是多个。在运行时,我想调用这两个服务器上的一些方法来更改变量。我的问题是,我无法通过基类的类方法访问fork的实例变量。此外,我不能在我的基类中使用线程,因为在幕后我正在使用另一个不是线程安全的库。所以我必须将每个服务器派生到它自己的进程。我用类变量试过了,比如@@server。但是当我试图通过基类访问这个变量时,它是nil。我读到在Ruby中不可能在分支之间共享类变量,对吗?那么,还有其他解决办法吗?我考虑过使用单例,但我不确定这是
我真的很习惯使用Ruby编写以下代码:my_hash={}my_hash['test']=1Java中对应的数据结构是什么? 最佳答案 HashMapmap=newHashMap();map.put("test",1);我假设? 关于java-等价于Java中的RubyHash,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/22737685/
我正在尝试使用boilerpipe来自JRuby。我看过guide从JRuby调用Java,并成功地将它与另一个Java包一起使用,但无法弄清楚为什么同样的东西不能用于boilerpipe。我正在尝试基本上从JRuby中执行与此Java等效的操作:URLurl=newURL("http://www.example.com/some-location/index.html");Stringtext=ArticleExtractor.INSTANCE.getText(url);在JRuby中试过这个:require'java'url=java.net.URL.new("http://www
我只想对我一直在思考的这个问题有其他意见,例如我有classuser_controller和classuserclassUserattr_accessor:name,:usernameendclassUserController//dosomethingaboutanythingaboutusersend问题是我的User类中是否应该有逻辑user=User.newuser.do_something(user1)oritshouldbeuser_controller=UserController.newuser_controller.do_something(user1,user2)我
什么是ruby的rack或python的Java的wsgi?还有一个路由库。 最佳答案 来自Python标准PEP333:Bycontrast,althoughJavahasjustasmanywebapplicationframeworksavailable,Java's"servlet"APImakesitpossibleforapplicationswrittenwithanyJavawebapplicationframeworktoruninanywebserverthatsupportstheservletAPI.ht
导读:随着叮咚买菜业务的发展,不同的业务场景对数据分析提出了不同的需求,他们希望引入一款实时OLAP数据库,构建一个灵活的多维实时查询和分析的平台,统一数据的接入和查询方案,解决各业务线对数据高效实时查询和精细化运营的需求。经过调研选型,最终引入ApacheDoris作为最终的OLAP分析引擎,Doris作为核心的OLAP引擎支持复杂地分析操作、提供多维的数据视图,在叮咚买菜数十个业务场景中广泛应用。作者|叮咚买菜资深数据工程师韩青叮咚买菜创立于2017年5月,是一家专注美好食物的创业公司。叮咚买菜专注吃的事业,为满足更多人“想吃什么”而努力,通过美好食材的供应、美好滋味的开发以及美食品牌的孵
相信很多人在录制视频的时候都会遇到各种各样的问题,比如录制的视频没有声音。屏幕录制为什么没声音?今天小编就和大家分享一下如何录制音画同步视频的具体操作方法。如果你有录制的视频没有声音,你可以试试这个方法。 一、检查是否打开电脑系统声音相信很多小伙伴在录制视频后会发现录制的视频没有声音,屏幕录制为什么没声音?如果当时没有打开音频录制,则录制好的视频是没有声音的。因此,建议在录制前进行检查。屏幕上没有声音,很可能是因为你的电脑系统的声音被禁止了。您只需打开电脑系统的声音,即可录制音频和图画同步视频。操作方法:步骤1:点击电脑屏幕右下侧的“小喇叭”图案,在上方的选项中,选择“声音”。 步骤2:在“声
这篇文章是继上一篇文章“Observability:从零开始创建Java微服务并监控它(一)”的续篇。在上一篇文章中,我们讲述了如何创建一个Javaweb应用,并使用Filebeat来收集应用所生成的日志。在今天的文章中,我来详述如何收集应用的指标,使用APM来监控应用并监督web服务的在线情况。源码可以在地址 https://github.com/liu-xiao-guo/java_observability 进行下载。摄入指标指标被视为可以随时更改的时间点值。当前请求的数量可以改变任何毫秒。你可能有1000个请求的峰值,然后一切都回到一个请求。这也意味着这些指标可能不准确,你还想提取最小/
HashMap中为什么引入红黑树,而不是AVL树呢1.概述开始学习这个知识点之前我们需要知道,在JDK1.8以及之前,针对HashMap有什么不同。JDK1.7的时候,HashMap的底层实现是数组+链表JDK1.8的时候,HashMap的底层实现是数组+链表+红黑树我们要思考一个问题,为什么要从链表转为红黑树呢。首先先让我们了解下链表有什么不好???2.链表上述的截图其实就是链表的结构,我们来看下链表的增删改查的时间复杂度增:因为链表不是线性结构,所以每次添加的时候,只需要移动一个节点,所以可以理解为复杂度是N(1)删:算法时间复杂度跟增保持一致查:既然是非线性结构,所以查询某一个节点的时候