JSON和ProtoBuf序列化

JSON和ProtoBuf序列化

我们在开发一些远程过程调用(RPC)的程序时,通常会涉及对象的序列化/反序列化的问题,例如一个“Person”对象从客户端通过TCP方式发送到服务器端;因为TCP协议(UDP等这种低层协议)只能发送字节流,所以需要应用层将Java POJO对象序列化成字节流,数据接收端再反序列化成Java POJO对象即可。“序列化”一定会涉及编码和格式化(Encoding & Format),目前我们可选择的编码方式有:

  • 使用JSON。将Java POJO对象转换成JSON结构化字符串。基于HTTP协议,在Web应用、移动开发方面等,这是常用的编码方式,因为JSON的可读性较强。但是它的性能稍差。

  • 基于XML。和JSON一样,数据在序列化成字节流之前都转换成字符串。可读性强,性能差,异构系统、Open API类型的应用中常用.

  • 使用Java内置的编码和序列化机制,可移植性强,性能稍差,无法跨平台(语言).

  • 其他开源的序列化/反序列化框架,例如Apache Avro, Apache Thrift,这两个框架和Protobuf相比,性能非常接近,而且设计原理如出一辙;其中Avro在大数据存储(RPC数据交换,本地存储)时比较常用;Thrift的亮点在于内置了RPC机制,所以在开发一些RPC交互式应用时,客户端和服务器端的开发与部署都非常简单。

评价一个序列化框架的优缺点,大概从两个方面着手:

(1)结果数据大小,原则上说,序列化后的数据尺寸越小,传输效率越高。(2)结构复杂度,这会影响序列化/反序列化的效率,结构越复杂,越耗时。

理论上来说,对于对性能要求不是太高的服务器程序,可以选择JSON系列的序列化框架;对于性能要求比较高的服务器程序,则应该选择传输效率更高的二进制序列化框架,目前的建议是Protobuf

Protobuf是一个高性能、易扩展的序列化框架,与它的性能测试有关的数据可以参看官方文档。Protobuf本身非常简单,易于开发,而且结合Netty框架,可以非常便捷地实现一个通信应用程序。反过来,Netty也提供了相应的编解码器,为Protobuf解决了有关Socket通信中“半包、粘包”等问题。无论是使用JSON和Protobuf,还是其他的反序列化协议,我们必须保证在数据包的反序列化之前,接收端的ByteBuf二进制包一定是一个完整的应用层二进制包,不能是一个半包或者粘包。

详解粘包和拆包

半包问题的实践案例

改造一下前面的NettyEchoClient实例,通过循环的方式,向NettyEchoServer回显服务器写入大量的ByteBuf,然后看看实际的服务器响应结果。注意:服务器类不需要改造,直接使用之前的回显服务器即可。改造好的客户端类——叫作NettyDumpSendClient。在客户端建立连接成功之后,使用一个for循环,不断通过通道向服务器端写ByteBuf。一直写到1000次,写入的Bytebuf的内容相同,都是字符串的内容:“疯狂创客圈:高性能学习者社群!”。代码如下:

        package com.crazymakercircle.netty.echoServer;
        //...
        public class NettyDumpSendClient {

            private int serverPort;
            private String serverIp;
            Bootstrap b = new Bootstrap();

            public NettyDumpSendClient(String ip, int port) {
                this.serverPort = port;
                this.serverIp = ip;
            }
            public void runClient() {
                //创建反应器线程组
                //...省略,启动客户端Bootstrap启动器配置和启动
                // 阻塞,直到连接完成
                f.sync();
                Channel channel = f.channel();

                //发送大量的文字
                String content= "疯狂创客圈:高性能学习者社群!";
                byte[] bytes =content.getBytes(Charset.forName("utf-8"));
                for (int i = 0; i< 1000; i++) {
                    //发送ByteBuf
                    ByteBuf buffer = channel.alloc().buffer();
                    buffer.writeBytes(bytes);
                    channel.writeAndFlush(buffer);
                }
                //...省略从容关闭客户端
            }
            public static void main(String[] args) throws InterruptedException {
                int port = NettyDemoConfig.SOCKET_SERVER_PORT;
                String ip = NettyDemoConfig.SOCKET_SERVER_IP;
                new NettyDumpSendClient(ip, port).runClient();
            }
        }

