本文经授权转载自公众号前端之巅。
作者 | Adrián García Diéguez
译者 | 王强
编辑 | Yonie、 张之栋
现在市面上有很多提供聊天和视频会议功能的免费应用,只需轻点几下鼠标,我们就能与世界各地的小伙伴愉快地交流。那么,你是否有兴趣构建一个属于自己的视频应用呢?让我们一起跟随作者,看看视频应用时如何构建的。
我们的应用程序主要有以下功能:
-
一些聊天房间,可以来回切换。
-
用户状态(在线 / 离开 / 下线)。
-
能够与同一房间内的任何人私聊(每位用户同时只能有一个私聊会话),对方正在与其他人说话或者关闭私聊窗口时可以发来通知。
-
能够在私聊会话中启用视频会议。
我们将使用以下技术构建这样的应用:
-
Vue.js。Vue 这个前端框架最近越来越受欢迎,这时候用它时机正好。我们还将使用 Vuex 存储用户数据和聊天信息。
-
后端将在 NodeJS 中使用 Express 实现。
-
Socket.io 和 WebRTC 用于实时引擎和通信。
-
Redis 作为内存数据库。
-
Docker 和 Docker Compose。
谈到 Sockets 和 WebRTC 时,我们可以展开来长篇大论,深入探讨高深的领域,但这不是本文的目的。我们想要构建的是一些简单的内容,各个基础部分要很容易理解,但是功能还得齐全,下面就开始干活儿吧。
应用骨架
首先我们安装并使用 @vue/cli 创建应用的主骨架,这一步很简单:
npm install -g @vue/cli vue create video-chat
之后系统会提示你选择预设。这里我们手动选择 Babel、Router、Vuex、CSS Pre-processor 和 Linter 支持。
为了加快这一步,我们将 vue-material 用作样式框架(它还在测试阶段,不过他们说 API 已经固定下来了)。
npm install vue-material --save
至于 HTTP 和 WebSocket 通信这块儿,我们使用 vue-resource和 vue-socket.io作为 Vue.js 的自定义实现:
npm install vue-resource vue-socket.io --save
安装完成后,我们可以在 main.js 文件中配置它们:
import VueSocketIO from 'vue-socket.io'
import VueResource from 'vue-resource'
import store from './store'
import { url } from './utils/config'
// Socket 配置
Vue.use(new VueSocketIO({
debug: true,
connection: `${url}/video-chat`,
vuex: {
store, // Attach the store
actionPrefix: 'SOCKET_',
mutationPrefix: 'SOCKET_'
},
}))
// Vue 资源,用于 http
Vue.use(VueResource)
// Vue 实例
new Vue({
router,
store, // 附加存储
render: h => h(App)
}).$mount('#app')
使用 vuex 配置中的 actionPrefix 和 mutationPrefix 可以触发服务 vuex 操作 和 突变。 但在我们的示例中不会用它们,因为我们将在侦听 Socket 服务器事件后在客户端中调度操作。
另外我们可以在 store.js 文件中用下面的状态配置附加到 VueSocketIO 和 Vue 实例的存储区:
import Vue from 'vue'
import Vuex from 'vuex'
import { STATUS_OPTIONS } from './utils/config'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
room: undefined, // 当前房间
username: undefined, // 用户名
status: STATUS_OPTIONS.available, // 用户状态
rooms: [] // 可用房间
},
mutations: {
// 每个操作的突变 (joinRoom, changeRoom, setRooms, leaveChat, changeStatus)
},
actions: {
// 这里我们定义所有会触发的操作:
// joinRoom, changeRoom, setRooms, leaveChat, changeStatus
}
})
每当用户触发一个操作时,我们都会将其发送到存储区,生成一个突变执行并以一个新状态结束。
一般来说,不管是哪种框架的状态管理模式都非常相似。 Vuex 实现的具体情况请查看 这里。
如你所见,Socket 配置需要一个连接 URL,因此在构建登录页面之前先来构建服务器的基础部分。
服务器
首先我们需要安装所有主程序包来搭建基础服务器组件。
npm install express http body-parser path cors socket.io --save
然后我们在项目根目录的 /server 文件夹中创建 index.js 和 app.js 文件作为我们的服务器主入口点:
const http = require('http');
const app = require('./app')
const config = require('./config')
const server = http.createServer(app);
app.io.attach(server) // 将服务器附加到 socket
app.io.origins([config.ORIGINS]) // 原始 socket 配置
server.listen(config.PORT, () => {
console.log(`Server Listening on port ${config.PORT}`)
});
我们在 config.js 文件中定义所有的服务器配置。 这样以后要配置多个应用程序实例的时候就很容易了。
const express = require('express');
const app = express();
const io = app.io = require('socket.io')();
const cors = require('cors');
const bodyParser = require('body-parser');
const path = require('path');
const users = require('./routes/user');
const rooms = require('./routes/room');
const chat = require('./chat_namespace');
app.use(cors())
app.use(bodyParser.json());
// 中间件
app.use((req, res, next) => {
console.log('Time: ', Date.now());
next();
});
// 路由
app.use('/auth', users)
app.use('/rooms', rooms)
app.use(express.static(path.join(__dirname, '../dist'))); // 静态路由
chat.createNameSpace(io) // 聊天 socket 名称
module.exports = app
通过上述配置,我们主要实现了:
-
创建和配置了 http 和 express 服务器。
-
为登录和房间定义了 REST API(为了简单起见,两个 API 都会将信息存储在内存中)。
-
创建了为前端所有静态文件提供服务的静态服务器。
-
创建了 websocket 命名空间并配置其服务器事件。
可命名空间是做什么的呢? 服务器事件又是什么意思?
命名空间 本质上是 WS 连接的端点或路径。 默认情况下它一直都是 /,并且 socket.io 客户端会默认连接到命名空间上。 本案中我们将其设置为 /video-chat。
这就是为什么 Socket 客户端会连接到 ${url}/video-chat。
至于事件这边,现在我们刚刚定义了加入房间的基本事件。 在 /chat_namespace 文件夹下,我们创建 index.js 文件:
const config = require('./../config')
// Socket 命名空间
let namespace;
const users = {
general: [],
sports: [],
games: []
};
const onConnection = (socket) => {
// 侦听加入房间的事件 (joinRoom event)
socket.on('joinRoom', ({ username, room }) => {
socket.join(room, () => {
// 将用户推向合适的房间
users[room].push({ username: username, privateChat: false })
// 通知房间中的所有用户
namespace.in(room).emit('newUser', users[room]);
});
});
}
exports.createNameSpace = (io) => {
namespace = io
.of(config.CHAT_NAMESPACE)
.on('connection', onConnection)
}
我们正在连接回调中侦听 joinRoom 事件。 一旦它被触发我们就加入房间,把用户添加到该房间中,并通过 newUser 事件发回该房间内的所有用户。 所以我们的前端将发出 joinRoom 事件,它将侦听 newUser 事件。
你可以在 socket.io 的 emit cheatsheet中查看所有可用的服务器事件。
现在我们可以开始构建前端了。
前端
我们会做两个主页: 登录界面和聊天主页。
我们不会使用任何身份验证机制,因此对于登录页面来说只需要用户和要加入的房间即可。 用户将作为系统中的主键,因此用户名必须是唯一的。 除了主房间外, 用户名还要用作私聊会话的 room 值 。
如果我们想允许同时发起多个私聊,可以为一个私聊会话中的两个用户名创建一个特殊的约束名称。
我们在新的 /views 文件夹中创建 Home.vue 文件,如下所示:
<template>
<div>
<h2>VIDEO CHAT</h2>
<div>
<form novalidate @submit.prevent="submitForm">
<md-field>
<label>Username</label>
<md-input v-model="username" type="string" id="username">
</md-input>
</md-field>
<md-field>
<label for="movie">Room</label>
<md-select v-model="room" name="room" id="room">
<md-option
v-for="room in rooms"
:key="room.id"
:value="room.name">{{room.name}}
</md-option>
</md-select>
</md-field>
<div v-if="error">
<p>{{error}}</p>
</div>
<div>
<md-button type="submit" :disabled="!(username && room)">JOIN
</md-button>
</div>
</form>
</div>
</div>
</template>
现在我们只需要获取房间并提交用户信息即可:
<script>
import { url, STORE_ACTIONS } from "./../utils/config";
export default {
name: "home",
data: // 函数返回对象,带有 username, room, rooms, error,
async created() {
try {
const data = await this.$http.get(`http://${url}/rooms`)
this.rooms = data.body;
this.$store.dispatch(STORE_ACTIONS.setRooms, this.rooms) // 保存房间
} catch (error) {
console.log(error);
}
},
methods: {
async submitForm() {
if(!(this.username && this.room)) return
try {
let response = await this.$http.post(`http://${url}/auth/login`, {
room: this.room,
username: this.username
})
if (response.body.code === 400 || response.body.code === 401 || response.body.code === 500) {
this.error = response.body.message
return
}
this.$store.dispatch(STORE_ACTIONS.joinRoom, data) // 保存房间
this.$router.push("/chat") // 返回主房间
} catch (error) {
console.log(error)
}
}
}
};
</script>
根据生命周期方法,我们获取房间并将它们保存在存储区中。 提交表单时也是一样,只要用户发送了正确的信息,我们就会保存房间并转到聊天主页。
我们把调度事件的状态改到 store 上,再加上合适的 payload:
this.$store.dispatch(
聊天主页这边我们主要使用 material app 组件,它的结构挺有用的。 我们的页面内容如下:
-
切换房间。
-
标头(房间名称和注销按钮)。
-
用户列表区域(和用户状态)。
-
消息区域。
-
发送消息的文本区域。
私聊页面这边我们将使用 material dialog 组件。
虽然我们使用了 material 组件,还是需要一些调整。 为此我在所有子组件中都使用了 样式封装,但两个父页面(登录和聊天)除外。 对于后者我使用了全局作用域,因为它们比较独立,而且这样一来要覆盖某些 material 样式时就很简单了。 你可以在这里查看 Vue.js 中作用域 CSS 的解释。
现在我们可以区分以下事件了:
-
joinRoom : 加入一个主房间(前面解释过了)。
-
publicMessage : 用户发送消息时的事件。 服务器向同一房间内的所有用户发回带有消息的 newMessage 事件。
-
leaveRoom : 当用户切换房间时的事件。 服务器离开房间,发回离开的房间的新用户列表,随后客户端会在 joinRoom 事件之后加入新房间。
-
leaveChat : 用户注销时的事件。 服务器通过 leaveChat 事件发出新用户列表并离开 Socket 房间。
-
changeStatus : 用户更改状态时的事件。 服务器用之前的 newUser 事件对其更新并发回新值。
-
joinPrivateRoom : 用户(A)与另一位用户(B)开始私聊时的事件。 服务器加入房间并发回 privateChat 事件以通知用户(B)。 如果用户(B)正在说话,则服务器通知用户(A)并使用 leavePrivateRoom 事件强制他退出私聊。
-
leavePrivateRoom : 用户退出私聊时的事件。 服务器发回相同的事件通知私聊对方。
-
privateMessage : 用户发送私聊消息时的事件。 服务器使用 privateMessage 事件向两位用户发回消息。
现在我们在同一个 /views 文件夹下使用主要组件创建聊天主页的 Chat.vue 文件:
<template>
<div>
<div>
<!-- 切换房间 -->
<md-field>
<label for="room">Room</label>
<md-select v-model="room" @md-selected="onChangeRoom" name="room" id="room">
<md-option v-for="room in this.$store.state.rooms" :key="room.id" :value="room.name">{{room.name}}</md-option>
</md-select>
</md-field>
</div>
<md-app md-waterfall md-mode="fixed">
<!-- 房间名称和注销 -->
<md-app-toolbar>
<span>{{room}}</span>
<md-button @click.native="logout()">
<md-icon>power_settings_new</md-icon>
</md-button>
</md-app-toolbar>
<!-- 接入用户列表 -->
<md-app-drawer md-permanent="full">
<!-- 显示用户,当用户发起私聊时发出事件 -->
<UserList
:users="users"
:openPrivateChat="openPrivateChat.chat"
@open-chat="openChat($event)"
></UserList>
</md-app-drawer>
<!-- 聊天区域,显示所有信息 -->
<md-app-content>
<!-- 作为输入显示来自服务器的信息 -->
<ChatArea :messages="messages"></ChatArea>
</md-app-content>
</md-app>
<!-- 文本输入区域。每当用户发送消息时发出一个事件 -->
<MessageArea
@send-message="sendMessage($event)">
</MessageArea>
<!-- 私聊。showDialog 输入控制我们是否发起一个私聊 -->
<!-- openPrivateChat 是 Vue 聊天组件定义的对象,包含处理私聊的信息 -->
<ChatDialog
:showDialog="openPrivateChat"
@close-chat="closePrivateChat()">
</ChatDialog>
</div>
</template>
这是我们的父组件,它将负责监听服务器发出的所有 Socket 事件。 为此我们只需要在 Vue 组件中创建一个 Socket 对象,并为每个服务器事件创建一个 监听 器方法:
<script>
export default {
name: "chat",
components: { UserList, ChatArea, MessageArea, ChatDialog },
// 服务器事件监听器
sockets: {
// newMessage 服务器事件
newMessage: function({ message, username }) {
if(message.replace(/s/g, "").length === 0) return // 没有空消息
const isMe = this.$store.state.username === username;
const msg = isMe ? ` ${message}` : {username, message};
this.messages.push({ msg, isMe });
},
// 其他监听器:
// newUser, privateChat, privateMessage, leavePrivateRoom, leaveChat
},
beforeCreate: function() {
this.$socket.emit(WS_EVENTS.joinRoom, this.$store.state); // 加入房间
},
data: // 聊天数据,
methods: {
sendMessage(msg) {
// 发送一条新的公共消息
this.$socket.emit(WS_EVENTS.publicMessage, {
...this.$store.state,
message: msg
});
},
// 其他方法:
// onChangeRoom, openChat, closePrivateChat, logout
}
};
</script>
在该示例中,当用户发送公共消息时我们发出 publicMessage 事件,并且监听 newMessage 服务器事件以获取消息。
请记住,所有用户都将运行相同的客户端代码,因此我们需要构建一种通用方法来处理所有逻辑。
详细介绍所有组件会花很长时间,因此我们只看主要的功能。 它们的原理是在 Socket 监听器被触发后获取输入数据,并在用户操作下向父级发出事件:
-
发送消息的文本区域。 每次用户发送公共消息时它都会向父级发出一个事件,从而向服务器发出一个 publicMessage Socket 事件。 -
它显示房间内的所有公共消息,用一条指令根据用户来格式化消息:
<template>
<div>
<div v-for="msg in messages" :key="msg.msg">
<p
v-if="!msg.join"
:class="{ own: msg.isMe, other: !msg.isMe}"
v-message="msg.msg"></p>
<p v-if="msg.join">{{msg.msg}}</p>
</div>
</div>
</template>
<script>
export default {
props: // messages: Array, maxMessageLength: Number
directives: {
message: {
bind: function(el, binding, vnode) {
let chunks
const maxLength = vnode.context.maxMessageLength
if(typeof binding.value === 'object') {
chunks = Math.ceil(binding.value.message.length / maxLength)
el.innerHTML = `<span>${binding.value.username}</span>:
${vnode.context.getChunkText(binding.value.message, maxLength, chunks)}`
} else {
chunks = Math.ceil(binding.value.length / maxLength)
el.innerHTML = vnode.context.getChunkText(binding.value, maxLength, chunks)
}
}
}
},
methods: {
// 将 chunck text 适配进 chat area
getChunkText(message, maxLength, index){}
}
};
</script>
-
显示当前用户状态和用户状态列表。 当用户想要发起私聊时,它会向父级发出一个事件以打开私聊模式并发出 joinPrivateRoom Socket 事件。 当用户更改状态时,它会更新状态并发出 changeStatus Socket 事件。 -
私聊。 它会对每条消息发出一个 privateMessage Socket 事件。 关闭对话时它会向父级发出一个事件,后者会发出 leavePrivateRoom Socket 事件。 它还包含
组件,该组件提供所有的视频功能。
现在将新的 监听 器添加到之前 /chat_namespace 文件夹中的 index.js 服务器文件中:
const onConnection = (socket) => {
socket.on('joinRoom', events.joinRoom(socket, namespace)) // 加入一个房间
socket.on('publicMessage', events.publicMessage(namespace)) // 新公共消息
socket.on('leaveRoom', events.leaveRoom(socket, namespace)) // 离开房间
socket.on('leaveChat', events.leaveChat(socket, namespace)) // 离开聊天
socket.on('joinPrivateRoom', events.joinPrivateRoom(socket, namespace)) // 加入私聊
socket.on('leavePrivateRoom', events.leavePrivateRoom(socket, namespace)) // 离开私聊
socket.on('privateMessage', events.privateMessage(namespace)) // 私聊消息
socket.on('changeStatus', events.changeStatus(socket, namespace)) // // 设置状态
}
exports.createNameSpace = (io) => {
namespace = io
.of(config.CHAT_NAMESPACE)
.on('connection', onConnection)
}
因为我们的事件越来越多,我们稍微改动了这个文件并在同一个 /chat_namespace 文件夹下创建了一个新的 events.js 文件,其中包含所有回调函数:
const joinRoom = (socket, namespace) => ({ username, room }) => {} // 定义上述内容
const publicMessage = (namespace) => ({ room, message, username }) => {
namespace.sockets.in(room).emit('newMessage', { message, username });
}
const privateMessage = (namespace) => ({ privateMessage, to, from, room }) => {
namespace.to(room).emit('privateMessage', { to, privateMessage, from, room })
}
const leaveRoom = (socket, namespace) => ({ room, username }) => {
socket.leave(room, () => {
let usersRoom = users[room]
usersRoom = usersRoom.filter((user) => (user.username !== username)) // 从数组中删除用户
namespace.sockets.in(room).emit('newUser', usersRoom); // 对房间中的所有用户
})
}
const joinPrivateRoom = (socket, namespace) => ({ username, room, to }) => {
socket.join(to, () => {
if (room !== null) {
let usersRoom = users[room];
let userToTalk = usersRoom.find(user => user.username === to)
if (userToTalk.privateChat) { // 如果他在说话
namespace.to(to).emit('leavePrivateRoom', { to, room, from: username,
privateMessage: `${to} is already talking`,
})
socket.leave(to, () => {
console.log(`user ${username} forced to left the room ${to}`);
})
return;
}
// 如果用户空闲,我们更新标记并通知对方
userToTalk.privateChat = true
namespace.sockets.in(room).emit('privateChat', { username, to });
}
});
}
const leavePrivateRoom = (socket, namespace) => ({ room, from, to }) => {
let usersRoom = users[room];
let userToTalk = usersRoom.find(user => user.username === to)
// 更新标记并通知对方
userToTalk.privateChat = false
namespace.to(to).emit('leavePrivateRoom', { to, from, privateMessage: `${to} has closed the chat`})
socket.leave(to, () => {
console.log(`user ${from} left the private chat with ${to}`);
})
}
// module.exports = { ... }
等一下,但如果运行服务器的实例不止一个怎么办?
我们把整个过程中的所有信息都存储在内存里。 这种方法适用于简单的情况,但是一旦我们需要做扩展就没法正常运行了,因为每个服务器实例都有自己的用户副本。 不仅如此,用户还可能会连接到不同的实例上,但没法与两个 Socket 连接同时通信。
加入 Redis
可以在我们的服务器中实现 Redis 适配器 来解决这个问题。
npm install socket.io-redis redis --save
我们在服务器入口点 index.js 中添加以下内容:
const redis = require('socket.io-redis');
// 我们在配置中配置了适配器,所以就能在部署时从环境变量获取值了 app.io.adapter(redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT
}));
此外,我们将使用 Redis 作为数据库来存储所有连接的用户。 为此我们在服务器中新建一个 /redis 文件夹,在里面创建一个 index.js 文件:
const redis = require('redis')
const bluebird = require('bluebird')
const config = require('./../config/')
bluebird.promisifyAll(redis); // 使用 promises
function ChatRedis() {
this.client = redis.createClient({ host: config.REDIS_HOST });
}
ChatRedis.prototype.addUser = function (room, socketId, userObject) {
this.client.hsetAsync(room, socketId, JSON.stringify(userObject)).then(
() => console.debug('addUser ', userObject.username + ' added to the room ' + room),
err => console.log('addUser', err)
);
}
ChatRedis.prototype.getUser = function(room, socketId){
return this.client.hgetAsync(room, socketId).then(
res => JSON.parse(res),
err => {
console.log('getUser ', err)
return null
}
)
}
// getUsers, delUser, setUser implementations
module.exports = new ChatRedis()
作为示例,这里我们实现了哈希模式(https://redis.io/commands/hset)来存储数据; 但我们可以根据搜索需求使用更多的 数据类型和抽象]。 此外还有一些 node.js redis 客户端 通过一层抽象提供了额外的功能。
之后我们只需更新内存中所有的用户引用,并将其更改为我们的 redis 实现即可。
那视频该怎么办? 你没忘吧?
WebRTC
WebRTC 是一个免费的开源项目,通过简单的 API 为 Web 和移动应用程序提供实时通信(RTC)功能。
WebRTC 实现了对等(P2P)通信,不过它仍然需要服务器来 处理信令。 信令是协调两个客户端之间的通信以交换建立通信所需的某些元数据(会话控制和错误消息、媒体元数据等)的过程。 但 WebRTC 没有为此指定任何方法和协议,所以需要应用程序来实现合适的机制。
在我们的案例中,我们将使用私人房间作为两个用户之间的信令机制。
要在生产环境中为 WebRTC 应用程序提供安全防护,必须使用 TLS(传输层安全性,https://en.wikipedia.org/wiki/Transport_Layer_Security)作为信令机制。
为此,我们将为服务器配置添加一个新的服务器监听器:
const privateMessagePCSignaling = (namespace) => ({ desc, to, from, room }) => {
// 向用户发送私聊信令消息
// desc 是发送事件的用户的本地会话描述 namespace.to(room).emit('privateMessagePCSignaling', { desc, to, from })
}
module.exports = {
// 其他事件
privateMessagePCSignaling
}
const onConnection = (socket) => {
// 其他监听器
// 私聊消息,用于 Signaling PeerConnection
socket.on('privateMessagePCSignaling', events.privateMessagePCSignaling(namespace))
}
exports.createNameSpace = (io) => {
namespace = io
.of(config.CHAT_NAMESPACE)
.on('connection', onConnection)
}
在 A(呼叫者)和 B(被呼叫者)之间建立通信的机制如下:
-
A 使用 ICE 服务器配置创建 RTCPeerConnection 对象。
-
A 使用 RTCPeerConnection createOffer 方法创建一个 offer(SDP 会话描述)。
-
A 使用 offer 调用 setLocalDescription(offer) 方法。
-
A 使用信令机制(privateMessagePCSignaling)将 offer 发送给 B 。
-
B 获得 offer 并使用 A 的 offer 调用 setRemoteDescription()(这样 B 的 RTCPeerConnection 就能知道 A 的设置了)。
-
B 使用 RTCPeerConnection createAnswer 方法创建 answer。
-
B 用 answer 调用 setLocalDescription(answer) 方法。
-
B 使用信令机制(privateMessagePCSignaling)将 answer 发回给 A。
-
A 使用 setRemoteDescription() 方法将 B 的 answer 设置为远程会话描述。
除此之外,两个用户都必须设置以下内容:
-
访问摄像头,获取视频流并将其附加到本地视频标签。
-
设置 RTCPeerConnection onaddstream 监听 器以获取远程轨道上的媒体并将其附加到远程视频标签。
-
设置 RTCPeerConnection onicecandidate 监听 器以将任何 ice 候选者发送给对方。
会话建立后 WebRTC 会尝试直接连接客户端(无需任何服务器的对等连接)以传输媒体和数据流。 但在现实世界中,大多数设备都处在一层或多层 NAT 之后,因此 WebRTC 使用 ICE 框架 来克服这些障碍。
所以我们在创建 RTCPeerConnection 对象时需要 ICE 服务器配置。 你可以 在此 测试 STUN/TURN 服务器的连接,检查它们是否存在并正确响应。
而 VideoArea.vue 组件会包含所有的逻辑:
<template>
<div>
<div>
<!-- 载入组件,同时等待远程视频轨 -->
<rotate-square5 v-if="!remoteStream"></rotate-square5>
<!-- 远程视频 -->
<video id="remoteVideo" autoplay></video>
</div>
<!-- 本地视频 -->
<video id="localVideo" autoplay></video>
</div>
</template>
<script>
export default {
props: // room (String), to(String), videoAnswer(Object)
data: // Media & Offer config, STUN ICE servers, RTC objec, streams & video
async created() {
await this.getUserMedia() // 获得摄像头权限
this.createPeerConnection(); // 创建 RTCPeerConnection 对象
this.addLocalStream(); // 添加本地视频流
this.onIceCandidates(); // 添加事件监听器
this.onAddStream();
!this.videoAnswer.video ? // 处理逻辑
this.callFriend() : // 呼叫者
this.handleAnser() // 被呼叫者
}
},
methods: {
// 呼叫者
callFriend() {
this.createOffer(); // 创建 offer
},
// 被呼叫者
async handleAnser() {
await this.setRemoteDescription(this.videoAnswer.remoteDesc); // 设置远程描述
this.createAnswer(); // 创建 answer
},
async getUserMedia() {
if ("mediaDevices" in navigator) {
try {
const stream = await navigator.mediaDevices.getUserMedia(this.constraints);
this.myVideo.srcObject = stream;
this.localStream = stream;
} catch (error) {
log(`getUserMedia error: ${error}`);
}
}
},
createPeerConnection() {
this.pc = new RTCPeerConnection(this.configuration) // RTCPeerConnection 对象
},
async createOffer() {
try {
const offer = await this.pc.createOffer(this.offerOptions) // 创建 offer
await this.pc.setLocalDescription(offer) // 添加本地描述
this.sendSignalingMessage(this.pc.localDescription, true) // 发送信令消息
} catch (error) {
log(`Error creating the offer from ${this.username}. Error: ${error}`);
}
},
async createAnswer() {
try {
const answer = await this.pc.createAnswer() // 创建 answer
await this.pc.setLocalDescription(answer) // 添加本地描述
this.sendSignalingMessage(this.pc.localDescription, false) // 发送信令消息
} catch (error) {
log(`Error creating the answer from ${this.username}. Error: ${error}`);
}
},
sendSignalingMessage(desc, offer) { // 向对方发送 offer
this.$socket.emit("privateMessagePCSignaling", {
desc,
to: this.to,
from: this.$store.state.username,
room: this.room
});
},
setRemoteDescription(remoteDesc) {
this.pc.setRemoteDescription(remoteDesc);
},
addLocalStream(){
this.pc.addStream(this.localStream)
},
addCandidate(candidate) {
this.pc.addIceCandidate(candidate);
},
onIceCandidates() { // 向对方发送 ice candidates
this.pc.onicecandidate = ({ candidate }) => {
this.$socket.emit("privateMessagePCSignaling", {
candidate,
to: this.to,
from: this.$store.state.username,
room: this.room
})
}
},
onAddStream() { // 附加远程视频轨
this.pc.onaddstream = (event) => {
if(!this.remoteVideo.srcObject && event.stream){
this.remoteStream = event.stream
this.remoteVideo.srcObject = this.remoteStream ;
}
}
}
}
};
</script>
呼叫者使用 privateMessagePCSignaling Socket 事件将 offer 和本地描述发送给另一位用户,因此我们需要在客户端中使用 Socket 监听 器来处理被呼叫端的消息(当被呼叫者发送 answer 时就反过来了)
上面提到的 ChatDialog 组件负责处理私聊,所以我们会在这里添加逻辑来监听我们的信令机制并控制我们传递给 VideoArea 组件的数据:
<template>
<div>
<md-dialog>
<div v-if="videoCall">
<VideoArea
:room="showDialog.room"
:to="showDialog.user"
:videoAnswer="videoAnswer"
@closeVideo="video(false)">
</VideoArea>
</div>
<div>
<!-- 私聊 UI -->
</div>
</md-dialog>
</div>
</template>
<script>
export default {
name: "ChatDialog",
components: { ChatArea, VideoArea },
props: { showDialog: Object },
sockets: {
// 信令监听器
privateMessagePCSignaling: function({ desc, from, candidate }) {
if (from !== this.$store.state.username) { // 如果这不是我的消息
try {
// 接收描述
if (desc) {
// 来电
if (desc.type === "offer") {
this.openChat(desc, from) // 发起私聊
// Answer
} else if (desc.type === "answer") {
this.videoAnswer = { ...this.videoAnswer, remoteDesc: desc };
} else {
console.log("Unsupported SDP type");
}
// 接收 CANDIDATE
} else if (candidate) {
this.videoAnswer = { ...this.videoAnswer, candidate };
}
} catch (error) {
console.log(error);
}
}
}
},
methods: {
openChat(description, from){
this.videoAnswer = {
...this.videoAnser,
video: true,
remoteDesc: description,
from
};
this.videoCall = true;
}
}
}
</script>
我们基于消息信息来检测它是发来的呼叫、呼叫的应答还是添加到对等对象的新候选者,然后根据情况采取对应行动。
到这一步,我们就可以开始测试这个视频聊天应用了!
我们将创建三个容器: 两个视频聊天实例和一个 Redis 容器:
为此,我们将使用以下配置在项目的根目录中创建 docker-compose.yml 文件:
version: '3'
services:
redis:
image: redis:4.0.5-alpine
networks:
- video-chat
ports:
- 6379:6379
expose:
- "6379"
restart: always
command: ["redis-server", "--appendonly", "yes"]
# Copy 1
chat1:
build:
context: .
args:
VUE_APP_SOCKET_HOST: localhost
VUE_APP_SOCKET_PORT: 3000
ports:
- 3000:3000
networks:
- video-chat
depends_on:
- redis
environment:
PORT: 3000
REDIS_HOST: redis
REDIS_PORT: 6379
# Copy 2
chat2:
build:
context: .
args:
VUE_APP_SOCKET_HOST: localhost
VUE_APP_SOCKET_PORT: 3001
ports:
- 3001:3001
networks:
- video-chat
depends_on:
- redis
environment:
PORT: 3001
REDIS_HOST: redis
REDIS_PORT: 6379
networks:
video-chat:
docker-compose 配置
其中 context 属性定义了 Dockerfile 目录。 这里我们描述了各种情况下编译客户端并使用适当的配置运行服务器的步骤。
FROM node:8.6
# Workdir and installing
WORKDIR /videochat
COPY package.json /videochat/
RUN npm install
COPY ./ /videochat
# 获取实例配置
ARG VUE_APP_SOCKET_HOST=NOT_SET
ARG VUE_APP_SOCKET_PORT=NOT_SET
# 构建应用
RUN export VUE_APP_SOCKET_HOST=${VUE_APP_SOCKET_HOST} VUE_APP_SOCKET_PORT=${VUE_APP_SOCKET_PORT} && npm run build
# 启动时运行服务器
CMD ["npm", "run", "run:server"]
现在我们只需使用 docker-compose build 和 docker-compose up 构建并运行容器,并在浏览器中打开两个视频聊天副本:
我们观察了各个应用程序如何与其服务器创建 WebSocket 连接,而 Redis 是同时与两个连接通信的唯一途径。
如果你跟着到了这一步...... 我们终于做到了!
我们搞定了! 我们没用多少代码就构建了一个简单的视频聊天应用程序。 这是一个非常简单的示例,只覆盖了基础内容,我们可以通过多种方式来做些改进: 添加登录和 websocket 身份验证、处理重新连接操作、增强 WebRTC 功能、改进摄像头和声音处理、支持多个私聊会话、允许用户接受或拒绝私聊视频通话等等。
本文的所有代码都可以在这个仓库中查看:https://github.com/adrigardi90/video-chat。
你还可以在这里找到部署好的简化版本。
我用 surge 部署了前端,在 AWS 中的微 EC2 实例部署了服务端,因此请尽量不要超载太多:)
根据位置状况,WebRTC 媒体有时会因 ICE 服务器配置而失败(或者你可能需要尝试多次视频通话),因此需要再做些更新和改进。
希望大家乐在其中。
英文原文:https://levelup.gitconnected.com/build-your-own-video-chat-with-vue-webrtc-socketio-node-redis-eb51b78f9f55
也许你还想看
(▼点击文章标题或封面查看)
加入搜狐技术作者天团
千元稿费等你来!
戳这里!☛
以上所述就是小编给大家介绍的《[译] Vue + Node + WebRTC 构建一个高逼格的视频应用》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
The Little Typer
Daniel P. Friedman、David Thrane Christiansen、Duane Bibby、Robert Harper、Conor Mcbride / MIT Press / 2018-10-16 / GBP 30.00
An introduction to dependent types, demonstrating the most beautiful aspects, one step at a time. A program's type describes its behavior. Dependent types are a first-class part of a language, and are......一起来看看 《The Little Typer》 这本书的介绍吧!
图片转BASE64编码
在线图片转Base64编码工具
HEX HSV 转换工具
HEX HSV 互换工具