(12) Explorer la pierre angulaire de la communication haute performance et du framework RPC : Json, ProtoBuf, détails de la sérialisation Hessian

introduction

À l'ère distribuée d'aujourd'hui, la technologie de communication réseau est une compétence que chaque technicien doit maîtriser, car quel que soit le type de technologie distribuée, elle est indissociable de mécanismes tels que le rythme cardiaque, l'élection, la perception des nœuds, la synchronisation des données, etc., et à sa racine , l'essence de ces technologies est l'interaction des données entre les réseaux. De ce fait, si vous souhaitez construire un composant/système distribué performant, vous devez réfléchir à une question : comment accélérer la transmission des données ?

Dans le même temps, dans de nombreux cas de développement de réseau, les paquets de données transmis ne sont pas seulement de simples données de base, mais des objets agrégés composés de diverses données, telles que :

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

Ce qui précède est un objet composé d'un entier + deux chaînes. Que dois-je faire si je veux le transmettre sur le réseau ? Je pense que la plupart des gens y penseront dès la première fois : implémenter Serializablel'interface, puis sérialiser l'objet en données binaires, et enfin le sortir sur le socket réseau .

Est-ce que ça va? La réponse est Yes, mais dans le contexte actuel, la plupart des programmes ont des exigences de performances de plus en plus élevées, et JDKcette méthode de sérialisation traditionnelle présente une série d'inconvénients qui sont critiqués. Existe-t-il un meilleur moyen de la remplacer ? Évoquons bien sûr le sujet de la « transmission sérialisée » dans cet article.

PS: Le livret "Lignes directrices pour la recherche d'emploi pour les techniciens" rédigé par moi a été complété, en partant du résumé technique, de la définition des attentes, de l'assaut technique, de l'optimisation du CV, de la préparation des entretiens, des techniques d'entretien, des compétences en négociation salariale, de l'examen des entretiens, des méthodes de sélection , Offernouveaux arrivants Onboarding, avancement, planification de carrière, management technique, augmentation de salaire, job hopping, compensation d'arbitrage, travail à temps partiel en marge... en même temps, nous vous avons préparé une réduction de 30 % Code : 3DoleNaE, les amis intéressés peuvent cliquer sur : s.juejin.cn/ds/USoa2R3/ pour plus de détails !

1. Sérialisation JDK traditionnelle

众所周知的一点,计算机程序必须运行在内存中,那么,在运行期间创建的一个个对象,必然也存活于内存当中,只不过计算机只认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相信一定再熟悉不过了,不过它只序列化数据,并不包含对象的状态,简单理解,你可以将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编译和优化,而后再进行真正的测试。当然,在咱们这个案例中,其实大差不差,性能排序也和上面的差不多,为此就不搞那么严谨啦~

Guess you like

Origin juejin.im/post/7264414580774928443