Spring boot + LayIM + t-io 单聊群聊的实现

Java框架

浏览数:29

2020-6-8

AD:资源代下载服务

前言        

    新人上任三把火,除了新人报道篇,本篇可以算是正式的第一篇了,本文是来自博客园《从零一起学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 原作者的优秀框架。

作者:丶Pz