前言
新人上任三把火,除了新人报道篇,本篇可以算是正式的第一篇了,本文是来自博客园《从零一起学Spring Boot之LayIM项目长成记》系列的第六篇,原标题为:《从零一起学Spring Boot之LayIM项目长成记(六)用户登录验证和单聊群聊的实现》。看前几篇的话,大家可以去 http://www.cnblogs.com/panzi/ 围观一下。不过由于用户登录没有去实现,所以就暂时介绍一下单聊和群聊的开发过程吧。
功能介绍
先简单介绍一下项目背景,突然发现, 项目背景就是标题。。。直接进入正题吧。当然除了LayIM我已经实现过N个版本以外,Springboot和t-io都是初次使用,难免有使用不当之处,欢迎批评指正。
其实从标题就可以看出来,单聊和群聊功能无非就是借助通讯框架来实现时时通讯,单聊,1对1,群聊1对多,或者1对1也能理解成为两个人的群聊。不过t-io相对于springboot中已经封装好的websocket来说,具有更强的开发灵活性,并且自带丰富功能的API。不过不得不说,这个框架用起来还真是挺爽的,尤其当你更加明确消息的走向的时候,你会发现,原来通讯这么好玩。另外,我的开发过程中和LayIM的业务结合比较强,所以通用性较差。学习更高级的部分可以去看 tio-im,它的实现就比较牛啦。
废话少说,直接上代码。
单聊
在文章 从零一起学Spring Boot之LayIM项目长成记(五)websocket 中已经实现了消息的发送,所以这里不在赘述,下文中将会详细的讲到消息发送以及处理上的细节。
前半段是js介绍部分,无兴趣的可以直接跳到server端
首先,我自己封装了一个简单的适用于Layui开发模式的socket模块。Layui模块化开发官网示例如下:
/** 项目JS主入口 以依赖layui的layer和form模块为例 **/ layui.define(['layer', 'form'], function(exports){ var layer = layui.layer ,form = layui.form; layer.msg('Hello World'); exports('index', {}); //注意,这里是模块输出的核心,模块名必须和use时的模块名一致 });
(题外话,osc的代码段还是比较好看的)同样的道理,照葫芦画瓢,先搭建一个模型再说:
//依赖jquery和layer layui.define(['jquery','layer'],function (exports) { var $ = layui.jquery; var layer = layui.layer; //定义 var socket = function () { } //导出 exports('socket',new socket()); }) /** 使用方式 layui.use('socket',function(socket){ //do something }) */
基架搭建完了,剩下的就是填充功能。那么socket.js要做什么事情呢?
- 连接服务器
- 监听各种事件
- 要能灵活配置
- 消息发送和接收(监听)
从以上几点呢,就可以分模块写代码了。首先连接服务器,很简单,就是判断支不支持websocket,做个提示,然后监听成功失败等事件。
//内部默认配置 var defaultOptions = { log:true,//是否打印日志 server:'ws://127.0.0.1:8888',//ws地址 reconn:false //是否断线重连 }; //连接代码片段 this.ws = new WebSocket(this.options.server); //监听事件 this.regWsEvent();
事件监听机制是我参考的 layim.js 源码中的写法。
socket.prototype.on=function(event,callback) { //回调是function,进行事件注册,已经注册过的事件不在注册 if(typeof callback === 'function'){ (!call[event]) && (call[event] = callback); tool.log('注册事件:【'+event+'】'); } return this; }
上文中 regWsEvent 源码,其实就是ws的几个默认事件,然后让我转化成了socket的监听事件方式。
regWsEvent:function () { if(this.ws){ //收到消息时 this.ws.onmessage = function (event) { call.msg&&call.msg(event); }; //关闭时 this.ws.onclose = function (event) { call.close&&call.close(event); }; //连接成功时 this.ws.onopen = function (event) { call.open&&call.open(event); }; //出错时 this.ws.onerror = function (event) { call.error&&call.error(event); }; } }
所以在外部调用是酱紫的:
socket.config({ log:true, server:'ws://127.0.0.1:8888/2' }); socket.on('open',function (e) { console.log("监听到事件:open"); }); socket.on('close',function (e) { console.log("监听到事件:close"); }); socket.on('error',function (e) { console.log("监听到事件:error"); }); socket.on('msg',function (e) { console.log("监听到事件:msg"); //和layim对接的地方会在这里 });
运行一下,看看打印,基本没啥问题

接触过的websocket的,或者做过demo的,上边的代码都不难理解,只不过我做了稍许封装。在进入服务端开发部分之前,先简单介绍一个layim中的聊天api,首先他发送消息的格式是酱紫的:


从以上截图可以看出来,消息体中包含了发送人的ID,头像,昵称等信息,接收人(群)的ID,头像,昵称等信息,并且消息类型是由 type 来区分的,一个 friend,一个group。但是我的消息体设计是不需要这么多字段的。
//base 中包含了 timestamp 和 mtype(消息类型) public class ChatRequestBody extends LayimBaseBody { /** * 接收者用户ID 或者群ID * */ private String toId; /** * 消息内容 * */ private String content; //getter setter }
为什么只要接收方ID和内容呢?因为,发送人就是当前登录用户,所以头像昵称可以从服务端获取,当然你想直接从客户端传给服务端也没有问题。另外由于群聊和单聊的结构差不多,只是type的区分。(当然群聊还有一个小细节处理,下文中会讲到)所以我们只要这么简单一个消息结构就可以满足客户端发送了。所以在LayIM发送事件中,编写如下代码:
//监听发送消息 layim.on('sendMessage', function(data){ var t = data.to.type=='friend'; socket.send({ mtype:(t?1:2),//用来解析服务器消息类型 content:data.mine.content,//消息内容 toid:data.to.id//具体接收人或者群ID }); return; });
哦了,客户端发送完了,服务端得解析吧。(下文中的内容稍微和第五篇中有些重复,不过没关系,着重细节处理)
在LayimServerAioHandler中,代码执行到handleDetail时候,就需要对消息进行处理了。
//接收过来的json数据 String text = ByteUtil.toText(bytes); //解析数据消息 LayimMsgProperty property = Json.toBean(text,LayimMsgProperty.class); //获取到消息类型 byte type = property.getMtype(); //获取消息处理器 LayimAbsMsgProcessor processor = LayimMsgProcessorManager.getProcessor(type); boolean unknown = processor == null; if(!unknown) { processor.process(websocketPacket, channelContext); } //这里应该增加未知消息处理
获取消息处理器部分呢,其实就是初始化的时候将各种消息处理类实例化加入到一个hashmap中
private static Map<String,LayimAbsMsgProcessor<?>> processorMap = new HashMap<String, LayimAbsMsgProcessor<?>>(); public static void init(){ //单聊消息处理 processorMap.put("CLIENT_TO_CLIENT",new ClientToClientMsgProcessor()); //群聊消息处理 processorMap.put("CLIENT_TO_GROUP",new ClientToGroupMsgProcessor()); }
消息处理器接收到消息,进行处理,这里需要强调一下的是,消息转换厚的结果只是略有不同,比如,区分type是friend还是group,id的设置。其他都是一样的,最主要的区别在于,一个是给单个对象发送,说白了,就是调用send方法,另外一个是调用sendToGroup方法。下面看详细代码。
@Override public WsResponse process(WsRequest layimPacket, ChatRequestBody body, ChannelContext channelContext) throws Exception { logger.info("ClientToClientMsgProcessor.process"); LayimToClientMsgBody msgBody = BodyConvert.getInstance().convertToClientMsgBody(body,channelContext); WsResponse toClientBody = BodyConvert.getInstance().convertToTextResponse(msgBody); //发送消息 send(channelContext,toClientBody,body.getToId()); return null; } /** * 这个方法提出来的目的,是让 ClientToGroupMsgProcessor 进行重写 *(当然这么设计只是符合Layim,讲究通用性的话应该是分开设计比较好) * */ public void send(ChannelContext channelContext,WsResponse toClientBody,String toId){ //调用t-io的接口获取对方的channel,如果没有的话,肯定是对方收不到消息的 ChannelContext toChannelContext = Aio.getChannelContextByUserid(channelContext.getGroupContext(),toId); //发射~~~~ Aio.send(toChannelContext,toClientBody); }
中间消息体的转化部分,其中的注释已经很详细了。上文中提到群组发送的细节就是,需要带上from参数,否则自己也会接收到群组消息,然后会导致重复加载消息的情况。客户端会根据from进行筛消息处理。
public WsResponse convertToTextResponse(Object body) throws IOException{ WsResponse response = new WsResponse(); if(body != null) { String json = Json.toJson(body); response.setBody(ByteUtil.toBytes(json)); response.setWsBodyText(json); response.setWsBodyLength(response.getWsBodyText().length()); //返回text类型消息(如果这里设置成 BINARY,那么客户端就需要进行解析了) response.setWsOpcode(Opcode.TEXT); } return response; } public LayimToClientMsgBody convertToClientMsgBody(ChatRequestBody requestBody, ChannelContext channelContext){ LayimToClientMsgBody msgBody = new LayimToClientMsgBody(); //先获取用户信息 ContextUser contextUser =(ContextUser)channelContext.getAttribute(channelContext.getUserid()); //设置当前用户名 msgBody.setUsername(contextUser.getUsername()); //用户头像 msgBody.setAvatar(contextUser.getAvatar()); /** * 这里要注意,如果是单聊,那么id为发送人id,否则为群组id * 根据requestBody的msgType判断 * 当msgType==1 的时候,toId为接收人的ID * 当msgType==2 的时候,toId为接收群的ID * 这里有了if else 判断,当时也考虑用抽象类实现。 * */ if(requestBody.getMtype()==LayimMsgType.CLIENT_TO_CLIENT){ msgBody.setId(contextUser.getUserid()); //消息类型:好友 msgBody.setType(LayimConst.CHAT_TYPE_FRIEND); }else if(requestBody.getMtype() == LayimMsgType.CLIENT_TO_GROUP){ msgBody.setId(requestBody.getToId()); //消息类型:群组 msgBody.setType(LayimConst.CHAT_TYPE_GROUP); //群聊消息会让用户都收到信息,所以,自己的就不给自己显示了,需要客户端根据from字段进行处理,另外,单聊消息就不给赋值了,没必要。 msgBody.setFrom(channelContext.getUserid()); } //消息内容 msgBody.setContent(requestBody.getContent()); //发送时间 msgBody.setTimestamp(requestBody.getTimestamp()); return msgBody; }
上文中讲了这么多,可能还会有很多同学云里雾里的,下面做一下演示,给大家看看流程打印。

客户端打印:

我们已经看到客户端打印出了消息。(为了测试,我让用户发送的消息在给自己发回来),然后直接调用layim的接口即可。 layim.getMessage(msg)
var msg = JSON.parse(e.data); layim.getMessage(msg);
效果如下(临时会话的原因是因为我自己和自己发消息,如果是和好友的话,不会出现这种情况):

群聊
下面在将一下群消息,群消息的处理和单聊差不多,只要,继承单聊消息处理器,重写 send方法即可
/** * 这里由于群聊消息用的接收消息体都是ChatRequestBody, * 所以,直接继承 ClientToClientMsgProcessor,并且重写process方法即可 * */ public class ClientToGroupMsgProcessor extends ClientToClientMsgProcessor { private static final Logger logger = LoggerFactory.getLogger(ClientToGroupMsgProcessor.class); public void send(ChannelContext channelContext,WsResponse toClientBody,String toId){ logger.info("execute ClientToGroupMsgProcessor.send"); Aio.sendToGroup(channelContext.getGroupContext(),toId,toClientBody); } }
群消息演示:

问题1:我的消息重复了
问题2:为什么左边的框也是我,但是消息在左边。
问题1解决:
通过from参数进行判断,
问题2解决:
写一个临时变量,判断是否该窗口发送的消息
socket.on('msg',function (e) { console.log("监听到事件:msg"); var msg = JSON.parse(e.data); //判断from是否为当前用户 if(msg.from==current_uid) { //发送消息flag,发送的时候赋值为true,这样其他多开的窗口这个值就是false了。 if(selfFlag) { selfFlag = false; return; }else{ //通过layim接口文档可以知道,加上mine参数为true,就是自己发的消息 msg['mine']=true; } } layim.getMessage(msg); });
最后演示效果:

总结
本文基本把群聊和单聊的各种细节以及消息处理流程介绍完了。OSChina 处女篇宣告完结。编辑起来还挺爽的,哈哈哈哈
参考资料:
https://gitee.com/tywo45/t-io
https://gitee.com/xchao/tio-im
本文代码地址:https://github.com/fanpan26/SpringBootLayIM 欢迎star,博客会继续更新,最后再次感谢 t-io 原作者的优秀框架。
