protocol buffers

0x00 前言

数据的模型

数据模型是一组由符号,文本组成的集合,用以准确表达信息景观,达到有效交流、沟通的目的。 在计算机中通常用使用名字、类型以及其他约束,来定义一系列属性(Property) 例如下面我们对一个人的基本信息建模,并用 go 语言表达:

//模型特性:
type User struct{
    Name string
    Age int
}

func NewUser(name string,age int){}

//模型的实例:
user1 := NewUser('a',10)
user2 := NewUser('b',11)

上面 user1,user2 对象实例在计算中有两种主要两种形态的存在

  1. 计算机内存中,数据对象的使用场景主要为 CPU 使用,为计算服务,所以对象在内存中会涉及到指针引用。
  2. 在计算机网络,或者是磁盘存储中,数据模型会序列化为特定的字节来传输,字段会有嵌套、组合关系。

0x01 protocol buffer

诞生背景

在上一段中,我们表述了数据模型,以及数据模型在计算机体系中,在内存为计算而优化的形态和为持久化,为传输而存在形态。计算节点之间为了交换数据,所以我们就会存在数据模型在两种形态间的转换。在计算场景中,不同的语言或者说数据模型在不同的 runtime 下其模型存在形态也可能不一样,所以数据模型表示的对象就有了在不同计算节点交换的需求和 在不同 runtime 下数据对象交换的需求。google 技术体系庞大,有不同的语言所构建的业务计算节点,为了方便这些节点交换数据对象,Google 便创造了一个和具体编程语言,平台无关的中间语言来描述数据模型,编码数据对象;这便是 protocol buffers 诞生的背景。

在提及 protocol buffers 的时候,不仅需要熟悉其作为 IDL 语言本身的特性,还可以更多的去了解 protocol buffers 的原理和基础工具链,以及基于基础工具链,我们如何去丰富和扩展工具链,以及当前围绕 protocol buffers 诞生的生态。为简化名词,我们下文称 protocol buffers 为 PB。

原理

序列化编码

PB 目前是序列化后字节流最小的序列化方式,其文档 中解释了其序列化的数据编码方式;

Base 128 Varints

变长编码方式,该方式主要用于整数的编码方式,优点数字越小编码后其占用的字节数就越少,对于大数,该编码方式并不能压缩需要的字节数,我们在日常中使用的大多是小数的情况,所以 PB 能有效的压缩整体数据对象占用的字节数; Base 128 中,每个字节只用 7 位,最高位标识是否还有后续字节。所以 Base 128 命名来源:2 的 7 次方即 128。具体的编码和解码详见文档 Base 128 小节;

zigZag 编码

zigZag 用于映射有符号到无符号的绝对值,然后用变长编码来编码字段。

值编码表单:

wire type meaning 编码对象
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
5 32 bit fixed32, sfixed32, float

字段编码

在数据模型实例编码中,字段名称并不会序列化到字节流中;例如:

message User {
    string name = 1;
    int64 age = 2;
}

在对象实例中序列化中,字段名 name,age 并不会序列化到字节流中;PB 在字节流中会记录字段的位置(name 的位置是 1,age 的位置是 2),并会记录该字段的数据类型;编码过程:首先会对字段的位置数字和字段类型合并然后使用变长编码(field_number «3 | wire_type) 放在字段信息字节流首。 下面复用官方文档中的示例来解释下:

//字节流如下:
08 96 01
//1. 先拆解位置和字段类型
08  = 000 1000
//类型
000 1000 >>3  结果为0 所以类型为int类型
//字段位置
000 1000 >>3 结果为1 ,所以该字段field_number = 1
//2. 存储的内容
96 01 = 1001 0110  0000 0001
000 0001  ++  001 0110 (去掉最高位,翻转字节)
10010110
128 + 16 + 4 + 2 = 150

tips:

下面是go 语言仓库中代码显示的 field number 的取值范围

//encoding/protowire/wire.go
// Number represents the field number.
type Number int32
const (
	MinValidNumber      Number = 1
	FirstReservedNumber Number = 19000
	LastReservedNumber  Number = 19999
	MaxValidNumber      Number = 1<<29 - 1
)

数据模型及数据对象兼容向前向后兼容性

随着业务的迭代,数据模型会不断的变化。例如属性字段的增减;那么服务在使用数据模型对象的时候会有如下情况:

说明:s->server,s1 ->server v1; d->data, d1->data v1;我们假设 d2 相对于 d1 增加了字段。

  1. (s1,d1) 服务和数据模型匹配
  2. (s2,d1) 新服务读旧数据模型,服务向前兼容
  3. (s2,d2) 新服务和新数据模型匹配
  4. (s1,d2) 旧服务读新数据模型,服务向后兼容

那么作为 IDL 语言的 PB 通过序列化的时候对字段打上 field_number 和类型的 tag(filed_number,field_type) 的方式来做到向前向后兼容;

message {
    string name = 1;
    int64 age = 2;
}

上面的实例中,name 的位置 tag 是 (1,string),age 的位置 tag 是 (2,int64);在序列化的过程中,PB 序列化库会将 tag 信息序列化到字节流中字段信息的首位,在反序列化的时候,如果字节流中没有对应的字段,但是待填充的对象需要该字段,PB 的序列化库会自动填入默认的值。这样保证对象的特性(属性)不变,从而做到兼容。在前文中(s2,d1) 由于 d1 中没有相应的字段,s2 反序列化的时候会将对象中的新属性填入默认值;对于(s1,d2)的情况,s1 的反序列化的时候,只会拿去自己需要的字段的值。PB 虽然提供了向前向后兼容的机制,但是还是需要我们在开发的时候遵守这个机制,不能在迭代的过程中变更相应属性字段的位置(field_number)和字段的类型,否者向前向后兼容就会失败。

自我描述

PB 做为 IDL 语言,可以做到自我描述。在目录中的 descriptor.proto 文件描述了 PB 语言本身的组成结构和类型定义,因为 PB 不支持逻辑运算表达式所以 PB 不能做到自举,其自我模型描述信息简要如下:

// 用于描述单个.proto 文件的信息
message FileDescriptorProto {}
// 描述单个消息信息即message本身(反射时使用)
message DescriptorProto {}
//描述消息中字段的信息
message FieldDescriptorProto {
    enum Type{
        TYPE_DOUBLE = 1;
        //....
        TYPE_SINT64 = 18;  // Uses ZigZag encoding.
    }
}
//描述枚举类型信息
message EnumDescriptorProto {}
//描述服务信息,即service xxx{};
message ServiceDescriptorProto{}
//还有些option选项信息和具体定义大家可以到仓库中去看文件内容,有具体的注释

从上述中我们可以看到其定义基本涵盖了 PB 在作为 IDL 语言时提供的基本的类型和结构关系;通过其自身描述了自身的类型和结构关系,这在后面我们通过其描述信息来开发工具插件铺好了基础。

PB 编译器源代码

在提到编译原理的时候,大家可能会想到著名的开源项目 LLVM; 其编译器分为前端、优化器、后端。clang 就是编译的前端;前端主要做词法分析和语法分析然后输出中间代码;中间代码再经优化器和后端生成目标代码;而在 PB 中,其用自身描述了中间模型,中间模型再经由生成器转换成目标代码中的模型。

编译器前端

在 PB 实现中其前端工具为 protoc 由 C++写成,下面简要介绍重要的目录和文件。

编译器源码目录,在该目录下 c++ 源码文件为编译器的前端实现文件:

  1. main.cc 为编译器应用入口。

  2. parser.hparser.cc 两个文件实现了 parser 组件,其功能将.proto 文件转换为 protocol 语言对象(自描述)本身;

  3. tokenizer.htokenizer.cc 为语法分析器

关键代码片段

//函数的功能为获得对应文件的 protocol描述
bool SourceTreeDescriptorDatabase::FindFileByName(const std::string& filename,
                                                  FileDescriptorProto* output) {
  std::unique_ptr<io::ZeroCopyInputStream> input(source_tree_->Open(filename));
  if (input == nullptr) {
    if (fallback_database_ != nullptr &&
        fallback_database_->FindFileByName(filename, output)) {
      return true;
    }
    if (error_collector_ != nullptr) {
      error_collector_->AddError(filename, -1, 0,
                                 source_tree_->GetLastErrorMessage());
    }
    return false;
  }

  // Set up the tokenizer and parser.
  SingleFileErrorCollector file_error_collector(filename, error_collector_);
  //构造语法分析器
  io::Tokenizer tokenizer(input.get(), &file_error_collector);

  Parser parser;
  if (error_collector_ != nullptr) {
    parser.RecordErrorsTo(&file_error_collector);
  }
  if (using_validation_error_collector_) {
    parser.RecordSourceLocationsTo(&source_locations_);
  }

  // Parse it. 转换
  output->set_name(filename);
  //返回文件描述对象(中间模型)
  return parser.Parse(&tokenizer, output) && !file_error_collector.had_errors();
}