运行程序查看结果之前,首先要启动的是前面介绍过的NettyEchoServer回显服务器。然后启动的是新编写的客户端NettyDumpSendClient程序。客户端程序连接成功后,会向服务器发送1000个ByteBuf内容缓冲区,服务器NettyEchoServer收到后,会输出到控制台,然后回写给客户端。服务器的输出如图8-1所示。

在这里插入图片描述

仔细观察服务端的控制台输出,可以看出存在三种类型的输出:(1)读到一个完整的客户端输入ByteBuf。(2)读到多个客户端的ByteBuf输入,但是“粘”在了一起。(3)读到部分ByteBuf的内容,并且有乱码。再仔细观察客户端的输出。可以看到,客户端和服务器端同样存在以上三种类型的输出。

对应于第1种情况,这里把接收端接收到的这种完整的ByteBuf称为“全包”。对应于第2种情况,多个发送端的输入ByteBuf“粘”在了一起,就把这种读取的ByteBuf称为“粘包”。对应于第3种情况,一个输入的ByteBuf被“拆”开读取,读取到一个破碎的包,就把这种读取的ByteBuf称为“半包”。为了简单起见,也可以将“粘包”的情况看成特殊的“半包”。“粘包”和“半包”可以统称为传输的“半包问题”。

半包问题

(1)粘包,指接收端(Receiver)收到一个ByteBuf,包含了多个发送端(Sender)的ByteBuf,多个ByteBuf“粘”在了一起。(2)半包,就是接收端将一个发送端的ByteBuf“拆”开了,收到多个破碎的包。换句话说,一个接收端收到的ByteBuf是发送端的一个ByteBuf的一部分。粘包和半包指的都是一次是不正常的ByteBuf缓存区接收,具体如图8-2所示。

在这里插入图片描述

半包现象的原理

寻根粘包和半包的来源得从操作系统底层说起。大家都知道,底层网络是以二进制字节报文的形式来传输数据的。读数据的过程大致为:当IO可读时,Netty会从底层网络将二进制数据读到ByteBuf缓冲区中,再交给Netty程序转成Java POJO对象。写数据的过程大致为:这中间编码器起作用,是将一个Java类型的数据转换成底层能够传输的二进制ByteBuf缓冲数据。解码器的作用与之相反,是将底层传递过来的二进制ByteBuf缓冲数据转换成Java能够处理的Java POJO对象。在发送端Netty的应用层进程缓冲区,程序以ByteBuf为单位来发送数据,但是到了底层操作系统内核缓冲区,底层会按照协议的规范对数据包进行二次拼装,拼装成传输层TCP层的协议报文,再进行发送。在接收端收到传输层的二进制包后,首先保存在内核缓冲区,Netty读取ByteBuf时才复制到进程缓冲区。在接收端,当Netty程序将数据从内核缓冲区复制到Netty进程缓冲区的ByteBuf时,问题就来了:

(1)首先,每次读取底层缓冲的数据容量是有限制的,当TCP底层缓冲的数据包比较大时,会将一个底层包分成多次ByteBuf进行复制,进而造成进程缓冲区读到的是半包。(2)当TCP底层缓冲的数据包比较小时,一次复制的却不止一个内核缓冲区包,进而造成进程缓冲区读到的是粘包。

如何解决呢?基本思路是,在接收端,Netty程序需要根据自定义协议,将读取到的进程缓冲区ByteBuf,在应用层进行二次拼装,重新组装我们应用层的数据包。接收端的这个过程通常也称为分包,或者叫作拆包。在Netty中,分包的方法,从第7章可知,主要有两种方法:(1)可以自定义解码器分包器:基于ByteToMessageDecoder或者ReplayingDecoder,定义自己的进程缓冲区分包器。(2)使用Netty内置的解码器。如,使用Netty内置的LengthFieldBasedFrameDecoder自定义分隔符数据包解码器,对进程缓冲区ByteBuf进行正确的分包。在本章后面,这两种方法都会用到。

JSON协议通信

Java处理JSON数据有三个比较流行的开源类库有:阿里的FastJson、谷歌的Gson和开源社区的Jackson。

Jackson是一个简单的、基于Java的JSON开源库。使用Jackson开源库,可以轻松地将Java POJO对象转换成JSON、XML格式字符串;同样也可以方便地将JSON、XML字符串转换成Java POJO对象。Jackson开源库的优点是:所依赖的jar包较少、简单易用、性能也还不错,另外Jackson社区相对比较活跃。Jackson开源库的缺点是:对于复杂POJO类型、复杂的集合Map、List的转换结果,不是标准的JSON格式,或者会出现一些问题。

