openfire群聊与QQ群对比

应该是去年的时候开始接触openfire,当时在分析后发现基于xmpp协议的openfire已经具备了群聊的功能。也就没太当回事,觉得加点功能就可以做成类似于QQ群的那种模式。后来仔细了解后才发现并不是这么简单:

  • muc其实聊天室的形式,房间创建后可以加入聊天,用户离开就退出聊天室了,并没有一个用户固化的功能,所以要单独为这部分开发
  • muc因为没有固化的成员关系,所以并没有1对1聊天的那种离线消息。而且考虑到消息量是群发的原因,所以服务器对于加入聊天室的成员只会推送一定量的消息,当然这个可以通过策略来配置为全部推送。事实上考虑到群聊天的特性,推送指定条数可能是更靠谱的。
  • 还有一些QQ特有的功能,比如邀请进群需要管理员审核之类的管理功能就更少了,这块都需要扩展实现

改造Openfire群聊天室为群

实际上对于openfire的muc改造来说,持久化成员是第一个重要的工作。我们期望的是这个房间里的人都是固定的成员,这些成员可以离开聊天室,但下次可以进来继续聊天。其实实现起来也挺简单的:

基于openfire的实现

  1. 建立数据表,用于保存成员列表

    在openfire里已经有一系列的表用于保存muc相关的数据:
  • ofMucRoom-这个是房间表,保存了聊天室的信息
  • ofMucAffiliation-这个是保存房间里管理员角色人员的表(owner(10)、admin(20)、outcast(40))
  • ofMucMember-这个是房间里成员的列表(对应的是member(30))

这里ofMucAffiliation+ofMucMember保存的数据其实是用于记录的是用户权限的,当然会发现其实这已经对应上我们需求上的群成员咯?确实是这样的。

只不过有一个问题,就是ofMucAffiliation+ofMucMember里只能知道用户的jid,但是群的话可能每个用户会修改自己的昵称,对不对?所以还要加一个表用于保存这种用户的个性化数据。当然这个表也挺简单的就不细写了。

  1. 通过openfire的插件体系增加一个插件,在服务端实现加群、退群等功能

    毕竟xmpp协议里是没有获得群列表和房间成员的功能的,以及一些加群、退群的管理功能都没有,所以要自己开发。这里可以通过openfire的插件体系来做,这样比较独立,不影响openfire内核功能。

这块涉及到写插件的技术,网上有很多,我就不多说了。

  1. 自己定义一套协议来完成客户端与服务端的通讯

    因为要走openfire,所以还是要定义xmpp协议,我用的是IQ。考虑到我使用的是smack做的,所以这部分就不再写了。有兴趣或者需要的网上找找IQ协议的写法就行了。

其他方式

其实这些功能无非就是增删改查,而且我们添加的功能完成可以独立于openfire之外,所以自己写一套也是可以的。比如用web的方式实现也是可以的。

特别是可以设计成rest api,这样对于端来说是比较友好通用的,兼顾PC、移动端就简单多了,特别是移动端走http协议总比走长链接方便吧。

分析openfire muc群聊历史消息的实现

简单的介绍了群的实现,另外一个比较头痛的问题就是muc离线消息。在openfire里是有类似的支持的,这里就做一些简单的分析吧。

历史消息策略HistoryStrategy

因为在openfire里历史消息推送策略是这样的,我们看一下它的策略类HistoryStrategy,里面设定了一个枚举:

/**
* Strategy type.
*/
public enum Type {
defaulType, none, all, number;
}

可以看出,其实就是三种:none(不显示历史记录)、all(显示整个历史记录)、number(指定条数记录)。默认的是number。

策略类会维护一个内存列表,用于给新加入的用户发送历史记录用:

private ConcurrentLinkedQueue<Message> history = new ConcurrentLinkedQueue<>();

实际上自己也可以实现一套策略来代替它,比如将消息存在redis之类。只不过Openfire并没有提供扩展,只能是修改openfire代码来实现咯。

历史消息的保存与维护

历史消息的保存是在openfire里的MultiUserChatServiceImpl里实现的,它会启动一个TimerTask,定时的将消息保存到历史消息表里。下面是定时任务的实现