编译器后端

源码目录下,不同的子分别为生成不同目标语言的后端代码目录,目录名即为对应的目标语言。

  1. cpp
  2. csharp
  3. java
  4. objectivec
  5. php
  6. Python
  7. ruby

PB 的编译器前后端是通过定义插件模型进行通信。插件模型定义在plugin.proto中,其定义了插件的输入和输出,后端的代码生成器只要满足插件的输入和输出即可。当前有两种插件通信方式,一种是内嵌在编译器中的插件,函数调用即可通信;还有一种是通过 IPC 的通信,选用的标准 IO 接口。

函数调用通信:

  1. 插件注册
//内嵌插件
cpp::CppGenerator cpp_generator;
cli.RegisterGenerator("--cpp_out", "--cpp_opt", &cpp_generator,
                        "Generate C++ header and source.");
  1. 目标代码生成

后端插件选择

  // Generate output.
  if (mode_ == MODE_COMPILE) {
    for (int i = 0; i < output_directives_.size(); i++) {
      std::string output_location = output_directives_[i].output_location;
      if (!HasSuffixString(output_location, ".zip") &&
          !HasSuffixString(output_location, ".jar") &&
          !HasSuffixString(output_location, ".srcjar")) {
        AddTrailingSlash(&output_location);
      }

      auto& generator = output_directories[output_location];

      if (!generator) {
        // First time we've seen this output location.
        generator.reset(new GeneratorContextImpl(parsed_files));
      }

      if (!GenerateOutput(parsed_files, output_directives_[i],
                          generator.get())) {
        return 1;
      }
    }
  }

插件调用

   // Regular generator.
    std::string parameters = output_directive.parameter;
    if (!generator_parameters_[output_directive.name].empty()) {
      if (!parameters.empty()) {
        parameters.append(",");
      }
      parameters.append(generator_parameters_[output_directive.name]);
    }
    if (!EnforceProto3OptionalSupport(
            output_directive.name,
            output_directive.generator->GetSupportedFeatures(), parsed_files)) {
      return false;
    }

    if (!output_directive.generator->GenerateAll(parsed_files, parameters,
                                                 generator_context, &error)) {
      // Generator returned an error.
      std::cerr << output_directive.name << ": " << error << std::endl;
      return false;
    }

IPC 通信

在上面的插件选择代码片段中,如果选择不是内嵌的代码生成器,则会调起插件子进程。

  1. 调起入口
 // This is a plugin.
    GOOGLE_CHECK(HasPrefixString(output_directive.name, "--") &&
          HasSuffixString(output_directive.name, "_out"))
        << "Bad name for plugin generator: " << output_directive.name;

    std::string plugin_name = PluginName(plugin_prefix_, output_directive.name);
    std::string parameters = output_directive.parameter;
    if (!plugin_parameters_[plugin_name].empty()) {
      if (!parameters.empty()) {
        parameters.append(",");
      }
      parameters.append(plugin_parameters_[plugin_name]);
    }
    if (!GeneratePluginOutput(parsed_files, plugin_name, parameters,
                              generator_context, &error)) {
      std::cerr << output_directive.name << ": " << error << std::endl;
      return false;
    }
  1. 通信过程精简
bool CommandLineInterface::GeneratePluginOutput( const std::vector<const FileDescriptor*>& parsed_files,
    const std::string& plugin_name, const std::string& parameter,
    GeneratorContext* generator_context, std::string* error){
  //插件proto中定义的 输入
  CodeGeneratorRequest request;
  //插件proto中定义的输出
  CodeGeneratorResponse response;
  //传递给插件子进程的参数
  std::string processed_parameter = parameter;
  //填充proto 中间模型对象到request中,传递给子进程使用。
  std::set<const FileDescriptor*> already_seen;
  for (int i = 0; i < parsed_files.size(); i++) {
    request.add_file_to_generate(parsed_files[i]->name());
    GetTransitiveDependencies(parsed_files[i],
                              true,  // Include json_name for plugins.
                              true,  // Include source code info.
                              &already_seen, request.mutable_proto_file());
  }
  //调起自进程
  if (plugins_.count(plugin_name) > 0) {
    subprocess.Start(plugins_[plugin_name], Subprocess::EXACT_NAME);
  } else {
    subprocess.Start(plugin_name, Subprocess::SEARCH_PATH);
  }
 // 进程间通信
 std::string communicate_error;
 if (!subprocess.Communicate(request, &response, &communicate_error)) {
    *error = strings::Substitute("$0: $1", plugin_name, communicate_error);
    return false;
 }
}

