十二章 序列化与反序列化:JSONProtobuf

我们在开发一些远程过程调用(RPC)的程序时通常会涉及对象的序列化/反序列化问题,例如一个Person对象从客户端通过TCP方式发送到服务端。由于TCP(或者UDP等类似低层协议)只能发送字节流,因此需要应用层将Java POJO对象序列化成字节流,发送过去之后,数据接收端再将字节流反序列化Java POJO对象即可。

序列化反序列化一定会涉及POJO的编码和格式化(Encoding & Format),目前我们可选择的编码方式有:

  1. 使用JSON。将Java POJO对象转换成JSON结构化字符串。基于HTTP,在Web应用、移动开发方面等,这种是常用的编码方式,因为JSON的可读性较强。这种方式的缺点是它的性能稍差。
  2. 基于XML。和JSON一样,数据在序列化成字节流之前需要转换成字符串。这种方式的可读性强,性能差,异构系统、Open API类型的应用中常用。
  3. 使用Java内置的编码和序列化机制,可移植性强,性能稍差,无法跨平台(语言)。
  4. 开源的二进制的序列化/反序列化框架,例如Apache AvroApache ThriftProtobuf等。前面的两个框架和Protobuf相比,性能非常接近,而且设计原理如出一辙。其中,Avro在大数据存储(RPC数据交换、本地存储)时比较常用;Thrift的亮点在于内置了RPC机制,所以在开发一些RPC交互式应用时,客户端和服务端的开发与部署都非常简单。

如何选择序列化/反序列化框架呢?

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

1)结果数据大小:原则上说,序列化后的数据尺寸越小,传输效率越高。

2)结构复杂度:会影响序列化/反序列化的效率,结构越复杂越耗时。

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

Protobuf是一个高性能、易扩展的序列化框架,性能比较高,其性能的有关数据可以参看官方文档。Protobuf本身非常简单,易于开发,而且结合Netty框架,可以非常便捷地实现一个通信应用程序。反过来,Netty也提供了相应的编解码器,为Protobuf解决了有关Socket通信中半包、粘包等问题。

无论是使用JSONProtobuf还是其他的传输协议,我们必须保证在数据包的反序列化之前,接收端的ByteBuf二进制数据包一定是一个完整的应用层二进制包,不能是一个半包或者粘包,这就涉及通信过程中的拆包技术。

12.1 详解粘包和拆包

什么是粘包和半包?先从数据包的发送和接收开始讲起。大家知道,Netty发送和读取数据的场所ByteBuf缓冲区。对于发送端,每一次发送就是向通道写入一个ByteBuf,发送数据时先填好ByteBuf,然后通过通道发送出去。对于接收端,每一次读取就是通过业务处理器的入站方法从通道读到一个ByteBuf。读取数据的方法如下:

public void channelRead(ChannelHandlerContext ctx, Object msg)

{

    ByteBufbyteBuf = (ByteBuf) msg;

    //省略入站处理

}

最为理想的情况是:发送端每发送一个ByteBuf缓冲区,接收端就能接收到一个ByteBuf,并且发送端和接收端的ByteBuf内容一模一样。然而,在实际的通信过程中并没有大家预料的那么完美。下面给大家看一个实例,看看实际通信过程中所遇到的诡异情况。

12.1.1 半包问题的实战案例

改造一下前面的NettyEchoClient实例,通过循环的方式向NettyEchoServer回显服务器写入大量的ByteBuf,然后看看实际的服务器响应结果。注意:服务器类不需要改造,直接使用之前的回显服务器即可。

改造好的客户端类——NettyDumpSendClient。在客户端建立连接成功之后,使用一个for循环不断通过通道向服务端发送ByteBuf,一直写到1000次,这些ByteBuf的内容相同,都是字符串的内容:"软件构件技术原理-java版源码!"。代码如下:

package cn.edu.bbc.computer.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= "软件构件技术原理-java版源码!";

            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客户端程序,连接成功后客户端会向服务器发送1000ByteBuf内容缓冲区,服务器NettyEchoServer收到后会输出到控制台,然后回写给客户端。服务器的输出如图12-1所示。

12-1 NettyEchoServer的控制台输出

仔细观察服务端的控制台输出,可以看出存在三种类型的输出:

1)读到一个完整的客户端输入ByteBuf

2)读到多个客户端的ByteBuf输入,但是“粘”在了一起。

3)读到部分ByteBuf的内容,并且有乱码。

除了观察服务端的输出之外,再仔细观察客户端的输出,可以看到客户端也存在以上三种类型的输出。

对应于第1种情况接收到的完整的ByteBuf,这里称为全包。对应于第2种情况,多个发送端的输入ByteBuf“在了一起,这里称为粘包。对应于第3种情况,一个发送过来的ByteBuf拆开接收,接收端读取到一个破碎的包,这里称为半包

为了简单起见,也可以将粘包的情况看成特殊的半包粘包半包可以统称为传输的半包问题

12.1.2 什么是半包问题

半包问题包含了粘包半包两种情况:

1)粘包:接收端(Receiver)收到一个ByteBuf,包含了发送端(Sender)的多个ByteBuf,发送端的多个ByteBuf在接收端“粘”在了一起。

2)半包:ReceiverSender的一个ByteBuf“拆”开了收,收到多个破碎的包。换句话说,Receiver收到了Sender的一个ByteBuf的一小部分。

无论是粘包还是半包都不是一次正常的ByteBuf缓存区接收,具体如图12-2所示。

12-2 粘包和半包现象(为粘包,为半包)

