记im过程(基于netty4)

基于项目需要,然则没有其他方面的技术支持,也走上了之前公司所需要但是没有让我有机会做的模块,一个类im的客服系统,基于能不能做这个神一般的问法,作为一个码仔,只能是不可能不会做,只能继续沿着前路摸爬。
在这里插入图片描述

要求达成效果:

没事,简单用户和商户一对一聊天就好了,只能用户端主动先发起:

卧槽,我心想,这不是信手拈来的程度吗?

直接套上netty管它呢直接一对一就是干;
基本架构
总体思路:
直接搭建起基本的netty服务为用户ID绑定一个发起的信道存储于缓存中,为商户ID绑定一个信道,保存沟通信道即可。(用户ID与商户ID基本是不变的,但是信道会伴随与发起的不同通讯而改变,此处主要是如何保存沟通关系的问题)

e x t r a \color{red}{extra} :但是这里会面临一个问题,即 一对一 时其他用户主动发起连接,这时其实是算客户端设计问题,我们将通讯过程的信息体设计得足够就足以应对,实际解决初步解决方案会在下方给予。

为了让用户能在下次登陆时有不一样的效果:

1、服务端:
登陆时加载以往聊天用户(即历史聊天列表)
2、客户端:
登陆时发送单方面请求


需要针对性根据要求设置初始心跳时加载数据返回的数据,前者返回历史列表,后者为前者增加关系链

设计初始化连接示意图:
在这里插入图片描述

同时,在初始化心跳连接过程中,因为仅能客户端向平台端主动发起连接,则需要校验是否为客户端,从而为关系链上增加沟通关系,以便平台端登陆时能加载到列表。


搭建环境:springboot + netty4 + redis+jdbcTemplate

主要采用自带的用户组,我仅用一个默认的信道组来实现管理所有信道

主要业务代码思路解析:

这个IM主要是维护关系网与离线信息的部分比较难整理。如何将A端与B端关联起来,将其消息存储起来,仅有单方面发起请求的问题。

主要登陆状态:

通过userId 判断是否存在该用户,如果不存在则无法发送信息(这里存在安全性问题,如果页面登陆的用户不存在一个值代表登陆状态存在的话,im这边是无法判断是否登陆的用户,导致任意建立连接发送信息)【其实这里应该判断是否存在用户并抛出异常,并作出处理回复】

  /**
     * 存贮userId/sellerId 组合 对应在线的用户
     */
    public static final String ALIVE_LIST = "CHAT_ALIVE";

除了用默认信道管理来存储会话之外,我们还需要绑定用户与会话之间的关系,所以用到了上面的枚举常量

	//RedisUtil只是本人封装的一个方法,主要在于用户id与信道id的关系存储,谨记信道id的获取方式要统一。
	/**通过握手成功时开始处理的idList.put(ctx.channel().id().asLongText(), "");//初始化数据 与
	*handlerRemoved 时的清除,能准确把握在线的用户的一个目的
	**/
  RedisUtil.getHashMethod().hset("CHAT_ALIVE", userId, channelId);

其实这个方法应该归于下方关系网管理处理之后再设置会比较妥当。

平台与用户端关系网管理:

需求规定:只能通过客户端首次发起沟通后,平台端才可以进行回复
这边用简单的初次连接成功后,websock发送一次伪装心跳连接来区分这个用户端,判断初次传入的数据中是否存在sellerId 这个参数来进行判别 平台/客户端
(其实这个判断现在看来比较草率,理论上应该不能这样去操控,而是通过服务端的数据去判别,这种方法导致,只要获悉了正确的商户id就可以直接建立对话的问题存在)

_平台端
RedisUtil.getHashMethod()
.hset(ChatFlag.CHAT_RELATE + userId, ChatFlag.USER_INFO, JSONObject.toJSONString(accountByUserId));
//以组合键的方式,将本身的信息存入缓存中
_用户端
UserInfo accountByUserId = userInfoService.findAccountByUserId(sellerId);//获取平台的信息
UserInfo selfInfo = userInfoService.findAccountByUserId(userId);//获取用户的信息
RedisUtil.getHashMethod().hset(ChatFlag.CHAT_RELATE + accountByUserId.getUserId(), ChatFlag.USER_INFO, JSONObject.toJSONString(accountByUserId));//存放平台端的用户信息
RedisUtil.getHashMethod().hset(ChatFlag.CHAT_RELATE + selfInfo.getUserId(),ChatFlag.USER_INFO , JSONObject.toJSONString(selfInfo));//存放当前用户端的用户信息
RedisUtil.getHashMethod().hset(ChatFlag.CHAT_RELATE + accountByUserId.getUserId(),selfInfo.getUserId() , JSONObject.toJSONString(selfInfo));//为对方存放自己的信息,建立关系网
//tips: 上面还缺少RedisUtil.getHashMethod().hset(ChatFlag.CHAT_RELATE + selfInfo.getUserId(), accountByUserId.getUserId(), JSONObject.toJSONString(accountByUserId));
//用于为自己添加平台端的信息
_更新用户信息
  String selfInfoCache = RedisUtil.getHashMethod().hget(ChatFlag.CHAT_RELATE + userId, ChatFlag.USER_INFO);
 //========存储联系关系================
  if (!userId.equals(sellerId)) {
    //存储自己的关系,不回复不会建立关系
    RedisUtil.getHashMethod().hset(ChatFlag.CHAT_RELATE + userId, sellerId, infoCache);
 }
