41次发布
0.12.1 | 2024年6月20日 |
---|---|
0.11.0 | 2024年6月19日 |
0.9.7 | 2023年8月25日 |
0.9.6 | 2023年6月18日 |
0.0.0 | 2021年3月9日 |
#151 in 编码
每月 299次下载
445KB
10K SLoC
典型:代数数据类型的互操作
典型 是一个 数据序列化 框架。您在一个名为 schema 的文件中定义数据类型,然后典型为各种语言生成高效的序列化和反序列化代码。生成的代码可以用于在服务之间打包消息、在磁盘上存储结构化数据等。紧凑的 二进制编码 支持不同版本 schema 之间的向前和向后兼容性,以适应不断变化的需求。
典型可以与 协议缓冲区 和 Apache Thrift 进行比较。主要区别在于典型有一个更现代的类型系统,它基于 代数数据类型,强调使用不可为空的类型和穷尽模式匹配的更安全的编程风格。如果您熟悉这种风格的编程语言,如 Rust 和 Haskell,您会感到很自在。典型提供了一种新的解决方案 (“非对称”字段) 来解决在 记录类型 中安全地添加或删除字段而不会破坏兼容性的经典问题。非对称字段的这一概念还解决了在 和类型 中添加或删除案例时如何保持兼容性的双重问题。
简而言之,典型提供了两个通常被认为相互矛盾的重要功能
- 无妥协的类型安全
- schema 版本之间的二进制兼容性
典型的设计受到了在谷歌使用协议缓冲区和在 Airbnb 使用 Apache Thrift 的经验的启发。这不是这两家公司的官方支持产品。如果您想支持典型,您可以通过 这里 做到。
支持的编程语言
以下语言目前得到支持
- Rust
- TypeScript
- JavaScript(通过 TypeScript)
教程
为了理解这一切,让我们通过一个示例场景进行说明。假设你想构建一个简单的API来发送电子邮件,并且你需要决定如何对请求和响应进行序列化。你可以使用JSON或XML这样的自描述格式,但你可能想要更好的类型安全和性能。Typical在这方面有很多话要说。
尽管我们的示例场景涉及客户端与服务器通信,但Typical没有客户端或服务器的概念。它只处理序列化和反序列化。网络、加密和认证等其他问题超出了Typical的范畴。
步骤 1:编写一个模式
你可以从创建一个名为 types.t
(或任何你喜欢的其他名称)的模式文件开始,其中包含你的API的一些类型
struct SendEmailRequest {
to: String = 0
subject: String = 1
body: String = 2
}
choice SendEmailResponse {
success = 0
error: String = 1
}
此模式定义了两种类型:SendEmailRequest
和 SendEmailResponse
。第一种类型是 结构体,这意味着它描述了包含一组固定字段的消息(在这种情况下,to
、subject
和 body
)。第二种类型是 联合体,这意味着它描述了包含从一组固定可能性中恰好一个字段的邮件(在这种情况下,success
和 error
)。结构体和联合体被称为 代数数据类型,因为它们可以抽象地理解为类型乘法和加法,但你不需要了解这些就可以使用Typical。
每个字段都有一个名称(例如,body
)和一个整数索引(例如,2
)。名称仅用于人类,因为只使用索引来识别二进制编码中的字段。只要不更改索引,你可以自由地重命名字段而不用担心二进制不兼容。
每个字段还有一个类型(例如,String
)。如果类型缺失,就像上面提到的 success
字段一样,则默认为内置类型 Unit
。Unit
类型不包含任何信息,编码时占用零字节。
一旦你编写了你的模式,Typical可以对其进行格式化,以确保一致的语法定义,如缩进、字母大小写等。以下命令将执行此操作,尽管由于我们的示例已经正确格式化,它不会产生任何影响
typical format types.t
步骤 2:生成序列化和反序列化代码
现在我们已经定义了一些类型,我们可以使用Typical来生成序列化和反序列化代码。例如,你可以使用以下命令生成Rust和TypeScript代码:
typical generate types.t --rust types.rs --typescript types.ts
有关如何自动化的说明,请参考示例项目。总之
- 对于Rust,你可以使用一个Cargo构建脚本,当调用
cargo build
时执行。 - 对于TypeScript,你可以使用你的
package.json
中的scripts
属性。
使用Typical不需要设置自动构建系统,但我们推荐这样做以提高便利性。
步骤 3:序列化和反序列化消息
在前一节生成的代码基础上,让我们编写一个简单的Rust程序来序列化一条消息。我们可以将消息写入内存缓冲区、套接字,或者任何实现了std::io::Write
的任何东西。在这个例子中,我们将数据流式传输到文件。
let message = SendEmailRequestOut {
to: "[email protected]".to_owned(),
subject: "I love Typical!".to_owned(),
body: "It makes serialization easy and safe.".to_owned(),
};
let mut file = BufWriter::new(File::create(REQUEST_FILE_PATH)?);
message.serialize(&mut file)?;
file.flush()?;
另一个程序可以按如下方式读取文件并反序列化消息
let file = BufReader::new(File::open(FILE_PATH)?);
let message = SendEmailRequestIn::deserialize(file)?;
println!("to: {}", message.to);
println!("subject: {}", message.subject);
println!("body: {}", message.body);
本例的完整代码可以在这里找到。TypeScript版本在这里。
在下一节中,我们将看到为什么我们的SendEmailRequest
类型变成了SendEmailRequestOut
和SendEmailRequestIn
。
必填、可选和非对称字段
字段默认是必填的。这是一个不寻常的设计决策,因为通常认为必填字段会在架构版本之间的前后兼容性中引起麻烦。让我们详细探讨这个主题,并看看Typical是如何处理它的。
添加或删除必填字段是危险的
经验告诉我们,向已经使用的类型中引入必填字段可能会有困难。例如,假设你的电子邮件API正在运行,你想要在请求类型中添加一个新的from
字段
struct SendEmailRequest {
to: String = 0
# A new required field
from: String = 3
subject: String = 1
body: String = 2
}
实施这个变更的唯一安全方式(按照这种方式编写)是在开始更新任何服务器之前完成所有客户端的更新。否则,仍在运行旧代码的客户端可能会向更新后的服务器发送请求,服务器会立即拒绝该请求,因为它缺少新字段。
这种部署可能不可行。你可能无法控制客户端和服务器更新的顺序。或者,也许客户端和服务器是一起更新的,但不是原子的。客户端和服务器甚至可能是同一个复制服务的一部分,因此无论你多么小心,都无法先更新一个再更新另一个。
删除必填字段可能会遇到类似的问题。假设,尽管存在上述挑战,你成功地将from
引入为必填字段。现在,另一个相关问题迫使你回滚。这就像最初添加它一样危险:如果客户端在服务器之前更新,那么该客户端可能会发送一个没有from
字段的请求,服务器会拒绝该请求,因为它仍然期望该字段存在。
将可选字段提升为必填或反之亦然是危险的
引入必填字段的一种稍微安全的方法是首先将其引入为可选的,然后将其提升为必填。例如,你可以安全地引入这个变更
struct SendEmailRequest {
to: String = 0
# A new optional field
optional from: String = 3
subject: String = 1
body: String = 2
}
然后更新客户端以设置新字段。一旦你确信新字段总是被设置,你就可以将其提升为必填。
问题在于,只要字段是可选的,你就不能依赖类型系统来确保新字段总是被设置。即使你确信你已适当地更新了客户端代码,你的队友可能在你有机会将其提升为必填之前,可能会引入一个新实例的字段未被设置。
当你将必填字段降级为可选时,也可能会遇到类似的问题。一旦字段已被降级,客户端可能会在服务器能够处理其缺失之前停止设置该字段,除非你能确保服务器首先更新。
使所有字段都可选既不舒适也不安全
由于必填字段相关的问题,传统观点是根本不用它们;所有字段都应该声明为可选。例如
struct SendEmailRequest {
optional to: String = 0
optional subject: String = 1
optional body: String = 2
}
然而,这种建议忽略了这样一个现实:有些事物在语义上确实是必需的,即使根据模式它们不是必需的。如果没有它所需的数据,API无法正常工作。将语义上必需的字段声明为可选,给作者和读者都带来了额外的负担:作者不能依赖类型系统来防止他们不小心忘记设置字段,而读者必须处理字段缺失的情况,以满足类型检查器,尽管这些字段始终应该是设置的。
不对称的字段可以安全地提升为必需,反之亦然
为了帮助您安全地添加和删除必需字段,Typical提供了一个介于可选和必需之间的中间状态:不对称。结构体中的不对称字段对于作者来说是必需的,但对于读者来说是可选的。与可选字段不同,不对称字段可以安全地提升为必需,反之亦然。
让我们用一个电子邮件API示例来具体说明这一点。我们不是直接将from
字段作为必需字段引入,而是首先将其作为不对称字段引入
struct SendEmailRequest {
to: String = 0
# A new asymmetric field
asymmetric from: String = 3
subject: String = 1
body: String = 2
}
让我们看看这个模式的生成代码;我们将使用Rust作为示例。生成的代码有两种版本的我们的SendEmailRequest
类型,一个是用于序列化,另一个是用于反序列化
pub struct SendEmailRequestOut {
pub to: String,
pub from: String,
pub subject: String,
pub body: String,
}
pub struct SendEmailRequestIn {
pub to: String,
pub from: Option<String>,
pub subject: String,
pub body: String,
}
impl Serialize for SendEmailRequestOut {
// Serialization code omitted.
}
impl Deserialize for SendEmailRequestIn {
// Deserialization code omitted.
}
我们可以看到from
作为一个不对称字段的影响:在SendEmailRequestOut
中,其类型是String
,但在SendEmailRequestIn
中,其类型是Option<String>
。这意味着客户端(使用SendEmailRequestOut
)现在必须设置新字段,但服务器(使用SendEmailRequestIn
)还不允许依赖它。一旦这个变更已经推出(至少针对客户端),我们就可以在后续变更中安全地将字段提升为必需。
反之亦然。假设我们现在想删除一个必需字段。直接删除字段可能是不安全的,因为这样客户端可能会在服务器处理其缺失之前停止设置它。但我们可以将其降级为不对称,这迫使服务器将其视为可选并处理其潜在的缺失,尽管客户端仍然必须设置它。一旦这个变更已经推出(至少针对服务器),我们就可以自信地删除字段(或将其降级为可选),因为服务器不再依赖它。
在某些情况下,一个字段可能会保持不对称状态数月,比如说,如果你在等待足够比例的用户更新你的移动应用。Typical可以通过在此期间防止引入不适当地使用该字段的新代码,在这些情况下发挥巨大作用。
选择也可以有可选和不一致的字段
我们之前的讨论都是围绕结构体进行的,因为它们对大多数程序员来说很熟悉。现在我们转向硬币的另一面:选择。
为选择生成的代码支持情况分析,因此客户端可以根据设置的字段采取不同的操作。幸运的是,这是以确保你已经处理了所有情况的方式来完成的。这被称为穷尽模式匹配,这是一个非常棒的功能,可以帮助你编写正确的代码。例如,在Rust中,我们可能如下对电子邮件API的响应进行模式匹配
fn handle_response(response: SendEmailResponseIn) {
match response {
Success => println!("The email was sent!"),
Error(message) => println!("An error occurred: {message}"),
}
}
如果我们向 SendEmailResponse
选择中添加一个新字段,那么 Rust 编译器将强制我们在此处承认新的情况。这是好事!但是在序列化和反序列化数据时,穷举模式匹配的严格性可能会双刃剑:如果读者不认识设置的该字段,他们可能无法反序列化该选择。
这意味着在一般情况下,在选项中添加或删除 必需 字段是不安全的——就像在结构体中一样。如果你添加一个必需字段,更新的写入者可能会在非更新的读者知道如何处理它之前开始设置它。相反,如果你删除一个必需字段,更新的读者将无法处理它,尽管非更新的写入者可能仍在设置它。
不用担心——选项可以像结构体一样具有可选和不对称字段!
选项和不对称字段在选项中必须使用后备字段构造,该字段在读者不认识或不想处理原始字段时用作备份。读者不需要处理可选字段;因此,可选。请注意,后备本身可能是可选的或不对称的,在这种情况下,后备必须有一个后备,等等。最终,后备链以必需字段结束。读者将扫描后备链以找到第一个他们认识的字段。
注意:选项中的可选字段并不仅仅是一个 选项类型/空值类型 字段。这里的“可选”一词意味着读者可以忽略它并使用后备,而不是它的有效负载可能缺失。人们可能会诱使假设对于结构体和选项的工作方式相同,但事实上,它们在 对偶 方式上工作:结构体的可选性减轻了写入者的负担(他们不必设置字段),而对于选项,负担减轻在读者身上(他们不必处理字段)。
虽然选项中的不对称字段必须使用后备字段构造,但后备字段不会暴露给读者;他们必须能够处理不对称字段本身。因此,选项中的不对称字段对写入者而言像可选字段,对读者而言像必需字段——与在结构体中的行为相反。对偶再次出现!就像结构体一样,选项中的不对称字段可以安全地提升为必需字段,反之亦然。为了强调:这就是不对称字段唯一的目的。
考虑我们 API 响应类型的更复杂版本
choice SendEmailResponse {
success = 0
error: String = 1
# A more specific type of error for curious clients
optional authentication_error: String = 2
# To be promoted to required in the future
asymmetric please_try_again = 3
}
让我们检查生成的代码。与结构体一样,我们最终会得到用于序列化和反序列化的独立类型
pub enum SendEmailResponseOut {
Success,
Error(String),
AuthenticationError(String, Box<SendEmailResponseOut>),
PleaseTryAgain(Box<SendEmailResponseOut>),
}
pub enum SendEmailResponseIn {
Success,
Error(String),
AuthenticationError(String, Box<SendEmailResponseIn>),
PleaseTryAgain,
}
impl Serialize for SendEmailResponseOut {
// Serialization code omitted.
}
impl Deserialize for SendEmailResponseIn {
// Deserialization code omitted.
}
必需情况(Success
和 Error
)在这两种类型中都是你预期的。
可选情况,AuthenticationError
,有一个 String
用于错误消息和一个第二个有效负载用于后备。写入者可能会将更不具体的 Error
情况作为后备。如果读者不想处理可选情况,他们可以使用后备,并且不知道可选情况的读者会自动使用后备。
不对称情况,PleaseTryAgain
,也要求写入者提供后备。但是,读者无法使用它。这是在将字段更改为必需(这将停止要求写入者提供后备)或将字段从必需更改为可选/删除之前(这将停止读者必须处理它)使用的一个安全中间状态。
关于默认值怎么办?
典型类型没有为每种类型定义“默认值”的概念。这意味着,例如,如果读者看到一个字段值为0
,它可以确信这个值是写者明确设置的,写者并非只是不小心忘记设置它。零值、空字符串、空数组等在语义上没有任何特殊之处。
安全模式更改的总结
假设每个模式更改都需要有限的时间来传播,任何用户定义的类型都可以通过一系列向后和向前兼容的更改安全地迁移到任何其他用户定义的类型。以下是单次更改允许的规则
- 您可以安全地重命名和重新排列字段,只要您不改变它们的索引。
- 您可以安全地添加和删除可选和非对称字段。
- 您可以安全地将非对称字段转换为可选或必需的,反之亦然。
- 您可以安全地将恰好有一个字段的struct转换为仅包含该字段的choice,反之亦然。这种更改很少见,但这是确保任何用户定义的类型最终都可以迁移到任何其他用户定义的类型所必需的。
- 其他更改不一定保证安全。
在数学术语中,这些规则定义了模式上的同构兼容关系,该关系是自反的(每个模式与其自身兼容)和对称的(向前兼容性和向后兼容性相互暗示),但不是传递的(两个单独安全的模式更改不一定作为一个单独的更改安全)。特别是,对称性是使典型比其他框架更安全的关键属性。
模式参考
模式仅包含两种事物:导入和用户定义的类型。任何导入都必须在用户定义的类型之前。空白将被忽略。
导入
您不需要将所有类型定义放入一个模式文件中。您可以根据自己的意愿将类型组织到单独的模式文件中,然后从其他模式导入模式。例如,假设您有一个名为email_util.t
的模式文件,其中包含以下内容
struct Address {
local_part: String = 0
domain: String = 1
}
然后您可以从另一个文件中导入它,例如我们的types.t
文件
import 'email_util.t'
struct SendEmailRequest {
to: email_util.Address = 0
subject: String = 1
body: String = 2
}
您只需要在types.t
上运行典型。生成的代码将包含来自types.t
和email_util.t
的类型,因为前者导入了后者。
导入路径被认为是相对于包含导入模式的目录的相对路径。典型没有“顶层”目录的概念,所有路径都以该目录为基础。
一个有用的约定是创建一个types.t
模式文件,它导入所有其他模式,无论是直接还是间接的。然后可以清楚地知道要将哪个模式提供给典型以生成代码。或者在大型组织中,您可能为每个项目有一个单独的顶层模式,它仅导入该项目所需的类型。这些都是约定,因为典型没有内在的“项目”概念。
如果您从不同的目录导入具有相同名称的两个模式,您需要区分这些模式的使用。例如,假设您尝试以下操作
import 'apis/email.t'
import 'util/email.t'
struct Employee {
name: String = 0
# Uh oh! Which schema is this type from?
email: email.Address = 1
}
幸运的是,典型会通知您这个问题,并要求您澄清您的意图。您可以使用导入别名这样做
import 'apis/email.t' as email_api
import 'util/email.t' as email_util
struct Employee {
name: String = 0
email: email_util.Address = 1
}
用户定义的类型
每个用户定义的类型要么是struct,要么是choice,并且它们具有相同的抽象语法:一个名称,一个字段列表,以及可选的已删除字段索引列表。以下是用户定义类型的一些示例
import 'apis/email.t'
import 'net/ip.t'
choice DeviceIpAddress {
static_v4: ip.V4Address = 0
static_v6: ip.V6Address = 1
dynamic = 2
}
struct Device {
hostname: String = 0
asymmetric ip_address: DeviceIpAddress = 1
optional owner: email.Address = 2
}
字段
字段由可选规则、可读性名称、可选类型和索引组成。
规则,如果存在,要么是optional
或asymmetric
。没有规则表示该字段是必需的。
名称是字段的可读性标识符。它用于在代码中引用字段,但永远不会在传输线上进行编码,可以随意重命名。名称的大小不会影响编码消息的大小,因此尽可能详细。
类型(如果存在),可以是内置类型(例如,String
)、同一架构中用户定义类型的名称(例如,DeviceIpAddress
),或导入的名称以及对应架构中类型的名称(例如,email.Address
)。如果类型不存在,则默认为Unit
。这可以用来创建传统的枚举类型。
choice Weekday {
monday = 0
tuesday = 1
wednesday = 2
thursday = 3
friday = 4
}
索引是一个非负整数,需要在类型内是唯一的。可能的最大索引是4,611,686,018,427,387,903
(即2^62 - 1
)。索引不需要是连续的或任何特定的顺序,但从连续的索引开始是一个良好的惯例。
已删除的字段
如果您删除了一个字段,您必须小心不要在仍然包含该删除字段的任何消息存在的情况下,为任何新字段重用该字段的索引。否则,旧字段将被解码为新字段,这很可能会引起反序列化错误,这几乎肯定不是您想要的。为了避免这种情况,您可以通过保留已删除字段的索引来防止它们被重用。例如,如果我们从上面的Device
结构中删除了ip_address
和owner
字段,我们可以按如下方式保留它们的索引:
struct Device {
hostname: String = 0
deleted 1 2
}
这样,典型情况下将阻止我们引入具有这些索引的新字段。
内置类型
以下内置类型受支持:
Unit
是一种不包含信息的类型。它主要用于表示枚举类型的选项的字段。F64
是按照IEEE 754定义的双精度浮点数类型。U64
是范围在0
到2^64
)内的整型。S64
是范围在-2^63
,2^63
)内的整型。Bool
是布尔类型的类型。- 您可以将自己的布尔类型定义为具有两个字段的选项,并且它将使用完全相同的空间。然而,内置的
Bool
类型通常更方便使用,因为它对应于生成的代码中的本地布尔类型。
- 您可以将自己的布尔类型定义为具有两个字段的选项,并且它将使用完全相同的空间。然而,内置的
Bytes
是二进制块的类型。String
是Unicode文本的类型。- 数组(例如,
[String]
)是一些其他类型的序列的类型。数组可以是嵌套的(例如,[[String]]
)。
注释
注释可以用来为您的模式添加有用的上下文。一个注释从#
开始,直到行尾,就像Python、Ruby、Perl等一样。
与大多数编程语言不同, Typical 模式中的注释与特定的项目相关联。具体来说,注释附着在结构、选择、单个字段或整个模式文件上。以下模式演示了注释可能使用的所有上下文。
# This file contains types relating to a hypothetical email sending API.
# A request to send an email
struct SendEmailRequest {
# To whom the email is addressed
to: String = 0
# The subject line of the email
subject: String = 1
# The contents of the email
body: String = 2
}
# The result of attempting to send an email
choice SendEmailResponse {
# The email was delivered
success = 0
# There was a problem sending the email
error: String = 1
}
标识符
标识符(类型、字段或导入的名称)必须以字母开头,后续的每个字符必须是字母、下划线或数字。如果您想将关键字(例如,choice
)用作标识符,可以通过在前面加$
(例如,$choice
)来实现。这个$
不包括在生成的代码中。
安全性
生成的反序列化代码旨在防止恶意输入,从保护不安全的内存访问(如缓冲区溢出、缓冲区溢出和任意代码执行)的角度来看是安全的。
为了减轻基于内存的拒绝服务攻击,拒绝不切实际的庞大消息而不是尝试反序列化它们是一种好的做法。通常,您可以在内存中期望反序列化消息的大小与对应于网络上序列化消息大小的同一数量级。然而,有一个例外:对于类型[Unit]
(单位数组)的值,只编码元素的数量,因为Unit
值本身在网络上占用零字节。如果期望具有该类型的字段,攻击者可以强制反序列化逻辑重新构造任意大的单位数组(参见十亿笑声攻击)。因此,我们强烈建议如果您打算消费不受信任的输入,请避免在模式中使用[Unit]
。然而,这并不是一个重大的损失,因为该类型本身通常没有用处。它仅为了类型系统的统一;数组可以包含任何东西,即使某些类型的数组没有实际用途。
请向[email protected]报告任何安全问题。
代码生成
每个代码生成器不管有多少个模式文件,都只生成一个自包含的源文件。示例项目演示了如何使用为每种语言生成的代码。下面的部分包含一些特定于语言的内容。
Rust
- Typical的类型系统可以直观地映射到Rust的
struct
和enum
,但命名约定略有不同。所有Typical类型都使用UpperCamelCase
(例如,String
),而Rust使用该名称和lower_snake_case
的组合(例如,u64
)。请注意,Typical的整数类型称为S64
和U64
(“S”表示有符号,“U”表示无符号),但在Rust中相应的类型是i64
和u64
(“i”表示整数,“u”表示无符号)。
JavaScript和TypeScript
-
生成的代码在Node.js和现代网络浏览器中运行。可以使用像Babel这样的工具针对旧版浏览器。对于网络应用程序,最好将生成的代码与您的其他应用程序代码一起压缩。
-
生成的代码从不使用反射或动态代码评估,因此在内容安全策略限制的环境中也能正常工作。
-
Typical的整数类型映射到
bigint
而不是number
。使用整数来表示货币或其他不应四舍五入的量是安全的。Typical的F64
类型映射到number
,正如预期的那样。 -
在给定了正确类型的参数时,生成的函数从不抛出异常。
deserialize
函数可以返回一个Error
来表示失败,TypeScript要求调用者承认这种可能性。 -
生成的代码导出了一个名为
unreachable
的函数,该函数可用于执行详尽的模式匹配。例如,假设您有以下模式struct Square { side_length: F64 = 0 } struct Rectangle { width: F64 = 0 height: F64 = 1 } struct Circle { radius: F64 = 0 } choice Shape { square: Square = 0 rectangle: Rectangle = 1 circle: Circle = 2 }
然后您可以这样对
Shape
进行模式匹配import { Types, unreachable } from '../generated/types'; function area(shape: Types.ShapeIn): number { switch (shape.$field) { case 'square': return shape.square.sideLength * shape.square.sideLength; case 'rectangle': return shape.rectangle.width * shape.rectangle.height; case 'circle': return Math.PI * shape.circle.radius * shape.circle.radius; default: return unreachable(shape); } }
如果选择中添加了新字段,TypeScript将强制您在相应的
switch
语句中添加适当的case
。
二进制编码
以下部分描述了Typical如何序列化您的数据。在大多数情况下,由于字段头更小、更有效的可变宽度整数编码以及可以从字段大小推断一些信息的技巧,Typical的编码方案比Protocol Buffers和Apache Thrift更紧凑。
可变宽度整数
许多情况需要Typical序列化整数值,例如,用于编码字段索引、缓冲区大小和用户提供的整数数据。在适当的情况下,Typical使用可变宽度编码,允许较小的整数使用更少的字节。在实际分布的情况下,大多数整数最终只需占用一个字节。
无符号可变宽度整数
无符号可变宽度整数的有效范围是[0
,2^64
).
令n
为要编码的整数。以下描述的编码方案是小端,因此最后一个字节包含最高位。
- 如果
0 <= n < 128
- 将
n
的7位嵌入1个字节中,如下所示:xxxxxxx1
。
- 将
- 如果
128 <= n < 16,512
- 将
n - 128
的 14 位嵌入 2 个字节中,如下所示:xxxxxx10 xxxxxxxx
。
- 将
- 如果
16,512 <= n < 2,113,664
- 将
n - 16,512
的 21 位嵌入 3 个字节中,如下所示:xxxxx100 xxxxxxxx xxxxxxxx
。
- 将
- 如果
2,113,664 <= n < 270,549,120
- 将
n - 2,113,664
的 28 位嵌入 4 个字节中,如下所示:xxxx1000 xxxxxxxx xxxxxxxx xxxxxxxx
。
- 将
- 如果
270,549,120 <= n < 34,630,287,488
- 将
n - 270,549,120
的 35 位嵌入 5 个字节中,如下所示:xxx10000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
。
- 将
- 如果
34,630,287,488 <= n < 4,432,676,798,592
- 将
n - 34,630,287,488
的 42 位嵌入 6 个字节中,如下所示:xx100000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
。
- 将
- 如果
4,432,676,798,592 <= n < 567,382,630,219,904
- 将
n - 4,432,676,798,592
的 49 位嵌入到 7 个字节中,如下所示:x1000000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
。
- 将
- 如果
567,382,630,219,904 <= n < 72,624,976,668,147,840
- 将
n - 567,382,630,219,904
的 56 位嵌入到 8 个字节中,如下所示:10000000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
。
- 将
- 如果
72,624,976,668,147,840 <= n < 18,446,744,073,709,551,616
- 将
n - 72,624,976,668,147,840
的 64 位嵌入到 9 个字节中,如下所示:00000000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
。
- 将
第一个字节中尾随零位数的数量表示后续字节数量。这允许通过大多数现代处理器的单个指令(例如,BSF
或 TZCNT
)高效地确定编码整数的字节数。
这种可变宽度整数编码类似于 Protocol Buffers 和 Thrift 的 compact protocol 中使用的 "base 128 varints"。然而,Typical 的编码在两个方面有所不同
- 出于时间效率的考虑,Typical 将所有延续位移动到第一个字节。这允许在大多数处理器上使用单个 CPU 指令计算延续位,如上所述。
- 为了提高空间效率,Typical 使用一种称为双射编号的技术,在某些情况下使用的字节数更少,并且永远不会比上面提到的基128 varint 编码使用更多的字节数。例如,数字
16,500
在 Typical 的编码中占用2个字节,但在 Protocol Buffers 和 Thrift 的 紧凑协议 中使用的编码中则占用3个字节。
有符号变长整数
有符号变长整数的有效范围是 [-2^63
, 2^63
)]。
Typical 将有符号整数转换为无符号的 "ZigZag" 表示形式,然后按照上述方法编码无符号结果。ZigZag 表示形式将小幅度有符号整数转换为小幅度无符号整数,将大幅度有符号整数转换为大幅度无符号整数。这使得可以使用更少的字节来编码小幅度有符号整数。
具体来说,64位两的补码整数 n
的 ZigZag 表示形式是 (n >> 63) ^ (n << 1)
,其中 >>
是一个算术右移。逆操作是 (n >> 1) ^ -(n & 1)
,其中 >>
是一个逻辑右移。
为了让你了解它的工作原理,以下是一些数字的 ZigZag 表示形式:(0
,-1
,1
,-2
,2
)分别对应(0
,1
,2
,3
,4
)。
用户定义的类型
结构体
结构体被编码为(头部,值)对的连续排列,每个字段一对,其中值根据其类型进行编码,头部编码为一个或两个部分
- 头部的第一部分是一个无符号整数 标签,它被编码为一个变长整数。标签的含义如下
- 标签的两个最低有效位(而非其变长编码)称为 大小模式,表示如何计算值的大小
0
:值的大小为0字节。1
:值的大小为8字节。2
:值以可变宽度整数进行编码,因此可以从其第一个字节确定其大小。值的大小既不是0字节也不是8字节,否则大小模式将分别是0
或1
。3
:值的大小由头部的第二部分给出(见下文)。它既不是0字节也不是8字节,否则大小模式将分别是0
或1
。值不是以可变宽度整数进行编码,否则大小模式将是2
。
- 标签的剩余位(不是其可变宽度编码)代表字段索引的无符号整数。
- 标签的两个最低有效位(而非其变长编码)称为 大小模式,表示如何计算值的大小
- 如果适用,头部的第二部分是作为可变宽度整数编码的值的大小。它仅在大小模式为
3
时存在。
对于类型为Unit
、F64
、U64
、S64
或Bool
且索引小于32的字段,头部以单个字节进行编码。
结构体必须遵循以下规则
- 编码规则
- 可选字段可能缺失,但必需和非对称字段必须存在。
- 解码规则
- 所有必需字段必须存在,但可选和非对称字段可能缺失。
- 未识别的字段将被忽略。
选择
选择以与结构体相同的方式进行编码,但具有不同的规则
- 编码规则
- 必须存在至少一个必需字段。
- 解码规则
- 必须存在至少一个必需或非对称字段。
- 使用读者识别的第一个字段。
对于简单的枚举类型(如上面的Weekday
),索引小于32的字段占用单个字节。
内置类型
Unit
用于编码不占用字节。F64
通常以IEEE 754定义的小端双精度浮点格式进行编码。因此,通常占用8字节进行编码。然而,对于字段值(而不是,例如,数组的元素),正零以0字节进行编码。U64
通常以可变宽度整数进行编码。因此,通常根据值的大小占用1-9字节进行编码。然而,对于字段值(而不是,例如,数组的元素),0
以0字节进行编码,并且大于或等于567,382,630,219,904
的值以固定宽度的8字节小端整数进行编码。S64
首先转换为无符号的ZigZag表示形式,然后以与U64
相同的方式进行编码,包括适用时字段值的相关特殊行为。Bool
首先被转换为一个整数,其中0
表示false
,而1
表示true
。然后,这个值会以与U64
相同的方式进行编码,包括适用于字段值的特殊行为(如果适用)。Bytes
以原样进行编码。String
以 UTF-8 格式进行编码。原始的代码点序列被保留,没有进行规范化。- 数组(例如,
[U64]
)根据元素类型以三种方式之一进行编码Unit
的数组通过以与U64
相同的方式编码的元素数量来表示,包括适用的字段值的特殊行为。由于元素(类型为Unit
)编码需要 0 字节,因此无法从缓冲区的大小推断出元素的数量。因此,它会被显式编码。F64
、U64
、S64
或Bool
的数组以元素的各自编码的连续排列来表示。元素的数量没有显式编码。- 任何其他类型的数组(如
Bytes
、String
、嵌套数组或嵌套消息)以 size(编码 element 的字节数)和 element 的连续排列进行编码,其中 size 以可变宽度整数编码。元素根据其类型进行编码。元素的数量没有显式编码。
请注意,当这些类型用作字段值时,可以利用更紧凑的表示。例如,可变宽度整数需要 1-9 字节进行编码,但 U64
和 S64
字段只需要 0-8 字节进行编码,不包括字段头。这似乎是不可能的——解决这个悖论的方案是,额外的信息来自字段头的尺寸模式。
基准测试
我们为每个代码生成器提供了粗粒度的基准测试,链接如下 这里。下面的数据是在 2022 年 MacBook Air 上,搭载 Apple M2 芯片和 8 GB 内存的情况下,平均运行 3 次得到的结果。Rust 基准测试是用 Rust 1.69.0 编译的,带有 --release
。TypeScript 基准测试是用 TypeScript 4.5.5 转换为 JavaScript,并用 Node.js 18.16.0 运行的。
一个基准测试序列化和反序列化包含数百兆文本的大消息
Rust | TypeScript | |
---|---|---|
每个线程的序列化速率 | 7.258 GiB/s | 3.345 GiB/s |
每个线程的反序列化速率 | 2.141 GiB/s | 2.408 GiB/s |
另一个基准测试反复序列化和反序列化包含许多小且深度嵌套值的有害消息
Rust | TypeScript | |
---|---|---|
每个线程的序列化速率 | 632.890 MiB/s | 42.953 MiB/s |
每个线程的反序列化速率 | 205.773 MiB/s | 2.061 MiB/s |
这些基准测试代表了两个极端。实际性能将介于中间。
使用方法
一旦 Typical 安装完成,您可以使用它通过以下方式生成名为 types.t
的模式的代码
typical generate types.t --rust types.rs --typescript types.ts
以下是支持的命令行选项
USAGE:
typical <SUBCOMMAND>
OPTIONS:
-h, --help
Prints help information
-v, --version
Prints version information
SUBCOMMANDS:
format
Formats a schema and its transitive dependencies
generate
Generates code for a schema and its transitive dependencies
help
Prints this message or the help of the given subcommand(s)
shell-completion
Prints a shell completion script. Supports Zsh, Fish, Zsh, PowerShell, and Elvish.
特别是,generate
子命令有以下选项
USAGE:
typical generate [FLAGS] [OPTIONS] <SCHEMA_PATH>
FLAGS:
-h, --help Prints help information
--list-schemas Lists the schemas imported by the given schema (and the given schema
itself)
OPTIONS:
--rust <PATH> Sets the path of the Rust file to emit
--typescript <PATH> Sets the path of the TypeScript file to emit
ARGS:
<SCHEMA_PATH> Sets the path of the schema
安装说明
在 macOS 或 Linux(AArch64 或 x86-64)上的安装
如果您正在运行 macOS 或 Linux(AArch64 或 x86-64),可以使用以下命令安装 Typical
curl https://raw.githubusercontent.com/stepchowfun/typical/main/install.sh -LSfs | sh
再次使用该命令可以更新到最新版本。
安装脚本支持以下可选环境变量
VERSION=x.y.z
(默认为最新版本)PREFIX=/path/to/install
(默认为/usr/local/bin
)
例如,以下命令将 Typical 安装到工作目录中
curl https://raw.githubusercontent.com/stepchowfun/typical/main/install.sh -LSfs | PREFIX=. sh
如果您不想使用此安装方法,可以从 发行页面 下载二进制文件,使用 chmod
命令使其可执行,并将其放置在您的 PATH
(例如 /usr/local/bin
)中的某个目录中。
Windows(AArch64 或 x86-64)上的安装
如果您正在运行 Windows(AArch64 或 x86-64),请从 发行页面 下载最新二进制文件,并将其重命名为 typical
(如果文件扩展名可见,则为 typical.exe
)。在您的 %PROGRAMFILES%
目录中创建一个名为 Typical
的目录(例如 C:\Program Files\Typical
),并将重命名的二进制文件放在其中。然后,在控制面板“系统属性”部分的“高级”选项卡中,单击“环境变量...”,并将新 Typical
目录的完整路径添加到“系统变量”下的 PATH
变量中。注意,如果 Windows 配置为非英语语言,则 Program Files
目录可能具有不同的名称。
要更新现有安装,只需替换现有二进制文件。
使用 Homebrew 安装
如果您有 Homebrew,可以按以下方式安装 Typical
brew install typical
您可以使用以下命令更新现有安装:brew upgrade typical
。
使用 Cargo 安装
如果您有 Cargo,可以按以下方式安装 Typical
cargo install typical
您可以使用 --force
参数运行该命令来更新现有安装。
依赖关系
~1.4–8.5MB
~53K SLoC