12.1.3 半包问题的根因分析

粘包和半包的来源得从操作系统底层说起。

大家都知道,底层网络是以二进制字节报文的形式来传输数据的。读数据的过程大致为:当IO可读时,Netty会从底层网络将二进制数据读到ByteBuf缓冲区中,再交给Netty程序转成Java POJO对象。写数据的过程大致为:编码器将一个Java类型的数据转换成底层能够传输的二进制ByteBuf缓冲数据。

在发送端Netty的应用层进程缓冲区中,程序以ByteBuf为单位来发送数据,但是到了底层操作系统内核缓冲区,底层会按照协议的规范对数据包进行二次封装,封装成传输层的协议报文,再进行发送。在接收端收到传输层的二进制包后,首先复制到内核缓冲区,Netty读取ByteBuf时才复制到应用的用户缓冲区。

在接收端,当Netty程序将数据从内核缓冲区复制到用户缓冲区的ByteBuf时,问题来了:

1)每次读取底层缓冲的数据容量是有限制的,当TCP内核缓冲区的数据包比较大时,可能会将一个底层包分成多次ByteBuf进行复制,进而造成用户缓冲区读到的是半包。

2)当TCP内核缓冲区的数据包比较小时,一次复制的是不止一个内核缓冲区包,进而会造成用户缓冲区读到粘包。

如何解决呢?基本思路是,在接收端,Netty程序需要根据自定义协议将读取到的进程缓冲区ByteBuf在应用层进行二次组装,重新组装应用层的数据包。接收端的这个过程通常也称为分包或者拆包。

Netty中分包的方法主要有以下两种:

1)可以自定义解码器分包器:基于ByteToMessageDecoder或者ReplayingDecoder,定义自己的用户缓冲区分包器。

2)使用Netty内置的解码器。例如,可以使用Netty内置的LengthFieldBasedFrameDecoder自定义长度数据包解码器对用户缓冲区ByteBuf进行正确的分包。

在本章后面会用到这两种方法。

12.2 使用JSON协议通信

JSONJavaScript Object NotationJS对象简谱)是一种轻量级的数据交换格式。它是基于ECMAScript(欧洲计算机协会制定的JS规范)的一个子集,采用完全独立于编程语言的文本格式来存储和表示数据。简洁和清晰的层次结构使得JSON成为理想的数据交换语言。

JSON协议是一种文本协议,易于人阅读和编写,同时也易于机器解析和生成,并能有效地提升网络传输效率。

12.2.1 JSON的核心优势

XML是一种常用的文本协议,和JSON一样都使用结构化方法来标记数据。和XML相比,JSON作为数据包格式传输的时候具有更高的效率。这是因为JSON不像XML那样需要有严格的闭合标签,让有效数据量与总数据包比大大提升,从而在同等数据流量的情况下减少了网络的传输压力。

下面来做一个简单的比较。

1)部分省市数据用XML表示如下:

<?xml version="1.0" encoding="utf-8"?>

<country>

    <name>中国</name>

    <province>

        <name>广东</name>

        <cities>

            <city>广州</city>

            <city>深圳</city>

        </cities>

    </province>

    <province>

        <name>新疆</name>

        <cities>

            <city>乌鲁木齐</city>

        </cities>

    </province>

</country>

2)以上部分省市数据用JSON表示如下:

{

    "name": "中国",

    "province": [

                {

        "name": "广东",

        "cities": {

            "city": ["广州", "深圳"]

        }

    }, {

        "name": "新疆",

        "cities": {

            "city": ["乌鲁木齐"]

        }

    }]

}

可以看到,JSON的语法格式和清晰的层次结构非常简单,明显要比XML容易阅读,并且在数据交换方面JSON所使用的字符要比XML少得多,可以大大节约传输数据所占用的带宽。

12.2.2 JSON序列化与反序列化开源库

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

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

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

阿里巴巴的FastJson是一个高性能的JSON库。顾名思义,FastJson库采用独创的快速算法,将JSON转成POJO的速度提升到极致,从性能上说,序列化速度超过其他JSON开源库。

在实际开发中,目前主流的策略是GsonFastJson结合使用。在POJO序列化成JSON字符串的应用场景下,使用谷歌的Gson库;在JSON字符串反序列化成POJO的应用场景下,使用阿里巴巴的FastJson库。

下面将JSON的序列化和反序列化功能放在一个通用类JsonUtil中,方便后面统一使用。代码如下:

package com.crazymakercircle.util;

//省略import

public class JsonUtil {

 

    //谷歌GsonBuilder构造器

    static GsonBuilder gb = new GsonBuilder();

    static {

        //不需要html escape

        gb.disableHtmlEscaping();

    }

 

    //序列化:使用GsonPOJO 转成字符串

    public static String pojoToJson(java.lang.Object obj) {

        String json = gb.create().toJson(obj);

        return json;

    }

 

    //反序列化:使用Fastjson将字符串转成 POJO对象

    public static <T> T jsonToPojo(String json, Class<T>tClass) {

        T t = JSONObject.parseObject(json, tClass);

        return t;

    }

}

12.2.3 JSON序列化与反序列化的实战案例

下面通过一个小实例演示一下POJO对象的JSON协议的序列化和反序列化。

首先定义一个POJO类,名称为JsonMsg,包含idcontent两个属性,然后使用lombok开源库的@Data注解为属性加上getter()setter()方法。POJO类的源码如下:

package cn.edu.bbc.computer.protocol;

//省略import

@Data

public class JsonMsg {