Google的Gson开源库是一个功能齐全的JSON解析库,起源于Google公司内部需求而由Google自行研发而来,在2008年5月公开发布第一版之后已被许多公司或用户应用。Gson可以完成复杂类型的POJO和JSON字符串的相互转换,转换的能力非常强。

阿里巴巴的FastJson是一个高性能的JSON库。传闻说FastJson在复杂类型的POJO转换JSON时,可能会出现一些引用类型而导致JSON转换出错,需要进行引用的定制。顾名思义,从性能上说,FastJson库采用独创的算法,将JSON转成POJO的速度提升到极致,超过其他JSON开源库。

JSON传输的编码器和解码器之原理

本质上来说,JSON格式仅仅是字符串的一种组织形式。所以,传输JSON的所用到的协议与传输普通文本所使用的协议没有什么不同。下面使用常用的Head-Content协议来介绍一下JSON传输。Head-Content数据包的解码过程如图8-3所示,具体如下:

在这里插入图片描述
先使用LengthFieldBasedFrameDecoder(Netty内置的自定义长度数据包解码器)解码Head-Content二进制数据包,解码出Content字段的二进制内容。然后,使用StringDecoder字符串解码器(Netty内置的解码器)将二进制内容解码成JSON字符串。最后,使用JsonMsgDecoder解码器(一个自定义解码器)将JSON字符串解码成POJO对象。
在这里插入图片描述

先使用StringEncoder编码器(Netty内置)将JSON字符串编码成二进制字节数组。然后,使用LengthFieldPrepender编码器(Netty内置)将二进制字节数组编码成Head-Content二进制数据包。LengthFieldPrepender编码器的作用:在数据包的前面加上内容的二进制字节数组的长度。这个编码器和LengthFieldBasedFrameDecoder解码器是天生的一对,常常配套使用。这组“天仙配”属于Netty所提供的一组非常重要的编码器和解码器,常常用于Head-Content数据包的传输。

JSON传输之服务器端的实践案例

为了清晰地演示JSON传输,下面设计一个简单的客户端/服务器传输程序:服务器接收客户端的数据包,并解码成JSON,再转换成POJO;客户端将POJO转换成JSON字符串,编码后发送到服务器端。

为了简化流程,此服务器端的代码仅仅包含Inbound入站处理的流程,不包含OutBound出站处理的流程。也就是说,服务器端的程序仅仅读取客户端数据包并完成解码。服务器端的程序没有写出任何的输出数据包到对端(即客户端)。服务器端实践案例的程序代码如下:

        package com.crazymakercircle.netty.protocol;
        //...
        public class JsonServer {

            //...省略成员属性,构造器
            public void runServer() {
              //创建反应器线程组
              EventLoopGroupbossLoopGroup = new NioEventLoopGroup(1);
              EventLoopGroupworkerLoopGroup = new NioEventLoopGroup();

              try {
                  //...省略:启动器的反应器线程,设置配置项
                  //5 装配子通道流水线
                  b.childHandler(new ChannelInitializer<SocketChannel>() {
                      //有连接到达时会创建一个通道
                      protected void initChannel(SocketChannel ch) throws Exception {
                          // 流水线管理子通道中的Handler业务处理器
                          // 向子通道流水线添加3个Handler业务处理器
                          ch.pipeline().addLast(
                          new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4));
                          ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));
                          ch.pipeline().addLast(new JsonMsgDecoder());
                      }
                  });
                  //....省略端口绑定,服务监听,从容关闭
            }

            //服务器端业务处理器
            static class JsonMsgDecoderextends ChannelInboundHandlerAdapter {
              @Override
              public void channelRead(ChannelHandlerContext ctx, Object msg) throws
                Exception {
                  String json = (String) msg;
                  JsonMsg jsonMsg = JsonMsg.parseFromJson(json);
                  Logger.info("收到一个Json数据包 =》" + jsonMsg);
              }
          }

          public static void main(String[] args) throws InterruptedException {
              int port = NettyDemoConfig.SOCKET_SERVER_PORT;
              new JsonServer(port).runServer();
          }
        }

JSON传输之客户端的实践案例

为了简化流程,客户端的代码仅仅包含Outbound出站处理的流程,不包含Inbound入站处理的流程。也就是说,客户端的程序仅仅进行数据的编码,然后把数据包写到服务器端。客户端的程序并没有去处理从对端(即服务器端)过来的输入数据包。客户端的流程大致如下:

(1)通过谷歌的Gson框架,将POJO序列化成JSON字符串。

(2)然后,使用StringEncoder编码器(Netty内置)将JSON字符串编码成二进制字节数组。

(3)最后,使用LengthFieldPrepender编码器(Netty内置)将二进制字节数组编码成Head-Content格式的二进制数据包。客户端实践案例的程序代码如下:

客户端实践案例的程序代码如下:

        package com.crazymakercircle.netty.protocol;
        //....
        public class JsonSendClient {
            static String content = "疯狂创客圈:高性能学习社群!";

            //...省略成员属性,构造器

            public void runClient() {
              //创建反应器线程组
              EventLoopGroupworkerLoopGroup = new NioEventLoopGroup();
              try {
                  //1 设置反应器线程组
                  b.group(workerLoopGroup);
                  //2 设置nio类型的通道
                  b.channel(NioSocketChannel.class);
                  //3 设置监听端口
                  b.remoteAddress(serverIp, serverPort);
                  //4 设置通道的参数
                  b.option(ChannelOption.ALLOCATOR,
                            PooledByteBufAllocator.DEFAULT);
                  //5 装配通道流水线
                  b.handler(new ChannelInitializer<SocketChannel>() {
                      //初始化客户端通道
                      protected void initChannel(SocketChannelch) throws Exception {
                          // 客户端通道流水线添加2个Handler业务处理器
                          ch.pipeline().addLast(new LengthFieldPrepender(4));
                          ch.pipeline().addLast(new
                                            StringEncoder(CharsetUtil.UTF_8));
                      }
                  });
                  ChannelFuture f = b.connect();
                  f.addListener((ChannelFuturefutureListener) ->
                  {
                      if (futureListener.isSuccess()) {
                        Logger.info("EchoClient客户端连接成功!");

                      } else {
                        Logger.info("EchoClient客户端连接失败!");
                      }
                  });

                  // 阻塞,直到连接完成
                  f.sync();
                  Channel channel = f.channel();

                  //发送Json字符串对象
                  for (int i = 0; i< 1000; i++) {
                      JsonMsg user = build(i, i + "->" + content);
                      channel.writeAndFlush(user.convertToJson());
                      Logger.info("发送报文:" + user.convertToJson());
                  }
                  channel.flush();
                  // 7 等待通道关闭的异步任务结束
                  // 服务监听通道会一直等待通道关闭的异步任务结束
                  ChannelFuturecloseFuture = channel.closeFuture();
                  closeFuture.sync();
              } catch (Exception e) {
                  e.printStackTrace();
              } finally {
                  // 从容关闭EventLoopGroup,
                  // 释放掉所有资源,包括创建的线程
                  workerLoopGroup.shutdownGracefully();
              }
            }

            //构建Json对象
            public JsonMsgbuild(int id, String content) {
              JsonMsg user = new JsonMsg();
              user.setId(id);
              user.setContent(content);
              return user;
            }

            public static void main(String[] args) throws InterruptedException {
              int port = NettyDemoConfig.SOCKET_SERVER_PORT;
              String ip = NettyDemoConfig.SOCKET_SERVER_IP;
              new JsonSendClient(ip, port).runClient();
            }
        }

执行次序是:先启动服务器端,然后启动客户端。启动后,客户端会向服务器发送1000个POJO转换成JSON后的字符串。如果能从服务器的控制台看到输出的JSON格式的字符串,说明程序运行是正确的。

Protobuf协议通信

Protobuf是Google提出的一种数据交换的格式,是一套类似JSON或者XML的数据传输格式和规范,用于不同应用或进程之间进行通信。Protobuf的编码过程为:使用预先定义的Message数据结构将实际的传输数据进行打包,然后编码成二进制的码流进行传输或者存储。Protobuf的解码过程则刚好与编码过程相反:将二进制码流解码成Protobuf自己定义的Message结构的POJO实例。

Protobuf既独立于语言,又独立于平台。Google官方提供了多种语言的实现:Java、C#、C++、GO、JavaScript和Python。Protobuf数据包是一种二进制的格式,相对于文本格式的数据交换(JSON、XML)来说,速度要快很多。由于Protobuf优异的性能,使得它更加适用于分布式应用场景下的数据通信或者异构环境下的数据交换。

