Netty启动
技术方案
上文说到,我们的技术选型为SpringBoot,那么Netty与SpringBoot如何同时并存,且有没有优雅的启动方式来带动两个端口启动,因为SpringBoot默认的web容器是tomcat,需要一个web端口,netty服务端也需要bind一个端口,以下是几种技术方案:
- SpringBoot容器先启动,容器启动后单独开启一个Thread去启动netty,关闭再暂停该线程
- 舍弃SpringBoot框架,直接使用java原生的main方法启动netty服务端
- 使用SpringBoot(Spring)容器提供的启动接口CommandLineRunner和ApplicationRunner在重写run方法中启动netty服务端
- 使用Spring容器的内置事件ContextRefreshedEvent和ContextStartedEvent启动,使用ContextClosedEvent关闭。
- 使用 @PostConstruct(启动netty)和 @PreDestroy(关闭netty)
- 在SpringBoot的启动类中获取ApplicationContext对象的getBean方法获取netty服务启动的bean,再调用该启动方法。
方案分析
首先明确一点,SpringBoot框架是作为整个技术选型的一部分,也是整个团队统一的技术栈,是坚决不能舍弃的,SpringBoot本身的优点自不用说,所以使用main方法欠妥,其次为了达到最好的效果,可以考虑方案组合,以下是每种方案的简要分析:
- 避免单独自己new线程,个中原因不单独分析(可以查看阿里巴巴规范),不利于回收和分配
- main方法欠妥,与技术选型不符
- 方案可行,官方推荐的启动方法,两者都是在Spring容器完全启动后再执行的接口,但缺少合适的关闭方法,CommandLineRunner和ApplicationRunner的区别之一在于重写的方法参数不一样,CommandLineRunner的参数是String数组(其实就是获取SpringBoot的main方法的String数组),ApplicationRunner的参数是容器参数,为了后期可能会用到容器启动参数等原因,建议使用ApplicationRunner,两者都可以结合 @Order来标明启动顺序,更加灵活。
- Spring容器的内置事件,主要是针对容器变化而产生的事件,可以使用,但ContextRefreshedEvent事件不能用于容器启动(虽然也可以启动成功),但是他事件的本意是刷新,而我们需要的是主要启动那那一刻带动netty就好,防止容器刷新事件影响到正在处理业务的netty
- 使用 @PostConstruct(启动netty)和 @PreDestroy(关闭netty)方法在本项目中也可行,值得一提的是 @PostConstruct 有自己的执行顺序,构造器》Autowrite/Resource 》 @PostConstruct
- 首先在启动类总获取ApplicationContext,如具体代码 ApplicationContext run = SpringApplication.run(EmcCollectorApplication.class, args);
NettyServer bean = run.getBean(NettyServer.class);该方案需要在启动类中启动netty,这样做和类的单一职责的原则相违背,SpringBoot启动类是启动SpringBoot的这一个职责,而不是启动netty服务这一职责,个人看法,如果抛开这一点,其实该技术方案也可行。
技术落地
最终我们选用 ApplicationRunner启动方案加spring容器的关闭事件 ContextClosedEvent,具体代码:
SpringBoot启动类:
/**
* Description:
* <p>
* 启动类
* </p>
* @author dzx
* @date 2019/2/25 14:48
*/
@EnableKafka
@EnableScheduling
@SpringCloudApplication
public class EmcCollectorApplication {
public static void main(String[] args) {
SpringApplication.run(EmcCollectorApplication.class, args);
}
}
StartupListener 监听类:
/**
* Description:
* <p>
* 容器完全启动后,执行此类的run方法
* </p>
* @author dzx
* @date 2019/2/25 16:49
*/
@Component
public class StartupListener implements ApplicationRunner {
/**
* Netty服务配置
*/
private final NettyServer nettyServer;
/**
* netty配置
*/
private final NettyConfig nettyConfig;
/**
* 定时任务job
*/
private final HeartBeatJob heartBeatsJob;
public StartupListener(NettyServer nettyServer,NettyConfig nettyConfig,HeartBeatJob heartBeatsJob) {
this.nettyServer = nettyServer;
this.nettyConfig = nettyConfig;
this.heartBeatsJob = heartBeatsJob;
}
@Override
public void run(ApplicationArguments args) throws IOException {
//更新当前连接数
heartBeatsJob.updateVehicleConnectNumRedis();
//启动netty,传入配置的端口
nettyServer.start(nettyConfig.getServerPort());
}
}
HeartBeatJob是和最小连接数有关操作,读者可以暂时不用理会更新连接数的updateVehicleConnectNumRedis方法
StartupListener 监听类:
/**
* Description:
* <p>
* 关闭监听
* </p>
*
* @author dengzhenxiang
* @date 2019/3/7 14:32
*/
@Component
public class ClosedListener implements ApplicationListener<ContextClosedEvent> {
/**
* netty服务端
*/
private final NettyServer nettyServer;
/**
* 最小连接服务
*/
private final ServerStatusCache serverStatusCache;
/**
* netty服务配置
*/
private final NettyConfig nettyConfig;
/**
* spring获取环境配置类
*/
private final Environment environment;
public ClosedListener(NettyServer nettyServer, ServerStatusCache serverStatusCache, NettyConfig nettyConfig, Environment environment) {
this.nettyConfig = nettyConfig;
this.serverStatusCache = serverStatusCache;
this.nettyServer = nettyServer;
this.environment = environment;
}
@Override
public void onApplicationEvent(ContextClosedEvent event) {
//删除本服务器中的key,避免在宕机情况下,发送宕机的服务ip和端口
serverStatusCache.deleteServer(environment.getProperty("netty.ip") + "-" + nettyConfig.getServerPort());
//关闭netty
nettyServer.close();
}
}
心跳Handler:
/**
* Description:
* <p>
* 心跳处理handler
* </p>
* @author dzx
* @date 2019/5/30 9:19
*/
@Slf4j
@ChannelHandler.Sharable
@Component
public class HeartBeatHandler extends ChannelInboundHandlerAdapter {
/**
* 超时关闭通道
* @param ctx 通道上下问题
* @param evt 对象
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
log.info("heart beat start,channelHandlerContext close....");
ctx.close();
ctx.fireUserEventTriggered(evt);
}
}
对HeartBeatHandler 的信息解析:
- @ChannelHandler.Sharable该心跳handler对所有NioSocketHandler都共用,提高对象的使用率,减少返回创建对象,这样标注若有成员变量要注意线程安全问题
- ChannelInboundHandlerAdapter 是netty的inbound事件(inbound接入事件),要实现自定义事件的处理(心跳事件),就需要继承该类,并重写自己关系的方法就好了
Netty服务启动类:
/**
* Description:
* <p>
* Netty服务端
* </p>
*
* @author dzx
* @date 2019/1/27 9:32
*/
@LogDebug
@Slf4j
@Component
public class NettyServer {
/**
* 注入数据实时上报类(包含驱动,发动机,报警等各种数据的大对象)
*/
private RealTimeInfoHandler realTimeInfoHandler;
/**
* 注入平台登入Handler
*/
private PlatLoginInHandler platLoginHandler;
/**
* 注入车辆登入Handler
*/
private VehicleLoginInHandler vehicleLoginHandler;
/**
* 注入车辆登出Handler
*/
private VehicleLoginOutHandler vehicleLoginOutHandler;
/**
* 注入空闲链路Handler
*/
private HeartBeatHandler heartBeatHandler;
/**
* 注入查看车辆状态Handler
*/
private WatchVehicleStateHandler watchVehicleStateHandler;
public NettyServer(RealTimeInfoHandler realTimeInfoHandler, PlatLoginInHandler platLoginHandler,
VehicleLoginInHandler vehicleLoginHandler,
VehicleLoginOutHandler vehicleLoginOutHandler,
HeartBeatHandler heartBeatHandler,
WatchVehicleStateHandler watchVehicleStateHandler) {
this.realTimeInfoHandler = realTimeInfoHandler;
this.platLoginHandler = platLoginHandler;
this.vehicleLoginHandler = vehicleLoginHandler;
this.vehicleLoginOutHandler = vehicleLoginOutHandler;
this.heartBeatHandler = heartBeatHandler;
this.watchVehicleStateHandler = watchVehicleStateHandler;
}
/**
* 创建bossGroup线程组,监听客户端连接线程组
*/
private EventLoopGroup bossGroup = null;
/**
* 创建workGroup线程组,处理客户端请求线程组
*/
private EventLoopGroup workGroup = null;
/**
* 创建handlerGroup线程组,处理具体业务
*/
private EventExecutorGroup handlerGroup = null;
/**
* 启动服务端
*/
public void start(int serverPort) {
if (bossGroup != null) {
return;
}
try {
// 创建线程组,服务配置对象
bossGroup = new NioEventLoopGroup(1);
workGroup = new NioEventLoopGroup();
handlerGroup = new DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors());
ServerBootstrap serverBootstrap = new ServerBootstrap();
// 配置缓存区大小,netty服务端类型,采用主从多线程模型
serverBootstrap.channel(NioServerSocketChannel.class)
.group(bossGroup, workGroup)
.option(ChannelOption.SO_BACKLOG, 1024)
.option(ChannelOption.SO_RCVBUF, 16 * 1024)
.option(ChannelOption.SO_SNDBUF, 16 * 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true);
// 设置处理器
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new IdleStateHandler(5,0,0, TimeUnit.MINUTES))
.addLast(heartBeatHandler)
.addLast(new Msg32960Decoder())
.addLast(handlerGroup, realTimeInfoHandler)
.addLast(handlerGroup, platLoginHandler)
.addLast(handlerGroup, vehicleLoginHandler)
.addLast(handlerGroup, vehicleLoginOutHandler)
.addLast(handlerGroup, watchVehicleStateHandler);
}
});
// 绑定端口启动
serverBootstrap.bind(serverPort).sync();
log.info("Netty server bind port {}", serverPort);
} catch (Exception e) {
//异常关闭
log.error("Netty access to resources fail", e);
close();
}
}
/**
* 关闭方法
*/
public void close() {
if (bossGroup != null) {
bossGroup.shutdownGracefully();
workGroup.shutdownGracefully();
handlerGroup.shutdownGracefully();
}
}
}
我们着重对Netty的启动过程做简单的分析:
- NioServerSocketChannel.class指定了服务端的socket类,若是客户端需要指定NioSocketChannel.class
- 线程模型选用主从多线程模型(支持React线程模式的三种:单线程、多线程、主从多线程),bossGroup是用于监听I/o事件的,只需要一个即可,如果构造传入多个,无意义,netty也只会使用一个,同时为了更好的释放workGroup中的线程,降低多线程的竞争,我们采用多开一组业务线程组DefaultEventExecutorGroup,与workGroup完全区分开,这里涉及到Netty的无锁化考虑,因为NioEventLoop是对原生的线程再封装,一个NioEventLoop包含多个任务队列Queue与一个Thread,一个Selector,这样设计的原因是为了尽可能的减少多线程的竞争导致的性能下降,与jdk的线程池设计有很大的不同,具体区别,看下方图示。
- Tcp参数的设置:SO_BACKLOG指tcp三次握手过程中已连接队列(三次握手都成功的情况下视为已连接)和未连接队列(还没有完成三次握手的过程视为未连接)之和,高并发场景下我们参考《Netty权威指南》给出的1024;SO_RCVBUF和SO_SNDBUF分别表示tcp接收缓冲和发送缓冲区的大小,这里选取了比较大的数16*1024,缓冲能力更强;SO_KEEPALIVE支持长连接(短信等场景适合短连接,本项目建立连接就需要不断的发包,直到公交车熄火);TCP_NODELAY表使用Nagle算法,把网络中的小包组成较大的包,再一次性发送的算法过程,可以有效提高网络应用效率,但是对时延敏感的应该关闭该算法,true为关闭该算法。
- new IdleStateHandler(5,0,0, TimeUnit.MINUTES)为每个新接入的NioSocketChannel添加心跳监测,设置5分钟的情况下没有读写,则触发userEventTriggered事件,关闭通道
- 为每个NioSocketChannel连接都new一个解码器
- 耗时的各种数据处理都交由handlerGroup业务线程组来处理
jdk的线程池设计与Netty线程设计对比图:
ps:解释说明下每个NioEventLoop中的selector,该selector不是分发内部的thread的,因为thread就一个,无需再分发,其实selector分发的是NioSocketChannel(代表每个客户端的连接通道)给thread。可能通过如下三者关系图,可以更好的理解NioEventLoop的内部结构:
粘包产生原因
`Netty粘包与粘包`在进入该技术方案之前,我们一起来探讨下粘包产生的原因与常用的解决办法
粘包产生原因
- 应用层的应用程序写入的数据大小大于tcp发送缓冲区大小
- 进行MSS(tcp数据包每次传输的最大数据大小)的tcp分段
- 以太网帧的payload大于MTU(最大传输单元由硬件规定,为1500字节)
粘包解决方案
常见的粘包处理策略:
- 消息定长,每个消息长度都定长发送,利用 FixedLengthFrameDecoder解码器,无论接收多少数据都会按照构造函数中设置的固定长度进行解码new FixedLengthFrameDecoder(100),如果是半包消息,FixedLengthFrameDecoder会缓存半包消息并等待下个包到达后进行拼包
- 分割符分割,使用换行符或者自定义分隔符分割,具体可以使用DelimiterBasedFrameDecoder解码器 ,new DelimiterBasedFrameDecoder(100, Unpooled.copiedBuffer("$#".getBytes()));
构造器传入两个参数,第一个参数100表示单条消息的最大长度,当达到该长度后仍然没有查到分隔符,就抛异常,第二个参数是分隔符缓冲对象。 - 换行符分割,其实换行符分割也属于分割符分割,可以利用 LineBasedFrameDecoder解码器 具体使用new LineBasedFrameDecoder(100),LineBasedFrameDecoder解码器遍历数据包的每个Byte看看是否有换行符号“\n\r”或者“\n”,当检索长度达到100还未遇见换行符,则抛异常,LineBasedFrameDecoder解码器可以与StringDecoder(把数据包全部转换成字符串)结合作为文本解码器
- 根据协议长度解析消息,LengthFieldBasedFrameDecoder解码器就是netty自带的根据协议长度解析消息,该解码器支持多种复杂协议场景,感兴趣读者可去查阅
- 更复杂的应用层协议
技术落地
参阅了如此多的粘包处理方案,对比GB32960协议,博主认为以上方案均只有LengthFieldBasedFrameDecoder解码器会稍微适合本项目,但本协议虽有描述消息长度的字段,但对于协议字段位置不定的情况下,可能需要多个LengthFieldBasedFrameDecoder解码器来支持,为了更好的说明,可以看如下图示:(下图演示是为了更好的说明该解码器不适合本项目,实际情况0x230x23是固定的开头)
后来最终采用的方案为,消息分消息头和消息体两部分解析,从消息头中获取了描述消息体的长度,消息体根据该长度读取ByteBuf,剩余的ByteBuf直接返回缓冲区,每次都只读一包的数据。为了更好的说明该过程,读者可以从下图中进一步了解这个解决过程(0x230x23是消息的开头)
/**
* Description:
* <p>
* 消息解码器
* </p>
*
* @author dzx
* @date 2019/2/17 17:40
*/
@Slf4j
@Component
public class Msg32960Decoder extends ByteToMessageDecoder {
/**
* 存储消息头
*/
private MsgHead msgHead = null;
/**
* 消息读取状态
*/
private MsgStatusEnum msgStatus = MsgStatusEnum.HEAD;
/**
* 重写ByteToMessageDecoder的decode
* 解决黏包拆包问题
*
* @param ctx 通道上下文
* @param in byteBuf数据
* @param out 解码对象集合
*/
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
switch (msgStatus) {
case HEAD:
//若消息长度小于协议的消息头长度,则返回
if (in.readableBytes() < MsgHeadEnum.MESSAGE_HEAD_LENGTH.getIntValue()) {
return;
}
//判断是否是消息体的开头
Integer headShort = Integer.parseInt(in.getShort(0) + "");
if (!headShort.equals(MsgHeadEnum.MESSAGE_HEAD.getIntValue())) {
//非法请求头,关闭通道
ctx.close();
return;
}
//记录msgHead数据
ByteBuf msgHeadByteBuf = in.readBytes(MsgHeadEnum.MESSAGE_HEAD_LENGTH.getIntValue());
try {
//解析消息头
msgHead = decodeMsgHead(msgHeadByteBuf);
} catch (Exception e) {
//关闭通道
ctx.close();
//结束解析
break;
}
//更改读取状态
msgStatus = MsgStatusEnum.BODY;
break;
case BODY:
//判断是否可读长度小于消息长度
if (in.readableBytes() < msgHead.getMsgContentLength()) {
return;
}
//开始解析
ByteBuf msgByteBuf = null;
try {
//读取消息体的ByteBuf
msgByteBuf = in.readBytes(msgHead.getMsgContentLength());
//解析消息体
Object msgObj = decodeMsgBody(msgByteBuf, msgHead, ctx);
//放入集合对象
if (msgObj != null) {
//解析成功,放入目标集合中
out.add(msgObj);
} else {
//解析失败,创建一个新的实时对象,以便继续完成handler处理,并输出到日志
out.add(new RealTimeInfo());
}
} catch (Exception e) {
//解析失败的处理
} finally {
//释放ByteBuf
if (msgByteBuf != null) {
msgByteBuf.release();
}
}
msgStatus = MsgStatusEnum.HEAD;
break;
default:
//没有匹配类型
try {
throw new Exception();
} catch (Exception e) {
log.error("no msgStatus match", e);
}
break;
}
}
/**
* 解析消息头
*
* @param byteBuf 数据byteBuf
* @return 消息头
*/
private MsgHead decodeMsgHead(ByteBuf byteBuf) {
//起始符 2个字节
int startChar = byteBuf.readUnsignedShort();
//命令单元 2个字节
String cmd = byteBuf.readUnsignedByte() + "";
String ackSign = byteBuf.readUnsignedByte() + "";
//唯一识别码 17个字节
String vinStr = byteBuf.readBytes(MsgHeadEnum.VIN_LENGTH.getIntValue()).toString();
//消息单元加密方式 1个字节
String encryptType = byteBuf.readUnsignedByte() + "";
//消息单元长度 两个字节
int dataUnitLength = byteBuf.readUnsignedShort();
//消息单元长度加上一位校验码 消息内容长度
int msgContentLength = dataUnitLength + 1;
//给消息头赋值
msgHead = new MsgHead();
msgHead.setStartChar(startChar)
.setCmd(MsgTypeEnum.getMsgType(cmd))
.setAckSign(ackSign)
.setVin(vinStr)
.setEncryptType(encryptType)
.setMsgBodyLength(dataUnitLength)
.setMsgContentLength(msgContentLength);
//释放byteBuf
byteBuf.release();
return msgHead;
}
}
关于以上代码,需要着重提几点:
- byteBuf的释放问题;byteBuf释放可以通过多种方法ReferenceCountUtil.release()或byteBuf.release(),其目的都是对byteBuf的引用次数-1,Netty底层对ByteBuf的释放采用了引用计数法,类似jvm一样对对象的引用次数。
- byteBuf转换为字符串问题,可以直接使用其本身的toString()方法,不建议单独创建一个byte数组,再强转为char类型。
- 对于重写方法decode里的List对象,无论解码成功失败与否,都要往list里面加入元素,否则netty检测到List的大小没有变化(size为0),会一直以为解码没有成功,会重复调用decode方法,重新传入byteBuf对象,陷入死循环,所以面对解码失败的包,需要加入特别的错误处理类(new一个空对象,表明解码成功,以便继续在channelPipleLine里面传播事件),本文省略了catch之后的错误处理代码。
- 和车载终端对接时候(使用C/C++开发的一款嵌入式设备),会经常传输到负数的情况(不在正常,异常范围),因为C里面定义的都是无符号数字,即都是0-256,而java里面都是-128-127的范围,所以读取byteBuf的时候,使用readUnsignedXxx()
参考资料
- 《Netty权威指南》第二版 李林锋著
- Stevens W R, Fenner B, Rudoff A M. UNIX network programming[M]. Addison-Wesley Professional, 2004.
- http://ifeve.com//谈谈netty的线程模型