#codec #data-encoding #binary-encoding #message #byte #field-value #binary-data

不使用 std bilrost

为 Rust 语言设计的紧凑的 protobuf 类型的序列化和反序列化器

16 个版本 (破坏性)

0.1010.0 2024 年 7 月 8 日
0.1008.0 2024 年 5 月 18 日
0.1004.0 2024 年 3 月 4 日
0.0.1 2023 年 11 月 22 日

#453编码

Download history 183/week @ 2024-04-22 150/week @ 2024-04-29 170/week @ 2024-05-13 40/week @ 2024-05-20 6/week @ 2024-06-03 137/week @ 2024-06-10 46/week @ 2024-06-17 2/week @ 2024-06-24 2/week @ 2024-07-01 158/week @ 2024-07-08 7/week @ 2024-07-15 93/week @ 2024-08-05

每月下载量 106
用于 bilrost-types

Apache-2.0

460KB
9K SLoC

continuous integration Documentation Crate Dependency Status

BILROST!

Bilrost 是一种为存储和传输结构化数据(如文件格式或网络协议)而设计的编码格式。该编码是二进制的,不适合直接由人类阅读;然而,它还具有其他有用的属性和优势。此包 bilrost 是其首次实现和实例化。

Bilrost 的设计目标是

  • 一个稳定的编码格式,简单易指定,即使在其他语言中也相对容易实现
  • 耐用的编码数据,适合在生成它的应用程序的多个版本之间保留,或者在具有非常不同版本的应用程序之间传输[^扩展]
  • 良好的性能,可比于类似设计编码所能达到的性能
  • 规范编码和区分解码
  • 无侵入性:实现应该能够高效地在类似于或与手动指定的结构体相同的结构体上实现编码和解码,而不是强制用户使用由工具生成的代码结构体[^derive-codegen]

[^derive-codegen]: bilrost Rust 库通过 derive 宏实现消息类型的编码和解码。技术上这是生成代码,但不需要额外的工具来生成或提交此代码,也不会对结构体的定义有任何影响!

[^extensions]:Bilrost 的设计,类似于 protobuf,通过在编码中引入新字段(并可能废弃旧字段)来实现版本控制,使新旧版本的应用程序仍然可以相互理解。

非目标包括[^lol]

  • 一种 自描述的格式
  • 最紧凑或可压缩的格式[^octet-aligned]
  • 最快的格式[^memcpy-fast]

[^lol]:也是一个非目标,未在此列出:一个小型自述文件 :)

[^octet-aligned]:Bilrost 是字节对齐的,并且不会通过填充或混淆字段键与其值之间的数据来尝试节省字节,这种做法虽然可以节省空间,但会增加复杂性,并使解码更加困难,更容易在实现中出错。

[^memcpy-fast]:Bilrost 在实现稳定表示、可扩展性和一般简单性方面做出的许多决策都牺牲了极端性能的机会。这些是有意为之的权衡,通常排除了执行类似某些其他编码中看到的快速无分支编码的能力,这些编码通常更类似于直接复制结构的内存,而不是明确编码每个字段的值。作为交换,模式更容易描述和移植,编码数据更持久。

Bilrost 在编码级别上基于 Protocol Buffers(protobuf) 并共享其许多特性,但不兼容。在某些方面,它的规范更简单、更灵活,旨在改进 protobuf 的一些缺陷。在这个过程中,它与 protobuf 断开了线缆兼容性。

Bilrost(作为一种规范)力求提供 protocol buffers 功能的超集,同时减少了一些错误和意外的表面面积;bilrost(实现库)力求以最大便利的方式提供所有这些功能的访问。

bilrostRust 语言 实现。它是 prost 的直接分支,并共享许多性能特性。(它不是最快的编码库,但它仍然很快,并且具有独特的优势。)像 prost 一样,bilrost 可以通过使用 derive 宏来编写简单、惯用的 Rust 代码,这些宏将二进制数据序列化和反序列化结构体。与 prost 不同,bilrost 免于 protobuf 生态系统中的大多数约束以及 protobuf 消息类型所需语义的约束。Bilrost(规范)和该库允许与现有的结构体类型及其正常语义有更广泛的兼容性。与依赖于从 protobuf .proto 模式定义生成代码不同,bilrost 设计成易于手动使用,作为用户已编写的类型的纯增强,而不是作为将用户引向仅为编码和解码而设计的具有偏见和专用结构体的系统。

🌈

内容

这份README是大量工作的成果,我们希望它能很好!如果有什么地方不清楚或者可以改进的地方,请随时提交问题或拉取请求!

概念概述

Bilrost是一种编码方案,可以将内存中的数据结构转换为普通的字节字符串,反之亦然。它通常适用于网络传输和长期保存的数据。它的编码数据不是人类可读的,但编码非常简单。它支持整数和浮点数、字符串和字节字符串、嵌套消息以及递归嵌套消息。所有这些都可以作为可选值、重复值、唯一值集合和有意义的键/值映射来支持。通过适当选择编码(这决定了表示形式),大多数这些结构可以几乎任意地嵌套。

编码后的Bilrost数据不包含其字段的名称;相反,它们被分配了事先由指定消息模式达成一致的字段编号。这可以使数据比“无模式”编码(如JSON、CBOR等)更加紧凑,同时不牺牲其可扩展性:可以添加新字段,删除旧字段,而不必一定破坏与旧版本编码程序的向后兼容性。在典型的“便捷”解码模式下,解码时忽略消息模式中未包含的字段,所以如果随着时间的推移添加或删除字段,保留的共同字段在两个版本的方案之间仍然是互相理解的。以这种方式,Bilrost与protobuf非常相似。另请参阅:设计理念与其他编码的比较以及编码规范

Bilrost还有编码和解码保证为规范表示的数据的能力:请参阅特殊解码部分。

设计理念

Bilrost被设计成一种简单指定、简单实现、简单跨语言和机器移植以及易于正确使用的编码格式。

具有模式的编码

它被设计为一个具有模式的数据库模型,尽管当然也可以用来编码“无模式”数据的表示。这种形式有其优点和缺点。由于字段的重复名称被替换为代理编号,编码的数据显著更小。同时,由于字段名称的固有文档缺失,数据的意义可能不太清楚。像JSON这样的无模式编码可以作为纯数据动态解码和访问,具有简单、统一的解码器实现,而像Bilrost和protobuf这样的编码需要模式才能确保值。

一种观点是,即使字段名称在编码中都指定了,它们也仅仅是低信息量的文档,有助于猜测或逆向工程。它们可以帮助诊断丢失的数据属于何处,或者神秘的数据意味着什么,通过轻微的自我文档化,但数据的意义仍然由发出它的代码决定。数据的意义基于其所在的位置,这种意义的文档不能仅通过简单地包括数据中所有字段的名称来完全取代。

一旦承认该论点,并承诺维护其编码数据的模式,就没有其他明显的缺点。在弃用后不应重复使用数字字段标签,但在无模式编码中也不应重复使用字段名。

可能最大的问题是同时发明问题。如果多个方面在彼此不沟通的情况下实施扩展,他们可能会选择相同的标签,这会导致这些字段意义上的冲突。序列数字标签比名称更容易被双方选择。最好的解决方法是提前规划扩展,鼓励潜在的合作伙伴同步并从为扩展预留的某些范围内选择分配的标签,或者在与名称或UUID相关的模式内提供扩展空间。

非强制转换数据

Bilrost旨在确保在消息解码无误时,其模式中的所有识别值将具有它们编码时的确切值。这意味着

  • 对于布尔字段,0代表false,1代表true;如果遇到值2,这始终是一个错误。
  • 对于数字字段,超出范围的值永远不会被截断以适应较小的数字类型。
  • bilrost(这个Rust库)中,浮点值始终与其表示的精确位来回传递。NaN位和-0.0始终被保留。
  • 如果映射中出现多个键,则整个消息被视为无效;对于集合中的值也是如此。不应有保留只保留第一个或最后一个此类条目或丢弃关于具有重复元素的集合信息的不同解释的数据的空间。

Bilrost不对未知字段数据执行这些相同的约束;如果在数据中发现包含模式中未出现的标签的字段,则它不会被视为规范,但解码可能成功。因为这些字段被丢弃,所以它们也没有被强制转换为不同的值,因此承诺得以保持。

设计用于规范性

Bilrost被设计为使几类非规范状态无法表示,从而大大简化了非规范数据的检测。

最大的变化是,顺序编码的消息字段是不可表示的;在protobuf中,这已经是最常见的消息类型的观察行为,但从未被承诺过,原因在此处不太相关(并且将在下面讨论)。这增加了仅当“oneof”(一组互斥字段)具有可能在消息字段的顺序中出现在不同位置的标签号时数据编码的复杂性;在实践中,这种情况相当罕见。

较小的变化是,构成编码核心的varint表示被设计为保证任何给定数字只有一个表示。这可能比传统的LEB128 varint略高,但并不像人们想象的那么多;快速解码LEB128 varint相当复杂,对于大多数varint的最大优化是在值足够小以至于可以适应一个字节时采取捷径,而Bilrost的varint在这一范围内编码相同。

区分解码

在某些应用中,能够将消息编码为一种保证的规范形式,并且在解码时能够区分规范和非规范编码,是非常有价值的。Bilrost可以提供这种功能,并且与其他许多编码相比,其复杂度和开销更低。