_恢复沟通网信息
Map<String, String> stringStringMap = RedisUtil.getHashMethod().hgetAll(ChatFlag.CHAT_RELATE + userId);
 msgContent.setRelateNet(JSONObject.toJSONString(stringStringMap));

用于返回沟通关系网的信息,将之前沟通过的用户返回到初始化连接中

消息收发:

信息收发取决于之前的用户关系网和信道的存储准确性,用户的唯一标识与信道的唯一标识的结合。

_在线信息
Iterator<Channel> iterator = group.iterator();//默认全局信道管理集合,存放所有当前存活的信道
                        Boolean founded = Boolean.FALSE;
                        while (iterator.hasNext() && !founded) {
                            Channel next = iterator.next();
                            if (chat_alive.equals(next.id().asLongText())) {
                                String infoCache = RedisUtil.getHashMethod().hget(ChatFlag.CHAT_RELATE + sellerId, ChatFlag.USER_INFO);
                                if (StringUtils.isBlank(infoCache)) {
                                ...
                                    //消息记录缓存
                                    RedisUtil.getHashMethod().hset(ChatFlag.CHAT_MSG + userId, LocalTime.now().toString(), text);
								...

用于判断该用户是否存在并决定返回是否在线信息与发送给指定用户所绑定的信道,并存放聊天信息

_离线信息

不符合上面在线信息要求的信息,走入离线处理方式

①存放离线信息

 //消息记录缓存
 RedisUtil.getHashMethod().hset(ChatFlag.CHAT_MSG + userId, LocalTime.now().toString(), text);
//没有信道,保存离线信息
RedisUtil.getHashMethod().hset(ChatFlag.OUT_LINE + sellerId, LocalDateTime.now().withNano(0).toString(), text);
ctx.writeAndFlush(new TextWebSocketFrame(notHererReason[random.nextInt(notHererReason.length)]));

②返回离线信息

 //离线信息
Map<String, String> outLineMsg = RedisUtil.getHashMethod().hgetAll(ChatFlag.OUT_LINE + userId);
msgContent.setOffLine(JSONObject.toJSONString(outLineMsg));
RedisUtil.getHashMethod().hdel(ChatFlag.OUT_LINE + userId);

总结

算是一个比较笼统的一个聊天系统,由于初次设计的缘故,很多问题都不能即刻发现,但是写这篇东西的时候,出于归纳的作用,的确发现不少问题值得去修复,去优化,无论是安全性还是数据传输问题,对于存在的粘包等问题,未进行处理,后续会根据进度,更改代码。

其实上面利用protobuff会提高传输效率,还有引用方面,比较累赘的部分,太过于集中业务在一个地方,处理特殊问题的时候不容易整合等。引用框架已有的东西的时候,对于计算机网络技术部分的知识又开始觉得有用了,对于网络基础知识的一种拓展,其实终归到底,底层的部分才是最关键的,任他什么超级框架与技术,也就是建立在最原始的那些规范与规定底层框架中拓展出来,总归是觉得大学知识起作用的时候。

github地址: 初始版本im系统的代码

_ _2019/3/27 补充 :聊天记录的保存
针对需求:需要双方聊天记录回显

 //消息记录缓存
 //己方对话保存
RedisUtil.getHashMethod().hset(ChatFlag.CHAT_MSG + userId + ":" + sellerId, LocalDateTime.now().withNano(0).toString(), text);
 //对方对话保存
 RedisUtil.getHashMethod().hset(ChatFlag.CHAT_MSG + sellerId + ":" + userId, LocalDateTime.now().withNano(0).toString(), text);

_ _2019/4/1 补充:在线状态,关系网建立模式改变
针对需求:①断开时间太快 ②在线状态的展示

  • 主要更新
  1. 客户端单向发送一条信息(含离线信息),才建立关系网
  2. 字段增加: 消息类型(用于区分消息类型) 、 是否已读
  3. 广播用户的上下线行为用于确认在线状态
  4. 稍微整理了一下代码

_ _2019/4/3 修改: 消息记录改用List保存,利于列表展示

  private void _saveCommucationReCode(String text, String userId, String sellerId) {
        //己方对话保存
        RedisUtil.getListsMethod().rpush(ChatFlag.CHAT_MSG + userId + ":" + sellerId, text);
        //对方对话保存
        RedisUtil.getListsMethod().rpush(ChatFlag.CHAT_MSG + sellerId + ":" + userId, text);
    }

_ _2019/4/9 优化:客服聊天记录获取问题
需求:动态加载聊天记录,一次加载10条,滑动至底部,再次获取最新记录

后端代码:

  /**
     * @Author: Coffeeanice
     * @Description: TODO: 用于获取历史聊天记录
     * @Date: 2019/4/9 16:44
     */
    @RequestMapping("/history")
    public void getLocation(HttpServletRequest request
            , HttpServletResponse response) throws IOException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json; utf-8");
        PrintWriter out = response.getWriter();
        JSONObject obj = new JSONObject();
        try {
            String pid = request.getParameter("pid");
            Long page = Long.parseLong(request.getParameter("page"));

            TbCommonUser tbCommonUser = (TbCommonUser) request.getSession().getAttribute(CommonUtils.USER_SESSION);
            StringBuilder stringBuilder = new StringBuilder("CHAT_MSG" + tbCommonUser.getUserId() + ":" + pid);
            long llen = RedisUtil.getListsMethod().llen(stringBuilder.toString());
            List<String> lrange = RedisUtil.getListsMethod().lrange(stringBuilder.toString(), page * 10, (page + 1) * 10);
            obj.put("total", llen);
            obj.put("data",lrange);
            obj.put("result", 1);
        } catch (RuntimeException e) {
            obj.put("result", 0);
        }
        out.print(obj);
        out.close();
        out.flush();
    }

页面js修改:

var currentpage = 0;//当前页码
var totalSize = 0;//总数量
var history_open = false;//判断历史记录窗口是否打开
var currentHeight = 0;//当前高度


$(".history_list_content").scroll(function () {
    let curHeight = $(".history_list_content").scrollTop();
    if (currentHeight == curHeight) {
        let rest = totalSize - (currentpage + 1) * 10
        if (rest > 0) {
            console.log("下一页")
            currentpage ++;
            refreshHistoryRecode();
        }else{
            console.log("最后一页")
        }
    }
})

//关闭窗口
function closeHistoryList() {
    $("#closeHistoryList").hide();
    currentpage = 0;
    totalSize = 0;
    history_open = false;
}

//打开窗口
function openHistoryList() {
    if (history_open) {
        closeHistoryList()
        return
    } else {
        history_open = true;
    }
    $("#closeHistoryList").show();
    $(".history_list_content li").remove()
    refreshHistoryRecode();
}

//获取记录数据
function refreshHistoryRecode() {
    let pojo = {}
    pojo.pid = $(".user_list .active").attr("id");
    pojo.page = currentpage;

    let curImg = $(".user_list .active img").attr("src")
    let curNick = $(".user_list .active span").text();
    if (curNick.length > 0) {
        curNick = curNick.split("(")[0]
    }
    $(".seller_name").text(curNick)
    $.doAjaxJSON(pojo, "./chat/history.html", function (data) {
        let msgArry = data.data;
        totalSize = data.total
        if (msgArry.length < 1) {
            return
        }
        for (var i = 0, len = msgArry.length; i < len; i++) {

            let majo = JSON.parse(msgArry[i])
            let _nick = curNick;
            let _curImg = curImg;
            if (userId == majo.userId) {
                _nick = ''
                _curImg = usavar
            }
            let myhtml = '<li class="clearfloat"><div class="avatar">' +
                '<img src="' + _curImg + '"/></div>' +
                '<div class="content"> <div class="content_top">' +
                '<span>' + _nick + '</span>' +
                '<span class="date">' + new Date(majo.time).toLocaleDateString() + ' ' + new Date(majo.time).toLocaleTimeString() + '</span></div>' +
                '<div class="content_main">' + majo.msg + '</div></div></li>'
            $(".history_list_content ul").append(myhtml)
        }
        currentHeight = $(".history_list_content").find("ul").height() - $(".history_list_content").height();
        ;
    })
}

思路:

所有元素获取高度 = 最后一个li元素的相对父类高度(postion) + 10//基于元素设置的高度
		
		
		
		初始滑动方案:
		   1、初始化后获取滑动高度
		   2、当滑动高度达到时,触发判断决定是否下一页
		   
		滑动高度 = $(".history_list_content").scrollTop()
		   
		   TH       SH        HH
		总高度 - 显示高度 = 隐藏高度
		
		TH = $(".history_list_content").find("ul").height();
		
		SH = $(".history_list_content").height();

猜你喜欢

转载自blog.csdn.net/CoffeeAndIce/article/details/88545799