    private int id; //id Field(字段)

    private String content;//content Field(字段)

    //序列化:调用通用方法,使用Gson转成字符串

    public String convertToJson() {

        return JsonUtil.pojoToJson(this);

    }

    //反序列化:使用FastJson转成Java POJO对象

    public static JsonMsg parseFromJson(String json) {

        return JsonUtil.jsonToPojo(json, JsonMsg.class);

    }

}

POJOJsonMsg中,首先加上了一个JSON序列化方法convertToJson(),它调用通用类定义的JsonUtil.pojoToJson(Object)方法将对象自身序列化成JSON字符串。另外,JsonMsg加上了一个JSON反序列化方法parseFromJson(String)。它是一个静态方法,调用通用类定义的JsonUtil.jsonToPojo(String, Class)方法将JSON字符串反序列化成JsonMsg实例。

使用POJOJsonMsg的序列化、反序列化的实战案例代码如下:

package cn.edu.bbc.computer.protocol;

//

public class JsonMsgDemo {

    //构建JSON对象

    public JsonMsg buildMsg() {

        JsonMsg user = new JsonMsg();

        user.setId(1000);

        user.setContent("疯狂创客圈:高性能学习社群");

        return user;

    }

 

    //测试用例:serialization & Deserialization

    @Test

    public void serAndDesr() throws IOException {

        JsonMsg message = buildMsg();

        //POJO对象序列化成字符串

        String json = message.convertToJson();

        //可以用于网络传输,保存到内存或外存

        Logger.info("json:=" + json);

 

        //JSON 字符串反序列化成POJO对象

        JsonMsg Msg = JsonMsg.parseFromJson(json);

        Logger.info("id:=" + inMsg.getId());

        Logger.info("content:=" + inMsg.getContent());

    }

}

12.2.4 JSON传输的编码器和解码器

从本质上来说,JSON格式仅仅是字符串的一种组织形式。所以,传输JSON所用到的协议与传输普通文本所使用的协议没有什么不同。下面使用常用的Head-Content协议来介绍一下JSON传输。

Head-Content数据包的解码过程(见图12-3)是:首先,使用Netty内置的LengthFieldBasedFrameDecoder解码Head-Content二进制数据包,解码出Content字段的二进制内容;然后,使用StringDecoder字符串解码器(Netty内置的解码器)将二进制内容解码成JSON字符串;最后,使用自定义业务解码器JsonMsgDecoderJSON字符串解码成自定义的POJO业务对象。

12-3 JSON格式Head-Content数据包的解码过程

Head-Content数据包的编码过程(见图12-4)是:首先,使用Netty内置StringEncoder编码器将JSON字符串编码成二进制字节数组;然后,使用Netty内置LengthFieldPrepender编码器将二进制字节数组编码成Head-Content二进制数据包。

12-4 JSON格式Head-Content数据包的编码过程

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

LengthFieldPrepender编码器有两个常用的构造器:

//构造器一

public LengthFieldPrepender(int lengthFieldLength) {

    this(lengthFieldLength, false);

}

//构造器二

public LengthFieldPrepender(int lengthFieldLength, Boolean lengthIncludesLengthFieldLength)

{

    this(lengthFieldLength, 0, lengthIncludesLengthFieldLength);

}

//省略其他的构造器

在上面的构造器中,第一个参数lengthFieldLength表示Head长度字段所占用的字节数,第二个参数lengthIncludesLengthFieldLength表示Head字段的总长度值是否包含长度字段自身的字节数,如果该参数的值为true,表示长度字段的值(总长度)包含了自己的字节数。如果该参数的值为false,表示长度值只包含内容的二进制数据的长度。lengthIncludesLengthFieldLength值一般设置为false

12.2.5 JSON传输的服务端的实战案例

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

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

package cn.edu.bbc.computer.protocol;

//

public class JsonServer {

    //省略成员属性、构造器

    public void runServer() {

        //创建反应器线程组

        EventLoopGroup bossLoopGroup = new NioEventLoopGroup(1);

        EventLoopGroup workerLoopGroup = new NioEventLoopGroup();

        try {

            //省略引导类的反应器线程、设置配置项等

            //5 装配子通道流水线

            b.childHandler(new ChannelInitializer<SocketChannel>() {

                //有连接到达时会创建一个通道

                protected void initChannel(SocketChannel ch){

                    //管理子通道中的Handler

                    //向子通道流水线添加3Handler

                    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){

            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();

    }

}

12.2.6 JSON传输的客户端的实战案例

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

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

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

3)使用LengthFieldPrepender编码器(Netty内置)将二进制字节数组编码成Head-Content格式的二进制数据包。

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

package cn.edu.bbc.computer.protocol;

//

public class JsonSendClient {

    static String content = "疯狂创客圈:高性能学习社群!";

    //省略成员属性、构造器

    public void runClient() {

        //创建反应器线程组

        EventLoopGroup workerLoopGroup = new NioEventLoopGroup();

        try {

           //省略引导类的反应器线程、设置配置项等

            //5 装配通道流水线

            b.handler(new ChannelInitializer<SocketChannel>() {

                //初始化客户端通道

                protected void initChannel(SocketChannel ch) {

                    //客户端通道流水线添加2Handler

                    ch.pipeline().addLast(new LengthFieldPrepender(4));

                    ch.pipeline().addLast(new StringEncoder(CharsetUtil.UTF_8));

                }

            });

            ChannelFuture f = b.connect();

            //

            //阻塞,直到连接完成

            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 等待通道关闭的异步任务结束

            //服务监听通道会一直等待通道关闭的异步任务结束

            ChannelFuture closeFuture = channel.closeFuture();

            closeFuture.sync();

        } catch (Exception e) {

            e.printStackTrace();

        } finally {

          //省略优雅关闭

    }

    //构建JSON对象

    public JsonMsg build(int id, String content) {

        JsonMsg user = new JsonMsg();

        user.setId(id);

        user.setContent(content);

        return user;

    }

     //省略main()方法

}

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

12.3 使用Protobuf协议通信

ProtobufProtocol Buffer)是Google提出的一种数据交换格式,是一套类似JSON或者XML的数据传输格式和规范,用于不同应用或进程之间的通信。Protobuf具有以下特点:

1)语言无关,平台无关

Protobuf支持JavaC++PythonJavaScript等多种语言,支持跨多个平台。

2)高效

XML更小(3~10倍)、更快(20~100倍)、更为简单。

3)扩展性、兼容性好