bilrost中,可以推导出扩展特质DistinguishedMessage,它提供了一种特殊的解码模式。在特殊解码模式下的解码包含额外的规范性检查:解码结果可以让我们知道解码的消息数据是否是规范的。任何可以实施特殊解码的消息类型都将始终以完全规范的形式进行编码;不存在“更规范”的备用编码模式。

正式来说,当一个消息类型实现了DistinguishedMessage时,该消息类型的值与所有字节字符串的子集一一对应,每个字节字符串都被认为是该消息值的规范编码。每个不同的字节字符串在特殊解码模式下解码为一个与所有其他此类字节字符串解码的消息值都不同的消息值,或者会在这种模式下产生错误或非规范结果。如果从字节字符串在特殊解码模式下成功且规范地解码出一个消息,并且未对其进行修改,然后重新编码,它将发出相同的字节字符串。

在Rust中,这种对等关系的最佳代理是Eq特质,它表示实现了它的任何类型的所有值之间都存在等价关系。因此,为了在bilrost中实现特殊解码,需要所有字段和消息类型都实现这个特质。

因此,如果存在任何被忽略的字段,bilrost将拒绝推导DistinguishedMessage,因为它们也可能参与类型的相等性。

bilrost以与自动推导的Eq实现相匹配的方式区分类型中的规范值(即,它基于每个组成部分字段的Eq特质进行匹配)。强烈建议,但不是必须的,自动推导相等特质。bilrost并不直接依赖于类型相等性的实现;相反,它作为一个合同护栏,设定了一个最低期望。

正常的(便捷的)解码可能接受其他字节字符串作为给定值的有效编码,例如包含未知字段或非规范编码值的编码[^noncanon]。大多数时候,这是所需的。

[^noncanon]:Bilrost中的“非规范”值编码主要包括那些虽然被编码表示,但被认为值为空的字段。对于消息类型,例如嵌套消息,它还包括包含未知标签字段的报文表示。

为了支持对特殊消息的“正好1:1”期望,某些类型被禁止,在特殊模式下不实现,尽管在理论上它们可以。这主要是指浮点数,它们的相等性语义不兼容。在Bilrost编码中,浮点数以今天大多数计算机的标准IEEE 754二进制格式表示。这伴随着特定的相等性语义规则,这些规则通常在所有语言中都是一致的,并且不形成等价关系。“NaN”值从不彼此相等,也不与自身相等。

规范顺序和特殊表示

Bilrost 指定了大部分制作这些消息模式所需的内容,不仅适用于不同架构和程序,还可以用于其他编程语言。目前有一个小警告:Bilrost 中值的 排序顺序 可能很重要。

在区分解码模式下,规范数据必须始终以排序顺序的 集合映射 来表示。当集合的项类型(或映射的键类型)不是一个具有已标准化排序顺序的简单类型(如整数或字符串)时,项的规范顺序取决于该类型的实现,并且在定义区分类型时必须注意标准化该顺序,以及消息字段的模式。

浮点值与区分解码

等价关系也不足以描述 Bilrost 中区分类型所需的所有属性;不仅必须考虑 值本身 是等价的,还必须 编码 为相同的字节。在编码和解码浮点值时,bilrost 会注意保留 +0.0 和 -0.0 之间的区别,这在 IEEE 754 中被认为是相等的;这 在过去是其他编码的问题。即使并非总是必需,当值在 bilrost 中编码后,再次解码该值将保证产生具有相同位数的相同值。

因此,目前还没有考虑为 Rust 的浮点类型实现区分解码的好主意,这些类型实现了 EqOrd(如 ordered_floatdecorum),因为它们仍然认为某些具有 不同位 的值是相等的。任何此类类型的未来实现都必须特别注意统一这些类型中任何等价类的编码表示,并 以可移植的方式标准化,这实际上也会在往返过程中导致一些数据丢失。无法保证这将被认为是有价值的或实现。

如果需要为浮点值的位表示实现区分编码,应首先将其转换为无符号整数,并以这种方式进行编码。这减少了错误的可能性,并使代码更清晰地表明浮点数需要在关心区分表示的代码中特别处理。

使用库

开始使用

要使用 bilrost,我们首先在 Cargo.toml 中将其添加为依赖项,使用 cargo add bilrost 或手动添加

bilrost = "0.1010"

然后,为我们定义的结构类型推导 bilrost::Message

use bilrost::Message;

#[derive(Debug, PartialEq, Message)]
struct BucketFile {
    name: String,
    shared: bool,
    storage_key: String,
}

let foo_file = BucketFile {
    name: "foo.txt".to_string(),
    shared: true,
    storage_key: "public/foo.txt".to_string(),
};

// Encoding data is simple.
let encoded = foo_file.encode_to_vec();
// The encoded data is compact, but not very human-readable.
assert_eq!(encoded, b"\x05\x07foo.txt\x04\x01\x05\x0epublic/foo.txt");

// Decoding data is likewise simple!
let decoded = BucketFile::decode(encoded.as_slice()).unwrap();
assert_eq!(foo_file, decoded);

之后,可以添加更多字段到该结构,它仍然可以解码相同的数据。

# use bilrost::Message;
#[derive(Debug, Default, PartialEq, Message)]
struct BucketFile {
    #[bilrost(1)]
    name: String,
    #[bilrost(5)]
    mime_type: Option<String>,
    #[bilrost(6)]
    size: Option<u64>,
    #[bilrost(2)]
    shared: bool,
    #[bilrost(3)]
    storage_key: String,
    #[bilrost(4)]
    bucket_name: String,
}

let new_file = BucketFile::decode(
    b"\x05\x07foo.txt\x04\x01\x05\x0epublic/foo.txt".as_slice(),
)
.unwrap();
assert_eq!(
    new_file,
    BucketFile {
        name: "foo.txt".to_string(),
        shared: true,
        storage_key: "public/foo.txt".to_string(),
        ..Default::default()
    }
);

包特征

bilrost 包有几个可选功能

  • "std"(默认):提供对 HashMapHashSet 的支持。
  • "derive"(默认):包含 bilrost-derive 包并重新导出其宏。如果正常使用 bilrost,则不太可能禁用此功能。
  • "detailed-errors"(默认值): 消息返回的解码错误类型将在解码数据中遇到错误的精确字段路径上提供更多信息。禁用此功能后,错误更难以理解,但可能更小、更快。
  • "auto-optimize"(默认值): 对一些性能相关实现细节做出一些自动选择。相关的功能可以作为分析和实验的有用控制,并在Cargo.toml中进行说明。大多数用例应启用此功能。
  • "no-recursion-limit": 移除旨在防止数据过度嵌套的递归限制。
  • "extended-diagnostics": 通过添加一个小依赖项,在派生和派生实现不起作用时尝试提供更好的编译时诊断。有点实验性质。
  • "arrayvec": 提供对arrayvec::ArrayVec的第一方支持
  • "bytestring": 提供对bytestring::Bytestring的第一方支持
  • "hashbrown": 提供对hashbrown::{HashMap, HashSet}的第一方支持
  • "smallvec": 提供对smallvec::SmallVec的第一方支持
  • "thin-vec": 提供对thin_vec::ThinVec的第一方支持
  • "tinyvec": 提供对tinyvec::{ArrayVec, TinyVec}的第一方支持

no_std 支持

禁用 "std" 功能后,bilrost 具有完整的 no_std 支持。如果需要,可以通过启用 "hashbrown" 功能来提供与 no_std 兼容的哈希映射。

要启用 no_std 支持,请禁用 bilrost(以及如果使用,请禁用 bilrost-types)中的 std 功能。

[dependencies]
bilrost = { version = "0.1010", default-features = false, features = ["derive"] }

派生宏

我们现在可以导入和使用其特性和派生宏。主要有三个

  • Message: 这是基本的工作单元。为结构体派生此功能以启用将它们编码和解码为二进制数据。
  • Enumeration: 这是一个派生功能,而不是特性,它实现了使用 bilrost 对枚举类型进行编码的支持。枚举不得有字段,并且其每个变体将对应于一个不同的 u32 值,该值将在编码中代表它。
  • Oneof: 这是一个特性和派生宏,用于表示消息结构体中的互斥字段。每个变体必须有一个字段,并且每个变体都必须有一个唯一的字段标签分配给它,同时在 oneof 中以及在它是其一部分的消息中。具有派生自 Oneof 的类型除了在 Message 结构体(或具有派生自 Message)时外,没有对库用户有用的 bilrost API。

派生 Message

可以派生 Message 特性,以便将几乎任何结构体编码为 Bilrost 消息,只要其字段类型得到支持。

如果没有其他指定,字段将按照在结构体中指定的顺序连续标记。如果未指定,具有命名字段的结构的字段从 1 开始标记,而具有匿名字段的元组结构的字段从 0 开始编号(与它们的 Rust 索引名相匹配)。

标签也可以显式指定。如果一个字段的标签是唯一提供的属性,则可以将标签号以“bilrost”属性的唯一内容的形式提供,如 #[bilrost(1)]。如果包含其他属性,则必须通过名称指定“tag”属性;例如,如 #[bilrost(tag(1), encoding(fixed))]。 “tag”属性也可以写成 tag = 1tag = "1"

我们可以通过在空缺后的第一个字段上指定要跳过的标签号来跳过已预留的标签或存在标签值之间的空缺。接下来的字段将从下一个数字开始连续标记。

在定义用于互操作的消息类型或字段可能被添加、删除或重新排列时,最好显式指定结构体中所有字段的标签,但这不是强制性的。