与JSON、XML相比,Protobuf算是后起之秀,是Google开源的一种数据格式。只是Protobuf更加适合于高性能、快速响应的数据传输应用场景。另外,JSON、XML是文本格式,数据具有可读性;而Protobuf是二进制数据格式,数据本身不具有可读性,只有反序列化之后才能得到真正可读的数据。正因为Protobuf是二进制数据格式,数据序列化之后,体积相比JSON和XML要小,更加适合网络传输。

总体来说,在一个需要大量数据传输的应用场景中,因为数据量很大,那么选择Protobuf可以明显地减少传输的数据量和提升网络IO的速度。对于打造一款高性能的通信服务器来说,Protobuf传输协议是最高性能的传输协议之一。微信的消息传输就采用了Protobuf协议。

一个简单的proto文件的实践案例

Protobuf使用proto文件来预先定义的消息格式。数据包是按照proto文件所定义的消息格式完成二进制码流的编码和解码。proto文件,简单地说,就是一个消息的协议文件,这个协议文件的后缀文件名为“.proto”。作为演示,下面介绍一个非常简单的proto文件:仅仅定义一个消息结构体,并且该消息结构体也非常简单,仅包含两个字段。实例如下:

        // [开始头部声明]
        syntax = "proto3";
        packagecom.crazymakercircle.netty.protocol;
        // [结束头部声明]

        // [开始java选项配置]
        option java_package = "com.crazymakercircle.netty.protocol";
        option java_outer_classname = "MsgProtos";
        // [结束java选项配置]

        // [开始消息定义]
        message Msg {
          uint32 id = 1;  //消息ID
          string content = 2; //消息内容
        }

        // [结束消息定义]

在“.proto”文件的头部声明中,需要声明“.proto”所使用的Protobuf协议版本,这里使用的是"proto3"。也可以使用旧一点的版本"proto2",两个版本的消息格式有一些细微的不同。默认的协议版本为"proto2"。

Protobuf支持很多语言,所以它为不同的语言提供了一些可选的声明选项,选项的前面有option关键字。“java_package”选项的作用为:在生成“proto”文件中消息的POJO类和Builder(构造者)的Java代码时,将Java代码放入指定的package中。“java_outer_classname”选项的作用为:在生成“proto”文件所对应Java代码时,所生产的Java外部类的名称。在“proto”文件中,使用message这个关键字来定义消息的结构体。在生成“proto”对应的Java代码时,每个具体的消息结构体都对应于一个最终的Java POJO类。消息结构体的字段对应到POJO类的属性。也就是说,每定义一个“message”结构体相当于声明一个Java中的类。并且message中可以内嵌message,就像java的内部类一样。

每一个消息结构体可以有多个字段。定义一个字段的格式,简单来说就是“类型名称 = 编号”。例如“string content = 2; ”,表示该字段是string类型,名为content,序号为2。字段序号表示为:在Protobuf数据包的序列化、反序列化时,该字段的具体排序。在每一个“.proto”文件中,可以声明多个“message”。大部分情况下,会把有依赖关系或者包含关系的message消息结构体写入一个.proto文件。将那些没有关联关系的message消息结构体,分别写入不同的文件,这样便于管理。

消息POJO和Builder的使用之实践案例

在Maven的pom.xml文件中加上protobuf的Java运行包的依赖,代码如下:

        <dependency>
            <groupId>com.google.protobuf</groupId>
            <artifactId>protobuf-java</artifactId>
            <version>${protobuf.version}</version>
        </dependency>

这里的protobuf.version版本号的具体值为3.6.1。也就是说,Java运行时的potobuf依赖包的版本和“.proto”消息结构体文件中的syntax配置版本,以及编译“.proto”文件所使用的编译器“protoc3.6.1.exe”的版本,这三个版本需要配套一致。

1.使用Builder构造者,构造POJO消息对象

        package com.crazymakercircle.netty.protocol;
        //...
        public class ProtobufDemo {
            public static MsgProtos.MsgbuildMsg() {
              MsgProtos.Msg.BuilderpersonBuilder = MsgProtos.Msg.newBuilder();
              personBuilder.setId(1000);
              personBuilder.setContent("疯狂创客圈:高性能学习社群");
              MsgProtos.Msg message = personBuilder.build();
              return message;
            }
         //…..
        }

Protobuf为每个message消息结构体生成的Java类中,包含了一个POJO类、一个Builder类。构造POJO消息,首先需要使用POJO类的newBuilder静态方法获得一个Builder构造者。每一个POJO字段的值,需要通过Builder构造者的setter方法去设置。注意,消息POJO对象并没有setter方法。字段值设置完成之后,使用构造者的build()方法构造出POJO消息对象。

