聊天消息拉取与推送

微信如何实现聊天消息实时推送,消息不丢失,多设备消息共享呢。带着这个疑问,我们尝试实现一个较为合理的消息拉取机制。

1.消息存储机制

消息存储在数据库,为了存储海量级的消息内容,肯定是需要分库分表等机制。这里我们不讨论这种级别的消息存储,只使用单表存储。

对于讨论组的每一条消息,假设讨论组有1000个人,用户发送一条消息,典型地,有两种方式来存储这条消息。

1.1.扩散读

该机制下,每条消息只存储一份数据,不同用户使用自己的消息seq来拉取数据。这种做法,最大的优势是减轻数据库压力。但缺点是,对于每个用户,不同的讨论组,用户要分别存储对应的seq。关于seq机制,后文介绍。

1.2.扩散写

该机制下,对于每条消息,每个用户都独立复制一份。这种做法,最大的优势是,统一seq。无论是讨论组还是qq群,还是点对点聊天,用户只需要保存统一的seq,就可以批量拉取所有消息。业务逻辑相对简单很多。(据说,企业微信是采用此方式)。但缺点是非常浪费存储,毕竟,1000个人,每个人发一条消息,就要存储100w条数据。

本文采用扩散读机制。

2.消息拉取机制

如何实现消息实时推送与消息不丢失呢?

2.1.消息实时推送

当有新消息到达,需要推送给用户知道。对于http客户端来说,只能使用http轮训。对于socket客户端,则可以主动推送消息。如果涉及到移动客户端,还需要借助移动设备的Push功能。

当消息到达之后,根据消息所属的频道,如果是讨论组,则目标是所有成员;如果是私聊,则目标是消息的发送方与接收方。这里为什么要加上发送方,有两个目的,一个是为了简化逻辑,不管是发送还是接收,都使用统一的拉取机制。第二个是为了保证消息已发送到服务器,而不会因为网络异常而丢失。

对于所有在线成员,推送一个新消息到达的协议。(这里不考虑移动设备的离线用户)

2.2.消息不丢失

对于在线用户,不能说因为用户在线,就认为用户一定可以收到消息,有可能是发送的时刻用户是在线的,但是消息到达用户客户端的时候,用户突然下线了。这时候,消息就丢失了。

当有新消息的时候,不使用推送的机制。而是告诉客户端有新消息了,然后客户端发送自己的消息seq,从服务器批量获取差量消息。同时,服务器返回消息列表的时候,不会自动更新客户端的seq。当客户端真正获取到信息之后,让发送确定协议,通知服务器更新seq。由于是客户端收到消息之后,才更新seq。这种机制就不会丢失消息。

当客户端收到消息之后,可以选择把消息保存到本地。这样,服务器理论上不会再把相同id的消息推送给客户端。

2.3.使用seq实现批量拉取

设想这种场景,讨论组成员噼里啪啦聊了很多东西。第二天,另外一个成员上线,如何高效批量获取获取这种消息呢?答案是使用最大seq机制。

前文说过,每一条消息都有一个递增性的消息id(可以使用数据库的自增长机制),这个id可以直接作为消息的seq。

对于群里的每个成员,都独立存在一个最大seq。当一个成员上线之后,会根据成员这个maxseq与群的消息id进行比较,所有大于成员seq的消息都是这个成员未读的,可以推送给成员。

当新成员加入讨论组,则把新成员的maxseq置为当前讨论组的最大消息的id。

3.服务器代码实现

3.1.适配各种频道

对于不同的频道,消息拉取机制都是一样的,大部分代码都是可以重用的。例如,对于私聊,讨论组,不同的只是接收方列表,最大seq的存储位置。

我们统一定义各种频道处理消息的行为接口

/**
 * 消息频道处理器
 */
public interface ChatChannelHandler {

    void send(Long senderId, Long target, MessageContent content);

    /**
     * 对消息进行持久化
     * @param senderId 发送方id
     * @param target 目标id,频道不同,目标id的种类也不同
     * @param content 消息类型,支持各种消息格式
     */
    void saveToDb(Long senderId, Long target, MessageContent content);

    /**
     * 消息的接收方列表
     * @param senderId
     * @param target
     * @return
     */
    Collection<Long> receivers(Long senderId, Long target);

    /**
     * 成员批量拉取消息
     * @param receiver
     * @param target
     * @param maxSeq
     * @return
     */
    List<ChatMessage> pullMessages(Long receiver, Long target, long maxSeq);