具有派生 Message 实现的结构示例

use bilrost::{Enumeration, Message};

#[derive(Clone, PartialEq, Message)]
struct Person {
    #[bilrost(tag = 1)]
    pub id: String, // tag=1
    // NOTE: Old "name" field has been removed
    // pub name: String,
    // given_name has tag 6
    #[bilrost(6)]
    pub given_name: String,
    // family_name has tag 7
    pub family_name: String,
    // formatted_name has tag 8
    pub formatted_name: String,
    // age has tag 3
    #[bilrost(tag = "3")]
    pub age: u32,
    // height has tag 4
    pub height: u32,
    // gender has tag 5
    #[bilrost(enumeration(Gender))]
    pub gender: u32,
    // NOTE: Skip to less commonly occurring fields
    #[bilrost(tag(16))]
    pub name_prefix: String, // has tag 16  (eg. mr/mrs/ms)
    pub name_suffix: String, // has tag 17  (eg. jr/esq)
    pub maiden_name: String, // has tag 18
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Enumeration)]
#[non_exhaustive]
pub enum Gender {
    Unknown = 0,
    Female = 1,
    Male = 2,
    Nonbinary = 3,
}

Oneof 字段

Bilrost 消息可以具有互斥字段集,一次只能存在其中一个。这些由每个变体有一个字段并分配一个字段标签的 enum 类型表示;然后可以使用 Oneof derive 宏来生成实现,允许 oneof 包含在消息中。

具有 oneof 的示例消息
use bilrost::{Message, Oneof};

#[derive(Oneof)]
enum NameOrUUID {
    #[bilrost(2)]
    Name(String),
    #[bilrost(tag(3), encoding(plainbytes))]
    UUID([u8; 16]),
}

#[derive(Message)]
struct Widget {
    #[bilrost(1)]
    id: u32,
    #[bilrost(oneof(2, 3))]
    label: Option<NameOrUUID>,
    #[bilrost(4)]
    description: String,
}

当 oneof 包含在消息中时,必须使用“oneof”属性声明它,提供所有字段标签的逗号分隔列表。(此属性也可以写成 oneof = "2, 3"。)[^tagranges] 由于 derive 宏在运行时无法访问字段类型的定义,因此它无法知道这些标签号是什么,但是在此属性中声明的标签列表和 oneof 实际拥有的标签列表在编译时进行静态检查以确保相等。

具有不匹配标签的 oneof 示例
use bilrost::{Message, Oneof};

#[derive(Oneof)]
enum Abc {
    #[bilrost(1)]
    A(String),
    #[bilrost(2)]
    B(i64),
    #[bilrost(3)]
    C(bool),
}

#[derive(Default, Message)]
struct TagsDontMatch {
    #[bilrost(oneof(1, 2))] // These tags don't match the oneof!
    label: Option<Abc>,
}

// In older versions of rust, the build may not fail until the message trait is
// actually used somewhere.
let _ = TagsDontMatch::default().encoded_len();

[^tagranges]:在oneof属性和reserved_tags属性中指定标签完整列表的方式相同:整个列表以逗号分隔,每个项可以是单个标签号,也可以是从最小到最大值的包含范围,范围值之间用连字符分隔(例如1-5)。对于reserved_tagsoneof,以下都是完全等价的:1, 2, 3, 4, 51-54, 5, 1-3

oneof中的字段标签必须是唯一的,既要在oneof内部,也要在包含它的任何消息中唯一。Oneof变体只能包含可以嵌套的类型(因此不支持“未打包”的集合)。从机制上讲,oneof的工作方式与为每个变体都有一个Option<T>字段的Option相同,但最多只有一个可以是Some

在上面的示例中,NameOrUUID oneof必须嵌套在Option中,才能使其能够表示没有任何字段存在的空状态。也可以在oneof枚举中包含最多一个单元变体。任何这样的变体都将用于表示其空状态。

具有“空”变体的oneof示例
use bilrost::{Message, Oneof};

#[derive(Oneof)]
enum NameOrUUID {
    #[bilrost(2)]
    Name(String),
    #[bilrost(tag(3), encoding(plainbytes))]
    UUID {
        octets: [u8; 16],
    },
    Neither,
}

#[derive(Message)]
struct Widget {
    #[bilrost(1)]
    id: u32,
    #[bilrost(oneof(2, 3))]
    label: NameOrUUID,
    #[bilrost(4)]
    description: String,
}

当oneof枚举类型具有空变体时,它可以直接包含在消息中;当它没有空变体时,它只能嵌套在Option中。

为枚举推导Message

MessageDistinguishedMessage也可以为具有相应oneof实现的枚举推导。它们以消息形式编码和解码,这些消息最多只有一个字段,就像类型是一个只包含枚举且每个变体对应一个字段的#[bilrost(oneof(..))]属性的Message一样。

Oneof枚举推导的Message示例

use bilrost::{Message, Oneof};

#[derive(Oneof, Message)]
enum Maybe {
    Nope,
    #[bilrost(1)]
    Yes(String),
    #[bilrost(2)]
    Very(String),
}

/// This struct encodes exactly the same as Maybe does with its own `Message`
/// impl; deriving `Message` on the enum just saves some work.
#[derive(Message)]
struct WrappedMaybe {
    #[bilrost(oneof(1, 2))]
    maybe: Maybe,
}

MessageDistinguishedMessage只能为具有“空”变体的oneof类型实现。

使用非空oneof枚举作为消息的示例
use bilrost::{Message, Oneof};

#[derive(Oneof, Message)]
//              ^^^^^^^ Error: Message can only be derived for Oneof enums
//                             that have an empty variant.
enum AB {
    #[bilrost(1)]
    A(bool),
    #[bilrost(2)]
    B(bool),
}

仍然可以通过包装它来将这样的枚举用作消息类型。

use bilrost::{Message, Oneof};

#[derive(Oneof)]
enum AB {
    #[bilrost(1)]
    A(bool),
    #[bilrost(2)]
    B(bool),
}