可以更新数据结构,而不影响和破坏原有的旧程序。

Protobuf既独立于语言又独立于平台。Google官方提供了多种语言的实现:JavaC#C++GOJavaScriptPythonProtobuf的编码过程为:使用预先定义的Message数据结构将实际的传输数据进行打包,然后编码成二进制的码流进行传输或者存储。Protobuf的解码过程刚好与编码过程相反:将二进制码流解码成Protobuf自己定义的Message结构的POJO实例。

JSONXML相比,Protobuf算是后起之秀,只是Protobuf更加适合于高性能、快速响应的数据传输应用场景。Protobuf数据包是一种二进制格式,相对于文本格式的数据交换(JSONXML)来说,速度要快很多。Protobuf优异的性能使得它更加适用于分布式应用场景下的数据通信或者异构环境下的数据交换。

JSONXML是文本格式,数据具有可读性;Protobuf是二进制数据格式,数据本身不具有可读性,只有反序列化之后才能得到真正可读的数据。正因为Protobuf是二进制数据格式,所以数据序列化之后体积相比JSONXML要小,更加适合网络传输。

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

12.3.1 一个简单的proto文件的实战案例

Protobuf使用proto文件来预先定义的消息格式。数据包按照proto文件所定义的消息格式完成二进制码流的编码和解码。proto文件简单地说就是一个消息的协议文件,这个协议文件的后缀文件名为“.proto”

作为演示,下面介绍一个非常简单的proto文件:仅仅定义一个消息结构体,并且该消息结构体也非常简单,仅包含两个字段。实例如下:

//[开始头部声明]

syntax = "proto3";

package cn.edu.bbc.computer.protocol;

//[结束头部声明]

//[开始 Java选项配置]

option java_package = "cn.edu.bbc.computer.protocol";

option java_outer_classname = "MsgProtos";

//[结束 Java选项配置]

 

//[开始消息定义]

message Msg {

  uint32 id = 1;        //消息ID

  string content = 2;   //消息内容

}

//[结束消息定义]

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

Protobuf支持很多语言,所以它为不同的语言提供了一些可选的配置选项,使用option关键字。option java_package选项的作用为:在生成proto文件中消息的POJO类和Builder(构造者)的Java代码时,将生成的Java代码放入该选项所指定的package类路径中。option java_outer_classname选项的作用为:在生成proto文件所对应的Java代码时,生成的Java外部类使用配置的名称。

proto文件中,使用message关键字来定义消息的结构体。在生成proto对应的Java代码时,每个具体的消息结构体将对应于一个最终的Java POJO类。结构体的字段(Field)对应到POJO类的属性(Attribute)。也就是说,每定义一个message结构体相当于声明一个Java中的类。proto文件的message可以内嵌message,就像Java的内部类一样。

每个消息结构体可以有多个字段。定义一个字段的格式为类型名称 = 编号。例如,“string content = 2;”表示该字段是String类型,字段名为content,编号为2。字段编号表示在Protobuf数据包的序列化、反序列化时该字段的具体排序。

在一个proto文件中可以声明多个message,大部分情况下会把存在依赖关系或者包含关系的message结构体写入一个proto文件,将那些没有关系、相互独立的message结构体分别写入不同的文件,这样便于管理。

12.3.2 通过控制台命令生成POJOBuilder

完成“.proto”文件定义后,下一步是生成消息的POJO类和Builder(构造者)类。生成Java类有两种方式:一种是通过控制台命令;另一种是使用Maven插件。

先看第一种方式:通过控制台命令生成消息的POJO类和Builder构造者。

首先从https://github.com/protocolbuffers/protobuf/releases下载Protobuf的安装包,可以选择不同的版本,这里下载的是3.17.3Java版本。在Windows下解压后执行安装。(备注:这里以Windows平台为例子,对于Linux或者Mac平台,大家可自行尝试。)

生成构造者代码需要用到安装文件中的protoc.exe可执行文件。安装完成后,设置一下path环境变量,将proto的安装目录加入path环境变量中。

下面开始使用protoc.exe文件生成JavaBuilder(构造者),生成的命令如下:

protoc.exe --java_out=./src/main/java/ ./Msg.proto

在上面的命令中,使用的proto文件的名称为./Msg.proto,所生成的POJO类和构造者类的输出文件夹为./src/main/java/

使用命令行生成Java类的操作比较烦琐,另一种更加方便的方式是使用protobuf-maven-plugin插件生成Java类。

12.3.3 通过Maven插件生成POJOBuilder

