(十二)探索高性能通信与RPC框架基石:Json、ProtoBuf、Hessian序列化详解

引言

如今这个分布式风靡的时代,网络通信技术,是每位技术人员必须掌握的技能,因为无论是哪种分布式技术,都离不开心跳、选举、节点感知、数据同步……等机制,而究其根本,这些技术的本质都是网络间的数据交互。正因如此,想要构建一个高性能的分布式组件/系统,不得不思考一个问题:怎么才能让数据传输的速度更快?

同时,在网络开发的很多情况下,传输的数据包并不仅是简单的基本数据,而是由多种数据组成的聚合对象,如:

public class ZhuZi {
    // 序号
    private Integer id;
    // 名称
    private String name;
    // 等级
    private String grade;
}

上述是一个整数型+两个字符串型组成的对象,想要将其放到网络上传输,该怎么办?相信大多数人第一时间会想到:实现Serializable接口,接着将对象序列化成二进制数据,最后输出到网络套接字

这种方式可以吗?答案是Yes,不过在如今的背景下,大多数程序对性能的要求越来越高,而JDK这种传统的序列化方式,存在一系列令人诟病的弊端,有没有更好的方式代替呢?当然有,本文一起来聊聊“序列化传输”这个话题。

PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选Offer方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职……,为大家打造了一套“从求职到跳槽”的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE,感兴趣的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!

一、传统的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 {}

当大家看到上述源码,或许会惊呼:你小子怎么回事,就复制一个类头?!?

扫描二维码关注公众号,回复: 16097582 查看本文章

这不是我偷懒,而是这个接口本身就是空接口,嗯?既然它是空接口,那咱们也定义个名叫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相信一定再熟悉不过了,不过它只序列化数据,并不包含对象的状态,简单理解,你可以将Json序列化理解成调用对象的toString()方法,毕竟这种方式,本身就是将对象的数据,以字符串的形式保存下来

2.1、FastJson框架实战

正因Json格式特别流行,随之演变出诸多Json库,通过使用这些库,可以让每位开发者更关注业务,屏蔽掉底层的序列化处理工作,Java中常用的库有:Gson、Jackson、FastJsonSpringMVC框架中默认使用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

ProtoBuf是谷歌推出的一种序列化技术,相较于JDK传统的Serializable序列化,从各方面都拥有绝对的碾压优势:

  • ①支持异构,序列化生成的数据多语言之间可识别;
  • ②使用二进制编码,序列化、反序列化时的性能更快;
  • ③使用紧凑的数据结构,序列化后的码流体积更小,网络传输效率更佳。

但这种序列化方式也不全是好处,因为序列化生成的是二进制数据,并不像Json那样可以直接阅读。同时,想要使用ProtoBuf技术,并不像JDK、Json序列化那么便捷,甚至可以说很麻烦,而且ProtoBuf生成的Java代码,与以往的传统代码对比,风格完全不一致,所以对原有代码侵入性较高,使用时的改造成本较大。

3.1、ProtoBuf环境搭建

ProtoBufXML一样,也具有独立的语法,想要使用它,必须先遵循语法编写一个.proto文件,接着再编译成相应语言的实体类,编译要用到官方的编译器,可点击:GitHub地址,根据电脑系统、开发语言下载。

PS:后续会基于下载的编译器来生成代码,为此编译器和Maven依赖版本要匹配,否则生成的代码会出问题,我这里下的3.20.0版本。

下载号编译器后,无需安装解压即用,不过要记得配置一下Path系统变量:

配置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配置,如下:

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类型的字段中,这个类型相当于JavaObject类型,从而保证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中默认的序列化机制,用于服务提供者与消费者之间进行数据传输,这里咱们也简单过一下。

HessianJDK原生的序列化技术,兼容度很高,相较于使用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编译和优化,而后再进行真正的测试。当然,在咱们这个案例中,其实大差不差,性能排序也和上面的差不多,为此就不搞那么严谨啦~

猜你喜欢

转载自juejin.im/post/7264414580774928443
今日推荐