引言
如今这个分布式风靡的时代,网络通信技术,是每位技术人员必须掌握的技能,因为无论是哪种分布式技术,都离不开心跳、选举、节点感知、数据同步……等机制,而究其根本,这些技术的本质都是网络间的数据交互。正因如此,想要构建一个高性能的分布式组件/系统,不得不思考一个问题:怎么才能让数据传输的速度更快?
同时,在网络开发的很多情况下,传输的数据包并不仅是简单的基本数据,而是由多种数据组成的聚合对象,如:
public class ZhuZi {
// 序号
private Integer id;
// 名称
private String name;
// 等级
private String grade;
}
上述是一个整数型+两个字符串型组成的对象,想要将其放到网络上传输,该怎么办?相信大多数人第一时间会想到:实现Serializable
接口,接着将对象序列化成二进制数据,最后输出到网络套接字。
这种方式可以吗?答案是Yes
,不过在如今的背景下,大多数程序对性能的要求越来越高,而JDK
这种传统的序列化方式,存在一系列令人诟病的弊端,有没有更好的方式代替呢?当然有,本文一起来聊聊“序列化传输”这个话题。
一、传统的JDK序列化
众所周知的一点,计算机程序必须运行在内存中,那么,在运行期间创建的一个个对象,必然也存活于内存当中,只不过计算机只认0、1
,因此这些对象是以字节序列的形式存储。
而所谓的序列化,即是将对象在内存中的字节数据,完全复刻一份出来,以便于实现数据持久化与传输。相反,将复刻出的字节数据,重新载入到内存,并将其恢复成一个“可用”的对象,这个过程被称为“反序列化”。
复习了序列化、反序列化两个概念后,接着聊聊传统的JDK
序列化,也就是大家所熟知的Serializable
,我们在定义各种实体对象时,都会先实现Serializable
接口,如:
public class Xxx implements Serializable {
private static final long serialVersionUID = 1L;
}
先抛个问题:Serializable
有什么作用,为什么要按上面这么写?很多人的第一反应是:可以用于辅助实现Java
对象序列化,然后……对它的认知就止步于此了;这么写的原因,是因为看别人都这么写~
1.1、Serializable有什么作用?
带着上面的疑惑,我们跟进Serializable
源码,来尝试一探究竟:
public interface Serializable {
}
当大家看到上述源码,或许会惊呼:你小子怎么回事,就复制一个类头?!?
这不是我偷懒,而是这个接口本身就是空接口,嗯?既然它是空接口,那咱们也定义个名叫NiuBi
的空接口,然后让实体类实现,能不能实现序列化?答案是不行的,JDK
这么设计的原因,主要是将其作成一个“标识接口”,JVM
在做对象序列化时,发现你的类实现了Serializable
接口,才会“认可”这个类!
就好比纸币,仅是一张纸,它本身没有价值,但你可以拿着它买鸡腿,因为它是法定、大家认可的货币;可如果当你拿张
A4
纸去买,虽然也是纸,而且比纸币更大,只是老板偏偏就不卖你……
理解这个空接口的设计,接着再来看看serialVersionUID
这个常量,名字翻译过来是:序列化版本号,在没有特殊需求的情况下,通常定义为1L
,作用是:反序列化时校验使用。
当执行序列化操作时,定义的这个UID
会被一起写入字节序列;反序列化时,会首先校验这个UID
,如果目前类最新的UID
,与序列化时的不一致,则会抛出InvalidClassException
异常。
其次再来聊聊,为什么要显式定义成1L
?其实不写也行,因为Javac
编译时会默认生成,只是Javac
每次编译都会生成一个新的,这就很容易出现UID
不一致导致报错的现象(例如你原本把对象序列化存储了,然后去改了一下实体类,反序列化时就会报错)。
1.2、Serializable序列化实战
上面扯了一堆理论,下面简单实战一下,代码如下:
@Data
@AllArgsConstructor
public class ZhuZi implements Serializable {
private static final long serialVersionUID = 1L;
// 序号
private Integer id;
// 名称
private String name;
// 等级
private String grade;
}
public class SerializableDemo {
public static void main(String[] args) throws Exception {
// 1. 序列化
ZhuZi zhuZi = new ZhuZi(1, "黄金竹子", "A级");
byte[] serializeBytes = serialize(zhuZi);
System.out.println("JDK序列化后的字节数组长度:" + serializeBytes.length);
// 2. 反序列化
ZhuZi deserializeZhuZi = deserialize(serializeBytes);
System.out.println(deserializeZhuZi.toString());
}
/**
* 序列化方法
* @param zhuZi 需要序列化的对象
* @return 序列化后生成的字节流
*/
private static byte[] serialize(ZhuZi zhuZi) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(zhuZi);
return bos.toByteArray();
}
/**
* 反序列化方法
* @param bytes 字节序列(字节流)
* @return 实体类对象
*/
private static ZhuZi deserialize(byte[] bytes) throws Exception {
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis);
return (ZhuZi) ois.readObject();
}
}
为了减少代码量,这里用了Lombok
的注解,上面这个案例没啥好说的,相信大家曾经都学习过,这里说明几点:
- ①
Serializable
具备向下传递性,父类实现了该接口,子类默认实现序列化接口; - ②
Serializable
具备引用传递性,两个实现Serializable
接口的类产生引用时,序列化时会一起处理; - ③序列化前的对象,和反序列化得到的对象,如案例中的
zhuZi、deserializeZhuZi
,是两个状态完全相同的不同对象,相当于一次深拷贝; - ④
JDK
并不会序列化静态变量,因为序列化只会保存对象的状态,而静态成员属于类的“状态”; - ⑤序列化机制会打破单例模式,如果一个单例对象要序列化,一定要手写一次
readResolve()
方法; - ④
Serializable
默认会把所有字段序列化,网络传输时想要对字段脱敏,可以结合transient
关键字使用。
重点来看看最后一点,这里提到一个少见的Java
原生关键字,我们可以将ZhuZi
类的一个属性,加上transient
关键字做个实验,如下:
private transient String grade;
这时来看看前后两次的执行结果对比:
JDK序列化后的字节数组长度:224
ZhuZi(id=1, name=黄金竹子, grade=A级)
=============================================
JDK序列化后的字节数组长度:204
ZhuZi(id=1, name=黄金竹子, grade=null)
从结果可明显观察出,被transient
修饰的属性,并不会参与序列化,grade=null
,并且序列化后的字节长度也有明显变化。
PS:Serializable
还有个派生叫Externalizable
,它提供了writeExternal()、readExternal()
两个接口方法,可以实现特定需求的序列化操作,但本文的重点不是这个,因此不再展开,感兴趣的小伙伴自行研究(实现了Serializable
接口的类,也可以重写writeObject()、readObject()
方法来控制序列化流程)。
1.3、Serializable序列化机制的缺点
前面说到过一点,JDK
原生的序列化机制一直令人诟病,这是为什么?明明用着挺方便的呀!背后的原因有三。
一、首要原因是序列化的性能,在现时代的性能要求下,JDK
序列化机制的效率,对比其他主流的序列化技术,序列化效率差了几十上百倍,这点后续会细说,通过实验来佐证。
二、不支持多语言异构,如今分布式思想大行其道,规模越大的系统,所用的语言越多,针对不同的业务、领域,为了集百家之长,所用的语言也不同。而序列化技术的使用场景,大多数是网络传输,可JDK
序列化生成的码流(字节序列),只有Java
程序能识别,这时别的语言拿到数据后,也无法正常识别。
三、生成的码流体积太大,同理,对比其他序列化技术,JDK
生成的码流体积要大几倍到几十倍(因为会序列化对象的状态,如类的继承信息、引用信息等);而网络传输中,数据的体积越大,传输的效率越低,耗时越长,也许单次对比看不出性能区别,然而将次数放大到百万、千万规模,各方面的开销差异明显。
除开上面三条外,还有一个原因就是Java
反序列化安全漏洞,如前两年的FsatJson
框架,包括其他许多大名鼎鼎的开源框架,都爆过反序列化漏洞,但我们不对此做过多描述,重点来讲讲Java
反序列化漏洞的根本原因。
从前面的案例来说,体验下来会发现JDK
序列化机制使用起来十分便捷,只需实现接口、定义UID
即可,序列化对象的生成、对象引用链的处理、继承链的处理……,JVM
都会帮你自动完成。
其次说说反序列化动作,现在序列化对象有个属性a
,它的值可能要调用x()
方法赋值,由于JVM
不清楚序列化前的对象,其状态和数据到底是怎么形成的,所以无法通过正常的初始化流程创建对象,这时只能基于反射机制来实现,先创建一个空对象,再从码流解析到数据,并通过反射机制填充到空对象中,从而完成对象的反序列化。
从上面的描述可知,对象序列化的过程没问题,问题出在了反序列化过程上:
- ①由于反序列化依靠反射实现,跳过了正常初始化的各种安全检测,可以反射调用私有构造器来创建对象;
- ②
Java
反序列化默认将码流视为可信任的数据,缺乏验证与过滤机制,攻击者可伪造恶意的序列化数据; - ③反序列化时会调用
readObject()
方法,攻击者可以覆盖此方法,通过各种运行时类库,执行恶意代码。
正因如此,就造成了反序列化时,不怀好意者可通过漏洞进行攻击,这也是Java
反序列化坏名声的由来。
二、Json序列化
有接触过WSDL
开发的小伙伴应该有印象,在早期的分布式系统开发中,为了支持异构,一般会采用XML
的形式来做序列化,因为XML
是一种与语言无关的数据交换格式,在可读性、安全性、拓展性等各方面,都有着不错的表现,不过现在嘛,还在使用XML
作为数据交换格式的项目,几乎都是陈年的老系统,毕竟XML
用起来“很重”。
为什么说XML
很重呢?道理很简单,就算现在只想给对端传递一个数值,也需使用一个.xml
文件来描述,这样一来,对端接收时复杂度变高、数据包传输体积变大、解析/组装时耗费性能,在种种原因的影响下,XML
方式成为了“序列化传输史上的退休老前辈”。
XML
的退场,换来了Json
的登台,Json
吸纳了XML
所有的优良特性,而且在复杂度、体积上更轻量。如今的前后端分离模式下,Json
成为了标准的数据交换格式,甚至在有些RPC
场景下,也有着不错的地位:
无论是什么方向的开发者,对Json
相信一定再熟悉不过了,不过它只序列化数据,并不包含对象的状态,简单理解,你可以将Json
序列化理解成调用对象的toString()
方法,毕竟这种方式,本身就是将对象的数据,以字符串的形式保存下来。
2.1、FastJson框架实战
正因Json
格式特别流行,随之演变出诸多Json
库,通过使用这些库,可以让每位开发者更关注业务,屏蔽掉底层的序列化处理工作,Java
中常用的库有:Gson、Jackson、FastJson
,SpringMVC
框架中默认使用Jackson
来解析请求参数,不过我们这里以FastJson
举例,首先引入相关依赖:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.27</version>
</dependency>
依旧使用之前ZhuZi
这个类,来演示Json
与对象的互相转换(序列化与反序列化):
public static void main(String[] args) {
ZhuZi zhuZi = new ZhuZi(1,"黄金竹子", "A级");
// 1. Java对象转Json字符串
String json = JSONObject.toJSONString(zhuZi);
System.out.println(json);
System.out.println("Json序列化后的体积:" + json.getBytes().length);
// 2. Json字符串转Java对象
ZhuZi zhuZiJson = JSONObject.parseObject(json, ZhuZi.class);
System.out.println(zhuZiJson);
}
/* 输出结果:
* {"grade":"A级","id":1,"name":"黄金竹子"}
* Json序列化后的体积:45
* ZhuZi(id=1, name=黄金竹子, grade=A级)
*/
使用起来特别简单,重点来看看输出结果里的体积,比JDK
序列化后的体积,大概小了五倍左右~
2.2、FastJson进阶操作
FastJson
提供了一系列实体类的注解,从而辅助完成特殊需求的Json
序列化需求,下面简单介绍一些常用的,首先说说最常用的@JSONField
,该注解有几个常用参数:
name
:指定字段的名称,相当于给实体类的属性起别名;serialize
:当前字段是否参与序列化,默认为true
,表示参与;deserialize
:当前字段是否参与反序列化,默认为true
,表示参与;serializerFeature
:定制序列化后的字符格式,可以通过SerializerFeature
枚举给值;format
:对日期、货币等类型的字段生效,用于格式化数据;ordinal
:指定序列化的字段顺序;defaultValue
:当字段为空时,序列化时的默认值;numericToString
:序列化时将数值转为字符显示,可以防止精度丢失。
除开上述这个注解外,还有其他一些注解,如下:
@JSONCreator
:指定反序列化时使用的构造器,默认使用无参构造器;@JSONPOJOBuilder
:作用同上,但比@JSONCreator
更加灵活;@JSONScannerAware
:可以指定一个自定义的JSONReader
,用于反序列化;@JSONType
:可以通过ignores、includes
参数指定参与、不参与序列化的字段;- ……
OK,上述这些大家可以自行实验,接着来讲两个常用的场景,如何处理集合类型以及多泛型对象?
先来看看集合类型的Json
序列化,如下:
private static void testList(){
List<ZhuZi> zhuZis = Arrays.asList(
new ZhuZi(1,"黄金竹子","A级"),
new ZhuZi(2, "白玉竹子", "S级"));
String json = JSONArray.toJSONString(zhuZis);
System.out.println(json);
List<ZhuZi> zhuZisJson = JSONArray.parseArray(json, ZhuZi.class);
System.out.println(zhuZisJson);
}
这里可以直接用JSONArray
类来转换,也可以用JSONObject
类,反序列化时调用parseArray()
方法即可。
接着来看看多泛型对象的处理,以Map
为例,Map
集合需要传入两个泛型,这时该如何反序列化呢?如下:
private static void testMap(){
Map<String, ZhuZi> zhuZiMap = new HashMap<>();
zhuZiMap.put("1", new ZhuZi(1,"黄金竹子","A级"));
zhuZiMap.put("2", new ZhuZi(2, "白玉竹子", "S级"));
String json = JSONObject.toJSONString(zhuZiMap);
System.out.println(json);
HashMap<String, ZhuZi> zhuZiMapJson = JSONObject
.parseObject(json, new TypeReference<HashMap<String, ZhuZi>>() {
});
System.out.println(zhuZiMapJson);
}
序列化操作与之前没区别,重点是反序列化时,由于泛型有两个,就无法通过前面那种方式指定,如果直接传入HashMap.class
,会被转换为HashMap<Object,Object>
类型,想要正确的完成转换,则需要传入一个TypeReference
对象,以此精准的告知反序列化类型。
三、Protocol Buffer序列化
上一阶段讲了应用最广泛的Json
序列化方案,不过它仅适用于HTTP
的场景中,如前后端数据交互、第三方接口传参等,在分布式系统内部的RPC
场景,又或游戏数据、IM
消息等场景中,再使用Json
作为数据交换格式,就显得不那么“合适”。
不合适的原因有好几个,首先由于Json
是字符串数据,转换时需要先转流,再从流转字符串,效率方面对比二进制序列化技术,性能上有所差异;其次,Json
字符串几乎是以明文形式放在网络上传输,存在数据泄露的风险;再者,对比二进制序列化技术,Json
生成的字符串数据,体积依旧较大,传输会带来额外的损耗。
综上所述,在高并发场景中,对码流的性能、体积要求较高的情况下,Json
并不能成为最优解,而基于二进制的Protocol Buffer
序列化技术,则是一个不错的选择!
ProtoBuf
是谷歌推出的一种序列化技术,相较于JDK
传统的Serializable
序列化,从各方面都拥有绝对的碾压优势:
- ①支持异构,序列化生成的数据多语言之间可识别;
- ②使用二进制编码,序列化、反序列化时的性能更快;
- ③使用紧凑的数据结构,序列化后的码流体积更小,网络传输效率更佳。
但这种序列化方式也不全是好处,因为序列化生成的是二进制数据,并不像Json
那样可以直接阅读。同时,想要使用ProtoBuf
技术,并不像JDK、Json
序列化那么便捷,甚至可以说很麻烦,而且ProtoBuf
生成的Java
代码,与以往的传统代码对比,风格完全不一致,所以对原有代码侵入性较高,使用时的改造成本较大。
3.1、ProtoBuf环境搭建
ProtoBuf
与XML
一样,也具有独立的语法,想要使用它,必须先遵循语法编写一个.proto
文件,接着再编译成相应语言的实体类,编译要用到官方的编译器,可点击:GitHub地址,根据电脑系统、开发语言下载。
PS:后续会基于下载的编译器来生成代码,为此编译器和
Maven
依赖版本要匹配,否则生成的代码会出问题,我这里下的3.20.0
版本。
下载号编译器后,无需安装解压即用,不过要记得配置一下Path
系统变量:
配置完成后,可以在cmd
命令行输入:protoc --version
命令来检测是否安装成功。
为了方便编写代码,这里先在IDEA
中装下插件,先在插件商城中搜索protobuf
关键字,接着会出现:
- ①
Protocol Buffers
:提供proto
语法支持、代码补全、语法高亮等功能; - ②
Protobuf Highlight
:提供proto
语法中,关键字高亮显示功能; - ③
Protobuf Generator
:提供.proto
文件快速生成.java
文件功能; - ④
Protobuf Support
:类似于前几个插件的集成版(有些IDEA
插件商城中搜不到); - ⑤
GenProtobuf
:提供基于.proto
文件生成.java
代码生成的功能。
咱们这里只需安装④、⑥|③两个插件即可,前者提供语法高亮、代码补全等功能,后者提供代码生成功能。
如果新建.proto
文件无法识别,可以按照下面步骤,在File Types
对应的选项里,新增加*.proto
的文件匹配:
3.2、ProtoBuf快速入门
搭建好环境后,想要基于ProtoBuf
实现数据传输,通常的流程为:
- ①先编写
.proto
文件,定义好要传输的数据结构; - ②手动编译
.proto
文件,生成.Java
文件; - ③基于生成的
Java
类,填充需要传输的数据; - ④将
Java
对象序列化成二进制数据进行传输。
先来完成第一步,创建一个名为zhuzi.proto
的文件,如下:
// 指定使用 proto3 版本的语法(默认使用proto2)
syntax = "proto3";
// 当前 .proto文件所在的目录(类似于Java文件的包路径)
package com.zhuzi.serialize.protobuf.proto;
// .proto文件生成 .java文件时的目录
option java_package = "com.zhuzi.game.protocol.protobuf";
// 生成的Java文件类名
option java_outer_classname = "ZhuZi";
// 是否生成equals和hash方法
option java_generate_equals_and_hash = true;
// 是否将自动生成的Java代码,划分为成多个文件
option java_multiple_files = false;
// proto对象的定义(类似于Java的class定义)
message ZhuZi {
// 自身属性的定义(后面的数字,代表属性的顺序)
int32 id = 1;
string name = 2;
string grade = 3;
}
上述便是ProtoBuf
的语法,每行代码的含义可参考给出的注释,接着可以基于此生成Java
代码了,可以去到编译器解压后的bin
目录中,打开终端执行下述命令:
protoc --experimental_allow_proto3_optional --java_out=目标目录 xx.proto文件目录
也可以选择引入protobuf-maven-plugin
这个Maven
插件来生成代码,不过我们选择使用IDEA
插件来生成,先配置下代码生成器:
配置完成后,然后可以在.proto
文件上右键,会发现多出了下述两个选项:
quick gen protobuf here
:在.proto
文件所在的目录下生成.java
文件;quick gen protobuf rules
:根据.proto
中配置的java_package
生成.java
文件。
这里我们点击后者,然后就能看到指定的目录下,生成了对应的Java
代码,但当打开对应的代码时,会出现一堆爆红,主要是由于缺少依赖,加一下:
<!-- 要和protoc编译器的版本保持一致 -->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.23.2</version>
</dependency>
接着来写个测试用例,简单感受一下ProtoBuf
的魅力:
public static void main(String[] args) throws InvalidProtocolBufferException {
// 1.通过Builder创建实例化对象
ZhuZiProto.ZhuZi.Builder zhuZi = ZhuZiProto.ZhuZi.newBuilder();
// 2.为实例化对象填充数据
zhuZi.setId(1).setName("黄金竹子").setGrade("A级");
// 3.3.构建实例化对象
ZhuZiProto.ZhuZi build = zhuZi.build();
// 4.将对象序列化,并将序列化后的数据转换为字节数组
byte[] zhuZiBytes = build.toByteArray();
System.out.println("protoBuf码流长度:" + zhuZiBytes.length);
// 5.将字节数组反序列化
ZhuZiProto.ZhuZi zhuZiProtoBuf = ZhuZiProto.ZhuZi.parseFrom(zhuZiBytes);
System.out.println(zhuZiProtoBuf.getName());
}
/* 输出结果:
* protoBuf码流长度:22
* 黄金竹子
*/
重点注意看序列化后的码流长度,相较于JDK
序列化机制而言,同样的结构,体积足足小了10+
倍,尤其是当对象数据达到MB
级别时,这个差异会更大。正因如此,通过ProtoBuf
作为网络传输的序列化技术,网络带宽消耗能节省10+
倍,并且传输的耗时更小,综合性能更强!
3.3、ProtoBuf语法详解
看完上一阶段的内容,大家对protobuf
语法只有模模糊糊的印象,而在实际开发场景中,可能需要编织出各种结构来满足业务,为此,下面来一点点讲述ProtoBuf
的语法。
3.3.1、ProtoBuf数据类型
ProtoBuf
内部拥有的数据类型众多,什么场景下使用什么类型最合适、最省空间,这个每位使用者要考虑的问题,为了能对ProtoBuf
有进一步了解,咱们先来聊聊其数据类型。
在前面的案例中能看出:ProtoBuf
的结构体,通过message
来定义,而结构体的字段(标量)定义如下:
int32 id = 1;
和Java
定义变量的语法很相似,但=
号后面的数字,并意味着赋值,而是指定排序值/标识号(order
),取值范围是1~536870911
,其中1~15
在序列化时,只占用一个字节;16~2047
只占用两字节……,总之ProtoBuf
会根据具体的值,去开辟对应大小的空间来存储,如2
这个数字,用一个字节能存下,就绝不会用两个字节。
注意:定义字段时,标识号不可重复,且不能使用
19000~19999
范围内的值,因为这是ProtoBuf
的预留号。
其次来看最前面的类型,这里是int32
,写法和C/C++
等语言类似,ProtoBuf
的数据类型有很多,这里先介绍一些常用的:
int32
:对应Java
里的int
整数型,默认值0
;int64
:对应Java
里的long
长整型,默认值0
;float
:对应Java
里的float
浮点型,默认值0
;double
:对应Java
里的double
双精度浮点型,默认值0
;bool
:对应Java
里的boolean
布尔型,默认值false
;string
:对应Java
里的String
字符串,默认值空字符串;bytes
:对应Java
里的ByteString
类型;
上述一些基本类型和Java
的没太大区别,不过ProtoBuf
的数值类型,存在有符号、无符号之分,如:
sint32
:有符号的整数类型,编码负数时比int32
高效;unit32
:无符号的整数类型,编码正数时比int32
高效;sint64、unit64
即代表着有符号、无符号的长整型;
同时,上述提到的所有类型,都会采用变长的方式存储,即根据实际数据长度来计算存储空间,proto
中也有定长的数值类型,如fixed32、sfixed32、fixed64……
。当然,Java
不区分有/无符号、定/变长,所以会统统转换为int、long
类型。
而Java
中的引用类型,在proto
中类似,同样支持枚举、内部类等语法,如:
syntax = "proto3";
package com.zhuzi.serialize.protobuf.proto;
import "zhuzi.proto";
message XiongMao {
uint32 id = 1;
string name = 2;
// 使用外部的ZhuZi作为字段
ZhuZi food = 4;
// 使用同级的枚举Color作为字段
Color color = 5;
// 使用内部的Detail作为字段
Detail detail = 6;
// 在内部定义新的结构体
message Detail {
double weight = 1;
double height = 2;
string sex = 3;
uint32 age = 4;
}
}
// 在同级定义枚举
enum Color {
// 枚举的第一个值,其标识必须为0,会作为默认值
BLACK_WHITE = 0;
}
在proto
文件中,内部结构可以定义在message
同级,也可以定义在内部;同时也可以靠import
关键字导入外部的.proto
文件,不过导入外部文件时,请确保路径正确,如果路径正确依旧爆红,记得修改下IDEA
配置,如下:
除开可以将自定义的结构体、枚举作为字段类型外,官方也定义了几个结构体,以此支持某些特定的业务场景,例如时间戳:
import public "google/protobuf/timestamp.proto";
message xxx {
google.protobuf.Timestamp Time = 1;
}
大家感兴趣,可以去翻翻依赖包的google/protobuf/
这个源码目录~
3.3.2、ProtoBuf复杂类型
除开前面聊到的单值字段外,假设需要一个List、Map
集合类型的字段怎么办?想要搞明白这点,则需要先清楚proto
中定义字段的规则,其中有三个关键字,如下:
message xxx {
// 等价于Java的:int x = 666;(proto3中不支持required)
required int32 x = 1 [default = 666];
// 等价于Java的:int y;
optional int32 y = 2;
// 等价于Java的:List<String> z;
repeated string z = 3;
}
required
:必传字段,必须赋值,只能赋单值,多次赋值会覆盖上一次的值;optional
:可选字段,可以不赋值,同样为单值,赋值规则同上;repeated
:重复字段,同样可选,多次赋值时,会以List
集合形式保存;
上述便是三个关键字的含义,不过在proto3
中,所有字段默认为optional
,并且不支持required
关键字,因此也无法使用default
关键字给定默认值,毕竟所有字段都是可选的,意味着不设置该字段的值,在序列化时就不会包含它,所以也没有必要为字段指定默认值。
关于Map
集合的定义,和Java
类似,声明K、V
类型即可,只不过Key
的类型只能为字符串或数值型:
// 等价于Java的:Map<String, ZhuZi> map;
map<string, ZhuZi> map = 4;
到这里,数据类型的话题先告一段落,更多的类型可参考《官方文档》(需梯子才能访问)。
3.3.3、ProtoBuf高级特性
聊完了数据类型这个话题,接着来聊聊ProtoBuf
的高级特性,先说说oneof
关键字,如下:
message xxx {
oneof x {
unit32 id = 1;
uint32 serial_number = 2;
}
string name = 3;
}
这个结构体中,定义了三个字段,不过id、serial_number
是被oneof
包裹着,这代表同时只能使用其中一个字段,设置其中任何一个值时,都会自动清除其他成员的值。
回想前面讲到的数据类型,其实大家不难发现,相较于Java
这种强大的语言,proto
的语法支持上,很多复杂的类型仍然不支持,例如Map<String,List<Map<Object,Object>>>
这种类型,在Java
中可以轻松构造出来,而ProtoBuf
则不行,正因如此,为了兼容各种复杂结构,谷歌设计了一个“万能”类型,如下:
syntax = "proto3";
import "google/protobuf/any.proto";
message MyMessage {
int32 id = 1;
google.protobuf.Any data = 2;
}
// ============以下为源码中的定义============
message Any {
string type_url = 1;
bytes value = 2;
}
从官方的定义来看,在其中使用了bytes
来存储value
值,也就是说,无论任何类型的数据,都可以被塞进Any
类型的字段中,这个类型相当于Java
的Object
类型,从而保证ProtoBuf
能兼容各种复杂的数据类型。
同时,假设你编写的一个.proto
文件,需要共享给其他人使用,这时你不想别人使用某些标识号、字段名怎么办?可以通过reserved
来声明保留内容,如下:
syntax = "proto3";
message MyMessage {
// 表示保留2、4、5、6、7、8、9、10这些标识号
reserved 2,4,5 to 10;
// 表示保留id、name这两个字段名称
reserved "id","name";
// 会提示:Field name 'id' is reserved
int32 id = 3;
// 会提示:Field 'name' uses reserved number 4
string name = 4;
}
当定义的字段试图使用你保留的标识号、字段名时,在编译时就会出现对应的错误信息。
最后再来说说继承,继承是OOP
里一种重要的思想,当某些字段会在多个结构体中重复出现时,这时最好的做法是抽象出一个“父亲”,而需要使用这些字段的结构体,直接继承即可,那在ProtoBuf
中如何实现继承呢?如下:
// 定义父结构体
message base {
int32 code = 1;
string status = 2;
}
message zz {
// 继承父结构体
extend base {
// 在父结构体的基础上继续拓展字段
int32 id = 1;
}
}
上述便是ProtoBuf
中继承的写法,不过很可惜,在proto3
中,已经不再支持这种语法,只能依靠结构嵌套来实现继承的效果,如:
// 定义父结构体
message base {
int32 code = 1;
string status = 2;
}
message zz {
// 通过嵌套base实现继承效果
base base = 1;
int32 id = 2;
}
OK,关于ProtoBuf
的语法暂告一段落,掌握上面这些基本够用了,下面来个例子把所有语法过一遍。
3.3.4、ProtoBuf综合案例
先上个.proto
文件的定义:
syntax = "proto3";
package com.zhuzi.serialize.protobuf.proto;
option java_package = "com.zhuzi.serialize.protobuf.entity";
option java_outer_classname = "PandaProto";
option java_multiple_files = false;
import "zhuzi.proto";
import "google/protobuf/any.proto";
message base {
uint32 id = 1;
string name = 2;
}
enum sex {
MALE = 0;
FEMALE = 1;
}
message panda {
base base = 1;
int32 age = 2;
sex sex = 3;
repeated string hobby = 4;
map<uint32, ZhuZi> foodMap = 5;
google.protobuf.Any expand = 6;
}
接着再上一下测试用例,如下:
public static void main(String[] args) throws InvalidProtocolBufferException {
// 1. 创建基础对象
PandaProto.base.Builder base = PandaProto.base.newBuilder();
base.setId(1).setName("花花");
// 2. 创建panda实例对象
PandaProto.panda.Builder panda = PandaProto.panda.newBuilder();
panda.setBase(base).setAge(8).setSex(PandaProto.sex.FEMALE);
// 3. 为集合字段赋值
List<String> hobbyList = Arrays.asList("打架", "喝奶", "睡觉");
panda.addAllHobby(hobbyList);
// 4. 为map字段赋值
ZhuZiProto.ZhuZi.Builder zhuZi1 = ZhuZiProto.ZhuZi.newBuilder();
ZhuZiProto.ZhuZi.Builder zhuZi2 = ZhuZiProto.ZhuZi.newBuilder();
zhuZi1.setId(1).setName("极品竹笋尖").setGrade("S级");
zhuZi2.setId(2).setName("帝王绿毛竹").setGrade("S级");
panda.putFoodMap(1, zhuZi1.build());
panda.putFoodMap(2, zhuZi2.build());
// 5. 为万能字段赋值(必须传入ProtoBuf生成的消息对象)
Any object = Any.pack(zhuZi1.build());
panda.setExpand(object);
// 6. 序列化,并转换为字节数组
byte[] pandaBytes = panda.build().toByteArray();
System.out.println("码流体积为:" + pandaBytes.length);
// 7. 反序列化
PandaProto.panda newPanda = PandaProto.panda.parseFrom(pandaBytes);
System.out.println(printToUnicodeString(newPanda));
// 8. 将万能字段中的对象解析出来
ZhuZiProto.ZhuZi zhuZi = newPanda.getExpand().unpack(ZhuZiProto.ZhuZi.class);
System.out.println(printToUnicodeString(zhuZi));
}
// ProtoBuf反序列化时,默认会将中文转为Unicode编码,该方法可以转换回中文
public static String printToUnicodeString(MessageOrBuilder message) {
return TextFormat.printer().escapingNonAscii(false)
.printToString(message);
}
结果大家可以自行运行一下,这里就不贴了;同时,如果需要将proto
对象转换为Json
,这里需要引入一下对应的依赖:
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>3.23.2</version>
</dependency>
接着可以通过下述方法完成Java
与对象之间的互转:
// 对象转json字符串
String json = JsonFormat.printer().print(要转换的对象);
// json字符串转对象
XXX 接收对象 = XXX.newBuilder();
JsonFormat.parser().ignoringUnknownFields().merge(json字符串, 接收对象);
好了,到这里ProtoBuf
的内容就此打住,如果后续开发过程中,需要使用到文中未曾提及的内容,可以自行去参考官方文档~
四、Hessian序列化
除开前面提到的几种序列化方案外,相信看过Dubbo
框架源码的小伙伴,一定还知道一种方案,即基于二进制实现Hessian
,这是Dubbo
中默认的序列化机制,用于服务提供者与消费者之间进行数据传输,这里咱们也简单过一下。
Hessian
和JDK
原生的序列化技术,兼容度很高,相较于使用ProtoBuf
而言,成本要低许多,首先导入一下依赖包:
<dependency>
<groupId>com.caucho</groupId>
<artifactId>hessian</artifactId>
<version>4.0.65</version>
</dependency>
接着依旧基于最开始的ZhuZi
实体类,来写一下测试代码:
public class HessianDemo {
public static void main(String[] args) throws Exception {
// 1. 序列化
ZhuZi zhuZi = new ZhuZi(1,"黄金竹子", "A级");
byte[] serializeBytes = serialize(zhuZi);
System.out.println("Hessian序列化后字节数组长度:" + serializeBytes.length);
// 2. 反序列化
ZhuZi deserializeZhuZi = deserialize(serializeBytes);
System.out.println(deserializeZhuZi.toString());
}
/**
* 序列化方法
* @param zhuZi 需要序列化的对象
* @return 序列化后生成的字节流
*/
private static byte[] serialize(ZhuZi zhuZi) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output h2o = new Hessian2Output(bos);
h2o.writeObject(zhuZi);
h2o.close();
return bos.toByteArray();
}
/**
* 反序列化方法
* @param bytes 字节序列(字节流)
* @return 实体类对象
*/
private static ZhuZi deserialize(byte[] bytes) throws Exception {
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
Hessian2Input h2i = new Hessian2Input(bis);
ZhuZi zhuZi = (ZhuZi) h2i.readObject();
h2i.close();
return zhuZi;
}
}
上述代码对比最开始的JDK
序列化方案,几乎一模一样,只是将输出/输入流对象,从ObjectOutputStream、ObjectInputStream
换成了Hessian2Output、Hessian2Input
,此时来看结果对比,如下:
JDK序列化后的字节数组长度:224
ZhuZi(id=1, name=黄金竹子, grade=A级)
=============================================
Hessian序列化后字节数组长度:70
ZhuZi(id=1, name=黄金竹子, grade=A级)
是不是特别惊讶?其余任何地方没有改变,仅用Hessian2
替换掉JDK
原生的IO
流对象,结果码流体积竟然缩小了3.2
倍!并且还完全保留了JDK
序列化技术的特性,还支持多语言异构……,所以,这也是Dubbo
使用Hessian2
作为默认序列化技术的原因,不过Dubbo
使用的是定制版,依赖如下:
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-serialization-hessian2</artifactId>
<version>3.2.0-beta.6</version>
</dependency>
感兴趣的可以去看看DecodeableRpcInvocation#decode()、encode()
这个两个方法,其中涉及到数据的编解码工作,默认采用Hessian2
序列化技术~
五、序列化技术总结
在前面详细讲解了四种序列化技术,这也是Java
中较为常用的四种,除此之外,序列化这个领域,也有许多其他方案,例如Avro、kryo、MsgPack(MessagePack)、Thrift、Marshalling……
,但咱们就不一一说明了,毕竟后面这些用的也比较少,主要掌握Json、ProtoBuf
这两种即可,最后来对比一下提到的四种序列化技术:
测试的基准对象:ZhuZi(id=1, name=黄金竹子, grade=A级)
=======================================================
JDK序列化后的字节数组长度:224
Json序列化后字节数组长度:45
ProtoBuf序列化后字节数组长度:22
Hessian2序列化后字节数组长度:70
这是前面每个案例得出的数据,从体积大小上来看,ProtoBuf
最佳,Json
其次,Hessian2
第三,JDK
最后,接着来看看编解码效率,代码如下:
public static void main(String[] args) throws Exception {
// 提前创建测试要用的实例化对象(结构完全相同)
ZhuZi zhuZi = new ZhuZi(1,"黄金竹子", "A级");
ZhuZiProto.ZhuZi.Builder zhuZiProto = ZhuZiProto.ZhuZi.newBuilder();
zhuZiProto.setId(1).setName("黄金竹子").setGrade("A级");
// 调用对应的编/解码效率测试方法
testJDK(zhuZi);
testJson(zhuZi);
testHessian(zhuZi);
testProtoBuf(zhuZiProto);
}
private static void testJDK(ZhuZi zhuZi) throws Exception {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(zhuZi);
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
ZhuZi newZhuzi = (ZhuZi) ois.readObject();
}
long endTime = System.currentTimeMillis();
long elapsedTime = endTime - startTime;
System.out.println("JDK十万次编/解码耗时: " + elapsedTime + "ms");
}
private static void testJson(ZhuZi zhuZi) {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
String json = JSONObject.toJSONString(zhuZi);
ZhuZi newZhuzi = JSONObject.parseObject(json, ZhuZi.class);
}
long endTime = System.currentTimeMillis();
long elapsedTime = endTime - startTime;
System.out.println("Json十万次编/解码耗时: " + elapsedTime + "ms");
}
private static void testProtoBuf(ZhuZiProto.ZhuZi.Builder zhuZi) throws Exception {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
ZhuZiProto.ZhuZi build = zhuZi.build();
ZhuZiProto.ZhuZi newZhuzi = ZhuZiProto.ZhuZi.parseFrom(build.toByteArray());
}
long endTime = System.currentTimeMillis();
long elapsedTime = endTime - startTime;
System.out.println("ProtoBuf十万次编/解码耗时: " + elapsedTime + "ms");
}
private static void testHessian(ZhuZi zhuZi) throws Exception {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output h2o = new Hessian2Output(bos);
h2o.writeObject(zhuZi);
h2o.flush();
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
Hessian2Input h2i = new Hessian2Input(bis);
ZhuZi newZhuzi = (ZhuZi) h2i.readObject();
}
long endTime = System.currentTimeMillis();
long elapsedTime = endTime - startTime;
System.out.println("Hessian十万次编/解码耗时: " + elapsedTime + "ms");
}
上面为每种序列化方式编写了对应的测试方法,通过完全相同的结构、完全相同的逻辑,分别对每种序列化技术做10W
次编/解码,最终结果如下:
JDK十万次编/解码耗时: 758ms
Json十万次编/解码耗时: 174ms
Hessian十万次编/解码耗时: 290ms
ProtoBuf十万次编/解码耗时: 42ms
从测试结果来看,依旧是之前的顺序:ProtoBuf
最佳,Json
其次,Hessian2
第三,JDK
最后。为此,从这两个实验对比中,大家就能明显感知到,现有的主流序列化方案中,ProtoBuf
才是高性能的代表。当然,虽然ProtoBuf
编/解码效率高、码流体积小、传输性能高,但是使用的成本也会更高,大家在做抉择时,也要视具体情况而定。
PS:上述编/解码效率测试,可能存在一定的不公平性,因为越前面调用的方法越吃亏,没有搭建基准测试的环境,真正公平的环境是:确保测试代码获得足够预热的前提下,让测试代码得到充分的
JIT
编译和优化,而后再进行真正的测试。当然,在咱们这个案例中,其实大差不差,性能排序也和上面的差不多,为此就不搞那么严谨啦~