    /**
     * 频道类型 {@link pers.kinson.im.common.constants.Channels}
     * @return
     */
    byte channelType();
}

3.1.1.私聊频道处理器

@Service
public class PersonalChannelHandler implements ChatChannelHandler {

    @Autowired
    MessageDao messageDao;

    @Override
    public void send(Long senderId, Long target, MessageContent content) {
        if (SpringContext.getUserService().getUserName(target) == null) {
            return;
        }
        saveToDb(senderId, target, content);

        ResNewMessageNotify notify = new ResNewMessageNotify();
        notify.setChannel(Channels.person);
        notify.setTopic(target);
        receivers(senderId, target).forEach(e -> SessionManager.INSTANCE.sendPacketTo(e, notify));
    }

    @Override
    public void saveToDb(Long senderId, Long target, MessageContent content) {
        Message message = new Message();
        message.setChannel(channelType());
        message.setType(content.getType());
        message.setDate(new Date());
        message.setSender(senderId);
        message.setReceiver(target);
        message.setContent(JsonUtil.object2String(content));
        messageDao.insert(message);
    }

    @Override
    public Collection<Long> receivers(Long senderId, Long target) {
        return Arrays.asList(senderId, target);
    }

    @Override
    public List<ChatMessage> pullMessages(Long receiver, Long target, long maxSeq) {
        List<Message> newMessages = messageDao.fetchNewPersonal(receiver, maxSeq);
        return newMessages.stream().map(e -> {
            ChatMessage vo = new ChatMessage();
            vo.setId(e.getId());
            vo.setType(e.getType());
            vo.setJson(e.getContent());
            vo.setDate(DateUtil.format(e.getDate()));
            vo.setSenderId(e.getSender());
            vo.setReceiverId(e.getReceiver());
            vo.setReceiverName(SpringContext.getUserService().getUserName(e.getReceiver()));
            vo.setSenderName(SpringContext.getUserService().getUserName(e.getSender()));
            return vo;
        }).collect(Collectors.toList());
    }

    @Override
    public byte channelType() {
        return Channels.person;
    }
}

3.1.2.讨论组频道处理器

@Service
public class PersonalChannelHandler implements ChatChannelHandler {

    @Autowired
    MessageDao messageDao;

    @Override
    public void send(Long senderId, Long target, MessageContent content) {
        if (SpringContext.getUserService().getUserName(target) == null) {
            return;
        }
        saveToDb(senderId, target, content);

        ResNewMessageNotify notify = new ResNewMessageNotify();
        notify.setChannel(Channels.person);
        notify.setTopic(target);
        receivers(senderId, target).forEach(e -> SessionManager.INSTANCE.sendPacketTo(e, notify));
    }

    @Override
    public void saveToDb(Long senderId, Long target, MessageContent content) {
        Message message = new Message();
        message.setChannel(channelType());
        message.setType(content.getType());
        message.setDate(new Date());
        message.setSender(senderId);
        message.setReceiver(target);
        message.setContent(JsonUtil.object2String(content));
        messageDao.insert(message);
    }

    @Override
    public Collection<Long> receivers(Long senderId, Long target) {
        return Arrays.asList(senderId, target);
    }

    @Override
    public List<ChatMessage> pullMessages(Long receiver, Long target, long maxSeq) {
        List<Message> newMessages = messageDao.fetchNewPersonal(receiver, maxSeq);
        return newMessages.stream().map(e -> {
            ChatMessage vo = new ChatMessage();
            vo.setId(e.getId());
            vo.setType(e.getType());
            vo.setJson(e.getContent());
            vo.setDate(DateUtil.format(e.getDate()));
            vo.setSenderId(e.getSender());
            vo.setReceiverId(e.getReceiver());
            vo.setReceiverName(SpringContext.getUserService().getUserName(e.getReceiver()));
            vo.setSenderName(SpringContext.getUserService().getUserName(e.getSender()));
            return vo;
        }).collect(Collectors.toList());
    }

    @Override
    public byte channelType() {
        return Channels.person;
    }
}

3.2.聊天消息接收门面

    public void chatToChannel(Long sender, byte channel, long target, byte contentType, String content) {
        ChatChannelHandler handler = handlers.get(channel);
        MessageContent chatMessage = SpringContext.getMessageContentFactory().parse(contentType, content);
        chatMessage.setType(contentType);
        handler.send(sender, target, chatMessage);
    }

