Netty综合案例(下)

本文为个人学习笔记整理,仅供交流参考,非专业教学资料,内容请自行甄别

文章目录


实验内容

实验内容(本篇从第5条开始):

  1. 消息实体的定义
  2. 客户端,服务端的定义
  3. 客户端,服务端Handler责任链的设计
  4. 半包,粘包的框架解决方案
  5. 应用层的握手,授权认证,报文加密解密
  6. 心跳检测(TCP有keep alive 为什么应用层还要有心跳?TCP的保活机制2小时,TCP的保活机制只能保证客户端和服务端的通信链路是通的,不能保证客户端或服务端的进程还是活着的)
  7. 业务数据的通信

五、报文加密解密

报文加密解密在实际项目中运用广泛,本案例中使用SM2加密的方式,使用SM2加密,首先需要生成一对公钥和私钥,公钥用于加密,私钥用于解密。私钥选择存储在Redis中。

Redis工具类:

java 复制代码
/**
 * 双检锁的单例
 */
public class JedisClient {

    private static volatile Jedis JEDIS_CLIENT = null;

    public static Jedis getJedisClient(){
        if (JEDIS_CLIENT == null){
            synchronized (new Object()){
                if (JEDIS_CLIENT == null){
                    JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
                    jedisPoolConfig.setMaxTotal(20);
                    jedisPoolConfig.setMaxIdle(10);
                    jedisPoolConfig.setMinIdle(5);
                    // timeout,这里既是连接超时又是读写超时,从Jedis 2.8开始有区分connectionTimeout和soTimeout的构造函数
                    try (JedisPool jedisPool = new JedisPool(jedisPoolConfig, "localhost", 6379, 3000, null)) {
                        return jedisPool.getResource();
                    }
                }
            }
        }
        return JEDIS_CLIENT;
    }
}

4.1、加密编码器

报文的加密,会经过以下的流程: MessageEntity ➝ JSON ➝ SM2加密 ➝ 字节数组 ➝ 输出到 ByteBuf。

选择将生成的私钥存入Redis,Redis需要一个唯一的key,对应每一条消息,使用uuid生成,同时将Key放在密文之前,在解密编码器中读取消息的前36个字节,转换为privateKeyId,从而从redis中获取私钥。

java 复制代码
        +---------------------+--------------------------+
        | 36字节的 privateKeyId |        SM2 加密的密文数据     |
        +---------------------+--------------------------+

因为客户端和服务端处于两个不同的进程中,有各自的Pipeline,不能使用传统的方式进行上下文传递:

java 复制代码
AttributeKey<String> key = AttributeKey.valueOf("privateKeyId");
channelHandlerContext.channel().attr(key).set(privateKeyId);
java 复制代码
public class SM2EncodeHandler extends MessageToByteEncoder<MessageEntity> {

    private final Jedis jedisClient = JedisClient.getJedisClient();

    @Override
    protected void encode(ChannelHandlerContext channelHandlerContext, MessageEntity messageEntity, ByteBuf byteBuf) throws Exception {
        System.out.println("SM2EncodeHandler执行" + Thread.currentThread().getName());
        // 生成SM2密钥对 公钥加密,私钥解密
        Map<String, String> stringStringMap = Sm2Util.generateKey();
        String publicKey = stringStringMap.get("publicKey");
        String privateKey = stringStringMap.get("privateKey");
        // 生成一个唯一标识
        String privateKeyId = UUID.randomUUID().toString();
        // 存入 Redis(设置过期时间)
        jedisClient.setex(RedisConstants.PRIVATE_KEY_PREFIX + privateKeyId, 10 * 60, privateKey); // 10分钟有效期

        //将 MessageEntity ➝ JSON
        String messageJSON = JSON.toJSONString(messageEntity);
        //将 JSON-> 加密
        String encrypt = Sm2Util.encrypt(publicKey, messageJSON);
        //SM2加密 -> 字节数组
        byte[] encryptedData = encrypt.getBytes(CharsetUtil.UTF_8);
        //写入密文
        byte[] privateKeyIdBytes = privateKeyId.getBytes(StandardCharsets.UTF_8);

        /*
        将私钥的唯一标识放在密文之前:
        +---------------------+--------------------------+
        | 36字节的 privateKeyId |        SM2 加密的密文数据     |
        +---------------------+--------------------------+
         */
        byteBuf.writeBytes(privateKeyIdBytes); // UUID 通常是 36 字节
        byteBuf.writeBytes(encryptedData);
    }
}