使用protobuf-maven-plugin插件可以非常方便地生成消息的POJO类和Builder(构造者)类的Java代码。在Mavenpom文件中增加此插件的配置项,两种方式,一是自动配置,具体如下:

<plugin>
    <groupId>org.xolstice.maven.plugins</groupId>
    <artifactId>protobuf-maven-plugin</artifactId>
    <version>0.6.1</version>
    <configuration>
        <protocArtifact>com.google.protobuf:protoc:3.17.3:exe:${os.detected.classifier}</protocArtifact>
        <pluginId>grpc-java</pluginId>
        <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.40.1:exe:${os.detected.classifier}</pluginArtifact>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>
                <goal>compile-custom</goal>
            </goals>
        </execution>
    </executions>
</plugin>

通过com.google.protobuf:protoc:3.17.3:exe:${os.detected.classifier},自动侦测系统,使用符合自己系统的protobuf编译器解释定义的proto的文件,通过io.grpc:protoc-gen-grpc-java:1.40.1:exe:${os.detected.classifier}自动根据系统不同使用和当前系统匹配的Java类生成器,生成pojo类和builder类,这里有个限制,必须讲定义好的proto文件放在工程的src/main/proto目录下,否则protobuf编译器找不到类和接口定义文件。

第二种方式就是下载protobuf编译器到指定目录,这里放在工程的/protobuf的目录下,然后配置pom.xml内容如下,指定proto文件所在目录,这里和编译器同目录,指定产生的源码放在什么目录,这里放在源码目录:

<plugin>
    <groupId>org.xolstice.maven.plugins</groupId>
    <artifactId>protobuf-maven-plugin</artifactId>
    <version>0.6.1</version>
    <extensions>true</extensions>
    <configuration>
        <!--proto文件路径-->
        <protoSourceRoot>${project.basedir}/protobuf</protoSourceRoot>
        <!--目标路径-->
        <outputDirectory>${project.build.sourceDirectory}</outputDirectory>
        <!--设置是否在生成java文件之前清空outputDirectory的文件-->
        <clearOutputDirectory>false</clearOutputDirectory>
        <!--临时目录-->
        <temporaryProtoFileDirectory>${project.build.directory}/protoc-temp</temporaryProtoFileDirectory>
        <!--protoc 可执行文件路径-->
        <protocExecutable>${project.basedir}/protobuf/protoc3.17.3.exe</protocExecutable>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>
                <goal>test-compile</goal>
            </goals>
        </execution>
    </executions>
</plugin>

protobuf-maven-plugin插件的配置项介绍如下:

protoSourceRootproto消息结构体所在文件的路径。

outputDirectory:生成的POJO类和Builder类的目标路径。

protocExecutableprotobufJava代码生成工具的protoc:3.17.3.exe可执行文件的路径。

配置好之后,执行插件的compile命令,Java代码就生成了;在Maven的项目编译时,POJO类和Builder类也会自动生成。

12.3.4 Protobuf序列化与反序列化的实战案例

Mavenpom.xml文件中加上protobufJava运行包的依赖,代码如下:

<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-netty-shaded</artifactId>
    <version>1.40.1</version>
</dependency>
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-protobuf</artifactId>
    <version>1.40.1</version>
</dependency>
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-stub</artifactId>
    <version>1.40.1</version>
</dependency>
<dependency>
    <!-- necessary for Java 9+ -->
    <groupId>org.apache.tomcat</groupId>
    <artifactId>annotations-api</artifactId>
    <version>6.0.53</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.17.3</version>
</dependency>
<dependency>
    <groupId>com.google.errorprone</groupId>
    <artifactId>error_prone_annotations</artifactId>
    <version>2.3.4</version>
</dependency>

这里的protobuf.version版本号为3.13.0。需要注意的是:Java运行时的Protobuf依赖坐标的版本,.proto消息结构体文件中的syntax配置项值(Protobuf协议的版本号),以及通过proto文件生成POJOBuilder类的protoc:3.17.3.exe可执行文件的版本,这三个版本需要配套一致。

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

package cn.edu.bbc.computer.protocol;

//

public class ProtobufDemo {

    public static MsgProtos.Msg buildMsg() {

        MsgProtos.Msg.Builder personBuilder = 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每一个字段的值需要通过Buildersetter()方法去设置。字段值设置完成之后,使用构造者的build()方法构造出POJO消息对象。

2. 序列化与反序列化的方式一

获得消息POJO的实例之后,可以通过多种方法将POJO对象序列化成二进制字节或者反序列化。方式一为调用Protobuf POJO对象的toByteArray()方法将POJO对象序列化成字节数组,具体的代码如下:

package cn.edu.bbc.computer.protocol;

//

public class ProtobufDemo {

 

    //1种方式:序列化与反序列化

    @Test

    public void serAndDesr1() throws IOException {

        MsgProtos.Msg message = buildMsg();

        //Protobuf对象序列化成二进制字节数组

        byte[] data = message.toByteArray();

        //可以用于网络传输,保存到内存或外存

        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

        outputStream.write(data);

        data = outputStream.toByteArray();

        //二进制字节数组反序列化成Protobuf对象

        MsgProtos.Msg inMsg = MsgProtos.Msg.parseFrom(data);

        Logger.info("id:=" + inMsg.getId());

        Logger.info("content:=" + inMsg.getContent());

    }

//

}

这种方式首先通过调用Protobuf POJO对象的toByteArray()方法将POJO对象序列化成字节数组,然后通过调用Protobuf POJO类的parseFrombyte[] data)静态方法从字节数组中重新反序列化得到POJO新的实例。

这种方式类似于普通Java对象的序列化,适用于很多将ProtobufPOJO序列化到内存或者外存(如物理硬盘)的应用场景。

3. 序列化与反序列化的方式二

这种方式通过调用Protobuf生成的POJO对象的writeTo(OutputStream)方法将POJO对象的二进制字节写出到输出流。通过调用Protobuf生成的POJO对象的parseFrom(InputStream)方法,Protobuf从输入流中读取二进制码然后反序列化,得到POJO新的实例。具体的代码如下:

package cn.edu.bbc.computer.protocol;

//

public class ProtobufDemo {

