java

134 阅读4分钟
package com.lld.im.tcp.handler;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.TypeReference;
import com.lld.im.codec.pack.LoginPack;
import com.lld.im.codec.pack.message.ChatMessageAck;
import com.lld.im.codec.pack.user.LoginAckPack;
import com.lld.im.codec.pack.user.UserStatusChangeNotifyPack;
import com.lld.im.codec.proto.Message;
import com.lld.im.codec.proto.MessagePack;
import com.lld.im.common.ResponseVO;
import com.lld.im.common.constant.Constants;
import com.lld.im.common.enums.ImConnectStatusEnum;
import com.lld.im.common.enums.command.GroupEventCommand;
import com.lld.im.common.enums.command.MessageCommand;
import com.lld.im.common.enums.command.SystemCommand;
import com.lld.im.common.enums.command.UserEventCommand;
import com.lld.im.common.model.UserClientDto;
import com.lld.im.common.model.UserSession;
import com.lld.im.common.model.message.CheckSendMessageReq;
import com.lld.im.tcp.feign.FeignMessageService;
import com.lld.im.tcp.publish.MqMessageProducer;
import com.lld.im.tcp.redis.RedisManager;
import com.lld.im.tcp.utils.SessionSocketHolder;
import feign.Feign;
import feign.Request;
import feign.jackson.JacksonDecoder;
import feign.jackson.JacksonEncoder;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.util.AttributeKey;
import org.redisson.api.RMap;
import org.redisson.api.RTopic;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;

import java.net.InetAddress;

/**
 * @author pious
 */
public class NettyServerHandler extends SimpleChannelInboundHandler<Message> {
    private final static Logger logger = LoggerFactory.getLogger(NettyServerHandler.class);
    /**
     * 消息-->路由层key-value(redis)-->netty
     * 根据brokerId区分服务,不可重复,区分的是netty服务 ==>
     * 消息-->路由层key-value存储channel在哪个server上(redis实现)-->具体nettyServer操作channel
     * 即记录用户session登录在哪台netty服务器(brokerId)上,这里的session是我们自定义的类
     */
    private final Integer brokerId;

    /**
     * Tcp服务的handler就可以知道消息的类型以及对消息做合法性校验(判断用户是否禁言、用户关系)回ack,
     * 避免无用消息到mq进而到逻辑层,同时避免在tcp层连接数据库,我们考虑使用远程调用feign
     */
    private final FeignMessageService feignMessageService;

    public NettyServerHandler(Integer brokerId, String logicUrl) {
        logger.info("brokerId-{},logicUrl-{}",brokerId, logicUrl);
        this.brokerId = brokerId;
        // 初始化FeignMessageService
        feignMessageService = Feign.builder()
                .encoder(new JacksonEncoder())
                .decoder(new JacksonDecoder())
                .options(new Request.Options(1000, 3500)) //设置超时时间
                .target(FeignMessageService.class, logicUrl);
    }