#[derive(Message)]
struct WrappedAB(#[bilrost(oneof(1, 2))] Option<AB>);

注意:请谨慎使用此方法!虽然这种方法对于完全以枚举形式表示且每个变体对应一个字段的类型来说非常方便,但推导OneofMessage都很容易不小心将oneof作为子消息字段而不是作为表示消息中不应共存的字段集合的“嵌入”oneof来包含。

编码

可以使用“编码”属性对bilrost消息字段和oneof变体进行注释,以指定在编码和解码该字段值时使用的编码类型。bilrost提供了几个标准编码,可以使用和组合来选择字段的表示方式。

# use bilrost::Message;
#[derive(Message)]
struct Foo {
    #[bilrost(encoding(general))]
    name: String,
}

编码属性可以通过两种方式指定,要么是上面显示的形式,要么是字符串形式,例如:#[bilrost(encoding = "general")]。此属性的值指定一个类型名称,使用正常的Rust类型语法。标准编码也是可用的,并且可以明确指定;这样做没有实际的原因,但作为一个演示

# use bilrost::Message;
#[derive(Message)]
struct Bar(
    // This is the same type as "general"
    #[bilrost(encoding = "::bilrost::encoding::General")] String,
);

assert_eq!(
    Bar("bar".to_string()).encode_to_vec(),
    b"\x01\x03bar".as_slice()
);

在这些编码的类型名称被评估的地方,标准编码作为别名提供,所有都是小写字母,以确保这些别名不太可能与作用域内的其他类型名称冲突。这些标准别名包括

  • general:默认编码,适用于大多数字段类型。将集合(vec和set)的编码委托给unpacked<general>,将映射类型委托给map<general, general>
  • varint:原始数值类型和bool,以varint编码。
  • fixed:固定宽度的四字节和八字节整数值、浮点数和字节数组。将集合的编码委托给unpacked<fixed>
  • plainbytes:将字节数组、Vec<u8>Cow<[u8]>编码为长度分隔值。将Vec<Vec<u8>>Vec<Cow<[u8]>>的编码委托给unpacked<plainbytes>
  • unpacked (unpacked<E = general>):将值作为零个或多个正常编码的字段编码到集合中,每个值一个字段。字段使用参数化的编码E编码,默认为general
  • packed (packed<E = general>):将值打包成一个单独的长度分隔值编码到集合中。值使用参数化的编码E编码,默认为general
  • map<KE, VE>:将键(使用参数化编码KE编码)和值(使用VE编码)交错打包成一个单独的长度分隔值进行编码。

将来可能会添加更多标准编码,但它们也会被转换为小写。

其他属性

在 "bilrost" 属性内部还有几个其他属性可用

预留标签
  • "reserved_tags":当放置在消息本身上时,表示给定的标签和标签范围[^tagranges]在此字段中未使用。这除了作为编译时的保护措施外没有其他作用;如果字段使用了已声明为预留的标签,编译将出错。
# use bilrost::Message;
#[derive(Message)]
#[bilrost(reserved_tags(2, 6-10, 25))]
struct Foo {
    #[bilrost(tag(5), encoding(general))]
    name: String,
    age: int64, // Oops! Uses tag 6! Compile error
}
忽略字段
  • "ignore":必须单独使用,没有标签或其他属性。这将导致生成的消息实现忽略该字段。如果消息中任何字段被忽略,则必须实现 Default 以实现 Message,这样当它们从编码数据创建时,这些字段将有值。

    目前忽略的字段被认为与区分解码不兼容。

辅助方法
  • "enumeration":如果字段类型为 u32Option<u32>,这将使消息类型具有以类型命名的辅助方法,用于获取和设置此属性指定的枚举类型的值。
编写递归消息
  • "recurses":在 bilrost 中可以递归地嵌套消息。如果这样做,由于消息类型与其自身特质的不可解析循环依赖,当前 Message 特质全部始终禁用。
# use bilrost::Message;
#[derive(Message)]
//       ^^^^^^^ the trait `Encoder<Vec<Tree>>` is not implemented for `General`
struct Tree {
    name: String,
    children: Vec<Tree>,
}

在某个地方,我们必须打破这个循环依赖链。为此,将 "recurses" 属性及其类型注释在链中的字段上,其类型将不再参与消息实现的 where 子句,循环将被打破,消息可以使用了。

# use bilrost::Message;
#[derive(Message)]
struct Tree {
    name: String,
    #[bilrost(recurses)]
    children: Vec<Tree>,
}

区分派生宏

有两个可派生的伴随特质,DistinguishedMessageDistinguishedOneof,当可能时,它们实现了用于区分解码的扩展特质。消息和 oneof 必须仅包含支持区分解码的字段,以便自身支持它。区分编码需要为每个字段、oneof 和消息类型实现 Eq;特质没有直接使用,但对于任何兼容类型来说,它是容易派生的。

编码和解码消息

Message 实现中,有各种方法和相关函数可用于编码和解码数据。

编码和解码消息最直接的方法是 encode_fastencode_to_vecdecode。有方法可以将消息编码和解码为几种类型和特质,带有和没有前缀长度分隔符。编码消息的长度分隔符始终采用 Bilrost varint 的形式。

  • encode_fastencode_length_delimited_fast:将消息编码到 ReverseBuffer 中并返回它。有关该类型的更多信息,请参阅该部分。同样,..length_delimited.. 变体在编码消息后还前缀编码数据以长度,使其适合使用相应的 "length_delimited" 解码函数解码。
  • encode_to_vecencode_to_bytes..length_delimited.. 变体:将消息编码到新的 vec 或 bytes 中并返回该容器。这并不总是像 encode_fast 那样高效,但始终生成在内存中连续的编码。
  • encode_contiguousencode_length_delimited_contiguousencode_fast 完全相同,但首先预测量并预留存储完成编码所需的确切大小。这保证了即使其大小在事先未知的情况下,结果缓冲区也将是连续的,并允许将结果 ReverseBuffer 直接转换为 Vec(见 ReverseBuffer::into_vec)。
  • encodeencode_length_delimited:将消息编码为 &mut bytes::BufMut,并将其附加到任何已经存在的数据之后。
  • prepend:将消息编码为 &mut bilrost::buf::ReverseBuf任何已存在数据之前。
  • decodedecode_length_delimited:从 bytes::Buf 解码消息类型。带有长度分隔符的调用版本将仅消耗长度分隔符(从 Buf 的前端读取)指示的字节数,而方法的标准版本将尝试解码整个内容。
  • replace_fromreplace_from_length_delimited:类似于 decode,但这些是修改现有实例值的方法,而不是返回包含新消息实例的 Result。如果解码失败,消息将保留其字段 空值
  • 还有 encode_dynreplace_from_slicereplace_from_dyn 方法,用于编码和解码,它们不提供上述方法所提供的内容,但可以从特质对象调用。

区分模式下的解码

DistinguishedMessage 有对应的解码和替换命名方法的对应方法 decode_distinguished_..replace_distinguished_..。与返回 Result<(), DecodeError>Result<Foo, DecodeError> 不同,这些返回 Result<Canonicity, DecodeError>Result<(Foo, Canonicity), DecodeError>Canonicity 是一个简单的枚举,表示解码的数据是否为 CanonicalHasExtensionsNotCanonical

提供了 bilrost::WithCanonicity 特质,以解包具有规范性信息的值和结果

  • .canonical():如果不完全规范,则转换为错误,否则解包
  • .canonical_with_extensions():如果任何 已知 字段不规范,则转换为错误,否则解包
  • .value():始终展开,丢弃规范信息。

此特类实现了对Canonicity本身的实现,对(T, Canonicity)Result类型进行实现,其中值实现了WithCanonicity,错误可以转换为DecodeErrorKind,以及对应的引用/.as_ref()类型。返回结果类型中的错误是DecodeErrorKind,它丢弃了任何指示解码错误发生位置的“详细错误”信息;如果需要该信息,请在规范错误之前检查解码错误。

使用dyn与对象安全消息特类

MessageDistinguishedMessage特类是对象安全的,可以通过特类对象使用。它们的所有功能(除了从数据创建消息值的方法decode外)都可通过对象安全的替代方案提供。消息可以被清除(重置为空值);测量它们的编码字节长度;编码到ReverseBufferVec<u8>Bytes或一个&mut dyn BufMut;或者从&[u8]切片或一个&mut dyn Buf解码(替换值)。

将解码到或从特类对象缓冲区的函数可能比它们的泛型、非对象安全对应函数效率低;最好使用encode(..)而不是encode_dyn(..),对于任何其他"_dyn"方法也是如此。同样,replace_from_slice(..)replace_from(..)等效,只是对象安全;其他"_slice"方法也是如此。

支持的类型和特类

由于 Bilrost 中的嵌套值在写入之前必须具有已知的编码长度(就像 protobuf 一样),如果消息有很多嵌套级别,则必须知道最内层消息的大小才能对包含它的每个消息进行编码。如果编码数据是从头到尾写入的,这意味着以下情况之一

  1. 在编码之前检查每个消息结构的编码长度
    • 在通常没有嵌套的情况下,这非常简单且非常快。
    • 如果一个包含100层嵌套的消息被编码,这意味着大约需要额外测量5,000次每个嵌套消息的编码长度。
    • 这是库的原始上游prost所做的选择。
  2. 在结构体中永久缓存每个消息的长度,并在更新时注意使缓存失效。
    • 大多数protobuf库选择这种选项,但这需要为每个消息结构体添加额外的字段,并在修改结构体的字段时强制执行额外的逻辑。这变得非常侵入性,是protobuf结构体通常与程序的其他部分不太兼容的主要原因之一。
  3. 在开始写入之前,缓存消息每个部分的长度。
    • 在某个时刻rust-protobuf就是这么做的。它避免了选项1的二次成本和选项2的侵入性,但代价是速度有所下降。

bilrost选择了第四种方案:而不是向前编码并使用技巧来确定将要写入的值的长度,编码可以向后构建。任何需要以长度为前缀的嵌套数据在需要知道其长度之前已经被编码,整个嵌套消息可以单次通过编码。

根据编码的消息的性质,正向编码(encode)和反向编码(prepend)之间的性能会有所不同。在某些情况下,反向编码可能会稍微慢一些,而在某些情况下,它会快得多;两种选项都可供选择。

ReverseBuf

bilrost::buf::ReverseBuf是一个与bytes::BufMut相对应的特质,它在几乎相同的方式下工作,除了写入其的字节块是添加到缓冲区中的现有数据之前,而不是之后。这可以使写入长度分隔编码,如Bilrost,变得非常高效,特别是当消息包含更多字段和更深层嵌套时。

ReverseBufbytes::Buf声明为一个超特质,因此任何此类型的值都可以作为缓冲区消费。

ReverseBuffer

bilrost::buf::ReverseBufferReverseBuf特质的主要实现。它具有预留容量、如果它在内存中连续,则将整个缓冲区作为切片检索的便利功能,并且具有buf_reader()方法,该方法返回一个只读视图的缓冲区,同时也实现了bytes::Buf,但在通过该特质读取时不会消耗缓冲区。

ReverseBuffer按需分配,指数增长,并将其数据存储在多个按大小递增的分配中。它通常是将bilrost消息编码为的最有效类型之一,并且它可以像其他选项(如VecBytes)一样有效地读取和复制出来。

ReverseBuffer 可以直接通过 into_vec 方法转换为 Vec<u8>;此方法在必要时会复制内容,但如果可能(如果缓冲区是一个完全初始化的切片),则将直接转换缓冲区而无需复制数据。

ReverseBufferReverseBufReader 还提供了一个 slices 方法,允许遍历缓冲区中的切片以进行向量写入。

编码和解码示例

use bilrost::{
    DistinguishedMessage, DistinguishedOneof, Message, Oneof,
    WithCanonicity,
};
use bytes::Bytes;
use std::collections::BTreeMap;

#[derive(Debug, PartialEq, Eq, Oneof, DistinguishedOneof)]
enum PubKeyMaterial {
    Empty,
    #[bilrost(1)]
    Rsa(Bytes),
    #[bilrost(2)]
    ED25519(Bytes),
}

use PubKeyMaterial::*;

#[derive(Debug, PartialEq, Eq, Message, DistinguishedMessage)]
struct PubKey {
    #[bilrost(oneof(1, 2))]
    key: PubKeyMaterial,
    #[bilrost(3)]
    expiry: i64, // See also: `bilrost_types::Timestamp`
}

#[derive(Debug, Default, PartialEq, Eq, Message, DistinguishedMessage)]
struct PubKeyRegistry {
    keys_by_owner: BTreeMap<String, PubKey>,
}

let mut registry = PubKeyRegistry::default();
registry.keys_by_owner.insert(
    "Alice".to_string(),
    PubKey {
        key: ED25519(Bytes::from_static(b"not a secret")),
        expiry: 1600999999,
    },
);
registry.keys_by_owner.insert(
    "Bob".to_string(),
    PubKey {
        key: Rsa(Bytes::from_static(b"pkey")),
        expiry: 1500000001,
    },
);
let encoded = registry.encode_to_vec();

// The binary of this encoded message breaks down as follows:
//
// (The first and only field, containing a map from String to PubKey)
// 05 - field key: tag 0+1 = 1, wire type 1 = length-delimited
//   2c - length: 44 bytes
//     (The key of the first map item, a String value)
//     05 - length: 5 bytes
//       "Alice"
//     (The value of the first map item, a PubKey message)
//     14 - length: 20 bytes
//       (The "ED25519" variant of the PubKeyMaterial oneof)
//       09 - field key: tag 0+2 = 2, wire type 1 = length-delimited
//         (A String value)
//         0c - length: 12 bytes
//           "not a secret"
//       (The "expiry" field of the PubKey message, an i64)
//       04 - field key: tag 2+1 = 3, wire type 0 = varint
//         fec7e9f50a - varint 3201999998, which is +1600999999 in zig-zag
//     (The key of the second map item, a string value)
//     03 - length: 3 bytes
//       "Bob"
//     (The value of the second map item, another PubKey message)
//     0c - length: 12 bytes
//       (The "RSA" variant of the PubKeyMaterial oneof)
//       05 - field key: tag 0+1 = 1, wire type 1 = length-delimited
//         (A String value)
//         04 - length: 4 bytes
//           "pkey"
//       (The "expiry" field of the PubKey message, an i64)
//       08 - field key: tag 1+2 = 3, wire type 0 = varint
//         82bbc0950a - varint 3000000002, which is +1500000001 in zig-zag

assert_eq!(
    encoded,
    b"\x05\x2c\
      \x05Alice\x14\x09\x0cnot a secret\x04\xfe\xc7\xe9\xf5\x0a\
      \x03Bob\x0c\x05\x04pkey\x08\x82\xbb\xc0\x95\x0a"
        .as_slice()
);

let decoded = PubKeyRegistry::decode_distinguished(encoded.as_slice())
    .canonical() // Check that the decoded data was canonical
    .unwrap();
assert_eq!(registry, decoded);

支持的消息字段类型

bilrost 结构可以编码具有各种类型的字段

编码 值类型 编码表示 区别
general & fixed f32 固定大小 32 位
general & fixed u32, i32 固定大小 32 位
general & fixed f64 固定大小 64 位
general & fixed u64, i64 固定大小 64 位
general & varint u64, u32, u16 varint
general & varint i64, i32, i16 varint
general & varint usize, isize varint
general & varint bool varint
通用 派生 枚举[^enum] varint
通用 String* 长度限定
通用 实现 消息[^boxmsg] 长度限定 可能
varint u8, i8 varint
plainbytes Vec<u8>* 长度限定
(E1, E2, ... EN) (T1, T2, ... TN) 长度限定 如果每个字段是

*其他类型也是可用的!请参见以下内容。

[^enum]: 如果枚举类型的值具有Bilrost表示的零(表示为精确的表达式 0),则可以直接包含该枚举类型,例如通过一个具有 #[bilrost(0)] 属性的 #[bilrost(0)] 属性,或者在没有属性的情况下,通过正常的判别值。否则,枚举类型必须始终嵌套。

[^boxmsg]: 在 Box 中的 Message 类型仍然实现 Message,具有覆盖实现;消息类型可以通过这种方式递归地嵌套 嵌套

这些类型可以直接包含在 bilrost 消息结构体中。如果该字段的值为空,则编码时不会发出任何字节。

除了直接包含它们之外,这些类型还可以嵌套在多个不同的容器中

编码 值类型 编码表示 可重新嵌套 区别
任何编码 选项<T> 相同的;如果 Some,则至少会编码一些字节,如果 None,则不编码任何字节 T
解包的<E> Vec<T>BTreeSet<T> 与编码 E 相同,每个值一个字段 T
解包的<E> [T; N][^arrays] 与编码 E 相同,每个值一个字段 T
解包的 * (与 unpacked<general> 相同) *
打包的<E> Vec<T>BTreeSet<T> 始终长度限定,依次使用 E 编码 T
打包的<E> [T; N][^arrays] 始终长度限定,依次使用 E 编码 T
打包的 * (与 packed<general> 相同) *
映射<KE, VE> BTreeMap<K, V> 始终长度限定,交替使用 KEVE 编码 K & V
通用 Vec<T>BTreeSet<T> (与 unpacked 相同) *
通用 BTreeMap (与 map<general, general> 相同) *

[^arrays]: 固定大小的数组类型([T; N])的行为类似于需要精确项目数的集合。在其他类型的集合在没有项目时被认为是空时,数组被认为是空的,当它们的每个值都是空时。

对于标量值和容器,还有许多其他替代类型可供选择!

值类型 替代 支持编码 区别 启用功能的特性
Vec<u8> Blob[^blob] 通用 (无)
Vec<u8> Cow<[u8]> plainbytes (无)
Vec<u8> bytes::Bytes[^bzcopy] 通用 (无)
Vec<u8> [u8; N][^plainbytearr] plainbytes (无)
u32, u64 [u8; 4], [u8; 8] 固定 (无)
String Cow<字符串> 通用 (无)
String bytestring::ByteString[^bzcopy] 通用 "bytestring"

[^bzcopy]: 从 bytes::Bytes 对象解码时,bytes::Bytesbytes::ByteString 都具有零拷贝优化,并将引用解码的缓冲区而不是复制。 (这也适用于任何其他具有零拷贝 bytes::Buf::copy_to_bytes() 优化的输入类型。)

[^plainbytearr]: 纯字节数组,正如我们所期望的,只接受一个确切的数据长度;其他长度被视为无效值。

[^blob]: bilrost::Blob 是对 Vec<u8> 的透明包装,在大多数情况下是直接替换,并支持默认的 general 编码以最大程度地简化使用。如果除了 Vec<u8> 之外没有其他选择,则 plainbytes 编码仍然将纯 Vec<u8> 编码为其字节值。

容器类型 替代 区别 启用功能的特性
Vec<T> Cow<[T]> T (无)
Vec<T> arrayvec::ArrayVec<[T; N]>[^bounded] T "arrayvec"
Vec<T> smallvec::SmallVec<[T]> T "smallvec"
Vec<T> thin_vec::ThinVec<[T]> T "thin-vec"
Vec<T> tinyvec::ArrayVec<[T; N]>[^bounded] T "tinyvec"
Vec<T> tinyvec::TinyVec<[T]> T "tinyvec"
BTreeMap<T> HashMap<T>[^hashnoncanon] "std" (默认)
BTreeSet<T> HashSet<T>[^hashnoncanon] "std" (默认)
BTreeMap<T> hashbrown::HashMap<T>[^hashnoncanon] "hashbrown"
BTreeSet<T> hashbrown::HashSet<T>[^hashnoncanon] "hashbrown"

[^bounded]: 一些容器,尤其是 ArrayVec 类型,具有内置的最大容量。在解码过程中遇到超出这些容器容纳的字节或项时,解码将失败,并出现“无效值”错误。

[^hashnoncanon]: 基于哈希表的映射和集合已实现,但与特定编码或解码不兼容。如果需要特定解码,则必须使用存储其值按顺序排列的容器。

虽然可以使用 BoxVec 等,嵌套和递归嵌套 Message 类型,但 bilrost 不进行任何运行时检查以避免循环时的无限递归。所选支持的类型和容器不应能够成为 无限,但即使发生这种情况,结果也不会好。(请注意,通过创造性使用 Cow<[T]> 可以创建编码异常大的消息,但借用检查器可以防止它们在数学上(如果不是实际上)无限增长。)

元组

元组类型可以包含在消息中,但有一些值得额外解释的显著特性。

可以使用与值形状相同的编码来指定元组每个成员的编码。例如,(i8, String, u32) 可以使用编码 (varint, general, fixed)!这种方法指定编码可以嵌套。

元组的编码和解码与嵌套消息的编码和解码完全相同,字段类型和编码相同,分配给那些字段的标签与元组成员的索引相同。因此,分配的标签从零开始;这与默认情况下从 1 开始分配标签的派生消息实现形成对比。

只要元组的每个字段都与 general 编码本身兼容,并且所有字段都使用该编码,general 编码就也直接适用于元组类型。

与 Rust 标准库的大多数内容一样,bilrost 实现了直到 12 个参数的元组的编码。

枚举

bilrost 可以从没有字段的变体的 enum 中派生出数值枚举类型的所需实现,其中每个变体要么有

  1. 一个显式的有效 u32 值的区分符,要么
  2. 属性 #[bilrost = 123]#[bilrost(123)],指定一个有效的 u32 常量表达式和匹配模式(此处为例值 123)。
#[derive(Clone, PartialEq, Eq, bilrost::Enumeration)]
enum SimpleEnum {
    Unknown = 0,
    A = 1,
    B = 2,
    C = 3,
}

const FOUR: u32 = 4;

#[derive(Clone, PartialEq, Eq, bilrost::Enumeration)]
#[repr(u8)] // The type needn't have a u32 repr
enum ComplexEnum {
    One = 1,
    #[bilrost = 2]
    Two,
    #[bilrost(3)]
    Three,
    #[bilrost(FOUR)]
    Four,
    // When both discriminant and attribute exist, bilrost uses the attribute.
    #[bilrost(5)]
    Five = 8,
}

所有枚举类型都是通过转换为 Rust u32 类型来编码和解码的,使用 Into<u32>TryFrom<u32, Error = bilrost::DecodeError>。除了使用 Enumeration 的特例外,还需要以下附加特质的实现:CloneEq(以及因此 PartialEq)。

如果枚举的区分符在任何情况下冲突,编译将失败;区分符必须在任何给定的枚举中是唯一的。

# use bilrost::Enumeration;
#[derive(Clone, PartialEq, Eq, Enumeration)]
enum Foo {
    A = 1,
    #[bilrost(1)] // error: unreachable pattern
    B = 2,
}

要使枚举类型有资格直接作为消息字段包含,而不是仅作为嵌套值(在 OptionVec 等)内),其中一个区分符必须确切拼写为 "0"。