/**
* Logs the conversation of the rooms that have this feature enabled.
*/
private class LogConversationTask extends TimerTask {
@Override
public void run() {
try {
logConversation();
}
catch (Throwable e) {
Log.error(LocaleUtils.getLocalizedString("admin.error"), e);
}
}
} private void logConversation() {
ConversationLogEntry entry;
boolean success;
for (int index = 0; index <= log_batch_size && !logQueue.isEmpty(); index++) {
entry = logQueue.poll();
if (entry != null) {
success = MUCPersistenceManager.saveConversationLogEntry(entry);
if (!success) {
logQueue.add(entry);
}
}
}
}

这是具体的保存聊天历史的代码,可以看到消息是放在logQueue里的,然后定时任务从里面取一定的条数保存到数据库存中。MUCPersistenceManager就是数据库的访问类。

在start方法里启动

@Override
public void start() {
XMPPServer.getInstance().addServerListener( this ); // Run through the users every 5 minutes after a 5 minutes server startup delay (default
// values)
userTimeoutTask = new UserTimeoutTask();
TaskEngine.getInstance().schedule(userTimeoutTask, user_timeout, user_timeout);
// Log the room conversations every 5 minutes after a 5 minutes server startup delay
// (default values)
logConversationTask = new LogConversationTask();
TaskEngine.getInstance().schedule(logConversationTask, log_timeout, log_timeout);
// Remove unused rooms from memory
cleanupTask = new CleanupTask();
TaskEngine.getInstance().schedule(cleanupTask, CLEANUP_FREQUENCY, CLEANUP_FREQUENCY); // Set us up to answer disco item requests
XMPPServer.getInstance().getIQDiscoItemsHandler().addServerItemsProvider(this);
XMPPServer.getInstance().getIQDiscoInfoHandler().setServerNodeInfoProvider(this.getServiceDomain(), this);
XMPPServer.getInstance().getServerItemsProviders().add(this); ArrayList<String> params = new ArrayList<>();
params.clear();
params.add(getServiceDomain());
Log.info(LocaleUtils.getLocalizedString("startup.starting.muc", params));
// Load all the persistent rooms to memory
for (LocalMUCRoom room : MUCPersistenceManager.loadRoomsFromDB(this, this.getCleanupDate(), router)) {
rooms.put(room.getName().toLowerCase(), room);
}
}

这里是聊天室服务启动的过程,它会启动LogConversationTask用于定期将聊天记录保存到库里。而且这里最后几句会发现启动时会从库里读取数据(MUCPersistenceManager.loadRoomsFromDB),loadRoomsFromDB实现了读取Hitory数据到historyStrategy里。

具体的数据保存在ofMucConversationLog表中。

如何推送历史消息给客户端

有了历史消息推送策略和数据,那么怎么样推送给客户端呢?这里有一个history协议,在LocalMUCUser处理packet的时候,如果这个packet是Presence,并且带有history节说明是客户端发来要历史记录的。

在LocalMUCUser.process(Presence packet)里有history消息节的处理代码,因为代码太多,就截取关键的部分:

// Get or create the room
MUCRoom room = server.getChatRoom(group, packet.getFrom());
// User must support MUC in order to create a room
HistoryRequest historyRequest = null;
String password = null;
// Check for password & requested history if client supports MUC
if (mucInfo != null) {
password = mucInfo.elementTextTrim("password");
if (mucInfo.element("history") != null) {
historyRequest = new HistoryRequest(mucInfo);
}
}
// The user joins the room
role = room.joinRoom(recipient.getResource().trim(),
password,
historyRequest,
this,
packet.createCopy());

这里可以看到,先获取到historyRequest节的信息,然后调用room.joinRoom方法。这里的room.joinRoom就是用户加入聊天室的关键部分。在joinRoom里会发送历史消息给这个用户:

 if (historyRequest == null) {
Iterator<Message> history = roomHistory.getMessageHistory();
while (history.hasNext()) {
joinRole.send(history.next());
}
}
else {
historyRequest.sendHistory(joinRole, roomHistory);
}

这里会发现有两种情况,1种是historyRequest为空的情况时,服务端默认按照策略的设置向用户发送历史消息。如果不为空,则根据客户端的请求参数发送。那么这里我们看看historyRequest的实现:

public class HistoryRequest {

