Java序列化与协议缓冲区
#java #protocolbuffers #serialization

Clarity AI,我们生成了一批数据,我们的应用程序必须加载和处理以向客户展示许多公司的社会影响信息。通过信息量,它不是大数据,但是足以在与在线用户的流程中有效阅读和加载它是一个问题。

该信息可以存储在数据库中或作为文件中,以标准格式序列化,并与您的数据工程团队一致。取决于您的信息和要求,它可以像CSV,XML或JSON一样简单,也可以像ParquetAvroORCArrow,或Message序列化格式(如Protocol BuffersProtocol BuffersFlatBuffersFlatBuffersMessagePackMessagePackThriftCap'n Proto9)等消息序列化形式一样简单。 /p>

我的想法是通过使用JSON作为参考的一系列文章分析其中的一些系统,以将所有内容与每个人都知道的东西进行比较。

允许任何人根据其要求得出自己的结论,我将分析不同的技术方面:创建时间,结果文件的大小,压缩时文件大小(GZIP),创建文件所需的内存,所使用的库的大小,避免时间或解析和访问数据所需的内存。

在我的情况下,因为我的访问模式是一旦阅读了很多,所以在我的最终选择中,读取因子将优先于写因子。

数据模型

使用新的Java记录给定组织及其属性的this data model

record Org(String name, String category, String country, Type type,
           List<Attr> attributes) { }

record Attr(String id, byte quantity, byte amount, boolean active, 
            double percent, short size) { }

enum Type { FOO, BAR, BAZ }

我将模拟一个沉重的场景,每个组织将随机具有40至70个不同属性,总共将拥有约40万个组织。属性的值也被随机化。


JSON

这是卓越的数据互换格式和Web服务通信中的事实标准。尽管它在2000年代初被定义为一种格式,但直到2013年,Ecma才发布了第一个版本,该版本在2017年成为国际标准。

使用简单的语法,它不需要预先定义模式即可解析它。因为它是一种基于纯文本的格式,所以它是人类可读的,并且有很多以所有语言处理的库。

序列化

使用像Jackson这样的库,没有特殊注释,代码为very simple

var organizations = dataFactory.getOrganizations(400_000);

ObjectMapper mapper = new ObjectMapper();
try (var os = new FileOutputStream("/tmp/organizations.json")) {
  mapper.writeValue(organizations, os);
}

指标:

  • 序列化时间:11 718 ms
  • File size: 2 457 MB
  • 压缩文件大小:525 MB
  • 需要内存:因为我们直接序列到OutputStream,所以除了所需的内部IO缓冲区外,它没有任何消耗。
  • 库大小(Jackson-XXX.Jar):1 956 679字节

避免

在此分析中很难定义如何测量避免化。目的是从其二进制代表中恢复实体的状态,但是哪些实体?原始类或工具提供的类似接口的类别?

为了利用每个工具的功能和优势,我们将保留每个库生成的实体的表示。以JSON为原始类,但在其他库中将是另一个类别。

杰克逊再次变得非常简单,并仅使用代码的3 lines解决了问题:

try (InputStream is = new FileInputStream("/tmp/organizations.json")) {
  ObjectMapper mapper = new ObjectMapper();
  List<Org> organizations mapper.readValue(is, new TypeReference<List<Org>>() {});
  ....
}
  • 避免时间:20 410 ms
  • 需要内存:因为正在重建原始对象结构,因此它们消耗2 193 MB

协议缓冲区

Protocol Buffers是Google在内部开发的系统,可以以CPU和存储有效的方式序列化数据,尤其是如果您将其与XML 在当时的工作方式进行比较。 2008年,它是根据BSD许可发布的。

它基于预定数据将通过IDL(接口定义语言)具有哪种格式,并从中生成源代码,该源代码将能够使用数据编写和读取文件。生产者和消费者必须以某种方式共享IDL中定义的格式。

该格式足够灵活,可以支持添加新字段并贬低现有字段而不破坏兼容性。

序列化后生成的信息是字节的数组,人类不可读

支持每种语言的代码生成器提供了对不同编程语言的支持。如果不正式支持一种语言,您始终可以从社区中find an implementation

Google已将其标准化,并将其作为服务器到服务器通信机制的基础:gRPC,而不是与JSON的通常休息。

尽管documentation discourages它与大型数据集的使用,并建议将大型集合分为单个对象的“串联”序列化,但我将评估并尝试。

IDL和代码生成

带有模式的文件可以是this

syntax = "proto3";

package com.jerolba.xbuffers.protocol;

option java_multiple_files = true;
option java_package = "com.jerolba.xbuffers.protocol";
option java_outer_classname = "OrganizationsCollection";

message Organization {
  string name = 1;
  string category = 2;
  OrganizationType type = 3;
  string country = 4;
  repeated Attribute attributes = 5;

  enum OrganizationType {
    FOO = 0;
    BAR = 1;
    BAZ = 2;
  }

  message Attribute {
    string id = 1;
    int32 quantity = 2;
    int32 amount = 3;
    int32 size = 4;
    double percent = 5;
    bool active = 6;
  }

}

message Organizations {
  repeated Organization organizations = 1;
}

要生成所有Java类,您需要安装编译器protoc并使用一些参数引用IDL文件的位置以及生成文件的目标路径:

protoc --java_out=./src/main/java -I=./src/main/resources ./src/main/resources/organizations.proto

您都有所有指令here,您可以从here下载编译器实用程序,但是我更喜欢直接将Docker与图像一起使用,准备执行命令:

 docker run --rm -v $(pwd):$(pwd) -w $(pwd) znly/protoc --java_out=./src/main/java -I=./src/main/resources ./src/main/resources/organizations.proto

序列化

协议缓冲区不会直接序列化您的pojos,但是您需要将信息复制到架构编译器生成的对象。

序列化Pojos所需的代码看起来像this

var organizations = dataFactory.getOrganizations(400_000)

var orgsBuilder = Organizations.newBuilder();
for (Org org : organizations) {
    var organizationBuilder = Organization.newBuilder()
        .setName(org.name())
        .setCategory(org.category())
        .setCountry(org.country())
        .setType(OrganizationType.forNumber(org.type().ordinal()));
    for (Attr attr : org.attributes()) {
        var attribute = Attribute.newBuilder()
            .setId(attr.id())
            .setQuantity(attr.quantity())
            .setAmount(attr.amount())
            .setActive(attr.active())
            .setPercent(attr.percent())
            .setSize(attr.size())
            .build();
        organizationBuilder.addAttributes(attribute);
    }
    orgsBuilder.addOrganizations(organizationBuilder.build());
}
Organizations orgsBuffer = orgsBuilder.build();
try (var os = new FileOutputStream("/tmp/organizations.protobuffer")) {
  orgsBuffer.writeTo(os);
}

代码是冗长的,但很简单。如果由于某种原因您决定使您的业务逻辑直接与协议缓冲区生成的类一起工作,那么所有复制代码都是不必要的。

  • 序列化时间:5 823 MS
  • File size: 1 044 MB
  • 压缩文件大小:448 MB
  • 需要内存:在序列化之前在内存中实例化所有这些中间对象,需要1 315 MB
  • 库大小(Protobuf-java-3.16.0.jar):1 675 739字节
  • 生成类的大小:41 229字节

避免

它也很简单,足以将InputStream传递到rebuild in memory整个对象图:

try (InputStream is = new FileInputStream("/tmp/organizations.protobuffer")) {
    Organizations organizations = Organizations.parseFrom(is);
    .....
}

对象是从模式生成的类的实例,而不是原始记录。

  • 避免时间:4 535 ms
  • 需要内存:重建由架构定义的对象结构占2 710 MB

分析和印象

json 协议缓冲区
序列化时间 11 718 ms 5 823 MS
文件大小 2 457 MB 1 044 MB
GZ文件大小 525 MB 448 MB
内存序列化 n/a 1,29 GB
避免时间 20 410 MS 4 535 MS
内存挑战 2 193 MB 2 710 MB
jar库大小 1 910 kb 1 636 kb
生成类的大小 n/a 40 kb

从数据以及通过使用格式来看的内容,我们可以得出结论:

  • JSON无疑更慢,更重,但它是一个非常舒适和方便的系统。
  • 协议缓冲区是一个不错的选择:快速序列化和绝对序列化,以及紧凑而可压缩的文件。它的API简单明了,格式被广泛使用,成为具有多种用例的市场标准。
  • 两种格式都是全部或全部:您需要对所有信息进行挑选以访问其中的一部分。在其他工具中,您可以访问任何元素,而无需穿越和解析其之前的所有信息。
  • 协议缓冲区对所有整数类型(int,short,byte)使用32位整数,但是当序列化代表它们时,其值为最小字节,节省一些字节。
  • 出于相同的原因,当协议缓冲器从IDL生成类时,它将所有内容定义为 32位整数,并将其值为INT32。这就是为什么避免物体的内存消耗比JSON高23%。它通过压缩整数在内存消耗中损失而获得的收益。
  • 对于标量值,协议缓冲区不支持null 。如果不存在一个值,则将相应原始的默认值(0、0.0或false)的默认值,而null字符串被应为“”。 This article提出了模拟无效值的不同策略。
  • 尽管在原始对象图中,国家或类别的名称是仅存在内存中的字符串,当序列化为二进制格式时,它们将显示与您所拥有的参考文献一样多次,当供应序列化时,您将拥有许多实例是您的参考文献。这就是为什么在这两种情况下,当值占据的记忆均两倍是最初序列化的 1
  • 的两倍。

1 自Java 8U20以来,由于JEP 192,我们在G1中有一个选择可以在垃圾收集期间删除字符串。但是默认情况下它是禁用的,当启用它时,我们无法控制它的执行时间,因此我们不能依靠该优化来减少供应序列化的大小。