关于IM的一些思考与实践

时间:2022-03-17 21:44:50

上一篇简单的实现了一个聊天网页,但这个太简单,消息全广播,没有用户认证和已读未读处理,主要的意义是走通了websocket-sharp做服务端的可能性。那么一个完整的IM还需要实现哪些部分?

一、发消息

用户A想要发给用户B,首先是将消息推送到服务器,服务器将拿到的toid和内容包装成一个完整的message对象,分别推送给客户B和客户A。为什么也要推送给A呢,因为A也需要知道是否推送成功,以及拿到了messageId可以用来做后面的已读未读功能。

关于IM的一些思考与实践

这里有两个问题还要解决,第一个是Server如何推送到客户B,另外一个问题是群消息如何处理?

实现推送

先解决第一个问题,在Server端,每次连接都会创建一个WebSocketBehavior对象,每个WebSocketBehavior都有一个唯一的Id,如果用户在线我们就可以推送过去:

 Sessions.SendTo(userKey, Json.JsonParser.Serialize(msg));

需要解决的是需要将用户的Id和WebSocketBehavior的Id关联起来,所以这就要求每个用户连接之后需要马上验证。所以用户的流程如下:

关于IM的一些思考与实践

由于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[];
if (!cacheManager.IsSet(infos[]))
{
cacheManager.Set(infos[], Id, );
}
//告之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 == ;
}
}
 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 ""://正常聊天
//先检查是否合法
if (!IsValid)
{
SendToSelf("请先验证!","");
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就可以知道哪些人读了哪些人未读。

关于IM的一些思考与实践

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 "":
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,这样切换自然,不用每次都要渲染。

关于IM的一些思考与实践

当用户点击左边栏的时候,就会在右侧插入一个.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");
}
}
}

这样就可以显示几人未读了:

关于IM的一些思考与实践

小结:大致的流程已经走通,但还有些问题,比如历史消息和消息存储还没有处理,文件发送,另外还有对于一个用户他可能不止一个端,要实现多屏同步,这就需要缓存下每个用户所有的WebSocketBehavior对象Id。 后续继续完善。