    /**
     * (一)登录指令要做的事情:
     *  1.为channel绑定属性
     *  2.redis中Hash结构 名为appId:userSession:userId,其下对应多个key-vue,key为clientType:imei,value为自定义的session对象
     *  3.绑定内存channel: key为appId+userId+clientType+imei组成的DTO,value为channel
     *  4.登录成功redis发布订阅模型+广播实现T人下线,由服务器通知客户端多端互斥下线,不要使用服务端直接删除session,4次挥手过程可能导致数据包丢失
     *  5.
     *
     * String --> Map,Map比String更方便 --> redisHash存储即Map
     * userId client1 session
     * userId client2 session
     * ...
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Message msg) {
        logger.info("NettyServerHandler-channelRead0-msg = {}",msg);
        // 消息操作指令,十六进制,一个消息的开始通常以0x开头
        Integer command = msg.getMessageHeader().getCommand();
        logger.info("command-{}",command);
        logger.info("command == MessageCommand.MSG_P2P.getCommand()-->{}",command == MessageCommand.MSG_P2P.getCommand());
        // 【登录command】
        if (command == SystemCommand.LOGIN.getCommand()) {
            LoginPack loginPack = JSON.parseObject(JSONObject.toJSONString(msg.getMessagePack()), new TypeReference<LoginPack>() {}.getType());
            String userId = loginPack.getUserId();
            // 登录时将userId设置到channel上,在退出登录时候是不够用的,需要明确哪一端退出登录,需要记录clientType
            // 为channel设置额外属性用户id
            ctx.channel().attr(AttributeKey.valueOf(Constants.USER_ID)).set(userId);
            String clientImei = msg.getMessageHeader().getClientType() + ":" + msg.getMessageHeader().getImei();
            // 为channel设置client和imei
            ctx.channel().attr(AttributeKey.valueOf(Constants.CLIENT_IMEI)).set(clientImei);
            // 为channel设置appId
            ctx.channel().attr(AttributeKey.valueOf(Constants.APP_ID)).set(msg.getMessageHeader().getAppId());
            // 为channel设置ClientType
            ctx.channel().attr(AttributeKey.valueOf(Constants.CLIENT_TYPE)).set(msg.getMessageHeader().getClientType());
            // 为channel设置Imei(区分同端的不同设备,T设备的时候可以使用)
            ctx.channel().attr(AttributeKey.valueOf(Constants.IMEI)).set(msg.getMessageHeader().getImei());

            logger.info("userId-{},clientImei-{},appId-{},clientType-{},imei-{}",
                    userId,
                    clientImei,
                    msg.getMessageHeader().getAppId(),
                    msg.getMessageHeader().getClientType(),
                    msg.getMessageHeader().getImei());

            //Redis map
            UserSession userSession = new UserSession();
            userSession.setAppId(msg.getMessageHeader().getAppId());
            userSession.setClientType(msg.getMessageHeader().getClientType());
            userSession.setUserId(loginPack.getUserId());
            userSession.setConnectState(ImConnectStatusEnum.ONLINE_STATUS.getCode());
            // 根据brokerId区分哪个netty服务
            userSession.setBrokerId(brokerId);

            logger.info("brokerId-{}",brokerId);

            userSession.setImei(msg.getMessageHeader().getImei());
            try {
                InetAddress localHost = InetAddress.getLocalHost();
                // 根据brokerId区分服务,服务器本机host
                userSession.setBrokerHost(localHost.getHostAddress());
            } catch (Exception e) {
                e.printStackTrace();
            }
            // 存入redis
            RedissonClient redissonClient = RedisManager.getRedissonClient();
            // eg:10000:userSession:lld(appId:userSession:userId)
            RMap<String, String> map = redissonClient.getMap(msg.getMessageHeader().getAppId() + Constants.RedisConstants.USER_SESSION_CONSTANTS + loginPack.getUserId());
            // key ==> (clientType+":"+imei) ==> 4:a401a7df-c74a-11ed-ac60-00d49ef66b3d
            // value ==> session值
            map.put(msg.getMessageHeader().getClientType() + ":" + msg.getMessageHeader().getImei(), JSONObject.toJSONString(userSession));
            // 存入内存channel
            SessionSocketHolder
                    .put(msg.getMessageHeader().getAppId(), loginPack.getUserId(),
                            msg.getMessageHeader().getClientType(), msg.getMessageHeader().getImei(), (NioSocketChannel) ctx.channel());

            // Redis的发布订阅模型实现T人下线的操作,登陆成功后,利用广播告诉其他服务器我已经登录,对相应的客户端进行T掉,广播的信息是当前登录的DTO
            UserClientDto dto = new UserClientDto();
            dto.setImei(msg.getMessageHeader().getImei());
            dto.setUserId(loginPack.getUserId());
            dto.setClientType(msg.getMessageHeader().getClientType());
            dto.setAppId(msg.getMessageHeader().getAppId());
            // 将dto通过redis发布订阅模式实现发送给其他端通知我已经上线
            RTopic topic = redissonClient.getTopic(Constants.RedisConstants.USER_LOGIN_CHANNEL);
            // 发送dto消息完成后需要监听该消息并完成踢人下线的处理--UserLoginMessageListener
            topic.publish(JSONObject.toJSONString(dto));

            // 12.3netty网关层用户状态变更通知逻辑层
            UserStatusChangeNotifyPack userStatusChangeNotifyPack = new UserStatusChangeNotifyPack();
            userStatusChangeNotifyPack.setAppId(msg.getMessageHeader().getAppId());
            userStatusChangeNotifyPack.setUserId(loginPack.getUserId());
            userStatusChangeNotifyPack.setStatus(ImConnectStatusEnum.ONLINE_STATUS.getCode());
            // 发送mq
            MqMessageProducer.sendMessage(userStatusChangeNotifyPack,
                    msg.getMessageHeader(),
                    UserEventCommand.USER_ONLINE_STATUS_CHANGE.getCommand());
            // 登录成功ack告知已经登录成功
            MessagePack<LoginAckPack> loginSuccess = new MessagePack<>();
            LoginAckPack loginAckPack = new LoginAckPack();
            loginAckPack.setUserId(loginPack.getUserId());
            loginSuccess.setCommand(SystemCommand.LOGIN_ACK.getCommand());
            loginSuccess.setData(loginAckPack);
            loginSuccess.setImei(msg.getMessageHeader().getImei());
            loginSuccess.setAppId(msg.getMessageHeader().getAppId());
            // 发送给客户端告知登录成功
            ctx.channel().writeAndFlush(loginSuccess);
        } else if (command == SystemCommand.LOGOUT.getCommand()) {
            // 退出登录2步(删除内存中的channel和redis中的路由关系即session)
            // 1.删除channel  --> remove()
            // 2.redis中的session删除
            // 3.关闭channel
            SessionSocketHolder.removeUserSession((NioSocketChannel) ctx.channel());
        } else if (command == SystemCommand.PING.getCommand()) {
            // 心跳包,将最后一次读写事件的时间写入handler中
            // 进入读写空闲,无需删除session只需要将用户状态改为离线即可,即后台session存在但状态为离线
            // 线切后台 ==> 删除内存中的channel,将redis中的session改为离线状态
            // 最后一次心跳时间绑定到channel上
            logger.info("PING-{}",System.currentTimeMillis());
            ctx.channel().attr(AttributeKey.valueOf(Constants.READ_TIME)).set(System.currentTimeMillis());
        } else if (command == MessageCommand.MSG_P2P.getCommand() || command == GroupEventCommand.MSG_GROUP.getCommand()) {
            // 调用校验消息发送方的接口,如果成功则投递到mq,如果失败则直接ack,不会到逻辑层再进行校验,这里校验节省带宽
            // 消息实时性-校验逻辑前置由tcp通过feign接口提前校验
            // Tcp服务的handler就可以知道消息的类型以及对消息做合法性校验(判断用户是否禁言、用户关系)回ack,
            // 避免无用消息到mq进而到逻辑层,同时避免在tcp层连接数据库,我们考虑使用远程调用feign
            try {
                String toId;
                CheckSendMessageReq req = new CheckSendMessageReq();
                req.setAppId(msg.getMessageHeader().getAppId());
                req.setCommand(msg.getMessageHeader().getCommand());
                JSONObject jsonObject = JSON.parseObject(JSONObject.toJSONString(msg.getMessagePack()));
                String fromId = jsonObject.getString("fromId");
                if (command == MessageCommand.MSG_P2P.getCommand()) {
                    toId = jsonObject.getString("toId");
                } else {
                    toId = jsonObject.getString("groupId");
                }
                req.setToId(toId);
                req.setFromId(fromId);
                logger.info("appId-{}-toId-{}-fromId-{}",msg.getMessageHeader().getAppId(),toId,fromId);
                // 发送消息前校验
                ResponseVO responseVO = feignMessageService.checkSendMessage(req);
                if (responseVO.isOk()) {
                    // 校验成功发送消息-->根据群聊/单聊消息不同的mq监听队列,消息到来后便立即执行
                    MqMessageProducer.sendMessage(msg, command);
                } else {
                    int ackCommand;
                    if (command == MessageCommand.MSG_P2P.getCommand()) {
                        // 单聊消息ACK
                        ackCommand = MessageCommand.MSG_ACK.getCommand();
                    } else {
                        // 群聊消息ACK
                        ackCommand = GroupEventCommand.GROUP_MSG_ACK.getCommand();
                    }
                    MessagePack<ResponseVO> ack = new MessagePack<>();
                    ChatMessageAck chatMessageAck = new ChatMessageAck(jsonObject.getString("messageId"));
                    responseVO.setData(chatMessageAck);
                    ack.setData(responseVO);
                    ack.setCommand(ackCommand);
                    ctx.channel().writeAndFlush(ack);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        } else {
            // 没有匹配上的都发送到mq中,即除了登录/退出登录/心跳/单聊/群聊之外的消息
            logger.info("*******************NettyServerHandler--没有匹配上的都发送到mq中**************");
            MqMessageProducer.sendMessage(msg, command);
        }

    }

    //表示 channel 处于不活动状态
//    @Override
//    public void channelInactive(ChannelHandlerContext ctx) {
//        //设置离线
//        SessionSocketHolder.offlineUserSession((NioSocketChannel) ctx.channel());
//        ctx.close();
//    }


    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        super.exceptionCaught(ctx, cause);
    }
}