4.2、解密编码器

报文的解密,会经过以下的流程:ByteBuf ➝ JSON -> SM2解密 ➝ 反序列化为 MessageEntity

首先需要配读取密文之前的36 字节的privateKeyId,从Redis中取出该条消息对应的私钥,用于进行解密。

java 复制代码
public class SM2DecodeHandler extends ByteToMessageDecoder {

    private final Jedis jedisClient = JedisClient.getJedisClient();

    @Override
    protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf, List<Object> list) throws Exception {
        System.out.println("SM2DecodeHandler执行" + Thread.currentThread().getName());
        // 读取 密文之前 的36 字节的 privateKeyId
        byte[] privateKeyIdBytes = new byte[36];
        byteBuf.readBytes(privateKeyIdBytes);
        String privateKeyId = new String(privateKeyIdBytes, StandardCharsets.UTF_8);
        //读取密文
        byte[] encryptedBytes = new byte[byteBuf.readableBytes()];
        byteBuf.readBytes(encryptedBytes);
        //拿到保存的私钥的key
        //根据私钥标识,从redis中拿到真正的私钥
        String privateKey;
        try {
            privateKey = jedisClient.get(RedisConstants.PRIVATE_KEY_PREFIX + privateKeyId);
        } finally {
            jedisClient.del(RedisConstants.PRIVATE_KEY_PREFIX + privateKeyId);
        }
        if (privateKey == null) {
            throw new RuntimeException("解密失败:未找到私钥 privateKey");
        }
        //转换为字符串
        String jsonStr = new String(encryptedBytes, CharsetUtil.UTF_8);
        //执行解密
        String jsonDecrypt = Sm2Util.decrypt(privateKey, jsonStr);
        //转换为消息对象
        MessageEntity messageEntity = JSON.parseObject(jsonDecrypt, MessageEntity.class);
        list.add(messageEntity);
    }
}

六、应用层的握手

当连接建立完成后,会触发客户端ClientLoginHandlerchannelActive事件,在该事件中向服务端发起登录请求(具体代码详见附件)

java 复制代码
    /**
     * 连接建立
     *
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        LOG.info("ClientLoginHandler.channelActive开始执行");
        //向服务端发送登录请求
        MessageEntity req = NettyUtil.makeMessage("请求登录", MessageTypeEnum.LOGIN_REQ.getValue(), null);
        ctx.writeAndFlush(req);
    }

服务端的LoginPreCheckHandler在接收到客户端的请求后,主要会做三件事:

  1. 要检查是不是登录认证请求
  2. 要检查同一IP是否重复登陆
  3. 要检查用户是否在白名单中

检查通过,将用户放入缓存,并且向客户端发送响应。

客户端会在ClientLoginHandler中接收到响应。如果登录成功,可以将当前的ClientLoginHandler从责任链中移除。因为在一次通信的生命周期中,只需要登录一次。

java 复制代码
 channelHandlerContext.pipeline().remove(this);

七、心跳机制

客户端的责任链中,放入了IdleStateHandlerReadTimeoutHandler,而服务端的责任链中,也放入了ReadTimeoutHandler,这些处理器是心跳机制实现的关键。

一般是由客户端主动向服务端发送心跳,在客户端构造IdleStateHandler时,传入了三个参数

当指定的时间段内没有进行对应的读或写操作,则会触发事件,我们这里设置的是5s内没有执行过写操作,则触发写空闲事件。

客户端的HeartBeatReqHandler中,userEventTriggered会进行监听,向服务端发送心跳请求,

java 复制代码
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        LOG.info("userEventTriggered触发,事件类型: {}", evt.getClass().getName());
        //用于发心跳包
        if (evt == IdleStateEvent.WRITER_IDLE_STATE_EVENT) {
            MessageEntity req = NettyUtil.makeMessage("客户端发送心跳", MessageTypeEnum.HEARTBEAT_REQ.getValue(),null);
            ctx.writeAndFlush(req);
        }
        //如果你重写了 userEventTriggered() 但不调用 super.userEventTriggered(),事件就不会继续传播。
        super.userEventTriggered(ctx, evt);
    }

当心跳请求在一定的时间间隔内正常发送,就不会触发ReadTimeoutHandler 的超时关闭连接。

八、整体流程