基於項目需要,然則沒有其他方面的技術支持,也走上了之前公司所需要但是沒有讓我有機會做的模塊,一個類im的客服系統,基於能不能做這個神一般的問法,作爲一個碼仔,只能是不可能不會做,只能繼續沿着前路摸爬。
要求達成效果:
沒事,簡單用戶和商戶一對一聊天就好了,只能用戶端主動先發起:
臥槽,我心想,這不是信手拈來的程度嗎?
直接套上netty管它呢直接一對一就是幹;
總體思路:
直接搭建起基本的netty服務爲用戶ID綁定一個發起的信道存儲於緩存中,爲商戶ID綁定一個信道,保存溝通信道即可。(用戶ID與商戶ID基本是不變的,但是信道會伴隨與發起的不同通訊而改變,此處主要是如何保存溝通關係的問題)
:但是這裏會面臨一個問題,即 一對一 時其他用戶主動發起連接,這時其實是算客戶端設計問題,我們將通訊過程的信息體設計得足夠就足以應對,實際解決初步解決方案會在下方給予。
爲了讓用戶能在下次登陸時有不一樣的效果:
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 補充:在線狀態,關係網建立模式改變
針對需求:①斷開時間太快 ②在線狀態的展示
- 主要更新
- 客戶端單向發送一條信息(含離線信息),才建立關係網
- 字段增加: 消息類型(用於區分消息類型) 、 是否已讀
- 廣播用戶的上下線行爲用於確認在線狀態
- 稍微整理了一下代碼
_ _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();