	private static final Logger Log = LoggerFactory.getLogger(HistoryRequest.class);
private static final XMPPDateTimeFormat xmppDateTime = new XMPPDateTimeFormat(); private int maxChars = -1;
private int maxStanzas = -1;
private int seconds = -1;
private Date since; public HistoryRequest(Element userFragment) {
Element history = userFragment.element("history");
if (history != null) {
if (history.attribute("maxchars") != null) {
this.maxChars = Integer.parseInt(history.attributeValue("maxchars"));
}
if (history.attribute("maxstanzas") != null) {
this.maxStanzas = Integer.parseInt(history.attributeValue("maxstanzas"));
}
if (history.attribute("seconds") != null) {
this.seconds = Integer.parseInt(history.attributeValue("seconds"));
}
if (history.attribute("since") != null) {
try {
// parse since String into Date
this.since = xmppDateTime.parseString(history.attributeValue("since"));
}
catch(ParseException pe) {
Log.error("Error parsing date from history management", pe);
this.since = null;
}
}
}
} /**
* Returns the total number of characters to receive in the history.
*
* @return total number of characters to receive in the history.
*/
public int getMaxChars() {
return maxChars;
} /**
* Returns the total number of messages to receive in the history.
*
* @return the total number of messages to receive in the history.
*/
public int getMaxStanzas() {
return maxStanzas;
} /**
* Returns the number of seconds to use to filter the messages received during that time.
* In other words, only the messages received in the last "X" seconds will be included in
* the history.
*
* @return the number of seconds to use to filter the messages received during that time.
*/
public int getSeconds() {
return seconds;
} /**
* Returns the since date to use to filter the messages received during that time.
* In other words, only the messages received since the datetime specified will be
* included in the history.
*
* @return the since date to use to filter the messages received during that time.
*/
public Date getSince() {
return since;
} /**
* Returns true if the history has been configured with some values.
*
* @return true if the history has been configured with some values.
*/
private boolean isConfigured() {
return maxChars > -1 || maxStanzas > -1 || seconds > -1 || since != null;
} /**
* Sends the smallest amount of traffic that meets any combination of the requested criteria.
*
* @param joinRole the user that will receive the history.
* @param roomHistory the history of the room.
*/
public void sendHistory(LocalMUCRole joinRole, MUCRoomHistory roomHistory) {
if (!isConfigured()) {
Iterator<Message> history = roomHistory.getMessageHistory();
while (history.hasNext()) {
joinRole.send(history.next());
}
}
else {
Message changedSubject = roomHistory.getChangedSubject();
boolean addChangedSubject = (changedSubject != null) ? true : false;
if (getMaxChars() == 0) {
// The user requested to receive no history
if (addChangedSubject) {
joinRole.send(changedSubject);
}
return;
}
int accumulatedChars = 0;
int accumulatedStanzas = 0;
Element delayInformation;
LinkedList<Message> historyToSend = new LinkedList<>();
ListIterator<Message> iterator = roomHistory.getReverseMessageHistory();
while (iterator.hasPrevious()) {
Message message = iterator.previous();
// Update number of characters to send
String text = message.getBody() == null ? message.getSubject() : message.getBody();
if (text == null) {
// Skip this message since it has no body and no subject
continue;
}
accumulatedChars += text.length();
if (getMaxChars() > -1 && accumulatedChars > getMaxChars()) {
// Stop collecting history since we have exceded a limit
break;
}
// Update number of messages to send
accumulatedStanzas ++;
if (getMaxStanzas() > -1 && accumulatedStanzas > getMaxStanzas()) {
// Stop collecting history since we have exceded a limit
break;
} if (getSeconds() > -1 || getSince() != null) {
delayInformation = message.getChildElement("delay", "urn:xmpp:delay");
try {
// Get the date when the historic message was sent
Date delayedDate = xmppDateTime.parseString(delayInformation.attributeValue("stamp"));
if (getSince() != null && delayedDate != null && delayedDate.before(getSince())) {
// Stop collecting history since we have exceded a limit
break;
}
if (getSeconds() > -1) {
Date current = new Date();
long diff = (current.getTime() - delayedDate.getTime()) / 1000;
if (getSeconds() <= diff) {
// Stop collecting history since we have exceded a limit
break;
}
}
}
catch (Exception e) {
Log.error("Error parsing date from historic message", e);
} } // Don't add the latest subject change if it's already in the history.
if (addChangedSubject) {
if (changedSubject != null && changedSubject.equals(message)) {
addChangedSubject = false;
}
} historyToSend.addFirst(message);
}
// Check if we should add the latest subject change.
if (addChangedSubject) {
historyToSend.addFirst(changedSubject);
}
// Send the smallest amount of traffic to the user
for (Object aHistoryToSend : historyToSend) {
joinRole.send((Message) aHistoryToSend);
}
}
}
}