2.序列化serialization & 反序列化Deserialization的方式一

        package com.crazymakercircle.netty.protocol;
        //...
        public class ProtobufDemo {

            //第1种方式:序列化serialization &反序列化Deserialization
            @Test
            public void serAndDesr1() throws IOException {
              MsgProtos.Msg message = buildMsg();
              //将Protobuf对象序列化成二进制字节数组
              byte[] data = message.toByteArray();
              //可以用于网络传输,保存到内存或外存
              ByteArrayOutputStreamoutputStream = new ByteArrayOutputStream();
              outputStream.write(data);
              data = outputStream.toByteArray();
              //二进制字节数组反序列化成Protobuf对象
              MsgProtos.MsginMsg = MsgProtos.Msg.parseFrom(data);
              Logger.info("id:=" + inMsg.getId());
              Logger.info("content:=" + inMsg.getContent());
            }
        //….
        }

这种方式通过调用POJO对象的toByteArray()方法将POJO对象序列化成字节数组。通过调用parseFrom(byte[] data)方法,Protobuf也可以从字节数组中重新反序列化得到POJO新的实例。这种方式类似于普通Java对象的序列化,适用于很多将Protobuf的POJO序列化到内存或者外层的应用场景。

3.序列化serialization & 反序列化Deserialization的方式二

        package com.crazymakercircle.netty.protocol;
        //...
        public class ProtobufDemo {
         //…
            //第2种方式:序列化serialization &反序列化Deserialization
            @Test
            public void serAndDesr2() throws IOException {
              MsgProtos.Msg message = buildMsg();
              //序列化到二进制码流
              ByteArrayOutputStreamoutputStream = new ByteArrayOutputStream();
              message.writeTo(outputStream);
              ByteArrayInputStreaminputStream =
              new ByteArrayInputStream(outputStream.toByteArray());

              //从二进码流反序列化成Protobuf对象
              MsgProtos.MsginMsg = MsgProtos.Msg.parseFrom(inputStream);
              Logger.info("id:=" + inMsg.getId());
              Logger.info("content:=" + inMsg.getContent());
            }
        //….
        }

这种方式通过调用POJO对象的writeTo(OutputStream)方法将POJO对象的二进制字节写出到输出流。通过调用parseFrom(InputStream)方法,Protobuf从输入流中读取二进制码流重新反序列化,得到POJO新的实例。在阻塞式的二进制码流传输应用场景中,这种序列化和反序列化的方式是没有问题的。例如,可以将二进制码流写入阻塞式的Java OIO套接字或者输出到文件。但是,这种方式在异步操作的NIO应用场景中,存在着粘包/半包的问题。

4.序列化serialization &反序列化Deserialization的方式三

        package com.crazymakercircle.netty.protocol;
        //...
        public class ProtobufDemo {
         //…
            //第3种方式:序列化serialization &反序列化Deserialization
            //带字节长度:[字节长度][字节数据],解决粘包/半包问题
            @Test
            public void serAndDesr3() throws IOException {
              MsgProtos.Msg message = buildMsg();
              //序列化到二进制码流
              ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                  message.writeDelimitedTo(outputStream);
                  ByteArrayInputStream inputStream
                        = new ByteArrayInputStream(outputStream.toByteArray());
              //从二进码流反序列化成Protobuf对象
              MsgProtos.MsginMsg = MsgProtos.Msg.parseDelimitedFrom(inputStream);
              Logger.info("id:=" + inMsg.getId());
              Logger.info("content:=" + inMsg.getContent());
            }
        }

这种方式通过调用POJO对象的writeDelimitedTo(OutputStream)方法在序列化的字节码之前添加了字节数组的长度。这一点类似于前面介绍的Head-Content协议,只不过Protobuf做了优化,长度的类型不是固定长度的int类型,而是可变长度varint32类型。反序列化时,调用parseDelimitedFrom(InputStream)方法。Protobuf从输入流中先读取varint32类型的长度值,然后根据长度值读取此消息的二进制字节,再反序列化得到POJO新的实例。这种方式可以用于异步操作的NIO应用场景中,解决了粘包/半包的问题。

Protobuf编解码的实践

Netty默认支持Protobuf的编码与解码,内置了一套基础的Protobuf编码和解码器。

1.ProtobufEncoder编码器