    //

    //2种方式:序列化与反序列化

    @Test

    public void serAndDesr2() throws IOException {

        MsgProtos.Msg message = buildMsg();

        //序列化到二进制码流

        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

        message.writeTo(outputStream);

        ByteArrayInputStream inputStream =new ByteArrayInputStream(outputStream.toByteArray());

        //从二进码流反序列化成Protobuf对象

        MsgProtos.Msg inMsg = MsgProtos.Msg.parseFrom(inputStream);

        Logger.info("id:=" + inMsg.getId());

        Logger.info("content:=" + inMsg.getContent());

    }

}

以上代码调用POJO对象的writeTo(OutputStream)方法将自己的二进制字节写出到输出流,然后调用静态类的parseFrom(InputStream)方法,Protobuf从输入流中读取二进制码重新反序列化,得到POJO新的实例。

在阻塞式的二进制码流传输应用场景中,这种序列化和反序列化的方式是没有问题的。例如,可以将二进制码流写入阻塞式的Java OIO套接字或者输出到文件。但是,这种方式在异步操作的NIO应用场景中存在粘包/半包的问题。

4. 序列化与反序列化的方式三

这种方式通过调用Protobuf生成的POJO对象的writeDelimitedTo(OutputStream)方法在序列化的字节码之前添加了字节数组的长度。这一点类似于前面介绍的Head-Content协议,只不过Protobuf做了优化,长度的类型不是固定长度的int类型,而是可变长度varint32类型。具体实例如下:

package cn.edu.bbc.computer.protocol;

//

public class ProtobufDemo {

 //

    //3种方式:序列化与反序列化

    //带字节长度:[字节长度][字节数据],用于解决粘包/半包问题

    @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.Msg inMsg =MsgProtos.Msg.parseDelimitedFrom(inputStream);

        Logger.info("id:=" + inMsg.getId());

        Logger.info("content:=" + inMsg.getContent());

    }

}

反序列化时,调用Protobuf生成的POJO类的parseDelimitedFrom(InputStream)静态方法,从输入流中先读取varint32类型的长度值,然后根据长度值读取此消息的二进制字节,再反序列化得到POJO新的实例。

这种方式用于异步操作的NIO应用场景中,解决了粘包/半包的问题。

12.4 Protobuf编解码的实战案例

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

12.4.1 Netty内置的Protobuf基础编码器/解码器

Netty内置的基础Protobuf编码器、解码器为ProtobufEncoderProtobufDecoder。此外,还提供了一组简单的解决半包问题的编码器和解码器。

1. ProtobufEncoder编码器

翻开Netty源代码,我们发现ProtobufEncoder的实现逻辑非常简单,直接调用了Protobuf POJO实例的toByteArray()方法将自身编码成二进制字节,然后放入NettyByteBuf缓冲区中,接着会被发送到下一站编码器。其源码如下:

package io.netty.handler.codec.protobuf;

@Sharable

public class ProtobufEncoder extends MessageToMessageEncoder<MessageLiteOrBuilder> {

    @Override

    protected void encode(ChannelHandlerContext ctx,MessageLiteOrBuilder msg, List<Object> out)

            throws Exception {

        if (msg instanceof MessageLite) {

            out.add(Unpooled.wrappedBuffer(((MessageLite) msg).toByteArray()));

            return;

        }

        if (msg instanceof MessageLite.Builder) {

            out.add(Unpooled.wrappedBuffer(((MessageLite.Builder) msg).build().toByteArray()));

        }

    }

}

2. ProtobufDecoder解码器

ProtobufDecoderProtobufEncoder相互对应,只不过在使用的时候ProtobufDecoder解码器需要指定一个Protobuf POJO实例作为解码的参考原型(prototype),解码时会根据原型实例找到对应的Parser解析器,将二进制的字节解码为Protobuf POJO实例。

new ProtobufDecoder(MsgProtos.Msg.getDefaultInstance())

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

3. ProtobufVarint32LengthFieldPrepender长度编码器

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

4. ProtobufVarint32FrameDecoder长度解码器

ProtobufVarint32FrameDecoderProtobufVarint32LengthFieldPrepender相互对应,其作用是根据数据包中长度域(varint32类型)中的长度值解码一个足额的字节数组,然后将字节数组交给下一站的解码器ProtobufDecoder

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

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

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

12.4.2 Protobuf传输的服务端的实战案例

为了清晰地演示Protobuf传输,下面设计一个简单的客户端/服务器传输程序:服务器接收客户端的数据包,并解码成ProtobufPOJO;客户端将ProtobufPOJO编码成二进制数据包,再发送到服务端。

在服务端,Protobuf协议的解码过程如下:

首先,使用Netty内置的ProtobufVarint32FrameDecoder,根据varint32格式的可变长度值,从入站数据包中解码出二进制Protobuf字节码。然后,使用Netty内置的ProtobufDecoder解码器将字节码解码成Protobuf POJO对象。最后,自定义一个ProtobufBussinessDecoder解码器来处理Protobuf POJO对象。

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