兼容宽化

虽然许多类型在编码中有不同的表示和解释,但有几类类型具有相同的编码 相同的解释,只要值适用于两种类型。例如,可以更改一个 i16 字段并将其类型更改为 i32,任何可以表示在 i16 中的数字都将在这两种类型中有相同的编码表示。

通过以下方式始终支持沿这些路径宽化字段:旧消息数据将始终解码为等效/对应值,而那些对应值将从新的宽化结构重新编码到相同的表示。

更改 对应值 当...打破向后兼容性
bool --> u8 --> u16 --> u32 --> u64,所有都使用 generalvarint 编码 true/false 变为 1/0 值超出较窄类型的范围
bool --> i8 --> i16 --> i32 --> i64,所有都使用 generalvarint 编码 true/false 变为 -1/0 值超出较窄类型的范围
String --> Vec<u8> 字符串变为其 UTF-8 数据 值包含无效的 UTF-8
T --> Option<T> T的默认值变为None Some()被编码;它将被视为非规范形式
Option<T> --> Vec<T>(使用unpacked编码) 可能包含的值是相同的 多个值在Vec
[T; N] --> Vec<T> 当每个数组值都为空时,Vec将是空的,而不是填充空值 数据长度与数组不同
Option<[T; N]> --> Vec<T> 没有变化 数据长度与数组不同
Message类型 --> 添加了新字段 没有变化,新字段为空 新字段不为空;它将被视为非规范形式
Enumeration类型 --> 添加了新变体 没有变化 值是新的变体