翻开Netty源代码,我们发现ProtobufEncoder的实现逻辑非常简单,直接使用了message.toByteArray() 方法将Protobuf的POJO消息对象编码成二进制字节,数据放入Netty的Bytebuf数据包中,然后交给了下一站的编码器。

2.ProtobufDecoder解码器

ProtobufDecoder解码器和ProtobufEncoder编码器相互对应。ProtobufDecoder需要指定一个POJO消息的prototype原型POJO实例,根据原型实例找到对应的Parser解析器,将二进制的字节解析为Protobuf POJO消息对象。

在Java NIO通信中,仅仅使用以上这组编码器和解码器会存在粘包/半包的问题。Netty也提供了配套的Head-Content类型的Protobuf编码器和解码器,在二进制码流之前加上二进制字节数组的长度。

3.ProtobufVarint32LengthFieldPrepender长度编码器

这个编码器的作用是,可以在ProtobufEncoder生成的字节数组之前,前置一个varint32数字,表示序列化的二进制字节数。

4.ProtobufVarint32FrameDecoder长度解码器

ProtobufVarint32FrameDecoder和ProtobufVarint32LengthFieldPrepender相互对应。其作用是,根据数据包中varint32中的长度值,解码一个足额的字节数组。然后将字节数组交给下一站的解码器ProtobufDecoder。

什么是varint32类型的长度,为什么不用int这种固定类型的长度呢?

varint32是一种紧凑的表示数字的方法,它不是一种具体的数据类型。varint32它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数,值越大使用的字节数越多。varint32根据值的大小自动进行长度的收缩,这能减少用于保存长度的字节数。也就是说,varint32与int类型的最大区别是:varint32用一个或多个字节来表示一个数字。varint32不是固定长度,所以为了更好地减少通信过程中的传输量,消息头中的长度尽量采用varint格式。

至此,Netty的内置的ProtoBuf的编码器和解码器已经初步介绍完了。可以通过这两组编码器 /解码器完成Length + Protobuf Data (Head-Content) 协议的数据传输。但是,在更加复杂的传输应用场景,Netty的内置编码器和解码器是不够用的。例如,在Head部分加上魔数字段进行安全验证;或者还需要对Protobuf Data的内容进行加密和解密等。也就是说,在复杂的传输应用场景下,需要定制属于自己的Protobuf编码器和解码器。

Protobuf传输之服务器端的实践

为了清晰地演示Protobuf传输,下面设计了一个简单的客户端/服务器传输程序:服务器接收客户端的数据包,并解码成Protobuf的POJO;客户端将Protobuf的POJO编码成二进制数据包,再发送到服务器端。在服务器端,Protobuf协议的解码过程如下:先使用Netty内置的ProtobufVarint32FrameDecoder,根据varint32格式的可变长度值,从入站数据包中解码出二进制Protobuf字节码。然后,可以使用Netty内置的ProtobufDecoder解码器将字节码解码成Protobuf POJO对象。最后,自定义一个ProtobufBussinessDecoder解码器来处理Protobuf POJO对象。