这里面主要是用于约定发送历史消息的一些参数:

private int maxChars = -1;
private int maxStanzas = -1;
private int seconds = -1;
private Date since;

这是可以设定的几个参数,具体的对应关系如下面的表格所示

历史管理属性

属性 数据类型 含义
maxchars int 限制历史中的字符总数为"X" (这里的字符数量是全部 XML 节的字符数, 不只是它们的 XML 字符数据).
maxstanzas int 制历史中的消息总数为"X".
seconds int 仅发送最后 "X" 秒收到的消息.
since datetime 仅发送从指定日期时间 datetime 之后收到的消息 (这个datatime必须 MUST 符合XMPP Date and Time Profiles 13 定义的DateTime 规则,).

还有sendHistory

当然这里还实现了一个sendHistory方法,也就是针对客户端提交了查询要求时的历史消息发送方法。具体的实现上面的代码吧。也就是根据历史管理属性里设定的几个参数进行针对性的发送。

但是这里有个关键点就是since属性,它表示客户端可以设定一个时间戳,服务端根据发送这个时间戳之后的增量数据给客户端。这个对于客户端而已还是很有作用的。

实现群离线消息的方法

那么看完了openfire的历史消息的实现,再来实现离线消息是不是就简单的多了。群聊天历史消息有几个问题:

  • 问题1:群人员庞大历史消息巨大服务端如何缓存这些历史数据?比如一个群1000人,一人一天发10条,就有10000条/天,一个月就是30万,这还只是一个聊天群的,100个群就是3000万。
  • 问题2:对于群成员而言,可能一个月未登录,那么可能就要接收这一个月的离线消息,客户端基本就崩了,网络流量也很巨大,怎么处理?

利用HistoryStrategy限制服务端推送条数

所以不用举太多问题,就这两个就够了,那么我觉得openfire的这种历史消息策略中使用number(条数)是很重要的。比如服务器只缓存最近1000条聊天历史,这样整体的服务器缓存量就低了。这就解决了第一个问题。

如果群用户需要查询历史上的数据,应该是另开一个服务接口专门用于查询历史数据,这样就不用在刚上线进入群时接收一堆的离线消息。

利用HistoryRequest来获取增量数据

前面分析HistoryRequest时提到了它可以设置一个时间戳参数,这个是告诉服务端从这个参数之后的历史消息推送过来。

比如,用户A昨天晚20:00下的线(最后消息时间戳是2017-06-07 20:00:00),今天早上8:00上线。在用户A离线的时间里有100条离心线消息记录。

那么用户A上线,客户端发送HistoryRequest(since=2017-06-07 20:00:00),服务器则只发送2017-06-07 20:00:00之后的聊天记录100条。这样就实现了增量的消息,对于服务端和客户端都是友好的。

当然,这里能发多少消息最终还是要看服务端缓存了多少消息用于发送给客户端,毕竟就像问题2中提出的那样,用户可能一个月都不上线,这期间的历史消息要是都推送那肯定崩了。所以上线时的历史消息推送这个功能仅适合推送少量的数据。这个在具体的系统设计时应该根据实际情况来设计。