Vec<T>和其他包含重复值的列表和集合,也可以在unpackedpacked编码之间进行更改,只要内部值类型T没有长度分隔表示。这将在字段存在且非时破坏双向区分解码的兼容性,因为它还会更改编码表示,但快速解码仍然有效。

优势、目标和优点

Bilrost的编码优势包括协议缓冲区的优势

  • 编码消息非常健壮,具有极大的向前兼容性
  • 编码消息相对非常紧凑,它们的“在线”表示非常简单
  • 编码最小[^floatbits]平台相关;每个字节都被指定,没有字节序不兼容问题
  • 在解码时,文本字符串和字节字符串数据被逐字表示,并且可以引用而不需要复制
  • 跳过无关的、不想要的或未知扩展数据成本低,因为大多数嵌套和重复字段都存储了长度前缀

...以及更多

  • 在Bilrost中,解码数据意味着它的说法。如果值被解码,它包含在编码中存在的所有信息(没有静默整数截断!)
  • Bilrost支持对有意义的类型的区分解码,并设计从协议级别开始,尽可能使无效值不可表示
  • 与protobuf相比,Bilrost更加紧凑,且不会产生显著的开销。任何在protobuf中可能实现而Bilrost无法表示或没有类似功能的细微表示,要么永久弃用,要么所有符合protobuf解码器的规范都需要忽略这些差异。
  • bilrost的目标是尽可能地在纯Rust中使用户界面友好,具有基本的注解和derive宏。这样的库使用起来可能非常方便!

[^floatbits]:潜在的不兼容性主要存在于信号NaN和静默NaN浮点值的表示;请参阅f64::from_bits()

Bilrost和库不会做的事情

Bilrost没有健壮的反射生态系统。它(目前)没有像protobuf那样的中间模式语言,也没有许多语言的实现,没有RPC框架支持,也没有独立的验证框架。这些事情是可能的,只是目前还没有实现。

此库也不支持将消息类型编码/解码为JSON或其他可读文本格式。然而,因为它支持从现有结构体派生Bilrost编码实现,所以可以使用其他现有的工具来完成此操作。也可以为bilrost消息类型派生Debug,以及其他类似支持从现有类型派生实现的编码。

编码规范

从哲学上讲,编码方案有两个“方面”:构成它的不可见数据和解释这些数据的标准。

不可见格式

Bilrost数据以零个或多个键值对的形式编码,称为“字段”。键是数字的,包含了字段标签和值的不透明类型信息。

Bilrost中的值以字节字符串或非负整数的形式编码,这些整数不大于无符号64位整数可以表示的最大值(2^64-1)。编码格式本身支持的唯一四个标量类型是这些整数、任意(64位表示)长度的字节字符串以及长度恰好为4或恰好为8的字节字符串。

这种不可见格式应该保持完全稳定,并且(就其价值而言)是自我描述的。标签及其值的含义可能根据使用的模式有很大差异(模式不是自我描述的),但除了不可见数据的解释外,格式不会发生变化。

消息

编码的Bilrost数据的基本功能单元是消息。一个编码消息是一串零个或多个字节,具有特定的长度。

字段

编码的消息由零个或多个编码字段组成。每个字段都有一个数字“标签”,一个范围为无符号32位整数可以表示的数字,以及某种类型的值。

每个字段由两部分编码:首先是键,然后是值。字段的键总是以varint的形式编码。该varint编码值的解释分为两部分:该值除以4是标签增量,除法的余数决定了值的wire类型。标签增量编码了之前编码的字段的标签(或为零,如果是第一个字段)与键所在字段的标签之间的非负差值。Wire类型映射到余数,并确定了字段值的形式和表示方式如下

0: varint - 值是一个不可见数字,以单个varint的形式编码。

1: length-delimited - 值是字节字符串;其长度以单个varint的形式编码,然后紧接着是恰好那么多字节,构成值本身。

2: 固定长度 32 位 - 该值是一个精确的 4 字节字符串,没有额外的前缀编码。

3: 固定长度 64 位 - 该值是一个精确的 8 字节字符串,没有额外的前缀编码。

注意,因为字段键只编码与上一个标签的增量,所以只能按标签的排序顺序编码字段。未排序的字段是无法表示的

如果字段键的标签增量指示一个大于无符号 32 位整数(2^32-1)的标签,则编码的消息是无效的,必须拒绝。

Varints (LEB128 双射编码)

Varints 是无符号 64 位整数值的可变长度编码。编码的 varints 长度为 1 到 9 字节,数值较小的具有较短的编码表示。同时,该范围内的每个数都有一个唯一的编码表示。

  1. Varint 的最后一个字节是第一个其最高位未设置的字节,或者第九个字节,哪个先出现。
  2. 编码的 varint 的值是每个字节的未签名整数值之和,乘以 128(对于每个先前的字节左移/上移 7 位)。
  3. 表示大于 2^64-1 的值的 varints 无效。

存在许多非常相似的 varint 编码的示例。

实现 格式 长度限制? 字节序 双射
sqlite 128 进制,带有延续位 是(9 个字节)
protobuf 128 进制,带有延续位 否(第 10 个字节只使用 1 位)
git 128 进制,带有延续位 否(大数值通常不相关)
bilrost 128 进制,带有延续位 是(9 个字节)
数学

Bilrost 的 varint 表示是一个带有延续位的 128 进制 双射编号 方案。在这种编号方案中,给定方案中的每个可能值都大于具有较少数字的每个可能值。(许多人已经不知不觉地通过电子表格软件的列名了解了双射编号:A, B, ... Y, Z, AA, AB, ...)

经典的双射编号没有零位数字,但使用空字符串表示零。这对我们来说不适用,因为我们必须始终编码至少一个字节以避免歧义。考虑以下

  • 一个 128 进制的双射编号
  • ,使用字节值 0 到 127 表示数值 1 到 128
  • 以最低有效位优先编码,每个字节的最高有效位带有延续位
  • 并编码表示的值加一...

...这几乎完全符合Bilrost varint编码。唯一的例外是,从值9295997013522923648(十六进制0x8102040810204080,编码为[128, 128, 128, 128, 128, 128, 128, 128, 128, 0])开始,直到最大值18446744073709551615(十六进制0xffff_ffff_ffff_ffff,编码为[255, 254, 254, 254, 254, 254, 254, 254, 254, 0]),总是有一个第十个字节,且该字节总是为零。

对于实际应用来说,没有必要能够编码超出64位范围的字节长度,编码超出该范围的值是罕见的,如果需要编码比这更大的整数(例如,128位UUID),则更有效的方法是使用长度限定值来表示,这样需要额外1个字节来表示其大小。因此,在Bilrost varint编码中,我们不编码这个尾随零字节。

示例varint值和算法
一些编码的varint示例
字节(十进制)
0 [0]
1 [1]
101 [101]
127 [127]
128 [128, 0]
255 [255, 0]
256 [128, 1]
1001 [233, 6]
16511 [255, 127]
16512 [128, 128, 0]
32895 [255, 255, 0]
32896 [128, 128, 1]
1000001 [193, 131, 60]
1234567890 [150, 180, 252, 207, 3]
987654321123456789 [149, 237, 196, 218, 243, 202, 181, 217, 12]
12345678900987654321 [177, 224, 156, 226, 204, 176, 169, 169, 170]
(最大 u64: 2^64-1) [255, 254, 254, 254, 254, 254, 254, 254, 254]
Varint算法