3.3.不同频道逻辑差异

 从私聊与讨论组消息的处理代码可知,最大的不同是消息拉取的逻辑。

对于讨论组而已,查找的是指定讨论组id,并且id>member.maxSeq, sql如下:

 SELECT * FROM message
        WHERE
        channel = #{channelType}
        AND receiver = #{receiver}
        AND id > #{maxSeq}

对于私聊来说,由于发送方,接收方采用同样的机制。对于消息发送方来说,也是发送之后,再重新根据自己的seq来拉取已经发送的消息。sql如下:

 SELECT * FROM message
        WHERE
        channel = 0
        AND (receiver = #{receiver} OR sender = #{receiver})
        AND id > #{maxSeq}

4.客户端代码实现

4.1.消息发送

客户端点击消息发送按钮之后,会把频道类型,接收方,消息类容(打包成json格式),发送到服务器

    public void sendMessageTo(long friendId, MessageContent content) {
        ReqChatToChannel request = new ReqChatToChannel();
        request.setChannel(Constants.CHANNEL_PERSON);
        request.setTarget(friendId);
        request.setContent(JsonUtil.object2String(content));
        request.setContentType(content.getType());
        IOUtil.send(request);
    }

4.2.消息拉取

服务器收到消息之后,推送一个新消息到达的消息给客户端。客户端收到之后,根据频道的类型,使用不同的maxseq作为参数(前文说的分散读存储机制),进行批量获取消息

    private void notifyNewMessage(Object packet) {
        ResNewMessageNotify message = (ResNewMessageNotify) packet;
        ReqFetchNewMessage reqFetchNewMessage = new ReqFetchNewMessage();
        // 这里先写点丑代码,后续优化
        if (message.getChannel() == Constants.CHANNEL_DISCUSSION) {
            DiscussionGroupVo targetDiscussionGroup = Context.discussionManager.getDiscussionGroupVo(NumberUtil.longValue(message.getTopic()));
            if (targetDiscussionGroup != null) {
                reqFetchNewMessage.setChannel(Constants.CHANNEL_DISCUSSION);
                reqFetchNewMessage.setTopic(targetDiscussionGroup.getId());
                reqFetchNewMessage.setMaxSeq(targetDiscussionGroup.getMaxSeq());

            }
        } else if (message.getChannel() == Constants.CHANNEL_PERSON) {
            reqFetchNewMessage.setTopic(Context.userManager.getMyUserId());
            reqFetchNewMessage.setMaxSeq(Context.userManager.getMyProfile().getChatMaxSeq());
        }
        IOUtil.send(reqFetchNewMessage);
    }

4.3.消息seq确认

客户端收到消息之后,对消息进行展示(同时将消息保存在本地客户端)。

此时,再发送消息跟服务器进行确认,如此,就不会发生消息丢失的情况了。

 private void refreshNewMessage(Object packet) {
        ResNewMessage message = (ResNewMessage) packet;
        if (message.getMessages() == null || message.getMessages().isEmpty()) {
            return;
        }
        long maxSeq = 0;
        for (ChatMessage e : message.getMessages()) {
            e.setContent(Context.messageContentFactory.parse(e.getType(), e.getJson()));
            maxSeq = Math.max(maxSeq, e.getId());
        }

        ReqMarkNewMessage reqMarkNewMessage = new ReqMarkNewMessage();
        reqMarkNewMessage.setChannel(message.getChannel());
        reqMarkNewMessage.setMaxSeq(maxSeq);
        // 根据消息来源进行分发
        if (message.getChannel() == Constants.CHANNEL_DISCUSSION) {
            long discussionId = message.getMessages().get(0).getReceiverId();
            reqMarkNewMessage.setTopic(discussionId);
            Context.discussionManager.receiveDiscussionMessages(maxSeq, message.getMessages());
        } else if (message.getChannel() == Constants.CHANNEL_PERSON) {
            Context.userManager.getMyProfile().setChatMaxSeq(maxSeq);
            Context.chatManager.receiveFriendPrivateMessage(message.getMessages());
        }

        // 收到消息之后再通知服务器,保证不丢消息
        IOUtil.send(reqMarkNewMessage);
    }

4.4.多端同步机制

对于支持多端登录的聊天软件,例如桌面版,手机版,网页版,可以对每个频道保存自己的maxseq,以实现各个频道消息同步接收不丢失。

全部代码已在github上托管

服务端代码请移步 --> 聊天室服务器

客户端代码请移步 --> 聊天室客户端

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

jforgame

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值