package cn.edu.bbc.computer.protocol;

//

public class ProtoBufServer

{

    //省略成员属性、构造器

    public void runServer()

    {

        //创建反应器线程组

        EventLoopGroup bossLoopGroup = new NioEventLoopGroup(1);

        EventLoopGroup workerLoopGroup = new NioEventLoopGroup();

        try

        {

            //省略引导类的反应器线程、设置配置项

            //5 装配子通道流水线

            b.childHandler(new ChannelInitializer<SocketChannel>()

            {

                //有连接到达时会创建一个通道

                protected void initChannel(SocketChannel ch)

                {

                    //流水线管理子通道中的Handler业务处理器

                    //向子通道流水线添加3Handler业务处理器

                    ch.pipeline().addLast( new ProtobufVarint32FrameDecoder());

                    ch.pipeline().addLast( new ProtobufDecoder(MsgProtos.Msg.getDefaultInstance()));

                    ch.pipeline().addLast(new ProtobufBussinessDecoder());

                }

            });

            //省略端口绑定、服务监听、优雅关闭

        }

 

        //服务端的Protobuf业务处理器

        static class ProtobufBussinessDecoder

                     extends ChannelInboundHandlerAdapter

        {

            @Override

            public void channelRead(ChannelHandlerContext ctx, Object msg) {

                MsgProtos.Msg protoMsg = (MsgProtos.Msg) msg;

                //经过流水线的各个解码器取得了POJO实例

                Logger.info("收到一个Protobuf POJO =>>");

                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();

    }

}

12.4.3 Protobuf传输的客户端的实战案例

在客户端开始出站之前,需要提前构造好ProtobufPOJO对象,然后可以使用通道的write/writeAndFlush方法启动出站处理的流水线执行工作。

客户端的出站处理流程中,Protobuf协议的编码过程(见图12-5),如下:

1)使用Netty内置的ProtobufEncoderProtobuf POJO对象编码成二进制的字节数组。

2)使用Netty内置的ProtobufVarint32LengthFieldPrepender编码器,加上varint32格式的可变长度。Netty会将完成了编码后的Length+Content格式的二进制字节码发送到服务端。

12-5 Protobuf协议的编码过程

一个简单的Protobuf传输的客户端的案例代码如下:

package cn.edu.bbc.computer.protocol;

//

public class ProtoBufSendClient {

    static String content = "疯狂创客圈:高性能学习社群!";

    //省略成员属性、构造器

    public void runClient() {

        //创建反应器线程组

        EventLoopGroup workerLoopGroup = new NioEventLoopGroup();

        try {

                  //省略反应器组、IO通道、通道参数等设置

            //5 装配通道流水线

            b.handler(new ChannelInitializer<SocketChannel>() {

                //初始化客户端通道

                protected void initChannel(SocketChannel ch) {

                    //客户端流水线添加2Handler业务处理器

                    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();

    }

}

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

12.5 详解Protobuf协议语法

Protobuf中,通信协议的格式是通过proto文件定义的。一个proto文件有两大组成部分:头部声明、消息结构体的定义。头部声明部分主要包含了协议的版本、包名、特定语言的选项设置等;消息结构体部分可以定义一个或者多个消息结构体。

Java中,当用Protobuf编译器(如protoc3.17.3.exe)来编译.proto文件时,编译器将生成Java语言的POJO消息类和Builder构造者类。通过POJO消息类和Builder构造者,Java程序可以很容易地操作在proto文件中定义的消息和字段,包括获取、设置字段值,将消息序列化到一个输出流中(序列化),以及从一个输入流中解析消息(反序列化)。

12.5.1 proto文件的头部声明

前面介绍了一个简单的proto文件,其头部声明如下:

//[开始声明]

syntax = "proto3";

//定义Protobuf的包名称空间

packagecn.edu.bbc.computer.protocol;

//[结束声明]

 

//[开始 Java 选项配置]

option java_package = "cn.edu.bbc.computer.protocol";

option java_outer_classname = "MsgProtos";

//[结束 Java 选项配置]

对其中用到的主要配置选项做一下简单的介绍:

1. syntax版本号

对于一个proto文件而言,文件第一个非空、非注释的行必须注明Protobuf的语法版本,这里为syntax = "proto3",如果没有声明,则默认版本是"proto2"

2. package

Java语言类似,通过package指定包名,用来避免消息名字相冲突。如果两个消息的名称相同,但是package包名不同,那么它们是可以共同存在的。

通过package,还可以实现消息的引用。例如,假设第一个proto文件定义了一个Msg结构体,package包名如下:

package cn.edu.bbc.computer.protocol;

message Msg{ }

假设另一个proto文件也定义了一个相同名字的消息,package包名如下:

package com.other.netty.protocol;

message Msg{

//

cn.edu.bbc.computer.protocol.Msg crazyMsg = 1;

//

}

我们可以看到,在第二个proto文件中,可以用包名+消息名称(全限定名)来引用第一个proto文件中的Msg结构体,而且不同包中的结构体可以同名。这一点和Javapackage的使用方法是一样的。

另外,package指定包名后会对应到生成的消息POJO代码和Builder代码。在Java语言中,会以package指定的包名作为生成的POJO类的包名。

3. option配置选项

不是所有的option配置选项都会生效,option选项是否生效与proto文件使用的一些特定语言场景有关。在Java语言中,以“java_”打头的option选项会生效。

选项option java_package表示Protobuf编译器在生成Java POJO消息类时,生成在此选项所配置的Java包名下。如果没有该选项,则会以头部声明中的package作为Java包名。

选项option java_multiple_files表示在生成Java类时的打包方式,具体来说有以下两种方式:

方式1:一个消息对应一个独立的Java类。

方式2:所有的消息都作为内部类,打包到一个外部类中。

此选项的值默认为false,即方式2,表示使用外部类打包的方式。如果设置option java_multiple_files= true,则使用第一种方式生成Java类,则一个消息对应一个POJO Java类,多个消息结构体会对应到多个类。

选项option java_outer_classname表示Protobuf编译器在生成Java POJO消息类时,如果采用的是上面的方式2(全部POJO类都作为内部类打包在同一个外部类中),就以此选项所配置的值作为唯一外部类的类名。

12.5.2 Protobuf的消息结构体与消息字段

定义一个Protobuf消息结构体的关键字为message。一个消息结构体由一个或者多个消息字段组合而成。下面是一个简单的例子:

//[开始消息定义]

message Msg {

  uint32 id = 1;        //消息ID

  string content = 2;   //消息内容

}

//[结束消息定义]

Protobuf消息字段的格式为:

限定修饰符① | 数据类型② | 字段名称③ | = | 分配标识号④

对以上格式中的4个部分介绍如下:

1)消息字段的限定修饰符

repeated限定修饰符:表示该字段可以包含0~N个元素值,相当于Java中的List(列表数据类型)。

singular限定修饰符:表示该字段可以包含0~1个元素值。singular限定修饰符是默认的字段修饰符。

reserved限定修饰符:指定保留字段名称(Field Name)和分配标识号(Assigning Tags),用于将来的扩展。下面是一个简单的reserved限定修饰符使用的例子:

message MsgFoo{

    //

    reserved 12, 15, 9 to 11;   //预留将来使用的分配标识号(Assigning Tags),

    reserved "foo", "bar";      //预留将来使用的字段名(field  name

}

2)消息字段的数据类型

类似于Java中的数据类型,详见下一节。

3)消息字段的字段名称

字段名称的命名与Java语言的成员变量命名方式几乎是相同的。Protobuf建议字段的命名以下划线分隔(例如first_name),而不是驼峰式(例如firstName)。

4)消息字段的分配标识号

