聊天室应用开发实践(二):实现基于 Web 的聊天室
在上一篇内容中,作者 monkeyHi 已经分享了声网Agora 信令 SDk 的基本使用,并分析了服务器端 Demo 的接口原理。这一篇,作者将带大家进行简单的实践,实现一个基于 Web 的简单的聊天室雏形。 欢迎访问 RTC 开发者社区,获取 Demo 链接,与作者交流。
基于Agora已经发布的 Web 版 Demo,只要简单配置就已经具备最基本的实时聊天室功能。
获取 Web 版 demo
我们基于官方的 Demo 来修改出一个聊天室。下载好 Web Demo 后解压,目录如下:
|---Agora_Signaling_Web
|---libs // sdk 在这里
|---samples // demo在这里
|---Agora-Signaling-Tutorial-Web // 聊天室demo
用Visual Studio Code 打开 samples 目录下的 Agora-Signaling-Tutorial-Web。
|——Agora-Signaling-Tutorial-Web
|—src
| ├─assets
| │ ├─images
| │ └─stylesheets
| ├─pages
| │ ├─index
| │ └─meeting
| └─utils
|—static
|--agora.config.js // 这里配置AppId
|--AgoraSig.js // 复制它到 assets 目录
安装依赖包和运行demo
先简单说一下 Web 端的 SDK,这个 SDK 包装程度很高,甚至不需要开发者懂websockt和WebRTC。只要调用对应的功能接口即可。
接下来,我们一起跑起Demo,这个Demo是一个webpack项目,专业的Web开发工程师一定不会觉着陌生。
-
首先,复制 AgoraSig.js到asstes目录
-
其次,npm install
-
接下来,配置 appid 打开 src\static\agora.config.js ,修改AGORAAPPID 为我们自己的AppId
-
最后,npm start
这时,浏览器应该已经弹出一个页面
随便输入一个用户名,我们就可以进入聊天室了,当然大家可以添加自己的用户鉴权业务。
我们打开两个标签页,分别用accontA 和accontB的身份加入同一个p2p频道,尝试互发消息。
笔者发现,我们这个聊天室并不需要另外运行server端。而前面介绍的server端,从接口上看,其功能更倾向于做系统广播、系统消息通知。因此,如果你想实现具备云消息备份的功能的聊天软件,必须要在端上实现存储和上传备份。
毫不夸张的说,这个聊天室,只要一个 page 服务就可以跑起来了。
Demo 代码讲解
拿到一个web端项目,我们首先要看的就是package.json。我们可以从中看到项目依赖哪些Package以及项目的启动脚本。
"scripts": {
"test": "jest ./test", // 测试
"lint": "eslint .", // eslint格式化当前目录
"format": "eslint . --fix", // eslint fix当前目录下的代码格式
"dev": "cross-env NODE_ENV=development webpack-dev-server --open", // 这个会启动 webpack-dev-server 并用浏览器打开页面
"start": "npm run dev", // 功能同上一条
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules" // 编译
},
配置文件 static/agora.config.js
只需要在以下两行代码中进行配置。
const AGORA_APP_ID = 'appid' // appid
const AGORA_CERTIFICATE_ID = '' // 如果开启了token模式,这里要配置certificate_id
SDK文件 static/AgoraSig.js
这里和lib目录中的SDK文件是一样的,可以用新的sdk来替换。新下载下来的demo ,还应该复制该文件到 src/assets目录下。
utils 目录
utils目录中封装了一些工具类。
signalingClient.js
这个文件中,主要对信令SDK做了进一步封装,将一些Action 转换为promise, 同时用 Event 替代了Callback。
/**
* Wrapper for Agora Signaling SDK
* Transfer some action to Promise and use Event instead of Callback
*/
import EventEmitter from 'events';
// 信令客户端类
export default class SignalingClient {
constructor(appId, appcertificate) {
this._appId = appId;
this._appcert = appcertificate;
// Init signal using signal sdk
this.signal = Signal(appId) // eslint-disable-line
// init event emitter for channel/session/call
this.channelEmitter = new EventEmitter();
this.sessionEmitter = new EventEmitter();
}
/**
* @description login agora signaling server and init 'session'
* 登录 信令服务,并初始化会话
* @description use sessionEmitter to resolve session's callback
* @param {String} account
* @param {*} token default to be omitted
* @returns {Promise}
*/
login(account, token = '_no_need_token') {
this.account = account;
return new Promise((resolve, reject) => {
this.session = this.signal.login(account, token);
// Proxy callback on session to sessionEmitter
[
'onLoginSuccess',
'onError',
'onLoginFailed',
'onLogout',
'onMessageInstantReceive',
'onInviteReceived'
].map(event => {
return (this.session[event] = (...args) => {
this.sessionEmitter.emit(event, ...args);
});
});
// Promise.then
this.sessionEmitter.once('onLoginSuccess', uid => {
this._uid = uid;
resolve(uid);
});
// Promise.catch
this.sessionEmitter.once('onLoginFailed', (...args) => {
reject(...args);
});
});
}
/**
* @description logout agora signaling server
* 退出信令服务
* @returns {Promise}
*/
logout() {
return new Promise((resolve, reject) => {
this.session.logout();
this.sessionEmitter.once('onLogout', (...args) => {
resolve(...args);
});
});
}
/**
* @description join channel
* 加入某个频道
* @description use channelEmitter to resolve channel's callback
* @param {String} channel
* @returns {Promise}
*/
join(channel) {
this._channel = channel;
return new Promise((resolve, reject) => {
if (!this.session) {
throw {
Message: '"session" must be initialized before joining channel'
};
}
this.channel = this.session.channelJoin(channel);
// Proxy callback on channel to channelEmitter
// 将回调 都代理到 对应channelEmitter
[
'onChannelJoined',
'onChannelJoinFailed',
'onChannelLeaved',
'onChannelUserJoined',
'onChannelUserLeaved',
'onChannelUserList',
'onChannelAttrUpdated',
'onMessageChannelReceive'
].map(event => {
return (this.channel[event] = (...args) => {
this.channelEmitter.emit(event, ...args);
});
});
// Promise.then
this.channelEmitter.once('onChannelJoined', (...args) => {
resolve(...args);
});
// Promise.catch
this.channelEmitter.once('onChannelJoinFailed', (...args) => {
this.channelEmitter.removeAllListeners()
reject(...args);
});
});
}
/**
* @description leave channel
* 离开当前频道
* @returns {Promise}
*/
leave() {
return new Promise((resolve, reject) => {
if (this.channel) {
this.channel.channelLeave();
this.channelEmitter.once('onChannelLeaved', (...args) => {
this.channelEmitter.removeAllListeners()
resolve(...args);
});
} else {
resolve();
}
});
}
/**
* @description send p2p message
* 发送点对点消息
* @description if you want to send an object, use JSON.stringify
* @param {String} peerAccount
* @param {String} text
*/
sendMessage(peerAccount, text) {
this.session && this.session.messageInstantSend(peerAccount, text);
}
/**
* @description broadcast message in the channel
* 发送频道消息
* @description if you want to send an object, use JSON.stringify
* 可以通过JSON.Stringify的方式发送object
* @param {String} text
*/
broadcastMessage(text) {
this.channel && this.channel.messageChannelSend(text);
}
}
pages
这里面分别是两个页面的实现,默认的index页面和聊天页面。大家可以在对webpack有一定了解的情况下,修改这两个页面。
pages/index.js
大家注意这里, 点击Join-meeting,我们获取id为account-name的DOM值,然后将account的值放到的url中。
$('#join-meeting').click(function(e) {
// Join btn clicked
e.preventDefault();
var account = $('#account-name').val() || '';
if (checkAccount(account)) {
// Account has to be a non empty numeric value
window.location.href = `meeting.html?account=${account}`;
} else {
$('#account-name')
.removeClass('is-invalid')
.addClass('is-invalid');
}
});
后续聊天页面的account值通过url中的account参数来传值。
pages/meeting.js
metting.js中主要定义了client类和聊天相关的类方法。首先,我们来看看文件尾部:
// 检测获取appid 并检测是否为空
const appid = AGORA_APP_ID || '',
appcert = AGORA_CERTIFICATE_ID || '';
if (!appid) {
alert('App ID missing!');
}
//从url获取account值 ,Browser 模块事先在util/index.js中定义好的。
let localAccount = Browser.getParameterByName('account');
let signal = new SignalingClient(appid, appcert);
// Let channelName = Math.random() * 10000 + "";
// by default call btn is disabled
// 信令登陆
signal.login(localAccount).then(() => {
// Once logged in, enable the call btn
let client = new Client(signal, localAccount);
$('#localAccount').html(localAccount);
});
接下来,我们应该关注client类,这里笔者只节选部分代码。相信很多朋友都发现了,demo中聊天头像都一样。其实,生成渲染消息的方法里市可以自定义头像的,默认被写为固定图片,大家其实可以根据accont来拼装头像链接的,当然你得自己做个用户头像接口。
buildMsg(msg, me, ts) {
let html = '';
let timeStr = this.compareByLastMoment(ts);
if (timeStr) {
html += `<div>${timeStr}</div>`;
}
let className = me ? 'message right clearfix' : 'message clearfix';
html += '<li class="' + className + '">';
// 注意看这里
html += '<img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/245657/1_copy.jpg">';
html +=
'<div class="bubble">' +
Utils.safe_tags_replace(msg) +
'<div class="corner"></div>';
html += '<span>' + this.parseTwitterDate(ts) + '</span></div></li>';
return html;
}
这里大家要注意跨域的问题。需要自己对接口url做代理来解决跨域安全问题。开发状态下,直接配置devServer的proxy即可。又有的朋友说啦,我想保留消息记录,其实在端上保存消息记录还是比较容易的。注意看onReceiveMessage()。
onReceiveMessage(account, msg, type) {
let client = this;
var conversations = this.chats.filter(function(item) {
return item.account === account;
});
if (conversations.length === 0) {
// No conversation yet, create one
conversations = [{ id: new Date().getTime(), account: account, type: type }];
client.chats.splice(0, 0, conversations[0]);
client.updateLocalStorage();
client.updateChatList();
}
// 可以看到下面对消息做了简单处理,然后丢到msgs中
for (let i = 0; i < conversations.length; i++) {
let conversation = conversations[i];
let msgs = this.messages[conversation.id] || [];
let msg_item = { ts: new Date(), text: msg, account: account };
msgs.push(msg_item);
this.updateMessageMap(conversation, msgs);
let chatMsgContainer = $('.chat-messages');
if (String(conversation.id) === String(this.current_conversation.id)) {
this.showMessage(this.current_conversation.id)
chatMsgContainer.scrollTop(chatMsgContainer[0].scrollHeight);
}
}
}
我们看一下引用它的位置就会发现,无论是p2p消息还是Chanel消息,都会调用这个onReceiveMessage()方法。因此,大家可以通过修改onReceiveMessage实现自己的聊天记录功能。具体是通过接口存储到我们自己的服务器,还是借助localStorage,都可以比较好的实现web端的聊天记录功能。诸如 window.localStorage.setItem('msglog',msgs)。
既然可以暂时保存在localStorage,那么,想导出聊天数据为json,csv也不会麻烦到哪里去。
可能会遇到的问题
1.npm install 报错。
解决方法: 更换仓库地址; 使用yarn install; 使用v*n
2.可以发送消息,但是收不到消息。
解决方法:检查asset目录和static目录,是否存在AgoraSig.js ,如果不存在,从sdk的lib目录中复制并重命名为AgoraSig.js
总结
总体来说,基于Agora信令实现聊天室非常简单,基于demo,自己扩展一些用户管理业务就可以实现。大家可以集中精力优化交互体验,美化UI,专注于端上业务。但是,如果想要更多的可控权,希望在server端实现聊天记录之类的功能。基于信令当前版本做这类功能,需要自己来开发。信令的优势在于,方便实现一些消息通知的场景。而且对接非常容易,只要简单封装即可直接嵌入端上。另外,对于弹幕的实现,大家可以尝试在端实发送消息是同时推送消息到保存消息的接口。