教你如何把openfire的muc聊天室改造为群的更多相关文章

  1. Android基于XMPP Smack openfire 开发的聊天室

    Android基于XMPP Smack openfire 开发的聊天室(一)[会议服务.聊天室列表.加入] http://blog.csdn.net/lnb333666/article/details ...

  2. Flask(4)- flask请求上下文源码解读、http聊天室单聊/群聊(基于gevent-websocket)

    一.flask请求上下文源码解读 通过上篇源码分析,我们知道了有请求发来的时候就执行了app(Flask的实例化对象)的__call__方法,而__call__方法返回了app的wsgi_app(en ...

  3. ASP.NET SignalR 与LayIM配合,轻松实现网站客服聊天室(二) 实现聊天室连接

    上一篇已经简单介绍了layim WebUI即时通讯组件和获取数据的后台方法.现在要讨论的是SingalR的内容,之前都是直接贴代码.那么在贴代码之前先分析一下业务模型,顺便简单讲一下SingalR里的 ...

  4. 网络编程(学习整理)---2--(Udp)实现简单的控制台聊天室

    1.UDP协议: 总结一下,今天学习的一点知识点! UDP也是一种通信协议,常被用来与TCP协议作比较!我们知道,在发送数据包的时候使用TCP协议比UDP协议安全,那么到底安全在哪里呢?怎么理解呢! ...

  5. 【C++】基于socket的多线程聊天室(控制台版)

    以前学习socket网络编程和多线程编程的时候写的一个练手程序 聊天室基本功能: 1.用户管理:登录,注册,登出,修改用户名,修改密码 2.聊天室功能:群聊,私聊,获取在线用户列表,获取所有用户列表 ...

  6. golang简易版聊天室

    功能需求: 创建一个聊天室,实现群聊和单聊的功能,直接输入为群聊,@某人后输入为单聊 效果图: 群聊:   单聊: 服务端: package main import ( "fmt" ...

  7. IM即时通讯:如何跳出传统思维来设计聊天室架构?

    因为视频直播业务的大规模扩张,聊天室这种功能在最近几年又火了起来.本篇文章将会重点挑选聊天室这个典型场景,和大家分享一下网易云信在实现这个功能时是如何做架构设计的. 相关推荐阅读几十万人同时在线的直播 ...

  8. Strophe.js连接XMPP服务器Openfire、Tigase实现Web私聊、群聊(MUC)

    XMPP(Extensible Messaging and Presence Protocol)是一种网络即时通讯协议,它基于XML,具有很强的扩展性,被广泛使用在即时通讯软件.网络游戏聊天.Web聊 ...

  9. android asmack 注册 登陆 聊天 多人聊天室 文件传输

    XMPP协议简介 XMPP协议(Extensible Messaging and PresenceProtocol,可扩展消息处理现场协议)是一种基于XML的协议,目的是为了解决及时通信标准而提出来的 ...

随机推荐

  1. MySQL字段的说明和备注信息

    转自:http://www.2cto.com/database/201202/119996.html 在MySQL下运行完下面这个建表语句后. 如何从数据字典中,检索出这个表的字段的相关信息? DRO ...

  2. 关于Linux虚拟化技术KVM的科普 科普三(From OenHan)

    http://oenhan.com/archives,包括<KVM源代码分析1:基本工作原理>.<KVM源代码分析2:虚拟机的创建与运行>.<KVM源代码分析3:CPU虚 ...

  3. 第三章——分类(Classification)

    3.1 MNIST 本章介绍分类,使用MNIST数据集.该数据集包含七万个手写数字图片.使用Scikit-Learn函数即可下载该数据集: >>> from sklearn.data ...

  4. datetime日期和时间

    datetime是Python处理日期和时间的标准库. from datetime import datetime # 获取当前时间 now = datetime.now() print(now) # ...

  5. redis基础操作~~数据备份与恢复、数据安全、性能测试、客户端连接、分区

    数据备份与恢复 数据备份redis save 命令用于创建当前数据库的备份. redis 127.0.0.1:6379> SAVE OK 该命令将在 redis 安装目录中创建dump.rdb文 ...

  6. Python3 randrange() 函数

    描述 randrange() 方法返回指定递增基数集合中的一个随机数,基数缺省值为1. 语法 以下是 randrange() 方法的语法: import random random.randrange ...

  7. 如何查看selenium的版本号

    方法一: 打开cmd,输入python >>> import selenium >>> help(selenium) Help on package seleniu ...

  8. golang string和[]byte的对比

    golang string和[]byte的对比 为啥string和[]byte类型转换需要一定的代价?为啥内置函数copy会有一种特殊情况copy(dst []byte, src string) in ...

  9. BZOJ_4002_[JLOI2015]有意义的字符串_矩阵乘法

    BZOJ_4002_[JLOI2015]有意义的字符串_矩阵乘法 Description B 君有两个好朋友,他们叫宁宁和冉冉.有一天,冉冉遇到了一个有趣的题目:输入 b;d;n,求 Input 一行 ...

  10. layer使用总结

    1.询问框的使用 主要体现在删除等重要操作 让用户进行二次确认的场景 //询问框 layer.confirm('您是如何看待前端开发?', { btn: ['重要','奇葩'] //按钮 }, fun ...