服务端的实践案例程序代码如下:

        package com.crazymakercircle.netty.protocol;
        //...
        public class ProtoBufServer {

            //...省略成员属性,构造器

            public void runServer() {
              //创建反应器线程组
              EventLoopGroupbossLoopGroup = new NioEventLoopGroup(1);
              EventLoopGroupworkerLoopGroup = new NioEventLoopGroup();

              try {
                  //...省略:启动器的反应器线程,设置配置项
                  //5 装配子通道流水线
                  b.childHandler(new ChannelInitializer<SocketChannel>() {
                      //有连接到达时会创建一个通道
                      protected void initChannel(SocketChannelch) throws Exception {
                          // 流水线管理子通道中的Handler业务处理器
                          // 向子通道流水线添加3个Handler业务处理器
                          ch.pipeline().addLast(newProtobufVarint32FrameDecoder());
                          ch.pipeline().addLast(
                            newProtobufDecoder(MsgProtos.Msg.getDefaultInstance()));
                          ch.pipeline().addLast(new ProtobufBussinessDecoder());
                      }
                  });
                  //....省略端口绑定,服务监听,从容关闭
            }

            //服务器端的业务处理器
            static class ProtobufBussinessDecoderextends ChannelInboundHandlerAdapter
            {
              @Override
              public void channelRead(ChannelHandlerContextctx, Object msg) throws
    Exception {
                  MsgProtos.MsgprotoMsg = (MsgProtos.Msg) msg;
                  //经过流水线的各个解码器,到此Person类型已经可以断定
                  Logger.info("收到一个MsgProtos.Msg数据包 =》");
                  Logger.info("protoMsg.getId():=" + protoMsg.getId());
                  Logger.info("protoMsg.getContent():=" + protoMsg.getContent());
              }
            }

            public static void main(String[] args) throws InterruptedException {
              int port = NettyDemoConfig.SOCKET_SERVER_PORT;
              new ProtoBufServer(port).runServer();
            }
        }

Protobuf传输之客户端的实践

在客户端开始出站之前,需要提前构造好Protobuf的POJO对象。然后可以使用通道的write/writeAndFlush方法,启动出站处理的流水线执行工作。客户端的出站处理流程中,Protobuf协议的编码如8-5所示,过程如下:

在这里插入图片描述
先使用Netty内置的ProtobufEncoder,将Protobuf POJO对象编码成二进制的字节数组;然后,使用Netty内置的ProtobufVarint32LengthFieldPrepender编码器,加上varint32格式的可变长度。Netty会将完成了编码后的Length+Content格式的二进制字节码发送到服务器端。

客户端的实践案例程序代码如下:

        package com.crazymakercircle.netty.protocol;
        //...
        public class ProtoBufSendClient {
            static String content = "疯狂创客圈:高性能学习社群!";

            //...省略成员属性,构造器

            public void runClient() {
              //创建反应器线程组
              EventLoopGroupworkerLoopGroup = new NioEventLoopGroup();
              try {
                  //1 设置反应器线程组
                  b.group(workerLoopGroup);
                  //2 设置nio类型的通道
                  b.channel(NioSocketChannel.class);
                  //3 设置监听端口
                  b.remoteAddress(serverIp, serverPort);
                  //4 设置通道的参数
                  b.option(ChannelOption.ALLOCATOR,
                          PooledByteBufAllocator.DEFAULT);

                  //5 装配通道流水线
                  b.handler(new ChannelInitializer<SocketChannel>() {
                    //初始化客户端通道
                    protected void initChannel(SocketChannelch) throws Exception {
                        // 客户端太多流水线添加2个Handler业务处理器
                        ch.pipeline().addLast(new
                                        ProtobufVarint32LengthFieldPrepender());
                        ch.pipeline().addLast(new ProtobufEncoder());
                    }
                  });
                  ChannelFuture f = b.connect();
                  //...
                  // 阻塞,直到连接完成
                  f.sync();
                  Channel channel = f.channel();

                  //发送Protobuf对象
                  for (int i = 0; i< 1000; i++) {
                    MsgProtos.Msg user = build(i, i + "->" + content);
                    channel.writeAndFlush(user);
                    Logger.info("发送报文数:" + i);
                  }
                  channel.flush();

                //省略关闭等待,从容关闭

          }
          //构建ProtoBuf对象
          public MsgProtos.Msgbuild(int id, String content) {
              MsgProtos.Msg.Builder builder = MsgProtos.Msg.newBuilder();
              builder.setId(id);
              builder.setContent(content);
              return builder.build();
          }

          public static void main(String[] args) throws InterruptedException {
              int port = NettyDemoConfig.SOCKET_SERVER_PORT;
              String ip = NettyDemoConfig.SOCKET_SERVER_IP;
              new ProtoBufSendClient(ip, port).runClient();
          }
        }

执行次序是:先启动服务器端,然后启动客户端。启动后,客户端会向服务器发送构造好的1000个Protobuf POJO实例。如果能从服务器的控制台看到输出的POJO实例的属性值,说明程序运行是正确的。

MsgProtos.Msg.Builder builder = MsgProtos.Msg.newBuilder();
builder.setId(id);
builder.setContent(content);
return builder.build();
}

      public static void main(String[] args) throws InterruptedException {
          int port = NettyDemoConfig.SOCKET_SERVER_PORT;
          String ip = NettyDemoConfig.SOCKET_SERVER_IP;
          new ProtoBufSendClient(ip, port).runClient();
      }
    }

执行次序是:先启动服务器端,然后启动客户端。启动后,客户端会向服务器发送构造好的1000个Protobuf POJO实例。如果能从服务器的控制台看到输出的POJO实例的属性值,说明程序运行是正确的。

**Protobuf消息字段的格式**为:限定修饰符① | 数据类型② | 字段名称③ | = | 分配标识号④
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yitian_hm

您的支持是我最大鼓励

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值