常用模型描述

常见模型类型的 protocol 描述文件:

  1. descriptor.proto protocol 语言模型描述
  2. duration.proto 时长描述
  3. empty.proto 空对象
  4. field_mask.proto 字段掩码,用于在接口中描述对象部分字段更新;
  5. timestamp.proto 时间戳描述

多语言运行时库

在仓库根目录下有相关语言的运行时代码,例如:javapython如果我们需要在对应语言中使用 PB 我们需要安装对应的语言 lib。在 GitHub 仓库中,我们也可以在 release 页下载目标语言的精简源码包。

运行时库的主要功能是桥接目标语言,使目标语言能够使用到 PB 对象序列化和反序列化能力;在反序列化的时候,PB 的官方实现方式大多基于反射(运行时类型识别)例如在 Python 运行时库中 symbol_database.py 用于记录类型符号表。下面用 GO 举例,展示关键代码片段(代码线索)从而大致了解 go 官方库的序列化和反序列化的过程:

  1. 字段编解码和解码
//decode:
func ConsumeField(b []byte) (Number, Type, int) {
  //拿字段标签
	num, typ, n := ConsumeTag(b)
	if n < 0 {
		return 0, 0, n // forward error code
	}
  //字段值
	m := ConsumeFieldValue(num, typ, b[n:])
	if m < 0 {
		return 0, 0, m // forward error code
	}
	return num, typ, n + m
}

// AppendTag encodes num and typ as a varint-encoded tag and appends it to b.
func AppendTag(b []byte, num Number, typ Type) []byte {
	return AppendVarint(b, EncodeTag(num, typ))
}
  1. 反射类型系统
message User {
  string name = 1;
  int64 age = 2;
}

生成的 go 语言代码中的反射相关的信息

import (
	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
	reflect "reflect"
	sync "sync"
)

type User struct {
	state         protoimpl.MessageState
	sizeCache     protoimpl.SizeCache
	unknownFields protoimpl.UnknownFields

	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
	Age  int64  `protobuf:"varint,2,opt,name=age,proto3" json:"age,omitempty"`
}
//反射方法代码片段,限于篇幅,这里没有放出和赘述file_resources_proto_msgTypes 的生成过程
//大家可以以此为线索去阅读官方基于反射的类型系统,下面这个方法是反射系统中使用的关键方法,在
//Marshal 和 Unmarshal的时候会使用到
func (x *User) ProtoReflect() protoreflect.Message {
	mi := &file_resources_proto_msgTypes[0]
	if protoimpl.UnsafeEnabled && x != nil {
		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
		if ms.LoadMessageInfo() == nil {
			ms.StoreMessageInfo(mi)
		}
		return ms
	}
	return mi.MessageOf(x)
}
  1. 字段序列化
//path:proto\encode.go
// Marshal returns the wire-format encoding of m.
func Marshal(m Message) ([]byte, error) {
	// Treat nil message interface as an empty message; nothing to output.
	if m == nil {
		return nil, nil
	}
  //代码生成器,在生成代码的时候会生成ProtoReflect 代码
	out, err := MarshalOptions{}.marshal(nil, m.ProtoReflect())
	if len(out.Buf) == 0 && err == nil {
		out.Buf = emptyBytesForMessage(m)
	}
	return out.Buf, err
}
//path: proto\decode.go
// Unmarshal parses the wire-format message in b and places the result in m.
// The provided message must be mutable (e.g., a non-nil pointer to a message).
func Unmarshal(b []byte, m Message) error {
	_, err := UnmarshalOptions{RecursionLimit: protowire.DefaultRecursionLimit}.unmarshal(b, m.ProtoReflect())
	return err
}

GO 语言的 runtime 除了官方基于反射做的 lib,还有第三方叫出名的gogo库。 gogo 兼容官方的 go lib,提供官方库调用的 Descriptor 方法,但是新版本 go 官方库已开始弃用该方法。 gogo 库的实现不基于反射来做,而是直接在对象身上实现 Marshal 和 Unmarshal 方法,从而避免调用反射带来的开销。

