网络|基于Netty构建的高性能车辆网项目实现(二)

Netty启动

       技术方案

       上文说到,我们的技术选型为SpringBoot,那么Netty与SpringBoot如何同时并存,且有没有优雅的启动方式来带动两个端口启动,因为SpringBoot默认的web容器是tomcat,需要一个web端口,netty服务端也需要bind一个端口,以下是几种技术方案:

  • SpringBoot容器先启动,容器启动后单独开启一个Thread去启动netty,关闭再暂停该线程
  • 舍弃SpringBoot框架,直接使用java原生的main方法启动netty服务端
  • 使用SpringBoot(Spring)容器提供的启动接口CommandLineRunnerApplicationRunner在重写run方法中启动netty服务端
  • 使用Spring容器的内置事件ContextRefreshedEventContextStartedEvent启动,使用ContextClosedEvent关闭。
  • 使用 @PostConstruct(启动netty)和 @PreDestroy(关闭netty)
  • 在SpringBoot的启动类中获取ApplicationContext对象的getBean方法获取netty服务启动的bean,再调用该启动方法。

       方案分析

       首先明确一点,SpringBoot框架是作为整个技术选型的一部分,也是整个团队统一的技术栈,是坚决不能舍弃的,SpringBoot本身的优点自不用说,所以使用main方法欠妥,其次为了达到最好的效果,可以考虑方案组合,以下是每种方案的简要分析:

  • 避免单独自己new线程,个中原因不单独分析(可以查看阿里巴巴规范),不利于回收和分配
  • main方法欠妥,与技术选型不符
  • 方案可行,官方推荐的启动方法,两者都是在Spring容器完全启动后再执行的接口,但缺少合适的关闭方法,CommandLineRunnerApplicationRunner的区别之一在于重写的方法参数不一样,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的线程模型
  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值