上一篇簡單的實現了一個聊天網頁,但這個太簡單,消息全廣播,沒有用戶認證和已讀未讀處理,主要的意義是走通了websocket-sharp做服務端的可能性。那么一個完整的IM還需要實現哪些部分?
一、發消息
用戶A想要發給用戶B,首先是將消息推送到服務器,服務器將拿到的toid和內容包裝成一個完整的message對象,分別推送給客戶B和客戶A。為什么也要推送給A呢,因為A也需要知道是否推送成功,以及拿到了messageId可以用來做后面的已讀未讀功能。
這里有兩個問題還要解決,第一個是Server如何推送到客戶B,另外一個問題是群消息如何處理?
實現推送
先解決第一個問題,在Server端,每次連接都會創建一個WebSocketBehavior對象,每個WebSocketBehavior都有一個唯一的Id,如果用戶在線我們就可以推送過去:
Sessions.SendTo(userKey, Json.JsonParser.Serialize(msg));
需要解決的是需要將用戶的Id和WebSocketBehavior的Id關聯起來,所以這就要求每個用戶連接之后需要馬上驗證。所以用戶的流程如下:
由於JavaScript和Server交互的主要途徑就是onmessage方法,暫時不能像socketio那樣可以自定義事件讓后台執行完成后就觸發,我們先只能約定消息類型來實現驗證和聊天的區分。
function send(obj) { //必須是對象,還有約定的類型 ws.send(JSON.stringify(obj)) } socketSDK.sendTo = function (toId,msg) { var obj = { toId:toId, content: msg, type: "002"//聊天 } send(obj); } socketSDK.validToken = function (token) { var obj = { content: token || localStorage.token, type: "001"//驗證 } send(obj); }
在后端拿到token就可以將用戶的guid存下來,所有用戶的guid與WebSocketBehavior的Id關系都保存在緩存里面。
var infos = _userService.DecryptToken(token); UserGuid = infos[0]; if (!cacheManager.IsSet(infos[0])) { cacheManager.Set(infos[0], Id, 60); } //告之client驗證結果,並把guid發過去 SendToSelf("token驗證成功");
調用WebSocketBehavior的Send方法可以將對象直接發送給與其連接的客戶端。接下來我們只需要判斷toid這個用戶在緩存里面,我們就能把消息推送給他。如果不在線,就直接保存消息。
群消息
群是一個用戶的集合,發一條消息到群里面,數據庫也只需要存儲一條,而不是每個人都存一條,但每個人都會收到一次推送。這是我的Message對象和Group對象。

public class Message { private string _receiverId; public Message() { SendTime = DateTime.Now; MsgId = Guid.NewGuid().ToString().Replace("-", ""); } [Key] public string MsgId { get; set; } public string SenderId { get; set; } public string Content { get; set; } public DateTime SendTime { get; set; } public bool IsRead { get; set; } public string ReceiverId { get { return _receiverId; } set { _receiverId = value; IsGroup=isGroup(_receiverId); } } [NotMapped] public Int32 MsgIndex { get; set; } [NotMapped] public bool IsGroup { get; set; } public static bool isGroup(string key) { return !string.IsNullOrEmpty(key) && key.Length == 20; } }

public class Group { private ICollection<User.User> _users; public Group() { Id = Encrypt.GenerateOrderNumber(); CreateTime=DateTime.Now; ModifyTime=DateTime.Now; } [Key] public string Id { get; set; } public DateTime CreateTime { get; set; } public DateTime ModifyTime { get; set; } public string GroupName { get; set; } public string Image { get; set; } [Required] //群主 public int CreateUserId { get; set; } [NotMapped] public virtual User.User Owner { get; set; } public ICollection<User.User> Users { get { return _users??(_users=new List<User.User>()); } set { _users = value; } } public string Description { get; set; } public bool IsDeleteD { get; set; } }
對於Message而言,主要就是SenderId,Content和ReceiverId,我通過ReceiverId來區分這條消息是發給個人的消息還是群消息。對於群Id是一個長度固定的字符串區別於用戶的GUID。這樣就可以實現群消息和個人消息的推送了:
case "002"://正常聊天 //先檢查是否合法 if (!IsValid) { SendToSelf("請先驗證!","002"); break; } //在這里創建消息 避免群消息的時候多次創建 var msg = new Message() { SenderId = UserGuid, Content = obj.content, IsRead = false, ReceiverId = toid, }; //先發送給自己 兩個作用 1告知對方服務端已經收到消息 2 用於對方通過msgid查詢已讀未讀 SendToSelf(msg); //判斷toid是user還是 group if (msg.IsGroup) { log("群消息:"+obj.content+",發送者:"+UserGuid); //那么要找出這個group的所有用戶 var group = _userService.GetGroup(toid); foreach (var user in group.Users) { //除了發消息的本人 //群里的其他人都要收到消息 if (user.UserGuid.ToString() != UserGuid) { SendToUser(user.UserGuid.ToString(), msg); } } } else { log("單消息:" + obj.content + ",發送者:" + UserGuid); SendToUser(toid, msg); } //save message //_msgService.Insert(msg); break;
而SendToUser就可以將之前的緩存Id拿出來了。
private void SendToUser(string toId, Message msg) { var userKey = cacheManager.Get<string>(toId); //這個判斷可以拿掉 不存在的用戶肯定不在線 //var touser = _userService.GetUserByGuid(obj.toId); if (userKey != null) { //發送給對方 Sessions.SendTo(userKey, Json.JsonParser.Serialize(msg)); } else { //不需要通知對方 //SendToSelf(toId + "還未上線!"); } }
二、收消息
收消息包含兩個部分,一個是發送回執,一個是頁面消息顯示。回執用來做已讀未讀。顯示的問題在於,有歷史消息,有當前的消息有未讀的消息,不同人發的不同消息,怎么呈現呢?先說回執
回執
我定義的回執如下:
public class Receipt { public Receipt() { CreateTime = DateTime.Now; ReceiptId = Guid.NewGuid().ToString().Replace("-", ""); } [Key] public string ReceiptId { get; set; } public string MsgId { get; set; } /// <summary> /// user的guid /// </summary> public string UserId { get; set; } public DateTime CreateTime { get; set; } }
回執不同於消息對象,不需要考慮是否是群的,回執都是發送到個人的,單聊的時候這個很好理解,A發給B,B讀了之后發個回執給A,A就知道B已讀了。那么A發到群里一條消息,讀了這條消息的人都把回執推送給A。A就可以知道哪些人讀了哪些人未讀。
js的方法里面我傳了一個toid,本質上是可以通過message對象查到用戶的id的。但我不想讓后端去查詢這個id,前端拿又很輕松。
//這個toid是應該可以省略的,因為可以通過msgId去獲取 //目前這么做的理由就是避免服務端進行一次查詢。 //toId必須是userId 也就是對應的sender socketSDK.sendReceipt = function (toId, msgId) {var obj= { toId: toId, content: msgId, type:"003" } send(obj) }
case "003": key = cacheManager.Get<string>(toid); var recepit = new Receipt() { MsgId = obj.content, UserId = UserGuid, }; //發送給 發回執的人,告知服務端已經收到他的回執 SendToSelf(recepit); if (key != null) { //發送給對方 await Sessions.SendTo(key, Json.JsonParser.Serialize(recepit)); } // save recepit break;
這樣前端拿到回執就能處理已讀未讀的效果了。
消息呈現:
我采用的是每個對話對應一個div,這樣切換自然,不用每次都要渲染。
當用戶點擊左邊欄的時候,就會在右側插入一個.messages的div。包括當收到了消息還沒有頁面的時候,也需要創建頁面。
function leftsay(boxid, content, msgid) { //這個view不一定打開了。 $box = $("#" + boxid); //可以先放到隱藏的頁面上去, word = $("<div class='msgcontent'>").html(content); warp = $("<div class='leftsay'>").attr("id", msgid).append(word); if ($box.length != 0) { $box.append(warp); } else { $box = $("<div class='messages' id=" + boxid + ">"); $box.append(word); $("#messagesbox").append($box); } }
未讀消息
當前頁面不在active狀態,就不能發已讀回執。
function unreadmark(friendId, count) { $("#" + friendId).find("span").remove(); if (count == 0) { return; } var span = $("<span class='unreadnum' >").html(count); $("#"+friendId).append(span); } sdk.on("messages", function (data) { if (sdk.isSelf(data.senderid)) { //自己說的 //肯定是當前對話 //照理說還要判斷是不是當前的對話框 data.list = [];//為msg對象增加一個數組 用來存儲回執 if (data.isgroup) selfgroupmsg[data.msgid] = data;//緩存群消息 用於處理回執 rightsay(data.content, data.msgid); } else { //別人說的 //不一定是當前對話,就要從ReceiverId判斷。 var _toid = data.senderid; if (!sdk.isSelf(data.receiverid)) { //接受者不是自己 說明是群消息 _toid = data.receiverid; } var boxid = _toid + viewkey; //如果是當前會話就發送已讀回執 if (_toid == currentToId) { sdk.sendReceipt(data.senderid, data.msgid); } else { if (!msgscache[_toid]) { msgscache[_toid] = []; } //存入未讀列表 msgscache[_toid].push(data); unreadmark(_toid, msgscache[_toid].length); } leftsay(boxid, data.content, data.msgid); } });
單聊的時候已讀未讀比較簡單,就判斷這條消息是否收到了回執。
$("#" + msgid).find(".unread").html("已讀").addClass("ed");
但是群聊的時候,顯示的是“幾人未讀”,而且要能夠看到哪些人讀了哪些人未讀,為了最大的減少查詢,在最初獲取聯系人列表的時候就需要將群的成員也一起帶出來,然后前端記錄下每一條群消息的所收到的回執。這樣每收到一條就一個人。而前端只需要緩存發送的群消息即可。
function readmsg(data) { //區分是單聊還是群聊 //單聊就直接是已讀 var msgid = data.msgid; var rawmsg = selfgroupmsg[msgid]; if (!rawmsg) { $("#" + msgid).find(".unread").html("已讀").addClass("ed"); } else { rawmsg.list.push(data); //得到了這個群的信息 var ginfo = groupinfo[rawmsg.receiverid]; //總的人數 var total = ginfo.Users.length; //找到原始的消息 //已讀的人數 var readcount = rawmsg.list.length; //未讀人數 var unread = total - readcount-1;//除去自己 var txt = "已讀"; if (unread != 0) { txt = unread + "人未讀"; $("#" + msgid).find(".unread").html(txt); } else { $("#" + msgid).find(".unread").html(txt).addClass("ed"); } } }
這樣就可以顯示幾人未讀了:
小結:大致的流程已經走通,但還有些問題,比如歷史消息和消息存儲還沒有處理,文件發送,另外還有對於一個用戶他可能不止一個端,要實現多屏同步,這就需要緩存下每個用戶所有的WebSocketBehavior對象Id。 后續繼續完善。