以下是一个Python示例代码,它为了清晰而不是性能而编写

def encode_varint(n: int) -> bytes:
    assert 0 <= n < 2**64
    bytes_to_encode = []
    # Encode up to 8 preceding bytes
    while n >= 128 and len(bytes_to_encode) < 8:
        bytes_to_encode.append(128 + (n % 128))
        n = (n // 128) - 1
    # Always encode at least one byte
    bytes_to_encode.append(n)
    return bytes(bytes_to_encode)


def decode_varint_from_byte_iterator(it: Iterable[int]) -> int:
    n = 0
    for byte_index, byte_value in enumerate(it):
        assert 0 <= byte_value < 256
        n += byte_value * (128**byte_index)
        if byte_value < 128 or byte_index == 8:
            # Varints encoding values greater than 64 bits MUST be rejected
            if n >= 2**64:
                raise ValueError("invalid varint")
            return n
    # Reached end of data before the end of the varint
    raise ValueError("varint truncated")

标准解释

为了使编码有用,这些不透明值对许多常见数据类型具有标准解释。

本节中的关键词“MUST”,“MUST NOT”,“REQUIRED”,“SHALL”,“SHALL NOT”,“SHOULD”,“SHOULD NOT”,“RECOMMENDED”,“MAY”和“OPTIONAL”的解释方式请参考RFC 2119

通常,当解码值代表一个超出了它将被解码到的字段类型的定义域的值时(例如,当字段类型是u16但值是一百万,或者当字段类型是一个枚举,但没有相应的枚举变体时),在任何解码模式下都必须拒绝解码并返回错误。

表示为varint的无符号整数将被精确解释。数字10的varint编码在u8u16u32u64字段类型中具有相同的意义。

表示为varint的有符号整数总是进行Zigzag编码,数字的符号由最低有效位表示。因此,非负整数通过加倍转换为无符号数进行编码,而负整数通过取反、加倍然后减去一进行转换。

布尔值使用 varint 值 0 表示 false,使用 1 表示 true

固定宽度的无符号整数必须按小端字节序编码;有符号整数也必须按小端字节序编码,并具有 二进制补码 表示。

浮点数必须按小端字节序编码,并具有 IEEE 754 binary32/binary64 标准表示。浮点数作为四个和八个字节的固定宽度值进行编码。

数组、原始字节字符串和集合必须按顺序编码,其最低索引(第一个)字节或项目首先编码。例如,u8 数组 [1, 2, 3, 4] 和 32 位无符号整数 0x04030201(67305985)的固定宽度编码是相同的。

上述示例
use bilrost::Message;

#[derive(Message)]
struct Foo<T>(#[bilrost(encoding(fixed))] T);

// Both of these messages encode as the bytes `b'\x06\x01\x02\x03\x04'`
assert_eq!(
    Foo(0x04030201u32).encode_to_vec(),
    Foo([1u8, 2, 3, 4]).encode_to_vec(),
);

字符串值必须是有效的 UTF-8 文本,包含某些 Unicode 代码点的规范编码。具有过长编码和代理代码点的代码点应在任何解码模式下拒绝并显示错误,并且必须被视为非规范。Bilrost 对有效非代理代码点的顺序或存在不施加任何限制;在应用程序中约束文本为规范形式(如 NFC)可能是可取的,但这应被视为超出 Bilrost 的 编码和解码 责任范围,而是属于 验证 的责任,这是应用程序的责任。

嵌套消息应表示为包含该消息编码字节的长度限定值。该值之后不能有任何额外的字节,并且嵌套消息的有效性必须包括解码该值的每个字节的成果。

以未打包表示法编码的项目集合(如 Vec<String>)由每个项目的一个字段组成。以打包表示法编码的集合由一个长度限定值组成,其中包含每个项目值的编码,依次排列。在便捷解码模式下,当预期打包表示法但检测到未打包表示法(或反之亦然)时,解码应成功(尽管编码必须被视为非规范)。只有当值本身从未具有长度限定表示法时,才能检测此情况,在这种情况下,字段的有效类型可用于区分这两种情况。

集合(唯一值的集合)的编码和解码形式与非唯一集合完全相同。如果在解码时集合中的值出现多次,则必须在任何解码模式下拒绝消息并显示错误。项目必须按照 规范顺序 进行编码,以便编码被视为规范。

映射表示为长度限定值,包含映射中每个条目的交替编码键和值。键必须是唯一的,如果发现映射有两个等效键,则必须在任何解码模式下拒绝消息并显示错误。在区分解码模式下,映射中的条目必须按照 规范顺序 进行编码,以便编码被视为规范。

任何值为 的字段始终应从编码中省略。任何用空值表示的字段的存在都应使编码被视为非规范。

类型无法编码到多个字段中的字段不能重复出现。如果它们出现,则必须在任何解码模式下拒绝消息并报错。这目前包括所有未使用未打包表示法编码的字段类型。

互斥字段集(Oneofs)的编码中不能存在冲突值。如果存在,则必须在任何解码模式下拒绝消息并报错。

在便捷解码模式下,如果遇到消息中未知的/未指定的标记字段,则应忽略它进行解码。

区分约束

在区分解码模式下,除了上述关于集合和映射中值顺序的约束外,所有值必须以它们编码的方式精确表示。如果在编码中发现空值被表示,则消息不是规范形式。(对于可选字段,Some(0)不被视为空值,并且与始终为空的值None不同;这是可选字段的目的所在。)

在区分模式下,如果遇到消息模式中不存在的标记字段,则编码不能再被认为是规范形式。

空值

Bilrost消息的每个字段类型都有一个“空”值,该值永远不会以编码数据的形式在传输线上表示。

类型 空值
布尔型 false
任何整数 0
任何浮点数 正好 +0.0
固定大小的字节数组 全部为零
文本字符串、字节字符串、集合、映射或集合 不包含任何字节或项
元组 (A, B, C, ...) 每个项都是空的
数组 [T; N] 每个项都是空的
枚举类型 由0表示的变体
消息 消息的每个字段都是空的
Oneof None或空变体
任何可选值(Option<T> None

空字节字符串始终是任何Bilrost消息类型的有效和规范编码,并且代表每个字段都具有其空值的消息值。

规范顺序

对于支持的非消息类型,以下顺序是标准化的

类型 标准顺序
布尔型 false,然后是true
整数 数值升序
文本字符串、字节字符串、字节数组 字典序升序,按字节或UTF-8字节[^u8bytes]
元组 字典序升序,按嵌套值
数组 字典序升序,按嵌套值
集合(vec) 字典序升序,按嵌套值
无序集合(set) 字典序升序,按升序嵌套值
映射 字典序升序,按升序键交替键值
浮点数 (未指定,也不推荐)
枚举类型 (未指定)
消息类型 (未指定)
选项<T> (不适用,不能重复)
Oneof类型 (不适用,不是单个值,不能重复)

[^u8bytes]:字节被认为是无符号的。最小值的字节是空字节 0x00,最大的是 0xff

本标准化对应于 Rust 语言中布尔值、整数、字符串、数组/切片、有序集合和有序映射的现有定义 Ord

bilrostprost

bilrostprost crate 的直接分支,尽管自那时起大部分已被重写。这两个库旨在实现相同的目的,但具有不同的功能和在不同情况下具有不同的优势。

prostProtobuf 的实现,因此它带来了该生态系统的好与坏。Protobuf 消息由专门的 schema 文件指定,实现这些类型的代码随后通常自动生成。prost 有工具可以通过 "protoc" 编译器来完成这项工作;其他实现各不相同,或者重新实现了该 DSL 的完整解析器。

相比之下,bilrost 是一种新的编码实现,与 Protobuf 不兼容。如果不特别需要 Protobuf,请考虑与 Protobuf 编码的 权衡比较

prost-build 生成的代码相对混乱且明确,至少与手写代码相比是这样的。这种生成的代码随后使用 derive 宏生成实现中更复杂的部分,因此理论上可以提交并修改生成的代码,但这种方式并没有显著增加灵活性。

bilrost 将编码实现重构为使用基于特质的调度,而不是为每个字段类型必须显式选择显式实现。这使得 bilrost 可以在不要求大多数字段上显式注释的情况下提供非常广泛的数据类型支持,并且在没有其他生成代码(除了 derive 宏)的情况下非常舒适且易于使用。(这种基于特质的调度可以回滚到 prost 以使其更容易使用,但它可能是一个重大的 API 破坏。)

bilrost 还实现了一些在 prost 中尚未提供的功能

  • 可以通过属性来 忽略消息字段
  • no_std-兼容的哈希表、内联短值的 vecs、ByteString 等,提供了实现
  • MessageDistinguishedMessage 特质是对象安全的,并提供作为特质对象 完整的功能。在编写本文时,prost0.12.3 仅以对象安全的方式公开了少量功能;唯一对象安全的方法是计算消息的编码长度并清除其字段。

与 Protobuf 的区别

Bilrost 编码在 Protobuf 的基础上进行了大量改进,只有少数关键更改。

  • Bilrost 支持更多类型
  • Bilrost 略为紧凑
  • Bilrost 对特定的规范编码提供了一等支持
  • Bilrost 去除了某些容易出错的选择
  • Bilrost 没有庞大的生态系统
更详细地
  • Varint 编码不同:Bilrost 的 Varint 是双射的(每个值只有一个可能的表现形式)并且具有更短的最大长度,因为扩展编码到 64 位整数以上没有意义。

    尽管 Protobuf 的 Varint 在名义上更简单,因为它们直接将编码的位转换成最终值,但在现实中实现这种简单性以提高性能是困难的;在现代计算硬件上,几乎所有成本都是由于值的大小是可变的字节数。

    由于 Protobuf 的 Varint 不是双射的,它们也容易受到零扩展的影响。这始终是在尝试保证 Protobuf 数据中规范表示时反复出现的问题,并且需要额外的注意。

  • 消息只能用其字段的升序标签顺序表示,几十年来 Protobuf 都拒绝执行或保证这一点,并且可能不会很快开始。

    符合规范的 Protobuf 实现通过不保证或强制字段顺序允许执行几个有趣的操作。

    • 未知字段可以保留为完全透明的字节序列,并连接到消息中。
    • 将字段连接到消息具有 合并 语义:单一字段的值被替换(如果它们是消息,则合并),重复字段被附加。这意味着有时可以盲目地将补丁连接到消息中,以覆盖它们的一些字段。

    通过在 Bilrost 中保证字段顺序,这些(几乎很少使用或希望)能力被丢失,但获得了几个强大的优势。

    • 当字段在消息中多次出现而应该只出现一次时,这一点总是显而易见的。不需要做出决定或执行特殊检查来处理这种情况。
    • 如果需要(可能不需要),甚至可以在运行时强制执行编码中特定字段的必需存在,而无需在解码时维护这些字段的存活性数据。

    强制字段排序的另一个隐藏好处是,由于字段标签被编码为差分,具有大量字段的消息在编码时会更小。Protobuf 字段键的标签大于 15 时始终需要多个字节来编码;在 Bilrost 中,只有当连续跳过超过 31 个标签时,字段键才需要超过一个字节。

  • 字段标签的限制较少。在 Protobuf 中,字段标签被限制在 [1, 2^29-1] 范围内;在 Bilrost 中,我们决定从 1 开始自然编号,但除此之外,允许任何无符号 32 位整数作为标签数字。

  • Protobuf 在字段键中使用三个位用于线类型,并且有六个这样的线类型分配;其中两个被用作“组”的无数据界定标记,这是一种过时且已废弃的方法,用于在消息内部嵌套数据。

    在近二十年的时间里,Protobuf 的作者从未找到理由填充最后两个未分配的线类型,这至少使我们有些信心,认为 Bilrost 借用的四个足够实际使用。

还有几个关于如何在 Bilrost 中解释值的关键变化,这些变化是受 Protobuf 经验的启发。

  • Bilrost 中有符号整数的表示总是 zig-zag 编码的。在 Protobuf 中,有符号整数有两种不同的模式:“int32”总是像二进制补码那样编码,而“sint32”是 zig-zag 编码。在实践中,这是一个巨大的问题,因为任何负整数在传输上总是变成 十字节。是的,即使是 32 位的,因为它们在字段可能被扩展到未来时被符号扩展到 64 位。

  • 从 Protobuf(以及 C/C++ 通常)的错误和失误中再次学习,Bilrost 也强制执行当值超出范围时的错误。Protobuf 值将通过截断强制转换为更小的类型,并且任何非零 varint 将静默地转换为布尔值 true。这通常是令人惊讶的、有缺陷的且不受欢迎的。

  • bilrost在编码和解码浮点数时特别注重保留每一位。只要可能,Bilrost的其他语言库应该匹配这种做法。

  • Bilrost对嵌套值更为宽容。长度定界值可以编码为“紧凑”表示形式,同时向用户发出警告;这允许在vec中嵌套vec,在map中嵌套map,等等,而不需要在每一层嵌套中都创建显式的子消息模式。

  • Bilrost有第一类映射。Protobuf中的map是一个未打包的重复值构造,它是嵌套的子消息,键和值分别标记为1和2的字段,这种情况的官方字段类型和API在其投入使用后才出现。Protobuf至今仍禁止使用字节字符串作为map键,原因可能与其在某些实现中将nul终止的C字符串用作map键表示有关。

    由于Bilrost的map被打包成一个单独的长度定界值,它们可以自由地具有可选的存在性或任意地重复或嵌套。

bilrost的序列化表示

Bilrost利用对varint表示和字段顺序的更改,为许多消息类型标准化了易于区分的规范编码。varint的零扩展和无序字段是导致Protobuf编码对同一意义有所不同的两个主要因素,而大部分剩余的涉及强制确保空值永远不会编码,紧凑/非紧凑集合具有匹配的表示,map键按顺序排列,并跟踪编码中是否存在任何未知字段。

与其他编码的比较

这是一个关于我们可能考虑的各种替代编码的不完整比较。

除了这个一般性总结之外,现在在rust_serialization_benchmark中也可以找到基准测试。

编码 编码复杂度 无模式? 向后/向前兼容? 可读性? 规范编码? 优于Bilrost? 劣于Bilrost?
Bilrost 非常低 有模式 ! 🌈 🌈
Protobuf 几乎一样简单 有模式 大型生态系统,有模式DSL 略为紧凑,更多陷阱,类型支持较少
ASN.1 DER 相当高 有模式 高度标准化和验证的规范性 使用和实现起来痛苦
Cap'n Proto 中等 有模式 非常快,支持零拷贝样式解码,模式DSL,支持多种语言 不够紧凑,严重依赖于生成的类型
Flatbuffers 中等 有模式 非常快,支持零拷贝样式解码,模式DSL,支持多种语言 不够紧凑,严重依赖于生成的类型
rkyv ? 固定到结构体 ? 极其快速的无拷贝归档编码 为完全不同的目的而构建
bincode 固定到结构体 ? 更快,更紧凑 当添加新字段时不可兼容
JSON 中等偏低 无模式 标准化,可能得到支持 几乎普遍支持,可读性好 不够紧凑,更多损失,不适合许多值类型
BSON 中等 无模式 它是JSON但更紧凑 不够紧凑,不是规范的
msgpack 中等 无模式 它是JSON但更紧凑 不够紧凑,不是规范的
CBOR 中等 无模式 标准化,它是JSON但更紧凑 不够紧凑
XML 哲学家们意见不一 显然是的 你已经听说了,你知道它,它无处不在 远不够紧凑,是一个来自过去的粗糙武器

常见问题解答

  1. 为什么还要另一个?

因为我可以创建一个可以做我想做的事情的产品。

尽管Protobuf功能强大、优雅,但它却背负着数十年的历史负担,包括存储数据和实际应用中的使用,这些都阻碍了它的改变。这导致了在实践中出现了一些奇特的行为,这些行为最初出于便捷性而实现,现在却深深地植入了编码的官方规范中(例如,非重复字段中嵌套消息的重复出现如何合并等)。

如果我们以一种谨慎的态度对待新的标准,我们可以解决这些问题中的许多,并创建一个与Protobuf非常相似但更加健壮的编码,这几乎不需要额外的开销(如果字段是无序的,检测它们是否重复需要开销,但如果它们必须有序,则非常简单)。在此基础上,通过做一点额外的工作,我们还实现了我们杰出消息类型的固有规范化。在Protobuf中完成同样的事情是一项繁重的任务,而且我在野外几乎从未看到过正确描述这项任务的。许多人,正如常说的那样,尝试过但失败了。

总结:我有这样的狂妄想法,认为我可以使Protobuf编码变得更好。对我来说,这是真的。也许这对你们来说也是一样。

  1. 能否将Bilrost编码实现为Serde的序列化器?

可能不行,尽管Serde专家可以自由发表意见。尝试使用Serde序列化Bilrost消息存在多个复杂问题。

  • Bilrost字段带有编号标签,目前看来,在serde中似乎没有适合此功能的机制。
  • Bilrost字段还与特定的编码相关联,如generalfixed,这可能会改变它们的表示。仅基于特质的调度对于此将表现不佳,特别是在值嵌套在其他数据结构(如map和Vec)中时,编码可能看起来像map<plainbytes, packed<fixed>>
  • Bilrost消息必须按标签顺序编码其字段,这在oneof字段的情况下可能(根据它们的值)而变化,而且不清楚如何在serde中解决这个问题。
  • Bilrost具有便捷和杰出的解码模式,并承诺对于实现了DistinguishedMessage的消息,编码总是产生规范输出。这可能超出了实际实现的范畴。

尽管如此,仍然可以在生成的类型上放置serde derive标签,因此同一结构可以同时支持bilrostSerde

为什么叫“Bilrost”?

起源于Google的Protocol Buffers,采用了“protobuf”这个合成词。相应地,Rust版本的Protobuf被称为“prost”。

为了分叉这个库,人们可能会称之为“Frost”?但这个名字已被占用。“Bifrost”是一个好名字,也是一种“frost, 2”的双关语;但这个名字也被占用了。“Bilrost”是原始北欧“Bifrost”的另一种说法,这个名字相当好,所以我们就用这个名字。

许可证

bilrost在Apache许可证(版本2.0)的条款下分发。

请参阅源代码中的授权协议 & 通知以获取详细信息,或访问github上的授权协议通知

版权所有 2023-2024 Kent Ross
版权所有 2022 Dan Burkert & Tokio 贡献者

依赖项

~0.1–11MB
~123K SLoC