Netty综合案例(下)
本文为个人学习笔记整理,仅供交流参考,非专业教学资料,内容请自行甄别
文章目录
- 实验内容
- 五、报文加密解密
- 4.1、加密编码器
- 4.2、解密编码器
- 六、应用层的握手
- 七、心跳机制
- 八、整体流程
实验内容
实验内容(本篇从第5条开始):
- 消息实体的定义
- 客户端,服务端的定义
- 客户端,服务端Handler责任链的设计
- 半包,粘包的框架解决方案
- 应用层的握手,授权认证,报文加密解密
- 心跳检测(TCP有keep alive 为什么应用层还要有心跳?TCP的保活机制2小时,TCP的保活机制只能保证客户端和服务端的通信链路是通的,不能保证客户端或服务端的进程还是活着的)
- 业务数据的通信
五、报文加密解密
报文加密解密在实际项目中运用广泛,本案例中使用SM2加密
的方式,使用SM2加密,首先需要生成一对公钥和私钥,公钥用于加密,私钥用于解密。私钥选择存储在Redis中。
Redis工具类:
/*** 双检锁的单例*/
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中获取私钥。
+---------------------+--------------------------+| 36字节的 privateKeyId | SM2 加密的密文数据 |+---------------------+--------------------------+
因为客户端和服务端处于两个不同的进程中,有各自的Pipeline,不能使用传统的方式进行上下文传递:
AttributeKey<String> key = AttributeKey.valueOf("privateKeyId");
channelHandlerContext.channel().attr(key).set(privateKeyId);
public class SM2EncodeHandler extends MessageToByteEncoder<MessageEntity> {private final Jedis jedisClient = JedisClient.getJedisClient();@Overrideprotected 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 ➝ JSONString 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中取出该条消息对应的私钥,用于进行解密。
public class SM2DecodeHandler extends ByteToMessageDecoder {private final Jedis jedisClient = JedisClient.getJedisClient();@Overrideprotected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf, List<Object> list) throws Exception {System.out.println("SM2DecodeHandler执行" + Thread.currentThread().getName());// 读取 密文之前 的36 字节的 privateKeyIdbyte[] 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);}
}
六、应用层的握手
当连接建立完成后,会触发客户端ClientLoginHandler
的channelActive
事件,在该事件中向服务端发起登录请求(具体代码详见附件)
/*** 连接建立** @param ctx* @throws Exception*/@Overridepublic void channelActive(ChannelHandlerContext ctx) throws Exception {LOG.info("ClientLoginHandler.channelActive开始执行");//向服务端发送登录请求MessageEntity req = NettyUtil.makeMessage("请求登录", MessageTypeEnum.LOGIN_REQ.getValue(), null);ctx.writeAndFlush(req);}
服务端的LoginPreCheckHandler
在接收到客户端的请求后,主要会做三件事:
- 要检查是不是登录认证请求
- 要检查同一IP是否重复登陆
- 要检查用户是否在白名单中
检查通过,将用户放入缓存,并且向客户端发送响应。
客户端会在ClientLoginHandler
中接收到响应。如果登录成功,可以将当前的ClientLoginHandler
从责任链中移除。因为在一次通信的生命周期中,只需要登录一次。
channelHandlerContext.pipeline().remove(this);
七、心跳机制
客户端的责任链中,放入了IdleStateHandler
和ReadTimeoutHandler
,而服务端的责任链中,也放入了ReadTimeoutHandler
,这些处理器是心跳机制实现的关键。
一般是由客户端主动向服务端发送心跳,在客户端构造IdleStateHandler
时,传入了三个参数
当指定的时间段内没有进行对应的读或写操作,则会触发事件,我们这里设置的是5s内没有执行过写操作,则触发写空闲事件。
客户端的HeartBeatReqHandler
中,userEventTriggered
会进行监听,向服务端发送心跳请求,
@Overridepublic 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
的超时关闭连接。