gogo 库: 对象已经知道具体要如何序列化和反序列化。

go 官方库:需要类型系统探测目标类型信息,统一的序列化和反序列化。

如果使用 gogo 库,那么一定要用代码生成器生成的 marshal 和 unmarshal 方法,序列化和反序列化效率才高。如果使用了 go 官方库的 marshal 和 unmarshal 函数方法来调用 gogo 的代码生成器代码,那么建议直接用官方库。

tips:

c++本身的运行时库和常见模型类型在同一个目录下。

0x02 protocol buffer 生态

从前文内容中,我们可以知道,PB 除了压缩效率高,向前向后兼容,数据模型能自描述,多语言支持等优点;基于 PB 的编译器分为前端统一的转换成中间模型,后端可以自定义代码生成器的优点,这个扩展特性使其生态中多个了更多的可能性。

接口场景

rpc

当前市面上比较流行的接口风格有 rpc 和 restful; PB 的接口描述能力能够支撑两种风格的接口;基于 PB 的开放的插件接口,开源社区挺多 RPC 框架采用和支持的都是 PB 的来做接口描述语言;基于 PB 还可以很方便的开发私有的 RPC 框架桩代码工具。

GRPC

依托 PB 的多语言扩展特性和 HTTP/2 协议, GRPC 在各个语言中都有相应实现。接口共用 PB 的描述 不仅方便了计算机本身的对象交换而且也方便了开发人员阅读接口。当前 GRPC 在语言实现种类上相较其他 rpc 框架多挺多。例如有: grpc-go,grpc-java,grpc-web,grpc-node,grpc(c++,python,ruby)

srpc

sogou 的企业级 RPC 框架,采用 C++编写,当前支持 PB 和 thrift 作为其接口描述语言。

基于 GRPC 协议的开源 RPC 框架

  1. go-Kratos
  2. go-zero

restful

基于api.proto 对接口模型的描述能力,GRPC 能够扩展到 HTTP 接口。从而支持 restful 风格的接口。比较出名的项目是:grpc-gateway,基于 PB 生成反向代理服务,实现 RESTful HTTP API into gRPC.

接口调试工具

由于接口需要调试,基于 GRPC 生态的开放性,社区也做了很多的接口调试工具,下面挑两个介绍下,更多可以查看仓库,里面有更多更丰富的内容。

bloomrpc

类似 postman UI 的接口调试工具,支持多页签,支持元数据等特性。基于 electron,grpc-web,protobufjs,reactjs 打造。有独立安装包,比较推荐使用。

grpcui

类似 http 请求工具的 curl 工具,也提供 webui

web 生态

protobuf.js

protobuf.js 通过 js 实现了 PB 编译器的前端和后端;如其仓库介绍的那样能够和 proto 文件一起使用。上面的 bloomrpc 也使用到了 protobuf.js 来解析 proto 文件。

其它插件

  1. rust 语言插件
  2. envoy validate 插件,用于校验请求参数。
  3. go-kratos error 插件,通过枚举值,自动生成错误对象。

0x03 各类型编码

JSON

JSON(JavaScript Object Notation) 是一个轻量的数据交换格式,非常易于人读写。JSON 是一个键值对集合。swagger使用 JSON 来描述 restful 接口。

xml

XML (Extensible Markup Language) 是和 HTML 类似的标签语言。没有预定的 tag,取而代之的是自定义的 tag。在 WSDL 中使用来描述 web 服务的接口信息。

flatbuffers

跨平台,强类型,可以直接访问序列化的数据,而不需要转换和解包。提供 IDL 能力,IDL 文件后缀名为 .fbs

msgpack

高效的二进制序列化协议。支持多语言,快,小。不提供 IDL 能力。

facebook thrift/ apache thrift

和 PB 类似,有 IDL 支持,多语言支持;但是压缩效率没有 PB 高。

如何选择?

  1. 序列化后字节流的长度,出于节约带宽的目的。
  2. 序列化,反序列化的效率(时间,空间的消耗)
  3. 扩展性,是否提供 IDL 能力,二次开发的能力。

0x04 参考

  1. google protocol buffer
  2. protocol buffer 实现代码仓库
  3. 官方 go 语言实现仓库
  4. Clang 和 LLVM 的关系及整体架构
  5. 《数据密集型应用》