• 关于IM的一些思考与实践
  • 上一篇简单的实现了一个聊天网页,但这个太简单,消息全广播,没有用户认证和已读未读处理,主要的意义是走通了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;
           }
        }
    View Code
     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; }
        }
    View Code

    对于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。 后续继续完善。

  • 相关阅读:
    关于内存数据与 JSON
    精进一项技能的要诀是边做边学(全然的学和全然的做都不是最有效率的。答应做的事,全部都要完成,如果觉得实在不可能,就要拒绝,或者提早寻求帮助)(那些接私活的,都是在做着一些底层的,浪费青春的活,把自己搞的很忙,专业的东西都无法去深入,只是在害自己,永远上不了台面)
    这个韦皇后她比李显的性格要强悍一些,要坚强(咱们宁可被人家杀死,不能被人吓死。天下的事都是祸福难料,你怎么知道这使者一定是来杀你的?)
    学习懈怠的时候,可以运行Qt自带的Demo,或者Delphi控件自带的Demo,或者Cantu书带的源码,运行一下Boost的例子(搞C++不学习Boost/Poco/Folly绝对是一大损失,有需要使用库要第一时间想到)(在六大的痛苦经历说明,我的理论性确实不强,更适合做实践
    你应当如何学习C++以及编程(细节是必要的,但不是重要的,把时间用在集中精力去解决问题,而不是学习新技术,那样练不成高手。在实践中提高才是最重要的。最最重要的内功还是长期学习所磨练出来的自学能力)good
    Qt主界面卡死的解决方案一些具体实现方式(五种方法)
    关于dotnet跨平台 和 移动开发&人工智能 微信公众号
    中国.NET开发者峰会特别活动基于k8s的微服务和CI/CD动手实践报名
    用户标签实践:如何建立标签体系实现精准营销?
    flutter RN taro选型思考
  • 【推广】 阿里云小站-上云优惠聚集地(新老客户同享)更有每天限时秒杀!
    【推广】 云服务器低至0.95折 1核2G ECS云服务器8.1元/月
    【推广】 阿里云老用户升级四重礼遇享6.5折限时折扣!
  • 原文地址:https://www.cnblogs.com/stoneniqiu/p/8626931.html
走看看 - 开发者的网上家园