在消息定义中,每个字段都有唯一的一个数字标识符,可以理解为字段编码值,叫作分配标识号(Assigning Tags)。通过该值,通信双方才能互相识别对方的字段。当然,相同的编码值,它的限定修饰符和数据类型必须相同。分配标识号是用来在消息的二进制格式中识别各个字段的,一旦开始使用就不能够再改变。

分配标识号的取值范围为1~2324 294 967 296)。其中,编号[1, 15]之内的分配标识号,时间和空间效率都是最高的。因为[1, 15]之内的标识号在编码的时候只会占用一个字节,[16, 2047]之内的标识号要占用两个字节。所以,那些频繁出现的消息字段应该使用[1, 15]之内的标识号。切记:要为将来有可能添加的、频繁出现的字段预留一些标识号。另外,[1900, 2000]之内的标识号为Protobuf内部保留值,建议不要在自己的项目中使用。

标识号的特点是:一个消息结构体中的标识号是可以不连续的;在同一个消息结构体中,不同的字段不能使用相同的标识号。

12.5.3 Protobuf字段的数据类型

Protobuf定义了一套基本数据类型,具体如表12-1所示,但是这些数据类型几乎都可以对应到C++/Java等语言的基本数据类型。

12-1 Protobuf定义的基本数据类型

变长编码的类型(如int32)表示打包的字节并不是固定的,而是根据数据的大小或者长度来定的。例如int32,如果数值比较小,在0~127时,就使用一个字节打包。

定长编码(如fixed32)和变长编码(如int32)的区别是:fixed32的打包效率比int32的效率高,但是使用的空间一般比int32多。因此,定长编码时间效率高,变长编码空间效率高,可以根据项目的实际情况选择。一般情况下可以选择fixed32,但是遇到对传输效率要求比较苛刻的环境时,可以选择int32

12.5.4 proto文件的其他语法规范

1. 声明

在需要多个消息结构体时,proto文件可以像Java语言的类文件一样按照模块进行分开设计,所以一个项目可能有多个proto文件,一个文件在需要依赖其他proto文件时可以通过import导入。导入的操作,这和Javaimport操作大致相同。

2. 嵌套消息

proto文件支持嵌套消息。消息中既可以包含另一个消息实例作为其字段,也可以在消息中定义一个新的消息。

message Outer {       //Level 0

  message MiddleA{    //Level 1

    message Inner {   //Level 2

      int64 ival = 1;

      bool  booly = 2;

    }

  }

  message MiddleB{    //Level 1

    message Inner {   //Level 2

      int32 ival = 1;

      bool   booly = 2;

    }

  }

}

如果想在父消息类型的外部重复使用这些内部的消息类型,那么可以使用Parent.Type的形式来引用,例如:

message SomeOtherMessage {

    Outer.MiddleA.Inner ref = 1;

}

3. 枚举

枚举的定义和Java相同,但是有一些限制:枚举值必须是大于等于0的整数。另外,需要使用分号(;)分隔枚举变量,而不是Java语言中的逗号“,”

enum VoipProtocol

{

    H323 = 1;

    SIP  = 2;

    MGCP = 3;

    H248 = 4;

}


0 条 查看最新 评论

没有评论
暂时无法发表评论