普通视图

发现新文章,点击刷新页面。
昨天以前首页

分析时序数据:从 InfluxQL 到 SQL 的演变

作者 tison
2024年6月5日 08:00

近年来,时序数据的增长是 Data Infra 领域一个不容忽视的趋势。这主要得益于万物互联带来的自然时序数据增长,以及软件应用上云和自身复杂化后的可观测性需求。前者可以认为是对联网设备的可观测性,而可观测性主要就建构在设备或应用不断上报的指标和日志等时序数据上。

分析时序数据的演变史几乎是大数据分析演变史的复现,即一开始都是把数据存在关系型数据库上,使用 SQL 分析;而后由于规模增长的速度超过传统技术增长,经历了一个折衷技术的歧出;最终,用户在 SQL 强大的理论框架和生态支持的影响下,回到解决了规模问题的 SQL 方案上来。

对于时序数据来说,这一歧出造成了大量时序数据库自创方言。其中以 InfluxDB 在 V1 版本创造了 InfluxQL 方言,在 V2 版本创造了 Flux 方言,又在 V3 里开始主推 SQL 的演变过程最为有趣。

查询语言简介

InfluxQL

InfluxQL 是 InfluxDB V1 自创的查询语言,大体上模仿了 SQL 的结构,以下是一些 InfluxQL 查询的示例:

1
2
3
4
5
SELECT * FROM h2o_feet;
SELECT * FROM h2o_feet LIMIT 5;
SELECT COUNT("water_level") FROM h2o_feet;
SELECT "level description","location","water_level" FROM "h2o_feet";
SELECT *::field FROM "h2o_feet";

InfluxDB 设计开发的年代,实现一个数据库的技术远远没有像今天一样有大量人才掌握。因此,尽管 InfluxQL 努力靠近 SQL 的语法,但是在当时,以关系代数为支撑实现完整的 SQL 查询并添加时序扩展是比较困难的。InfluxQL 转而实现了大量专为时序数据分析设计的功能和运算符。例如,所有查询会默认返回时间列并按升序排序,所有查询必须带有 field 列才会返回结果,面向时间线粒度设计的特殊查询语法,等等。

基本上,InfluxQL 就是 InfluxDB 对以数值指标为主的时序数据分析需求的直接翻译。随着 InfluxDB 产品的发展,InfluxQL 还支持连续查询和指定保留策略,以实现某种程度的实时数据处理。

虽然 InfluxQL 在 InfluxDB V2 中也能使用,但是由于 InfluxDB V2 主推 Flux 查询语言,使用 InfluxQL 会面临一系列模型失配导致的额外挑战

Flux

Flux 是 InfluxDB V2 自创的查询语言。不同于 InfluxQL 模仿 SQL 的语法结构,Flux 的语法应该算作 DataFrame 的流派。Elixir 的开发者大概会对 Flux 的语法感到亲切,以下是 Flux 查询的示例:

1
2
3
4
5
from(bucket: "example-bucket")
|> range(start: -1d)
|> filter(fn: (r) => r._measurement == "example-measurement")
|> mean()
|> yield(name: "_result")

从设计理念上说,Flux 的目的是要支持各种数据源上的时序数据的联合分析。它允许用户从时序数据库(InfluxDB)、关系型数据库(PostgreSQL 或 MySQL),以及 CSV 文件上获取数据,然后进行分析。例如,可以用 sql.fromcsv.from 相关的语法从数据源拉取数据,替代上述示例中 from(bucket) 的部分,后接其他分析算子。

Flux 语言只能在 InfluxDB V2 中使用,V1 上不支持,V3 上被弃用。原因想必大家看完上面这个例子也可以想象:学习成本巨高。更不用说没有专业的语言开发者支持,要在扩展语法的同时修复各种设计实现问题,这几乎是不可负担的工程成本。

SQL

SQL 大家耳熟能详了。它的大名是结构化查询语言(Structured Query Language),理论基础是关系代数。

不同于从业务中生长出来的,专为业务场景定制的方言,SQL 有坚实的理论支持。从 E. F. Codd 发表了经典论文 A Relational Model of Data for
Large Shared Data Banks
之后,五十多年来积累在关系型数据库上的研究汗牛充栋。

尽管各家 SQL 数据库都会实现独特的扩展,有时让用户也挺摸不着头脑,但是在关系代数理论的支持下,基本的查询分析能力,每一个 SQL 数据库都能一致实现。如果在十几二十年前,或许 Data Infra 的舆论场还会出现 SQL 已死或者 NoSQL 才是未来的论调。但是在今天,毫无疑问 SQL 作为数据分析的默认选择已经王者归来。几十年来,SQL 不断地被改进和扩展,并经由一系列久经考验的实现推广,在全球范围内得到了广泛采用。

InfluxDB V3 号称实现了 SQL 查询的支持,并在该版本中推荐用户使用 SQL 分析时序数据。GreptimeDB 在技术选型上和 InfluxDB V3 不谋而合,率先自主实现了面向时序数据的 SQL 数据库,并在多个严肃生产环境当中部署使用。

抛开时序查询扩展不谈,在 GreptimeDB 上可以用标准 SQL 执行查询

1
SELECT idc, AVG(memory_util) FROM system_metrics GROUP BY idc;

SQL 的理论支持帮助新的时序数据库可靠地实现复杂的查询逻辑,以及完成日常数据管理任务。SQL 丰富的生态,也使得新的时序数据库能够快速接入到数据分析的技术栈上。例如,此前制作的输入行为分析示例,就利用 GreptimeDB 支持 MySQL 协议这点,零成本地集成到 Streamlit 上实现了可视化。

时序分析的挑战

SQL

虽然 SQL 有着理论支持强大和分析生态丰富两个核心优势,但是在传统的 SQL 数据库在处理时序数据时仍然会面临一系列的挑战,其中最突出的就是数据规模带来的挑战。

时序数据的价值密度大多数时候非常低。设备上传的信息大部分时候你都不会专门去看,应用上报自己状态健康的数据,也不需要额外留意。因此,存储时间数据的成本效率就至关重要。如何利用新时代的云共享存储降低成本,通过针对时序数据的极致压缩来减少数据本身需要的容量,都是时序数据库需要研究的课题。

此外,如何高效地从大量时序数据中提取关键信息,很多时候确实需要特定的查询扩展来优化。GreptimeDB 支持 RANGE QUERY 以帮助用户分析特定时间窗口下的数据聚合就是一个例子。

Flux

毋庸赘言,学习成本就杀死了这个方言。同样,复述一遍前文的观点,作为一个单一提供商独木难支的方言,其语言本身的健壮性,性能优化能做的投入,以及生态的开发,都面临巨大的挑战,更不用说现在这个唯一提供商还放弃了继续发展 Flux 方言。这下已死勿念了。

InfluxQL

虽然 InfluxQL 查询写起来有些像 SQL 的语法,但是其中细微的区别还是非常让人恼火的。而且,即使努力的 Cosplay SQL 的语法,InfluxQL 从根上还是一个从主要关注指标的时序分析业务需求长出来的方言。它在后续开发和维护成本上的挑战和 Flux 不会有本质的差别。

例如,InfluxQL 不支持 JOIN 查询。虽然你可以写类似 SELECT * FROM "h2o_feet", "h2o_pH" 这样的查询,但是它的含义是分别读出两个 measurement 上的数据(😅):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
> SELECT * FROM "h2o_feet","h2o_pH"

name: h2o_feet
--------------
time level description location pH water_level
2015-08-18T00:00:00Z below 3 feet santa_monica 2.064
2015-08-18T00:00:00Z between 6 and 9 feet coyote_creek 8.12
[...]
2015-09-18T21:36:00Z between 3 and 6 feet santa_monica 5.066
2015-09-18T21:42:00Z between 3 and 6 feet santa_monica 4.938

name: h2o_pH
------------
time level description location pH water_level
2015-08-18T00:00:00Z santa_monica 6
2015-08-18T00:00:00Z coyote_creek 7
[...]
2015-09-18T21:36:00Z santa_monica 8
2015-09-18T21:42:00Z santa_monica 7

此外,虽然 InfluxDB V3 在强烈的用户呼声下支持了 InfluxQL 以帮助用户逐步迁移到新版本,但是 InfluxDB V3 主推的还是基于 SQL 的查询方案。换句话说,大胆点判断,InfluxQL 也是一个已死勿念的方言。

注意 InfluxQL 是查询方言,不包括 InfluxDB 行协议(Line Protocol)的部分。InfluxDB 行协议是一个简洁、完整、高效的数据写入接口。它几乎没有再开发和维护的成本,并且通过 Telegraf 的插件生态,能够快速跟一系列数据上报方案做集成。

如何迁移到 SQL 分析

上文提到,InfluxDB V3 仍然支持 InfluxQL 的核心原因是用户需求。诚然,InfluxDB 过去很长一段时间可说是时序数据库的代名词,并且现在仍然是 DB-Engines 上时序数据分类中最高影响力的数据库。因此,不少时序数据分析的用户现有的分析逻辑是用 InfluxQL 写成的。

这里介绍 InfluxQL 跟 SQL 的核心不同,从而说明如何从 InfluxQL 迁移到 SQL 分析。

时间列

应用逻辑迁移当中,最重要的一个区别就是 SQL 对时间列没有特殊的处理,而 InfluxQL 会默认返回时间列,且结果按时间戳升序排列。SQL 查询需要显式指定时间列以在结果集中包含时间戳,也需要手动指定排序逻辑。

数据写入时,InfluxQL 会默认自动用当前时间填充时间列,而 SQL 必须手动指定时间列的值。如果是当前时间,也需要明确写出:

1
2
3
4
-- InfluxQL
INSERT INTO "measurement" (tag, value) VALUES ('my_tag', 42);
-- SQL
INSERT INTO measurement (ts, tag, value) VALUES (NOW(), 'my_tag', 42);

InfluxQL 不支持一个 INSERT 语句插入多列,SQL 数据库通常支持一个 INSERT 语句插入多列:

1
INSERT INTO measurement (ts, tag, value) VALUES (NOW(), 'my_tag_0', 42), (NOW(), 'my_tag_1', 42);

此外,InfluxQL 查询使用 tz() 函数指定查询的时区,而 SQL 通常有其他设定时区的方式。例如,GreptimeDB 支持 MySQLPostgreSQL 设置时区的语法。

时间线

InfluxQL 有一些时间线粒度的查询语法,例如 SLIMITSOFFSET 等。

SLIMIT 会限制结果集中单个时间列返回数据的数量,例如 SLIMIT 1 意味着每个时间列最多返回一个符合过滤条件的结果。

SQL 不是专为时序数据分析设计的,因此需要一些取巧的手段,例如:

1
SELECT DISTINCT ON (host) * FROM monitor ORDER BY host, ts DESC;

这个查询返回以 host 为标签区分的时间列,每个时间列唯一一个结果:

1
2
3
4
5
6
+-----------+---------------------+------+--------+
| host | ts | cpu | memory |
+-----------+---------------------+------+--------+
| 127.0.0.1 | 2022-11-03 03:39:58 | 0.5 | 0.2 |
| 127.0.0.2 | 2022-11-03 03:39:58 | 0.2 | 0.3 |
+-----------+---------------------+------+--------+

通常,时序数据库会实现各自的语法扩展或特殊函数来支持时间列粒度的查询。

时间间隔

InfluxQL 的时间间隔语法形如 1d12m 等,SQL 的时间间隔语法有标准:

1
2
INTERVAL '1 DAY'
INTERVAL '1 YEAR 3 HOURS 20 MINUTES'

数据列和标签列

InfluxQL 从模型上就区分了数据列和标签列,只 SELECT 了标签列的查询是查不出数据的。此外,InfluxQL 支持 ::field::tag 后缀来指定数据列或标签列,并由此支持同名的数据列和标签列。

SQL 标准不区分数据列和标签列,都是普通的一列。不过在具体系统实现上,可能会对概念做一些映射。例如,GreptimeDB 的数据模型就区分了时间列、标签列和数据列,并有对应的映射规则。

GreptimeDB 的数据模型

函数名称

部分函数的名称未必相同。例如,InfluxQL 当中的 MEAN 函数对应 SQL 当中的 AVG 函数。

其他函数,例如 COUNT / SUM / MIN 等等,许多还是相同的。

标识符

InfluxQL 的标识符很多时候需要用双引号括起来,而 SQL 则支持无引号的标识符。

值得注意的是,SQL 的标识符默认是大小写不敏感的,如果需要大小写敏感的标识符,则需要用对应的引号括起来。在 GreptimeDB 当中,默认是用双引号括起。但是在 MySQL 或 PostgreSQL 客户端链接上来的时候,会尊重对应方言的语法。

InfluxQL 标识符引号的部分使用区别示例如下:

InfluxQLSQL
WHERE(“value”) > 42where value_col > 42
GROUP BY “tag”GROUP BY tag_col
SELECT MEAN(“value”) FROM “measurement”SELECT AVG(value_col) FROM measurement

JOIN

InfluxQL 不支持 JOIN 查询,SQL 数据库的一个重要甚至是基础能力就是支持 JOIN 查询:

1
2
3
4
5
6
7
8
-- Select all rows from the system_metrics table and idc_info table where the idc_id matches
SELECT a.* FROM system_metrics a JOIN idc_info b ON a.idc = b.idc_id;

-- Select all rows from the idc_info table and system_metrics table where the idc_id matches, and include null values for idc_info without any matching system_metrics
SELECT a.* FROM idc_info a LEFT JOIN system_metrics b ON a.idc_id = b.idc;

-- Select all rows from the system_metrics table and idc_info table where the idc_id matches, and include null values for idc_info without any matching system_metrics
SELECT b.* FROM system_metrics a RIGHT JOIN idc_info b ON a.idc = b.idc_id;

以上是来自 GreptimeDB JOIN 的示例。目前时序数据库在 JOIN 查询上支持最全的应该是 QuestDB 数据库。

时间范围查询

InfluxQL 的 GROUP BY 语句支持传递一个时间间隔,以按照特定长度的时间窗口来聚合数据。

SQL 没有这样特定的查询能力,最接近的应该是 OVER ... PARTITION BY 的语法,但是这个语法还挺难理解的。

支持 SQL 的时序数据库大多会实现自己的范围查询扩展:

GreptimeDB 的 RANGE QUERY 是其中最强大的。不过其中 ALIGN / RANGE / FILL 的含义和应该出现的位置需要一点点学习成本,我应该近期会写一篇文章来讨论这个场景的需求和 RANGE QUERY 的实现。

持续聚合

InfluxQL 支持持续聚合,这在 SQL 当中是标准的物化视图(Materialized View)的需求,TimescaleDB 就使用了 MATERIALIZED VIEW 的相关语法来实现持续聚合。

不过物化视图在大部分 SQL 数据库中的实现都比较脆弱,目前仍然是一个有待探索的领域。部分时序数据库会实现自己的持续集合方案,例如 GreptimeDB 基于数据流引擎实现了持续聚合

KQIR 查询引擎:Apache Kvrocks 实现 SQL 和 RediSearch 之路

作者 tison
2024年6月3日 08:00

『太长不看版』

Apache Kvrocks 作为 Redis 的开源替代,近期支持了以下查询语法:

欢迎试用或跳转文末到完整示例段落查看具体步骤的含义。

原文作者 twice 发表于 Apache Kvrocks 官方博客。本文是取得原文作者许可的中文译文,翻译过程中间略有措辞顺序调整和演绎。

项目背景

首先介绍一下 Apache Kvrocks 项目:它是 Redis 的开源替代,构建在 RocksDB 之上。因此,就缓存场景而言,Kvrocks 跟 Redis 是有设计上的差距的,但是在数据持久化和数据存储成本方面,Kvrocks 就有设计上的优势。总的来说,Kvrocks 是一个实现了绝大部分 Redis 命令的 NoSQL 数据库。

Kvrocks 支持 RESP 通信协议,包括 v2 和 v3 两个版本。因此,绝大部分 Redis 生态的工具都可以无缝接入到 Kvrocks 上。上面演示的例子就是从 Redis 提供的 redis-cli 命令行工具接入到 Kvrocks 后操作的。

Kvrocks 作为一个使用 C++ 从头重新实现 Redis 的开源替代,不仅支持了 Redis 最基本的命令,还支持了 Redis Stack 当中的高级功能,包括:

Redis Stack 的功能源码一直是商业协议下发布的,新版本的 Redis 也使用了其自制的专有协议发布。Kvrocks 在保证接口行为一致的前提下,完全使用 C++ 从头重新实现,不存在合规风险。本文介绍的 KQIR 更是相同用户体验下完全原创的底层实现方案,与上游实现是相互独立的。

复杂查询的需求

近二十年来,NoSQL 数据库迎来了一段蓬勃发展的黄金时期,一度盖过传统数据库的风头。这主要是因为 NoSQL 数据库性能更好,易于扩展,并且针对特定使用场景有极致的优化。例如,Redis 作为 KV 数据库的代表,MongoDB 作为文档数据库的代表,Apache HBase 作为某种表格数据库的代表,各领风骚数年。

然而,许多用户不愿意仅仅出于性能原因而放弃传统 SQL 数据库的提供基本功能,尤其是 ACID 事务、SQL 固有复杂查询能力,以及结构化数据和关系代数提供的优化和抽象。因此,一批自诩 NewSQL 的新型数据库就此诞生。其中典型包括 TiDB 和 CockroachDB 等。

如前所述,Kvrocks 大体上是一个 NoSQL 数据库。虽然 Kvrocks 算不上 NewSQL 数据库,但是它仍然努力在 NoSQL 和 NewSQL 范式之间取得平衡。在保证 NoSQL 的高性能和灵活性的前提下,Kvrocks 努力实现事务保证和支持更复杂的查询。

RediSearch?

RediSearch 是一个 Redis 模块,它通过查询、辅助索引和全文搜索功能实现了对 Redis 的增强。

虽然其对应的 Redis 命令FT. 开头(即 Full-Text 全文),但它不仅仅是全文搜索。

实际上,Redis 正快速向 SQL 数据库靠拢。RediSearch 允许用户在现有 Redis JSON 或 HASH 数据上创建结构化模式,以用于索引构建。这些模式支持各种字段类型,如数字、标记、地理、文本和矢量,后两者用于全文搜索和矢量搜索。不同于直接支持 SQL 查询的方案,RediSearch 提供了一种独特的查询语法,称为 RediSearch 查询语言。

RediSearch 在许多领域都能找到应用场景,例如利用其矢量搜索功能来支持检索增强生成(RAG)。例如,LangChain 将 Redis 作为其矢量数据库之一。如果 Kvrocks 能够实现 RediSearch 的接口,那么 Kvrocks 就可以作为这些生态当中 Redis 位置的一个潜在选项。对于那些更关注成本和持久化的用户来说,Kvrocks 将成为一个非常有吸引力的选项。

SQL?

RediSearch 自定义了一套语法来进行查询。这产生了一些额外的问题。

首先,RediSearch 的模式(也称为索引,使用 FT.CREATE 创建)可以对应到 SQL 数据库中的一个表。它的查询语法在语义上也与 SQL 查询一致。考虑到这种相似性,支持 SQL 查询并不会带来太多额外工作量。那么,为什么我们不把 SQL 查询也顺带支持上呢?

其次,SQL 的使用范围非常广,为许多人所熟悉,上手更简单。理解 RediSearch 查询语法需要花费相当的时间,而适应新的 SQL 数据库通常更不费力。此外,SQL 为各种查询功能提供了强大的支持,增强了表达能力(例如 JOIN、子查询和聚合等等)。

最后,RediSearch 查询语法受到一些历史设计的影响。例如,AND 和 OR 运算符(在 RediSearch 查询中用空格和 | 运算符表示)的优先级在不同的方言版本中有所不同。这些村规增加了用户的理解成本,而常用的标准 SQL 给到用户的基本假设是相对一致的。

综合考虑,我们认为在 Kvrocks 支持复杂查询时,将 SQL 作为查询语言会是一个不错的决定。

『译注』

自研查询语言很多时候都是死路一条。InfluxDB 自己搞的 Flux 脚本语言已经进入维护模式了,说白了就是死了还没埋。新版本的 InfluxDB V3 一开始只想支持 SQL 作为查询接口,后来迫于存量压力实现了 InfluxQL 的兼容,但是显然 InfluxQL 也是其官方定义下的明日黄花了。

KQIR 的设计与实现

KQIR 的总体架构

为了在 Kvrocks 系统当中支持 SQL 查询,我们需要设计一个健壮的查询引擎。它需要考虑到系统的扩展性、可维护性,以及强大的查询计划和优化能力。

Kvrocks 的方案是 KQIR 框架。在 Kvrocks 的语境下,KQIR 代表着:

  1. 完整的查询引擎,包括语法解析、查询优化和算子执行,等等。
  2. 查询引擎全阶段操作的一种中间表示(IR)。

多层级的 IR

实现 KQIR 的一个主要目的是同时支持 SQL 和 RediSearch 的查询方言。为了屏蔽不同方言下用户输入的差异,我们需要设计一个统一的查询中间表示。

目前,Kvrocks 已经实现了一个支持 MySQL 语法和 RediSearch 查询语法的一个子集的语法解析器。它能够将这两者对应的抽象语法树统统转换为 KQIR 的形式。

KQIR 是一个多层级的中间表示,可以表示优化过程中不同级别的查询结构。抽象语法树首先会转换成 Syantatic IR 的形式,这是某些语法表达式的高级表示。这个形式的 IR 经过优化器处理后,会转变为 Planning IR 的形式。Planning IR 则是一种在查询引擎中表达查询执行计划的低级表示。

此外,我们将在优化之前对 IR 进行语义检查,以确保查询在语义上是正确的。这包括验证它是否不包括任何未定义的模式或字段,并使用适当的字段类型。

IR 优化器

KQIR 优化器由多个阶段(Pass)组成。这仿照了 LLVM 的概念和设计。每个阶段都以某种形式的 IR 作为输入,执行相应的分析和更改,然后生成新的 IR 作为输出。

『译注』

twice 同时也是 LLVM 相关项目的活跃贡献者:

目前,优化器的过程分为三组:

  • 表达式分析:主要优化逻辑表达式,如 AND、OR、NOT 运算符等;
  • 数值分析:通过区间分析优化数值比较,例如消除不必要的比较,或改进比较表达式来实现查询优化;
  • 查询计划生成:把 Syntatical IR 转换成 Planning IR 并通过选择最佳索引以及消除不必要排序来增强查询计划。

Kvrocks 的阶段管理器会控制上述阶段的运行顺序。每个阶段可能运行多次,但最终会收敛并交给执行器执行。

查询计划执行

KQIR 计划执行器是一个 Volcano 模型的实现。

一旦 IR 优化器完成所有优化,计划执行器就可以拿到最终的 Planning IR 结果。然后,计划执行器会将 IR 转化为具体的执行算子,串接成为一个从源端拉取数据,经过层层转换后输出结果的流水线。

随后,Kvrocks 从最终结果的迭代器中轮询拉取数据,取得查询结果。

磁盘上的索引

不同于 Redis 在内存中存储索引数据,Kvrocks 需要在磁盘上构建索引。这意味着,对于任何字段类型,我们都需要设计编码来将索引转换为 RocksDB 上的键值对。

此外,我们需要在执行 JSON 或 HASH 命令前后分别递增地创建索引,以确保查询结果是实时的。

现状与限制

KQIR 功能目前已经合并到 unstable 分支上,支持 FT.CREATEFT.SEARCHFT.SEARCHSQL 等命令。我们鼓励用户进行测试和发布反馈。

然而,KQIR 仍处于早期开发阶段,我们无法保证兼容性,并且,许多功能仍然不完整。因此,即将发布的版本 2.9.0 将不包括 KQIR 组件。我们将在 2.10.0 版本开始发布 KQIR 功能。

字段类型支持

目前,我们只支持两种字段类型:标记(tag)和数字(numeric)。

标记字段用多个 tag 标记了每个数据记录,以便在查询中进行筛选。

数字字段保存双精度浮点范围内的数字数据,允许按特定的数值范围进行排序和过滤。

未来,我们计划扩大支持范围,将向量搜索和全文检索功能与其他字段类型一起实现。

事务保证

目前,KQIR 的事务保证非常弱,这可能会导致使用过程中出现意外问题。

Kvrocks 社群有另一个项目,计划通过建立结构化框架来增强 Kvrocks 的事务保证,从而在 KQIR 实现的 ACID 支持。

『译注』

上述项目也是今年开源之夏(OSPP)的一个项目。

IR 优化器的限制

目前,KQIR 在优化排序时没有使用成本模型,而是依赖一段专门的逻辑。这点会在未来的版本里以高优先级做改进。

此外,KQIR 目前没有使用基于运行时统计数据的优化。我们未来的重点将是将运行时统计信息集成到成本模型中,以实现更精确的索引选择。

与其他功能的关系

KQIR 与命名空间功能集成良好。

FT.CRAETE 创建的任何索引都限制在当前命名空间中,不能在其他命名空间中访问,这与命名空间中访问其他数据的方式一致。

目前,KQIR 无法在集群模式下启用。集群模式支持目前还没有计划,但是这是我们想要实现的功能。欢迎在 Kvrocks 社群当中分享你的需求场景或设计思路。

合规问题

虽然 KQIR 实现了 RediSearch 的接口,但它不包括任何来自 RediSearch 的代码。如前所述,KQIR 采用了一个全新的框架,其查询架构(包括解析、优化、执行)均独立于 RediSearch 的实现。

这点非常重要,因为 RediSearch 并不是开源软件,而是专有许可下的扩展。Kvrocks 的实现保证用户在开源协议下使用相关功能,而无需担心额外的合规风险。这也是 Apache 软件基金会品牌的一个重要保证。

这是一次冒险!

KQIR 目前仍处于早期实验阶段。我们建议用户在生产环境中使用 KQIR 功能时要慎重考虑,因为我们不保证兼容性。但是我们非常欢迎用户试用和提供反馈,这将有助于我们尽快稳定相关功能并正式发布。

未来计划

目前,twice 和 Kvrocks 的其他成员正在快速开发 KQIR 框架。所有上文提到的内容都将继续发展。如果你对这些主题感兴趣,欢迎在 GitHub 上随时了解最新进展。我们欢迎任何期望参与这些工作的开发者加入 Apache Kvrocks 社群并共同创造出有价值的软件。

作为 Apache 软件基金会旗下的开源社群,Kvrocks 社群完全由志愿者组成。我们致力于提供一个开放、包容和供应商中立的环境。

向量搜索

支持向量搜索的设计和实现目前正在进行中。相关进展非常乐观。

Kvrocks 社群的一些成员正在讨论,并提出了在 KQIR 上实现向量搜索的编码设计。

根据计划,我们将首先在磁盘上实现 HNSW 索引,然后引入向量字段类型。

全文检索

目前,Kvrocks 社群还没有全文搜索的设计方案。

不过,我们正在探索通过 CLucenePISA 将全文索引纳入 KQIR 的可能性。

欢迎任何有兴趣参与的开发者分享想法或建议!

SQL 功能

未来,我们计划逐步支持更多 SQL 功能,可能包括子查询、JOIN操作、聚合函数和其他功能。

Kvrocks 的 SQL 能力主要关注的仍然是事务处理,而不是分析任务。

完整示例

首先,我们需要启动一个 Kvrocks 的实例。可以运行下述命令,启动一个 Kvrocks 的 Docker 容器:

1
docker run -it -p 6666:6666 apache/kvrocks:nightly --log-dir stdout

当然,你也可以选择克隆 unstable 分支的最新版本代码,并从源码构建出 Kvrocks 二进制并运行。

成功启动 Kvrocks 实例之后,我们用 redis-cli 工具连接上实例。运行一下命令:

1
FT.CREATE testidx ON JSON PREFIX 1 'test:' SCHEMA a TAG b NUMERIC

这个命令创建了一个名为 testidx 的索引,包括一个名为 a 的 tag 字段和名为 b numeric 字段。

然后,我们可以使用 Redis JSON 命令写入一系列的数据:

1
2
3
JSON.SET test:k1 $ '{"a": "x,y", "b": 11}'
JSON.SET test:k2 $ '{"a": "y,z", "b": 22}'
JSON.SET test:k3 $ '{"a": "x,z", "b": 33}'

写入数据也可以在 FT.CREATE 创建索引之前,执行顺序并不会影响最终效果。

最后,我们就可以用 SQL 语句来基于刚才创建的索引,在这些数据上运行查询了:

1
FT.SEARCHSQL 'select * from testidx where a hastag "z" and b < 30'

除了使用 SQL 查询,RediSearch 语法的查询也是支持的:

1
FT.SEARCH testidx '@a:{z} @b:[-inf (30]'

欢迎下载试用、探索和发表反馈。

Zig 语义分析

作者 tison
2023年12月13日 08:00

本文承接《Zig 中间表示》的内容,继续讨论 Zig 程序编译的下一步:从 ZIR 指令序列,经过语义分析的过程,生成 AIR 指令序列。

本文翻译自 Mitchell Hashimoto 关于 Zig 的系列博客第四篇:

语义分析是 Zig 程序编译的核心环节,且它包括了 Zig 语言独特的设计:编译时求值。不同于其他语言常常需要使用额外的语法来定义和计算类型(泛型),Zig 采用编译时求值的方式来完成类型计算。这使得很多原本需要宏或者模板的逻辑,现在可以使用跟主语言相同的语法来编写代码完成。

我在推特上发文讲过:

Zig 的泛型是用编译时类型计算来实现的。这有一个挺有趣的提示:如果 Zig 的编码体验提升,很多类型计算方法(类型论的实现)可以作为 Zig 库来提供,而不用像之前一样嵌入到编译器里作为编译器的内部实现。这可能是一个开放编译时计算的方向。

本文讨论了 AIR 制导生成的主要流程,但是没有深入讨论编译时求值的细节,也没有包括变量活性分析的内容,比较可惜。

以下原文。

AstGen 阶段之后是 Sema 阶段。这一编译阶段接收 AstGen 阶段输出的 ZIR 指令序列,并生成 AIR 指令序列。AIR 是“经过分析的中间表示(Analyzed Intermediate Representation)”的缩写,是一个完全类型化的中间表示。作为对比,ZIR 是一个无类型的中间表示。AIR 可以直接转换为机器代码。

正如 AstGen 一文中所指出的,ZIR 不完全类型化的一个原因,是完全类型化一个 Zig 程序需要进行编译时求值,才能完全实现泛型类型等功能。因此,Sema 阶段还会执行 Zig 程序的所有编译时求值。这就是魔法发生的地方!

AIR 是按函数而不是按文件生成的,就像 ZIR 或 AST 一样。本文将重点介绍如何将函数体从 ZIR 指令转换为 AIR 指令。

原注:有一些 AIR 指令是在文件范围内生成的,因此不能武断地说 AIR 是按函数生成的。然而,理解文件范围的 AIR 过程还需要更深入地讨论编译过程,本文将作为理解那些过程的重要基石。

AIR 是什么样的?

让我们看一个 AIR 的例子:

1
2
3
export fn add(a: u32, b: u32) u32 {
return a + b;
}
1
2
3
4
5
6
7
# Begin Function AIR: add:
%0 = arg("a", u32)
%1 = arg("b", u32)
%2!= dbg_stmt(2:5)
%3 = add(%0!, %1!)
%4!= ret(%3!)
# End Function AIR: add

你可以使用 zig build-obj --verbose-air <file.zig> 指令查看任意程序的 AIR 指令序列。这需要一个 DEBUG 模式构建的 Zig 编译器,可以查看 Zig 仓库的 Wiki 页面了解如何构建。

AIR 是按函数生成和打印的。上面的输出包含了以注释形式注明的 add 函数的 AIR 输出的行。请注意,只有导出或被引用的函数才会生成 AIR 指令,因为 AIR 是按需生成的。因此,为了调试目的,我通常会导出我想要查看 AIR 的函数。

如果你已经阅读了关于编译器先前阶段的内容,你会注意到 AIR 的打印形式跟 ZIR 非常相似。虽然如此,并且在许多情况下 ZIR 和 AIR 甚至有相同的指令标签名称,但是 AIR 是一个完全独立的中间表示。

%1 是指令的索引。当它后面跟着 ! 时,意味着该指令未被使用或未被任何其他已知部分的 Zig 程序引用。在上面的示例中,加法对应的指令 %0%1 用于构造 %3 指令,而 %3 则被返回指令使用。但是,调试语句 %2 和返回结果 %4 都未被使用。在编译器后端将 AIR 转换为最终格式时,可以按需使用这些信息。

原注:如前所述,有一些 AIR 是在文件范围而不是函数范围内生成的。例如,文件范围的变量初始化、编译时块等。目前无法打印此类 AIR 的内容。文件范围的 AIR 通常只是一系列常量指令,因为它总是在编译时求值。

剖析 AIR 结构

在讨论 ZIR 如何转换成 AIR 之前,我将首先介绍 AIR 的格式并剖析单条 AIR 指令。理解 AIR 的结构有助于理解 AIR 是如何构造的。

AIR 的结构和 ZIR 非常相似,但也有一些细微的差别:

1
2
3
4
5
pub const AIR = struct {
instructions: std.MultiArrayList(Inst).Slice,
extra: []const u32,
values: []const Value,
};

跟 ZIR 一样,AIR 在 instructions 字段中存储了一系列指令。再有,跟 ZIR 和 AST 一样,指令可能在 extra 字段存储相关的额外数据。这两个字段的工作方式跟 ZIR 和 AST 中的同名字段一样。如果你对这部分知识还不熟悉,我建议你回顾前几篇文章相关的内容,尤其是 AST 构造是如何填充 extra 字段的细节。

原注:我略过了对 MultiArrayList 的介绍,因为这部分信息在前面的文章里已经详细解释过了。

values 是 AIR 结构独有的新字段。它包含了从 ZIR 的编译时执行中获得的已知值。AIR 指令可以引用编译时已知的值。例如,如果源文件中有一个 const a = 42; 语句,那么值 42 将存储在 values 列表中,因为它是编译时已知的。我们稍后将看到更多关于编译期值的例子。

请注意,诸如字符串常量的字符串表等字段不存在。AIR 构建过程和使用 AIR 的代码生成过程仍然可以访问 ZIR 指令序列,也就可以访问 ZIR 的字符串表中的数据。

剖析单条 AIR 指令

Inst 代表了单条 AIR 指令的结构:

1
2
3
4
pub const Inst = struct {
tag: Tag,
data: Data,
};

这个结构跟 ZIR 的 Inst 结构是一致的:枚举类型的 tag 字段 + 标签对应的 data 字段。AIR 的标签和数据类型不同于 ZIR 的类型,但在功能上是相同的。

让我们看一个简单的例子。当语义分析确定一个值是常量时,它创建了 .constant 指令。常量标记指令的数据是 ty_pl 字段,其中包含常量的类型,而有效载荷是一个索引,指向值数组中的编译时已知值。

1
const c = 42;
1
%0 = constant(comptime_int, 42)
1
2
3
4
5
6
7
8
9
Inst{
.tag = .constant,
.data = .{
.ty_pl = . {
.ty = Type.initTag(.comptime_int),
.pl = 7, // index into values
},
},
};

上面的代码块展示了一段 Zig 代码,它对应的 AIR 文本表示和内部结构表示。注意在 AIR 中不存在 c 标识符,因为用于产生 AIR 的 ZIR 只包括赋值语句的右值。

值、类型和带类型的值

Sema 阶段经常使用以下三种类型:值(Value)、类型(Type)和带类型的值(TypedValue)。

Value 表示编译时已知的值,例如整数、结构体等。Type 是编译时已知的类型,例如 u8 等(注意,所有类型都是编译时已知的)。而 TypedValue 是一个具有确切已知类型的值:例如将值 42 与类型 u16 配对。

可能看起来有些奇怪的是,类型在 Zig 中也是有效的值。类型是类型为 type 的值。例如,在 Zig 中,可以编写 const c = u8 语句,其中 c 是类型为 type 的值,u8 是 c 的值。我们将在下面展示许多这些情况的示例,以使其更加直观。

Value 结构定义如下所示:

1
2
3
4
pub const Value = extern union {
tag_if_small_enough: Tag,
ptr_otherwise: *Payload,
}

一个类型(type)的值具有描述其值类型(kind)的标签。我在这里故意使用 kind 而不是 type 是因为一个值是无类型的。尽管在某些情况下,类型可以从值中直接推断出来。某些标签值没有有效载荷,而其他情况下则需要有效载荷。ptr_otherwise 字段是指向有效载荷的指针,其中包含获取该值所需的更多信息。

Payload 类型是指向更具体有效载荷类型的 Payload-typed 字段的指针。这是 Zig 中使用多态类型的一种方式。然后,Zig 代码可以使用 @fieldParentPtr 指令确定完整类型。这是 Zig 中常见的模式,详细解释超出了本文的范围。请搜索 @fieldParentPtr 指南以了解其工作原理。

译注:这段相关代码有一个大重构,可以查阅 PR-15569 了解细节。

整数

接下来看一个整数值的例子,42 可以被表示成一个 Value 结构的实例:

1
2
3
4
5
6
Value{
.ptr_otherwise = &Payload.U64{
.tag = .int_u64,
.data = 42,
},
};

原注:这个值不是完全正确的。ptr_otherwise 字段指向 Payload 结构而不是完整的 Payload.U64 结构。不过,这种表现形式能更好的展示指针背后的内容,所以我会在本文中一致使用这种形式。

可以看到,42 对应到 int_u64 标签。这是因为 int_u64 被用于表示所有可以用 u64 表示的值。这并不意味着实际使用的 42u64 类型,它可能是 u8 或者 u16 或者其他。不与 Type 关联的 Value 是无类型的。或者,如果你觉得我们这里总归是有一个类型标签,你可以认为这个值没有对应到一个准确的类型。

类型的值

Zig 的类型也是值。例如,语句 const c = u8 是完全有效的 Zig 代码:它将类型 u8 赋值到常量 c 上,而 c 本身是 type 类型的。这可能会非常令人困惑,随着类型的值在实际语义分析中的使用,它会变得更加令人困惑。因此,我将在这里提前解释。

作为一个值,常量 u8 可以用下面的 Value 表示:

1
2
3
Value{
.tag_if_small_enough = .u8_type,
};

这个值没有 payload 内容,因为它可以被 .u8_type 完全清楚的表示:值是 u8 类型。

这个值本身仍然是无类型的。在这个场景里,我们可以简单的知道值的类型,因为 u8_type 的类型除了 type 没有别的可能。但是,从技术上说,u8 对应的 Value 实例仍然是无类型的。

原注:例如,这个事实允许 Zig 在未来定义一个 inttype 关键字来代表所有整数类型,此时 u8_type 就可能被解释成 typeinttype 之一。

我们来看一个更复杂的类型的例子,const c = [4]bool 对应的 Value 表示如下:

1
2
3
4
5
6
7
8
9
Value{
.ptr_otherwise = &Payload.Ty{
.tag = .ty,
.data = Type{
// .tag = .array
// type data including element type (bool), length (4), etc.
},
},
};

这个值具有 .ty 标签(type 的缩写),这代表这个值是某种类型。值的有效载荷是一个 Type 结构,其值描述了一个由四个 bool 元素组成的数组。我们将在下一节讨论 Type 结构的细节。这里的关键点是,上述 Value 实例的值是一个数组类型,而不是一个数组值。

如果还有不明白的地方,你可以查看源代码中 Type 结构的 toValue 函数。Type 实例总是可以被转变成一个 Value 实例,因为类型在 Zig 中总是一个值,但是值不一定总是类型。

译注:原文解释了很多,反而把事情搞得有点复杂了。应该说,在绝大多数语言里,类型都不是值,它们需要各种特殊的语法来进行类型计算和泛型标记。Zig 把类型作为编译时已知的值,暴露了一系列操作类型的函数,使得泛型和函数重载可以使用编译时求值技术编写 Zig 代码完成,而不需要独特的语法。

类型

Type 结构的定义和 Value 结构是一样的,但是定义成一个新的类型以做区分:

1
2
3
4
pub const Type = extern union {
tag_if_small_enough: Tag,
ptr_otherwise: *Payload,
}

这里我们没有太多补充,因为大部分内容跟 Value 结构一样。让我们看一看 [4]bool 类型是怎么表示的,免得我们完全不介绍一个具体例子:

1
2
3
4
5
6
7
8
9
Type{
.ptr_otherwise = &Payload.Array{
.tag = .array,
.data = .{
.len = 4,
.elem_type = Type{.tag_if_small_enough = .bool},
},
}
}

带类型的值

TypedValue 其实就是一个 Type 和一个 Value 组成的结构,从而将值绑定到一个确切的类型上。只有这样,值的类型才是准确已知的:

1
2
3
4
pub const TypedValue = struct {
ty: Type,
val: Value,
};

剖析 Sema 结构

在 AstGen 阶段之后,Zig 编译器将进行 Sema 阶段,Sema 是语义分析(Semantic Analysis)的缩写。Sema 也是主要负责此阶段的结构体的名称。Sema 的源代码位于 src/Sema.zig 文件内。这是一个非常大的文件(在编写本文时超过 18000 行),它的文档注释自称为 “Zig 编译器的核心”。

译注:0.11 版本上,Sema.zig 已经超过 36000 行。

Sema 有许多公共接口,但最重要的是 analyzeBody 函数。Sema 结构体有许多用于内部状态的字段。我们不会列举所有字段,但下面列出了一些主要的字段。为了便于解释类似的字段,这些字段的顺序与源代码中的顺序不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub const Sema = struct {
mod: *Module,
gpa: Allocator,
arena: Allocator,
perm_arena: Allocator,

code: Zir,
owner_decl: *Decl,
func: ?*Module.Fn,
fn_ret_ty: Type,

air_instructions: std.MultiArrayList(Air.Inst) = .{},
air_extra: std.ArrayListUnmanaged(u32) = .{},
air_values: std.ArrayListUnmanaged(Value) = .{},
inst_map: InstMap = .{},

// other fields...
};

第一组字段是 Sema 过程的主要输入:

  • gpa 用于分配在 Sema 过程和声明生命周期之后仍然存在的数据。
  • arena 用于分配在 Sema 之后将被释放的临时数据。
  • perm_arena 用于分配与正在进行语义分析的声明的生命周期相关联的数据。
  • mod 是正在分析的模块。本文内容不涵盖模块,但模块封装了单个程序中的所有 Zig 代码。本文将忽略所有跟模块相关的接口。

第二组字段是 Sema 进行语义分析的输入:

  • code 是包含正在分析的声明的文件的 ZIR 指令序列。
  • owner_decl 通常是当前正在分析的声明,例如函数、comptime 块、测试等。

当分析函数时,funcfn_ret_ty 存储了可能得额外信息。

第三组字段是 Sema 过程的输出。这些输出主要是构建 Air 结构的字段。这些字段在整个 Sema 过程中被填充,并用于构建最终的 Air 结果。

其中,inst_map 字段最为重要。它是一个 ZIR 到 AIR 的映射,在整个 Sema 过程中都会使用。并非所有的 ZIR 指令都会产生 AIR 指令,但是 Sema 还是经常使用这个映射以使 AIR 指令可以引用稍后解析的特定 ZIR 指令:例如加法操作数、函数参数等。

分析函数体

Sema 结构上的核心接口是 analyzeBody 函数。这个函数用于分析函数体、循环体或代码块体等对应的 ZIR 指令序列,并产生对应的 AIR 指令序列。最简单的例子是函数体。下面是一个具体的例子:

1
2
3
export fn add() u32 {
return 40 + 2;
}
1
2
3
4
5
6
7
8
9
# Begin Function AIR: add:
%1 = constant(comptime_int, 40)
%2 = constant(comptime_int, 2)
%3 = constant(comptime_int, 42)
%4 = constant(u32, 42)

%0!= dbg_stmt(2:5)
%5!= ret(%4!)
# End Function AIR: add

原注:可以使用 zig build-obj --verbose-air example.zig 指令打印导出函数的 AIR 指令序列。

从上述 AIR 指令序列中,我们可以直观地知道发生了什么事情。粗略地说,这些指令大致执行了以下步骤:

  1. 我们看到一个无类型的整数常数 40 - 对应 %1 指令。
  2. 我们看到一个无类型的整数常数 2 - 对应 %2 指令。
  3. 我们执行了编译时求值,得到加法的结果:无类型的整数常数 42 - 对应 %3 指令。
  4. 常量 42 关联到类型 u32 上(%4 指令)。这不需要任何转换,因为值 42 可以自动匹配到 u32 类型上。这个类型关联是必要的,因为返回类型被定义成 u32 类型。
  5. 返回 %4 输出的值 - 对应 %5 指令。

太棒了!这里有一些冗长之处,但很明显可以看出 add 函数的编译过程。此外,可以看到在这个阶段进行了编译时求值:40 + 2 对应的加法操作已经完成,因此结果已经预先知道。当这段代码最终被转换为机器代码时,实际的加法结果已经预先计算好了。

译注:在新版 Zig 编译器中,所有中间 AIR 都能被优化,最终只产生一条 %4!= ret(<u32, 42>) 指令。

接下来,让我们逐步了解这个 AIR 是如何生成的。

逐步分析函数

函数体 AIR 主要通过 analyzeBodyInner 生成。这个函数会按顺序迭代分析每个 ZIR 指令,并为每条 ZIR 指令生成零或多条 AIR 指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const result = while (true) {
const inst = body[i];
const air_inst: Air.Inst.Ref = switch (tags[inst]) {
.alloc => try sema.zirAlloc(block, inst),
.alloc_inferred => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_const)),
.alloc_inferred_mut => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_mut)),
.alloc_inferred_comptime => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_const)),
.alloc_inferred_comptime_mut => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_mut)),
.alloc_mut => try sema.zirAllocMut(block, inst),
// hundreds more...
};

try sema.inst_map.put(sema.gpa, inst, air_inst);
i += 1;
}

这段代码清楚展示了 ZIR 翻译成 AIR 的过程。你可以打印 Zig 程序对应的 ZIR 指令序列并逐个分析每个指令如何生成对应的 AIR 指令。上述 add 函数的 ZIR 指令序列如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
%0 = extended(struct_decl(parent, Auto, {
[25] export add line(6) hash(4ca8d4e33898374bdeee80480f698dad): %1 = block_inline({
%10 = func(ret_ty={
%2 = break_inline(%10, @Ref.u32_type)
}, body={
%3 = dbg_stmt(2, 5)
%4 = extended(ret_type()) node_offset:8:5
%5 = int(40)
%6 = int(2)
%7 = add(%5, %6) node_offset:8:15
%8 = as_node(%4, %7) node_offset:8:15
%9 = ret_node(%8) node_offset:8:5
}) (lbrace=1:21,rbrace=3:1) node_offset:7:8
%11 = break_inline(%1, %10)
}) node_offset:7:8
}, {}, {})

其中,函数体从 %3 指令开始,到 %9 指令结束。因此,analyzeBodyInner 分析 add 函数体时,看到的第一条指令是 %3 指令。%2%10 将会在早些时候被分析,我们会在后面讨论这个过程。

%3: dbg stmt

第一条分析的指令是一个 .dbg_stmt 指令。在 analyzeBodyInner 的主循环代码中,我们可以看到这将引导向 zirDbgStmt 分支。该分支的代码经简化后如下所示:

1
2
3
4
5
6
7
8
9
10
fn zirDbgStmt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!void {
const inst_data = sema.code.instructions.items(.data)[inst].dbg_stmt;
_ = try block.addInst(.{
.tag = .dbg_stmt,
.data = .{ .dbg_stmt = .{
.line = inst_data.line,
.column = inst_data.column,
} },
});
}

这是一个很好的、简单的翻译 ZIR 到 AIR 的例子。没有比这更简单的了。在这种情况下,dbg_stmt ZIR 指令几乎一比一的被翻译成 dbg_stmt AIR 指令。这生成了之前显示的 %0 AIR 指令:

1
%0!= dbg_stmt(2:5)

%4: extended(ret type())

类似地,ZIR 指令 .extended 会引导调用 zirExtended 函数,其主循环分析子操作码(child opcode)并在遇到 .ret_type 时调用 zirRetType 函数。zirRetType 函数内容如下:

1
2
3
4
5
6
7
8
9
fn zirRetType(
sema: *Sema,
block: *Block,
extended: Zir.Inst.Extended.InstData,
) CompileError!Air.Inst.Ref {
const src: LazySrcLoc = .{ .node_offset = @bitCast(i32, extended.operand) };
try sema.requireFunctionBlock(block, src);
return sema.addType(sema.fn_ret_ty);
}

被分析函数中的返回类型可以在 Sema 的 fn_ret_ty 字段中找到。addType 函数将为类型定义添加一条指令。对于我们的函数,结果类型是 u32 类型。这是一个常用类型,因此不会生成额外的指令。

为了进行实验,如果您将返回值更改为一个不常用的类型 u9 那么 AIR 会生成以下指令:

1
%5 = const_ty(u9)

%5: int(40)

下一条指令是对应常数 40 的 %5 指令。它会引导调用 zirInt 函数。zirInt 函数读取整数值,并调用 addConstant 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn zirInt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!Air.Inst.Ref {
const int = sema.code.instructions.items(.data)[inst].int;
return sema.addConstant(ty, try Value.Tag.int_u64.create(sema.arena, int));
}

pub fn addConstant(sema: *Sema, ty: Type, val: Value) SemaError!Air.Inst.Ref {
const gpa = sema.gpa;
const ty_inst = try sema.addType(ty);
try sema.air_values.append(gpa, val);
try sema.air_instructions.append(gpa, .{
.tag = .constant,
.data = .{ .ty_pl = .{
.ty = ty_inst,
.payload = @intCast(u32, sema.air_values.items.len - 1),
} },
});
return Air.indexToRef(@intCast(u32, sema.air_instructions.len - 1));
}

addConstant 函数在 Sema 阶段的很多地方都会被调用,用于加入一个编译时已知的常量。其内部逻辑首先通过 addType 添加对应到最后一条指令的类型。对于我们这里的例子,它是 comptime_int 类型。因为它是一个常用类型,所以没有额外的指令产生。紧接着,常量值被加入到 air_values 中。最后,constant AIR 指令被加入到 air_instructions 中。最终结果就是下述 AIR 指令:

1
%1 = constant(comptime_int, 40)

需要注意的一点是,.constant 指令的有效负载引用了 air_values 中的索引。所有在编译时已知的值都存储在 air_values 中,指令中的任何引用都存储为 air_values 切片中的索引。

指令 %6 不做讨论,因为它跟 %5 基本相同,只是有一个不同的常量值。

%7: add(%5, %6)

下一条指令是我们遇到的第一条有实际逻辑操作的指令:执行加法。.add 指令引导调用 zirArithmetic 函数。这个函数用于许多二元数学操作中,其函数体如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn zirArithmetic(
sema: *Sema,
block: *Block,
inst: Zir.Inst.Index,
zir_tag: Zir.Inst.Tag,
) CompileError!Air.Inst.Ref {
const inst_data = sema.code.instructions.items(.data)[inst].pl_node;
sema.src = .{ .node_offset_bin_op = inst_data.src_node };
const lhs_src: LazySrcLoc = .{ .node_offset_bin_lhs = inst_data.src_node };
const rhs_src: LazySrcLoc = .{ .node_offset_bin_rhs = inst_data.src_node };
const extra = sema.code.extraData(Zir.Inst.Bin, inst_data.payload_index).data;
const lhs = sema.resolveInst(extra.lhs);
const rhs = sema.resolveInst(extra.rhs);

return sema.analyzeArithmetic(block, zir_tag, lhs, rhs, sema.src, lhs_src, rhs_src);
}

这段代码中,需要特别关注 lhs 和 rhs 的复制。resolveInst 函数用于查找给定 ZIR 指令的 AIR 指令索引。因此,对于给定的 %5%6 这两条 ZIR 指令,分别将 lhs 和 rhs 设置为它们的结果 AIR 索引。这些 AIR 指令是在先前的循环迭代中创建 .constant 指令时设置的。

ZIR 到 AIR 的映射在 analyzeBodyInner 循环的 inst_map 字段中维护。虽然也有其他一些逻辑可以更新这个状态,但是 body 循环是主要的逻辑。

下一步,代码逻辑进到 analyzeArithmetic 函数里。这是一个很长的函数,几乎所有代码都用于确定是否能够对此算术操作进行编译时分析。下面是关键的代码行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const maybe_lhs_val = try sema.resolveMaybeUndefVal(block, lhs_src, casted_lhs);
const maybe_rhs_val = try sema.resolveMaybeUndefVal(block, rhs_src, casted_rhs);

if (maybe_lhs_val) |lhs_val| {
if (maybe_rhs_val) |rhs_val| {
if (is_int) {
return sema.addConstant(
scalar_type,
try lhs_val.intAdd(rhs_val, sema.arena),
);
}
}
}

return block.addBinOp(.add, casted_lhs, casted_rhs);

这段代码首先调用 resolveMaybeUndefVal 函数。它接受一个 AIR 指令的索引,并尝试加载其编译时已知的值。这个表达式返回一个可空值,因为如果该值不能在编译时确定,则返回 null 值。

接下来,我们尝试解包这些可空值。如果我们能够找到 lhs 和 rhs 的编译时已知值,并且它们都是整数类型,那么我们可以进行编译时加法,得到最终的常量。这就是我们的程序生成结果为 42 的 .constant AIR 指令的过程:

1
%3 = constant(comptime_int, 42)

我在代码中包含了 block.addBinOp 语句,以避免值不是编译时已知的情况。这个语句将添加一个 .add AIR 指令进行运行时计算(而不是编译时)。为了进行实验:我们将常量 2 更改为变量,例如 var b: u32 = 2 语句,并将其用于加法运算。这将产生一个 .add 操作,因为变量不能在编译时操作。

%8: as_node(%4, %7)

下一条指令实现了安全类型转换。从 ZIR 的角度来看,这将加法结果转换为返回类型的值。具体来说,根据已知信息,我们需要将编译时整数 42 转换为 u32 类型。

.as_node 指令引导调用 zirAsNode 函数,最终调用到 coerce 函数的核心逻辑。coerce 函数在 Sema 过程中被广泛用于执行从一种类型到另一种类型的安全类型转换。

我将让读者自行研究这个函数。逻辑相当直接,但由于需要处理许多类型转换情况,所以会比较冗长。对于 comptime_int 到 u32 的转换,他首先确定该值能被 u32 类型表示,并将该值原样返回。类型转换不需要进行额外的处理。

%9: ret_node(%8)

最后,返回指令在 ZIR 中被编码为 ret_node 指令。这将引导调用 zirRetNode 函数,它创建一个带有结果的 .ret AIR 指令。创建该指令的所有相关知识点前面都已经讲过。

zirRetNode 函数始终返回 always_noreturn 值。这个值强制 analyzeBodyInner 循环退出,从而完成函数体 AIR 生成。

原注:返回语句是否总是完成函数体 AIR 生成?如果多个返回语句,又该怎么办?

返回语句总是完成当前块的 AIR 生成。不可达代码是非法的,并在 AstGen 期间被捕获,这意味着多个返回语句的唯一方式是它们位于不同的块中。因此,在函数的上下文中,多个返回语句是可以的,因为它将递归进入多个 analyzeBodyInner 调用。

编译时不可知

上面第一个示例有点无聊,因为所有的逻辑都是编译时可知的。编译时可知仅适用于 const 值,而不适用于 var 值,因此我们可以通过使用 var 来查看运行时加法的 AIR 指令序列:

1
2
3
4
export fn add() u32 {
var b: u32 = 2;
return 40 + b;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Begin Function AIR: add:
%2 = constant(comptime_int, 2)
%3 = constant(u32, 2)
%6 = constant(comptime_int, 40)
%8 = constant(u32, 40)

%0!= dbg_stmt(2:5)
%1 = alloc(*u32)
%4!= store(%1, %3!)
%5!= dbg_stmt(3:5)
%7 = load(u32, %1!)
%9 = add(%8!, %7!)
%10!= ret(%9!)
# End Function AIR: add

译注:新版 Zig 编译器会报错 var b: u32 = 2 应该是一个常量,需要其他手段来写一个真正的变量做这个实验。

将常量 2 更改为变量赋值的值 2 会产生更多的 AIR 指令!现在我们有了使用 .alloc 进行分配的指令、使用 .store 进行值存储的指令,还可以看到运行时的 .add 操作。

这是一个很好的程序,可以用来学习和追踪 AIR 指令的生成过程。由于它遵循了我们之前示例中的许多相同的代码路径,我将把这个函数编译过程的分析作为读者的练习。

编译时目标平台仿真

对于编译时计算的代码,Zig 编译器将在必要时模拟目标平台的特性。这是一个至关重要的功能,使得在 Zig 中进行编译时计算变得安全且可行。

@floatCast 的实现中可以看到这个功能的一个例子:

1
2
3
4
5
6
7
8
const target = sema.mod.getTarget();
const src_bits = operand_ty.floatBits(target);
const dst_bits = dest_ty.floatBits(target);
if (dst_bits >= src_bits) {
return sema.coerce(block, dest_ty, operand, operand_src);
}

return block.addTyOp(.fptrunc, dest_ty, operand);

该函数通过 getTarget 获取有关目标平台的信息,然后确定目标平台上浮点数支持的位数。如果目标类型的位数多于源类型,可以安全地强制转换类型。否则,浮点数将被截断。

完成语义分析过程

Sema 过程为每个函数调用一次,且仅针对每个被引用的声明调用一次,而不是遍历每个声明。为了确定所有被引用的声明,Sema 从处理入口点函数开始查找。

这实现了 Zig 的延迟分析能力,意味着除非引用了声明,否则某些错误不会产生编译器错误。这个特性使 Zig 的编译过程非常快速,生成的代码更小,因为只编译了被引用的代码,但有时会导致令人困惑的行为。

译注:为了绕过这里的引用问题,Zig 有一个内部开洞的 std.testing.refAllDecls 函数。可以阅读 ISSUE-12838 了解更多细节。

基于本文介绍的基本知识,你现在应该能够跟踪任何 Zig 程序并确定它如何转换为 AIR 指令序列。请记住经常使用 zig ast-checkzig build-obj --verbose-air 命令来查看 Zig 编译器生成的内容。

语义分析完成后,AIR 指令序列将传递给 CodeGen 过程,并在该过程中被转换为最终格式。CodeGen 是编译器前端和多个后端之间的边界。后端可以是 LLVM 或诸如 WASM 之类的本机后端。

Zig 中间表示

作者 tison
2023年12月12日 08:00

本文承接《Zig 词法分析和语法解析》的内容,继续讨论 Zig 程序编译的下一步:从抽象语法树(AST)中生成中间表示(IR)。

本文翻译自 Mitchell Hashimoto 关于 Zig 的系列博客第三篇:

翻译本文的过程中,我越来越回想起自己使用 Perl 6 做编译实习作业的时候。通过 Perl 6 内嵌的 Grammar 语法,我基本把词法分析和语法分析的内容给快速解决了。余下的大部分时间都在完成从 AST 到课程定义的中间表示的翻译,数据流分析和生成 riscv 汇编代码的工作。应该说,北京大学仿照虎书内容做的编译实习课程还是很有含金量的。

感兴趣的读者可以查看我当时的代码仓库,其中包括一个完整的 PDF 报告。

以下原文。

构建出抽象语法树(AST)后,许多编译器的下一步是生成中间表示(IR)。AST 是一棵树,而 IR 通常开始为程序的各个块,例如文件、函数等,创建一系列指令。这种指令序列的格式更容易进行优化分析和转换为可执行的机器码。

Zig 编译器具有多个中间表示。AST 首先通过 AstGen 的阶段转换为 Zig 中间表示(ZIR)。ZIR 是一种无类型的 IR 形式。每个 Zig 文件对应一系列 ZIR 序列。

ZIR 是什么样的?

讨论 ZIR 的结构及其创建过程之前,我们先看一个简单的例子来初步建立对 ZIR 的印象。目前,你不需要理解下面展示的 ZIR 是什么含义。

给定一个简单的 Zig 源文件:

1
2
3
4
5
const result = 42;

export fn hello() u8 {
return result;
}

其对应的 ZIR 指令序列打印如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
%0 = extended(struct_decl(parent, Auto, {
[24] result line(0) hash(193c9ec04fd3f33e5e849a85f0c3b7fd): %1 = block_inline({
%2 = int(42)
%3 = break_inline(%1, %2)
}) node_offset:1:1
[32] export hello line(2) hash(322ad1340dd6faf97c80f152e26dfdd4): %4 = block_inline({
%11 = func(ret_ty={
%5 = break_inline(%11, @Ref.u8_type)
}, body={
%6 = dbg_stmt(2, 5)
%7 = extended(ret_type()) node_offset:4:5
%8 = decl_val("result") token_offset:4:12
%9 = as_node(%7, %8) node_offset:4:12
%10 = ret_node(%9) node_offset:4:5
}) (lbrace=1:22,rbrace=3:1) node_offset:3:8
%12 = break_inline(%4, %11)
}) node_offset:3:8
}, {}, {})

ZIR 只是一个内部中间表示,并不需要是一个稳定的格式,因此今天的 Zig 编译器未必对上述源文件产生和这里展示的 ZIR 指令序列相同的输出。不过,对于 ZIR 的初见来说,上面展示的内容应该是足够接近的了。

需要注意的是,ZIR 将程序的实际逻辑分解为更细粒度的指令。ZIR 指令可以通过它们的索引进行引用。如上面所示,可以使用 % 符号来索引。例如,指令 9 将结果引用转换为 hello 函数的返回类型。

原注:如何打印 ZIR 指令序列?

如果你从源码中用 DEBUG 模式构建得到了 Zig 编译器,你可以通过 zig ast-check -t <file> 命令生成任意 Zig 源文件对应的 ZIR 指令序列。编写一些简单的 Zig 程序并使用这一技巧生成对应的 ZIR 指令序列,是学习 AstGen 步骤的一个很好的方法。

为什么 ZIR 是无类型的?

ZIR 是一种无类型的中间格式。这并不意味着 ZIR 不知道类型,而是类型没有被完全计算。Zig 有两个语言定义特性与此相关。其一是 Zig 将类型作为一等值,其二是 Zig 将编译时求值作为泛型类型的机制。因此,在 AST 和 ZIR 格式中,类型可能仍然是未知的。

这很容易 ZIR 输出示例中验证。首先,让我们看一个大部分已经确定类型的形式:

1
const result: u32 = 42;
1
2
3
4
5
6
7
%0 = extended(struct_decl(parent, Auto, {
[10] a line(0) hash(63c8310741c228c128abf6692d07292d): %1 = block_inline({
%2 = int(42)
%3 = as_node(@Ref.u32_type, %2) node_offset:1:16
%4 = break_inline(%1, %3)
}) node_offset:1:1
}, {}, {})

指令 %3 将无类型的 42 转换为 u32 类型。在这个简单的例子里,ZIR 看起来是完全具备类型的。

不过,仍然有不具备类型的指令。例如,指令 %2 没有对应的类型。虽然它对应了一个值为 int(42) 的常量,但是这个常量仍然可以被解释成 u8/u16/u32 等类型。直到 Zig 代码将无类型常量分配给有类型常量时,ZIR 才会产生类型强制转换指令,即 as_node 指令。

接下来,我们看一个明确无类型的形式:

1
2
const t = bool;
const result: t = 42;
1
2
3
4
5
6
7
8
9
10
11
12
%0 = extended(struct_decl(parent, Auto, {
[16] t line(0) hash(243086356f9a6b0669ba4a7bb4a990e4): %1 = block_inline({
%2 = break_inline(%1, @Ref.bool_type)
}) node_offset:1:1
[24] result line(1) hash(8c4cc7d2e9f1310630b3af7c4e9162bd): %3 = block_inline({
%4 = decl_val("t") token_offset:2:10
%5 = as_node(@Ref.type_type, %4) node_offset:2:10
%6 = int(42)
%7 = as_node(%5, %6) node_offset:2:14
%8 = break_inline(%3, %7)
}) node_offset:2:1
}, {}, {})

result 的类型是常量 t 被赋予的值。这种情况下,常量 t 的值是显而易见的。但是 Zig 允许为 t 赋值为任何编译时表达式。只要所有值都是编译时已知的,那么这意味着 t 的值几乎可以是任意 Zig 代码执行的结果。这是 Zig 的一个非常强大的特性。

鉴于这种动态的可能性,你可以看到分配给 result 的 ZIR 更加复杂。指令 %4%5 加载由 t 标识的值,并将其强制转换为类型 type 的值。type 是类型对应的类型,而不是值的类型。然后指令 %7 做 as_node 强制转换,但这次类型操作数引用的是 ZIR 指令 %5 的结果,而不是静态值。

最后,我们看一个极端的例子:

1
2
const std = @import("std");
const result: std.ArrayListUnmanaged(u8) = .{};

这里我们不展示这两行代码对应的 ZIR 指令序列,因为它将是一大段文本。在这个例子中,result 的类型是通过函数 ArrayListUnmanaged 编译时求值生成的泛型类型。ZIR 无法指向 result 的预定义类型,因为在编译时求值之前根本不知道它的类型。

这就是为什么 ZIR 是无类型的:ZIR 是用于编译时求值和进一步语义分析的预备中间形式。在编译时求值阶段后,所有的类型都将被确定,并且可以形成完全具备类型的中间表示(AIR)。

AstGen 过程的解析

AstGen 是一个把 AST 转换为 ZIR 的阶段,其源代码位于 src/AstGen.zig 文件中。AstGen 不是一个公开导出的结构体,它只用于管理 ZIR 生成过程的内部状态。外部调用方调用的是 generate 函数,该函数接受一个 AST 树并返回整个树的 ZIR 指令序列。

AstGen 结构体有许多用于内部状态的字段。我们不会列举所有的字段,但下面显示了一些重要的字段。以下结构体的字段顺序与源代码中的顺序不同:

1
2
3
4
5
6
7
8
9
10
11
const Astgen = struct {
gpa: Allocator,
arena: Allocator,
tree: *const Ast,

instructions: std.MultiArrayList(Zir.Inst) = .{},
extra: ArrayListUnmanaged(u32) = .{},
string_bytes: ArrayListUnmanaged(u8) = .{},

// other fields, not covered...
};

第一组字段是 AstGen 过程的输入:

  • gpa 用于分配在 AstGen 过程之外还需存活的数据
  • arena 用于分配仅在 ZIR 生成期间使用的临时数据,这些数据在返回 ZIR 之前被释放
  • tree 是要转换为 ZIR 的 AST 示例

原注:为什么 arena 叫这个名字?

Arena allocator 是一种内存分配器的分类,它一次性分配和释放整个内存区域,而不是逐个追踪和释放单个项。它通常用于具有共同生命周期的分配,因为相比于追踪单个项,释放整个内存块要更容易且性能更好。有关分配器和 Zig 的基础知识,可以参考 What’s a Memory Allocator Anyways? 主题演讲。

第二组字段是 AstGen 过程的输出。这一组字段非常重要,因为它是生成的 ZIR 的核心结构:

  • instructions 存储 ZIR 指令序列,该序列中的每个条目对应一个单独的指令。例如,前文 ZIR 输出中,这一字段的第 9 个条目将是 as_node(%7, %8) 指令。
  • extra 是 ZIR 指令可能需要存储的额外数据。这与 AST 节点及其 extra_data 字段遵循相同的模式。如果您不了解 AST 中 extra_data 的存储访问模式,请查看上一篇文章进行复习,因为在 AstGen 过程中到处都使用了这种模式!
  • string_bytes 是用于标识符、字符串字面量、文档注释等的字符串池。所有静态字符串都作为 AstGen 的一部分进行了池化,并且 ZIR 指令引用了该字符串字节列表中的偏移量,而不是存储字符串本身的副本。

ZIR 指令结构的解析

在深入介绍 AST 转换为 ZIR 的过程之前,我将花费大量篇幅来讨论单个 ZIR 指令的格式。我建议仔细阅读和理解本节,因为这是理解 ZIR 构建的基本构件。

ZIR 实际上是一个指令列表。ZIR 指令的格式与 AST 节点使用的模式很相似,因此本文可能会忽略一些结构细节。在这些情况下,我将特别指出结构之间的相似之处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub const Inst = struct {
tag: Tag,
data: Data,

pub const Tag = enum(u8) {
add,
addwrap,
// many more...
};

pub const Data = union {
// various fields that can be set depending on tag
};
};

这个结构跟 AST 节点非常相似。tag 的存储访问模式与 AST 节点相同,但标签是完全不同的集合。例如,整数字面量的 AST 是 .integer_literal 而无论整数的大小如何。但在 ZIR 中,具体取决于整数是否在 u64 表示范围内,对应的 tag 将会是 .int.int_big 等。

每个指令都与数据相关联,数据存储都 Data 联合字段上。Data 联合中的有效字段取决于标签值。数据可以直接存储在 Data 字段中,例如整数字面量值。也有可能 Data 字段是指向其他信息的位置的引用。

与 AST 节点类似,ZIR 也有一个 extra 字段。它的行为与 AST 节点的 extra_data 字段几乎相同:一些数据条目可能指向 extra 字段中的一个条目,从那里开始的一些字段将包含与 ZIR 指令相关的特定值。有关更多详细信息,请参阅 Zig 语法解析器源码以及下面的示例。

静态值

最简单的 ZIR 指令是存储静态值的指令,让我们看一个例子:

1
const x = 42;
1
2
3
4
5
6
%0 = extended(struct_decl(parent, Auto, {
[7] x line(0) hash(5b108956afe84d38689dcd3f2d652f04): %1 = block_inline({
%2 = int(42)
%3 = break_inline(%1, %2)
}) node_offset:1:1
}, {}, {})

静态值对应的是 %2 指令(int(42))。如你所见,静态值 42 被直接内联到指令当中。这个指令对应的内部结构如下:

1
2
3
4
5
6
Zir.Inst{
.tag = .int,
.data = .{
.int = 42,
},
}

这是所有可能的 ZIR 指令中最简单形式:数据直接嵌入到指令中。在这种情况下,活跃数据的标签是 .int 标签,活跃数据是内联的静态整数值。

更常见的情况是数据是一个引用,或者标签包含更复杂的信息,无法直接适应 Inst 结构。下面是一些示例。

引用

许多 ZIR 指令包含对值的引用。例如,一元运算符 ! 可以是任何表达式的前缀,如静态值 !true 或变量 !myVar 或函数调用 !myFunc() 等。理解 ZIR 如何编码引用非常重要,因为它们非常常见。

ZIR 引用对应到 Zir.Inst.Ref 类型,这是一个未穷尽的枚举。类型定义的标签用于表示原始值或非常常见的值。否则,该值是对另一个 ZIR 指令索引的引用。

带标签的引用

让我们看一个具体的例子:

1
const x = !true;
1
2
3
4
5
6
%0 = extended(struct_decl(parent, Auto, {
[7] result line(0) hash(0be0bb45d9cb29941abcc19d4176dde6): %1 = block_inline({
%2 = bool_not(@Ref.bool_true) node_offset:1:16
%3 = break_inline(%1, %2)
}) node_offset:1:1
}, {}, {})

关注指令 %2 的内容,它包括一个对应 ! 运算符的 bool_not 指令,而 @Ref.bool_true 为其参数。bool_not 指令接受一个参数,它大致的内部表示如下(部分字段省略,它们跟本例关系不大):

1
2
3
4
5
6
Zir.Inst{
.tag = .bool_not,
.data = .{
.un_node = .{ .operand = Ref.bool_true },
},
}

Ref.bool_true 是表示静态值 true 的一个 Ref 枚举的标签。Ref 枚举还有许多其他标签。例如,每种内置类型都有一个标签。一个具体地例子是,尽管下面的示例毫无意义,但它确实产生有效的 ZIR 指令(编译器稍后会报错):

1
const x = !u8; // ZIR: bool_not(@Ref.u8_type)

对于常见的原始类型和值,ZIR 使用带标签的 Ref 来表示。

指令的引用

对于不常见的值,Ref 代表的是一个指令索引。下面是一个具体的例子:

1
2
const input = true;
const x = !input;
1
2
3
4
5
6
7
8
9
10
11
%0 = extended(struct_decl(parent, Auto, {
[13] input line(0) hash(1af5eb6ed7d8836d8d54ff390bb38c7d): %1 = block_inline({
%2 = break_inline(%1, @Ref.bool_true)
}) node_offset:1:1
[21] result line(1) hash(4ba2a2df9e05a2d7963b6c1eb80fdcea): %3 = block_inline({
%4 = decl_val("input") token_offset:2:17
%5 = as_node(@Ref.bool_type, %4) node_offset:2:17
%6 = bool_not(%5) node_offset:2:16
%7 = break_inline(%3, %6)
}) node_offset:2:1
}, {}, {})

%6 是查找指令引用的关键指令。我们看到了熟悉的 bool_not 指令,但这一次它的参数是 %5 即另一条指令。通读所有的指令序列,你应该能凭直觉理解到这个参数对应从 input 变量中读到的值。

这段 ZIR 指令的内部表示大致表示如下:

1
2
3
4
5
6
Zir.Inst{
.tag = .bool_not,
.data = .{
.un_node = .{ .operand = Ref.typed_value_map.len + 5 },
},
}

要确定 Ref 值是标记还是指令索引,我们可以检查该值是否大于标记的数量。如果是,则值减去标记长度等于指令。这个操作非常常用,因此 Zig 内部定义了 indexToRefrefToIndex 两个辅助函数来执行对应操作。在上面的示例中,操作数 %5 对应的是 indexToRef(5) 的值。如果调用 refToIndex(operand) 则可以得到 5 作为返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const ref_start_index: u32 = Inst.Ref.typed_value_map.len;

pub fn indexToRef(inst: Inst.Index) Inst.Ref {
return @intToEnum(Inst.Ref, ref_start_index + inst);
}

pub fn refToIndex(inst: Inst.Ref) ?Inst.Index {
const ref_int = @enumToInt(inst);
if (ref_int >= ref_start_index) {
return ref_int - ref_start_index;
} else {
return null;
}
}

译注:这两个函数现在重构了,分别变成了 Zir.Inst.Index.toRefZir.Inst.Ref.toIndex 方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub const Index = enum(u32) {
// ...
pub fn toRef(i: Index) Inst.Ref {
return @enumFromInt(@intFromEnum(Index.ref_start_index) + @intFromEnum(i));
}
}

pub const Ref = enum(u32) {
// ...

pub fn toIndex(inst: Ref) ?Index {
assert(inst != .none);
const ref_int = @intFromEnum(inst);
if (ref_int >= @intFromEnum(Index.ref_start_index)) {
return @enumFromInt(ref_int - @intFromEnum(Index.ref_start_index));
} else {
return null;
}
}
}

额外数据

一些 ZIR 指令包含对额外字段的引用,有时也称为“尾部”数据。这与 AST 节点中的 extra_data 字段非常相似,因此在这里不会详细介绍其编解码过程。如果您对 AST 节点中的 extra_data 字段不熟悉或不理解,我建议您复习一下该部分。

我们来看一个额外数据的例子:

1
const x = 1 + 2;
1
2
3
4
5
6
7
%0 = extended(struct_decl(parent, Auto, {
[10] x line(0) hash(48fa081b63af0a1c7f2d11a7bf9fbbc3): %1 = block_inline({
%2 = int(2)
%3 = add(@Ref.one, %2) node_offset:1:13
%4 = break_inline(%1, %3)
}) node_offset:1:1
}, {}, {})

这个输出的 ZIR 指令包括了我们在上面已经学到的一切:指令 %2 是一个静态值,指令 %3 中的 @Ref.one 是一个带标签的引用,指令 %3 中的 %2 引用了另一个指令。

二进制加法操作使用了额外字段。在 ZIR 指令的文本输出中,这并不明显,因为 ZIR 渲染器理解 add 指令并从额外字段(指令 %3)中以美化形式打印了数据。实际上,它的内部表示如下:

1
2
3
4
5
6
7
8
9
10
// Instruction
Zir.Inst{
.tag = .add,
.data = .{
.pl_node = .{ .payload_index = 7 },
},
}

// Exra data
[ ..., Ref.one, %2, ... ]

该指令使用了 pl_node 数据标签(pl_node 是 payload node 的缩写)。其中包含一个 payload_index 字段,它指向 extra 数组中附加数据的起始位置。通过查看 .add 标签的注释,可以了解到 extra 字段存储了一个 Zir.Inst.Bin 结构,该结构包含 lhs 和 rhs 字段。在这种情况下,我们有:

  • lhs = Ref.one
  • rhs = %2

跟 AST 节点类似,AstGen 源代码包含一个名为 addExtra 的辅助函数,以类型安全的方式编码结构化的额外数据。

额外数据本身可以包含静态值或其他 Zig.Inst.Ref 值等等。你需要查看每个标签的源代码才能理解它所编码的值。

其他数据类型

ZIR 还有许多其他的数据类型,但是我不会在本文中穷举其所有。我个人的感受是,上面这些类型囊括了编译器中编码 ZIR 指令的信息的绝大部分模式。

AstGen 的组件

AstGen 过程中有许多用于构建 ZIR 的共享组件。这些组件包含了通用的共享逻辑,了解这些逻辑对于理解 AstGen 的完整行为非常关键。

这些组件大多数是 AstGen 中函数的参数,下面介绍其中的一部分。我们不会介绍 AstGen 的所有功能,但是会强调一些关键要点。

作用域

AstGen 可以感知作用域,这使它可以引用父级作用域中的标识符,在使用未定义的标识符时抛出错误,或检测标识符被隐藏的问题。

AstGen.zig 文件中的 Scope 结构定义了作用域。作用域是多态的,可以是多个子类型之一。Zig 使用 @fieldParentPtr 模式来实现这一点。解释这个模式超出了本文的范围,但它 Zig 中很常见。

译注:以下材料可以帮助理解 @fieldParentPtr 模式。

在撰写本文时,有七种作用域类型:

  • Scope.Top 表示文件的最外层作用域。它始终是最顶层的作用域,没有父级作用域。该作用域不跟踪任何附加数据。
  • GenZir 通常表示 Zig 中的一个块,但也用于跨 AST 节点进行大量的额外状态跟踪。它跟踪当前块标签(如果有)、指令列表、是否在编译时位置等。
  • Scope.Namespace 该作用域包含一个无序的可以进行引用的声明集合。这里的关键词是“无序”。该作用域通常是结构体、联合等块的子作用域。例如:结构体变量可以引用文件中稍后定义的变量,而函数体则不行。因为结构体是命名空间,但函数体不是。
  • Scope.LocalValScope.LocalPtr 表示已定义的单个标识符,例如变量或常量。随着标识符的定义,这将创建一个新的该类型的子作用域,使其“在作用域内”。这与命名空间不同,因为它表示确切的一个声明,而不是一个无序列表。
  • Scope.Defer 表示 defer 或 errdefer 周围的作用域。它用于跟踪 defer 的存在,以在所有的退出点生成指令。

作用域具有一个 parent 字段,可用于通过作用域向上遍历。这就是标识符解析的工作原理。

字符串内化

字符串(字节数组)不直接存储在 ZIR 指令中。字符串会被内化,并存储在一个连续的 string_bytes 数组中。字符串的起始索引存储在 ZIR 指令中。这意味着共享的字符串(例如标识符)只存储一次。

到目前为止,ZIR 是编译流水线上第一个存储字符串的结构。AST 节点存储 Token 索引,而 Token 存储其在源代码中的起始和结束偏移量。这要求 AST 和源代码保持可用。在 AstGen 之后,可以释放 AST 和源代码。这个设计使得编译器可以解析非常大的 Zig 程序,并且只在内存中存储所需的内容。

结果的位置

ResultLoc 结构用于跟踪表达式树的最终结果应该写入的位置。在遍历 AST 并生成 ZIR 时,某些写操作的值可能嵌套得很深,AstGen 需要知道最终值应该写入的位置。

考虑以下示例:

1
2
3
4
5
6
const x = blk1: {
switch (someUnion) {
.someTag => break :blk1 42,
else => break :blk1 0,
}
}

在最外层作用域中,常量 x 正在定义。在这段代码的 AST 树中,我们需要嵌套进入一个块,然后是一个 switch 语句,然后是一个 switch 的匹配,最后是一个带标签的 break 语句。在递归超过四个函数以后,AstGen 需要依靠 ResultLoc 结构来定位写入标记跳出值的位置。

ResultLoc 是一个带有许多可能结果类型的标记联合。它有很好的注释,我建议阅读源代码。这里将解释一些示例标记:

  • .discard 表示赋值被丢弃,因为赋值左侧是 _ 标识符。在这种情况下,AstGen 知道不需要生成任何存储指令,我们可以直接丢弃该值。
  • .ty 表示赋值是给某个类型化的值。这会生成一个 as_node 指令来强制转换返回之前的结果值。
  • .ptr 表示赋值被写入某个内存位置。这会生成一个 store 指令,以便将结果写入该内存位置。

ZIR 的生成

现在让我们来了解一下 AstGen 是如何将 AST 转换为 ZIR 的。抽象地说,这个过程将遍历 AST 并为每个 AST 节点发出指令,从而将其转换为 ZIR 指令序列。AstGen 采用立即求值的模式将整个 AST 转换为 ZIR 指令序列,这意味着 AstGen 不会延迟求值(在后续编译阶段我们将看到延迟求值的例子)。

重要的是,AstGen 引入了我们第一个重要的语义验证过程。例如,AstGen 验证标识符是否被定义,是否不会遮蔽外部作用域,并且知道如何遍历父作用域。回想一下之前提到的 AST 构建引入了结构验证:它确保像 x pub == 7 这样的 Token 序列会抛出语法错误,但它并没有验证任何标识符 x 是否被定义。而词法分析本身只是逐个验证 Token 的有效性,例如 72 是一个有效的字面量,而 72a 不是一个有效的标识符。

学习 ZIR 的生成过程最简单的方式是查看 expr 函数。这个函数为 Zig 语言中任何有效的表达式生成对应的 ZIR 指令序列。我发现从语言的最简单组件开始,然后逐步构建起来是最容易理解这一过程的方式,因为 IR 生成是一个深度递归的过程。

整数字面量

让我们看一个简单的静态整数值的例子:

1
42

这将被解析成带有 .integer_literal 标签的 AST 节点,进而引导 expr 函数调用 integerLiteral 函数。integerLiteral 函数的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn integerLiteral(gz: *GenZir, rl: ResultLoc, node: Ast.Node.Index) InnerError!Zir.Inst.Ref {
const tree = astgen.tree;
const main_tokens = tree.nodes.items(.main_token);
const int_token = main_tokens[node];
const prefixed_bytes = tree.tokenSlice(int_token);
if (std.fmt.parseInt(u64, prefixed_bytes, 0)) |small_int| {
const result: Zir.Inst.Ref = switch (small_int) {
0 => .zero,
1 => .one,
else => try gz.addInt(small_int),
};

return rvalue(gz, rl, result, node);
} else |err| switch (err) {
error.InvalidCharacter => unreachable, // Caught by the parser.
error.Overflow => {},
}

// ... other paths
}

这里有很多内容!这就是为什么我从最简单的事物开始。如果我从函数声明之类的东西开始,我会陷入太多细节中,学起来会很困难。即使看整数字面量,你可能仍然感到有些不知所措,但这已经是最简单的情况了,所以让我们深入了解一下。

首先,这个函数在 AST 中查找整数字面量对应的 Token 值。然后,我们可以使用 Token 的起始位置,通过 tree.tokenSlice 获取与 Token 关联的文本。这将得到 “42” 字符串,或者更具体地说,是 4 和 2 两个字节。

接下来,我们尝试将该字符数组解析为无符号 64 位整数。如果数字太大,代码就会走到 other parts 注释的逻辑,其中涉及存储“大整数”的复杂内容,我们在这里不进行探讨。在这个示例中,我们假设所有的整数常量都小于或等于无符号 64 位整数的最大值(18446744073709551615)。

原注:无符号?那负数怎么办?

负数前面的符号 - 会作为一个单独的 AST 节点存储,并在 ZIR 中单独生成一个一元操作。整数字面量始终为正数。

上述过程最终成功解析数字,因为 42 可以解析为有效的 u64 类型的值。接下来,我们处理特殊情况,即值为 0 或 1 的情形,因为它们具有特殊的标记引用。否则,我们生成一个 .int 标签的 ZIR 指令,并将指令索引存储在 result 中。

最后,我们调用 rvalue 函数,它将 ResultLoc 的语义应用于该值,以确定我们是否需要原样返回它,将其转换为已知类型,或将其存储在内存位置等等。在许多情况下,这将是一个空操作,并且只会简单地返回带 .int 标签的 ZIR 指令。

译注:翻译时,integerLiteral 函数已被重构为 numberLiteral 函数

加法

理解静态值对应 ZIR 的生成过程,我们在进一步分析一个加法的生成过程:

1
42 + 1

这将被解析成带有 .add 标签的 AST 节点,并引导 expr 函数调用 simpleBinOp 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn simpleBinOp(
gz: *GenZir,
scope: *Scope,
rl: ResultLoc,
node: Ast.Node.Index,
op_inst_tag: Zir.Inst.Tag,
) InnerError!Zir.Inst.Ref {
const astgen = gz.astgen;
const tree = astgen.tree;
const node_datas = tree.nodes.items(.data);

const result = try gz.addPlNode(op_inst_tag, node, Zir.Inst.Bin{
.lhs = try reachableExpr(gz, scope, .none, node_datas[node].lhs, node),
.rhs = try reachableExpr(gz, scope, .none, node_datas[node].rhs, node),
});
return rvalue(gz, rl, result, node);
}

这是我们第一个递归的例子。它通过递归构建左表达式和右表达式的 ZIR 指令序列,进而生成一个带有 lhs 和 rhs 数据的 .add ZIR 指令。其中 lhs 对应 42 字面量,rhs 对应 1 字面量,根据我们对整数字面量的探索,我们知道这将进一步转化为 .int 指令。

生成的 ZIR 大致如下:

1
2
%1 = int(42)
%2 = add(%1, @Ref.one)

赋值

接下来,我们把上面加法的值赋值给一个未标识类型的常量:

1
const x = 42 + 1;

expr 函数不会处理赋值,因为它不是一个表达式,而是一个语句。statement 函数也不处理赋值,因为 Zig 的变量赋值只能在容器(结构体)或块(函数体)内进行。因此,处理赋值的是 containerMembersblockExprStmts 函数。前者用于结构体的主体,后者用于函数体。我认为首先看 blockExprStmts 会更容易,因为 containerMembers 比较复杂。

blockExprStmts 函数有一个 switch 语句,它在处理赋值时会跳转到 varDecl 函数处理变量或常量声明。varDecl 函数的代码量非常庞大!我不会在下面粘贴完整的函数,而只包括最关键的代码路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const type_node = var_decl.ast.type_node;
const result_loc: ResultLoc = if (type_node != 0) .{
.ty = try typeExpr(gz, scope, type_node),
} else .none;
const init_inst = try reachableExpr(gz, scope, result_loc, var_decl.ast.init_node, node);

const sub_scope = try block_arena.create(Scope.LocalVal);
sub_scope.* = .{
.parent = scope,
.gen_zir = gz,
.name = ident_name,
.inst = init_inst,
.token_src = name_token,
.id_cat = .@"local constant",
};
return &sub_scope.base;

首先,代码递归地计算类型表达式(如果有的话)和初始化表达式。对于常量 const x: t = init 来说,t 是类型表达式,init 是初始化表达式。对于我们的示例,我们没有类型表达式,而初始化表达式是一个 .add 指令。

接下来,代码创建一个新的 LocalVal 作用域来表示这个值。这个作用域定义了标识符 x 和值指令 init_inst 并指向 varDecl 函数给出的父作用域。新定义的作用域是函数的返回值,调用者 blockExprStmts 将当前作用域从该语句起替换为这个新作用域,以便将来的语句和表达式可以引用这个赋值。

此前的示例返回 ZIR 指令索引,而这个示例返回了一个作用域。请注意,对 x 的命名赋值不会生成 ZIR 指令。如果你查看生成的 ZIR,你不会在任何地方看到 x 的命名:

1
2
%2 = int(42)
%3 = add(%2, @Ref.one)

x 被实现为一个作用域,而这个作用域被 inst 值指令追踪。如果 x 曾经被引用,我们知道它的值对应到前述值指令,并且可以通过值指令来引用 x 的值。具体地说,我们看到函数体中的后续 ZIR 指令序列:

1
2
const x = 42 + 1;
const y = x;
1
2
3
%2 = int(42)
%3 = add(%2, @Ref.one) // x assignment
%4 = ensure_result_non_error(%3) // y assignment

注意指令 %4 对应的对 y 的赋值直接引用了指令 %3 作为参数。ZIR 指令不需要进行显式的数据存储和加载,因为它可以直接引用指令的结果。

请注意,当生成机器代码时,后端可能会确定需要进行加载或存储数据,这取决于计算机体系结构。但中间表示不需要做出这个决定。

作为读者的练习:下一步,我建议研究 const y = x 语句的 ZIR 生成过程,并学习标识符引用的工作原理。这将解释上述的 ZIR 是如何生成的,并为应对更复杂的编译过程打下良好的基础。

完成 AstGen 流程

AstGen 进程为每个 Zig 文件各运行一次,并递归地构建整个文件的 ZIR 指令序列,最终在函数的末尾返回一个 Zir 值。

通过本文介绍的细节,你应该能够跟踪任何 Zig 语言结构并学习 ZIR 是如何生成的。你可以经常使用 zig ast-check 命令来查看 Zig 编译器实际生成的内容,并从中知道应该深入分析哪些函数。

Zig 词法分析和语法解析

作者 tison
2023年12月11日 08:00

Zig 语言是近几年来逐渐声名鹊起的一个新编程语言,也是数目稀少的系统编程语言家族中的一个新成员。它由 Andrew Kelley 于 2015 年开始创造,至今已经开发了八个年头,但是仍然还未发布 1.0 版本。

不过,已经有不少新锐项目选择使用 Zig 开发,例如 JavaScript 运行时和完整开发套件 bun 和分布式金融数据库 tigerbeetle 等。

Hashicorp 的创始人 Mitchell Hashimoto 也在前年卸任 CEO 成为 IC 后开始投入大量时间开发 Zig 程序,包括开源的 libxev 库和目前尚未公开的 Ghostty 等等。

Mitchell 在开发 Zig 程序的过程中,撰写了系列博客介绍 Zig 程序的编译过程。这些内容有助于理解 Zig 语言的设计,以及它如何在 LLVM 提供的抽象和系统开发者之间建立起一层抽象。

我在取得 Mitchell 的同意后对这些文章做一个翻译,以飨读者。

本文翻译自系列第一篇和第二篇博客:

这一部分属于编译器的前端,相对而言比较简单。

以下原文。

词法分析

词法分析是典型编译器流程中的第一步。词法分析是将字节流,即编程语言的语法,转换为 Token 流的过程。

例如,Zig 的 comptime {} 语法经过词法分析后,将得到 [.keyword_comptime, .l_brace, .r_brace] 的结果。词法分析通常不处理语义问题,也不关心分析得到的 Token 是否有实际意义。例如,comptime [] 不是有效的 Zig 语法,但是词法分析过程仍然会将其解释为合法的 [.keyword_comptime, .l_bracket, .r_bracket] 输出。解析 Token 流的语义以及在无效情况下失败推出,是下一章要介绍的语法解析的内容。

词法分析器的实现方式多种多样。本文主要介绍 Zig 的词法分析工作原理,并不会跟其他的方案做对比。

词法分析器

Zig 语言的词法分析器是标准库的一部分,代码位于 lib/std/zig/tokenizer.zig 文件内,对外暴露为 std.zig.Tokenizer 结构。

Zig 词法分析器接受一个字节数组切片作为参数,不断产生 Token 直到遇见结束符(EOF)。词法分析过程没有任何内存分配。

在深入讲解词法分析过程前,我们先看到它的一个基本使用方式:

1
2
3
4
5
6
7
8
const std = @import("std");
const expect = std.testing.expect;

const tok = std.zig.Tokenizer.init("comptime {}");
try expect(tok.next() == .keyword_comptime);
try expect(tok.next() == .l_brace);
try expect(tok.next() == .r_brace);
try expect(tok.next() == .eof);

Zig 的词法分析有两个重要特点:

  1. 在字节数组切片上操作,而不是字节流。词法分析的参数类型是 [:0] const u8 而不是字节流。这意味着如果输入字符量巨大,调用方需要自己处理输入缓存和攒批处理的工作。实践当中,编程语言代码输入一般不会太大,所以整个源文件的词法分析可以一次性完成。这就是 Zig 当前的工作方式。
  2. 没有内存分配。Zig 的词法分析器不做任何的堆上内存分配。对于系统编程语言,理解接口的内存使用和分配行为总是有意义的。Zig 的所有分配都是显式的,因此我们可以立即从词法分析器的参数列表中没有 Allocator 得到它不会做内存分配的结论。

如下所示,词法分析器结构的定义是相对简单的:

1
2
3
4
5
6
7
pub const Tokenizer = struct {
buffer: [:0]const u8,
index: usize,
pending_invalid_token: ?Token,

// ...
};

即使你对词法分析一无所知,从上面结构定义中你也应该能理解词法分析的工作原理。词法分析器逐个处理切片上的字节,同时前移索引并产生 Token 输出。

Token 的结构

理解了词法分析的高级接口之后,紧接着下一个问题就是:Token 结构如何定义?

Zig 当中 Token 的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub const Token = struct {
tag: Tag,
loc: Loc,

pub const Loc = struct {
start: usize,
end: usize,
};

pub const Tag = enum {
invalid,
// ...
};
};

其中,tag 保存了 Token 的类型,可能的取值包括 .keyword_comptime, .l_brace, .r_brace, .eof 等等。目前,Zig 大约定义了 120 个不同的 Token 类型。

此外,loc 字段定义了 Token 的位置。它由 startend 两个索引组成。词法分析的调用者可以通过 source[tok.loc.start .. tok.loc.end] 来取得 Token 对应的文本内容。Token 结构中只保存两个索引是一种常见的效率优化手段。

Token 结构中只提供了最基本的信息:Token 的类型及其位置。不过,在不同的词法分析器中,Token 结构的定义可能有所不同。

例如,Golang 的词法分析器把 Token 定义成一系列整型常数。同时,对应到 next() 的函数返回代表 Token 类型的整数、代表偏移量的单一整数,以及对应 Token 文本内容的字符串。

1
2
3
4
5
6
7
8
9
10
func (s *Scanner) Scan() (pos token.Pos, tok token.Token, lit string)

type Pos int
type Token int

const (
ILLEGAL Token = iota

// ...
)

这与 Zig 大致相同。但是其中微妙的差异,对编译器的工效学和性能有重要影响。

查找下一个 Token

在词法分析器上每调用一次 next() 方法都将得到一个 Token 输出。

next() 函数从缓冲区的当前索引开始,逐个字节查看以构建 Token 输出。它使用一个状态变量来实现状态机,以跟踪可能出现的下一个状态。

在分析 Zig 词法分析器的实现细节之前,我们先从高层抽象上思考 next() 的工作流程。比如,输入包含空白符的语句 while 如何被分词。

首先,词法分析器会遇到 w 字符。此时,我们知道这个 Token 不会是数字,但它仍然可以是关键字或标识符。因此,我们不能确定 Token 的类型。词法分析器每次只看一个字符,所以我们接下来获取 h 字符。它仍然可以是标识符或关键字,所以我们继续读取字符,并依次读到 ile 三个字符。

现在,词法分析器得到了 while 输入,我们知道它是一个独立的关键字。但是,它仍然可以是标识符,因为可能还有更多字符,所以我们需要再向前看一个字符。

下一个输入是空格。我们现在明确知道它是 while 关键字,而不是标识符。于是,我们返回 .keyword_while Token 作为结果。反之,如果下一个字节是数字 1 这样的字符,我们会知道我们正在构建一个标识符,并且它绝不会是一个关键字,因为没有以 while1 开头的关键字。

这就是 Zig 词法分析器的工作原理。它维护当前状态,比如“正在构建一个标识符或关键字”,并逐个字符查看,直到明确确定了 Token 的类型和内容。

词法分析器只查看状态和当前字符:它不会向后查看,也不会向前查看。大多数词法分析器都是这样的。正如开头所提到的,词法分析器不关心语义,因此诸如 if while comptime x = 7 { else } 这样的无意义输入会产生一个有效的 Token 流。接下来,负责语法解析的解析器会分析 Token 流对应的语义含义。

词法分析的实现

next() 的实现基于嵌套的 whileswitch 语句,其中一个代码片段如下:

1
2
3
4
5
6
7
8
9
10
11
while (true) : (self.index += 1) {
const c = self.buffer[self.index];
switch (state) {
.start => switch (c) {
'a'...'z', 'A'...'Z', '_' => {
state = .identifier;
result.tag = .identifier;
},
}
}
}

外部的 while 是一个无限循环,每次处理缓冲区里的一个字符。循环体将在发现一个 Token 或字符耗尽时退出。

Zig 的一个好特性是缓冲区 buffer 具有 [:0] const u8 类型,其中的 :0 代表缓冲区以字节 0 结尾。因此,我们可以在发现 0 是退出循环。

接下来,词法分析器查看当前的 state 值。对于每个具体的 state 值,词法分析器再查看当前字符的值。结合这两者,词法分析器决定下一个动作应该是什么。

在上面的代码片段中,我们可以看到,如果词法分析器处于 .start 状态,下一个字符假设是 A 的话,词法分析器将进入到 .identifier 状态并试图得到一个标识符。

从 Token 到抽象语法树

词法分析完成后,下一个阶段是语法解析。语法解析器接收由词法分析器生成的 Token 流,并将其转换为更有意义的抽象语法树。

语法解析

语法解析是编译流程中紧随词法分析的下一步。语法解析负责从 Token 流构建抽象语法树。我之前已经写过关于 Zig 如何将字节流(源代码)转换为 Token 流的内容。

Zig 语法解析器的代码位于 lib/std/zig/parse.zig 文件中,对外暴露成标准库中的 std.zig.parse() 接口。语法解析器接受整个源代码文本,输出 std.zig.Ast 抽象语法树实例。Ast 结构定义在 lib/std/zig/Ast.zig 文件中。

译注:0.11 版本后,语法解析的接口有所变化,但是整体流程仍然是相似的。

MultiArrayList

从解析开始,Zig 编译器大量使用 MultiArrayList 结构。为了理解语法解析器的工作原理以及抽象语法树的结构,我们首先介绍 MultiArrayList 的设计。

在讲解 MultiArrayList 之前,我想先简要介绍一下常规的 ArrayList 的设计。ArrayList 是一个动态长度的值数组。当数组已满时,会分配一个更大的新数组,并将现有项复制到新数组中。这是一个典型的、动态分配的、可增长或缩小的项目列表。

例如,考虑如下一个 Zig 结构:

1
2
3
4
pub const Tree = struct {
age: u32, // trees can be very old, hence 32-bits
alive: bool, // is this tree still alive?
};

ArrayList 中,多个 Tree 结构会按如下形式存储:

1
2
3
       ┌──────────────┬──────────────┬──────────────┬──────────────┐
array: │ Tree │ Tree │ Tree │ ... │
└──────────────┴──────────────┴──────────────┴──────────────┘

每个 Tree 结构需要 8 字节内存存储,所以 4 个 Tree 值的数组需要 32 字节内存来存储。

原注:为什么是 8 字节?

一个 u32 值需要 4 字节存储,一个 bool 值需要 1 字节存储。但是结构体本身有一个 u32 字段,所以 bool 字段需要是 4 字节对齐的,也就占用了 4 字节的内存(其中 3 字节是浪费的)。总的就是 8 个字节。

MultiArrayList 同样是一个动态分配的值列表。然而,存储在数组列表中的类型的每个字段都存储在单独的连续数组中。这带来了两个主要优点:

  1. 由于对齐而减少了浪费的字节
  2. 具有更好的缓存局部性

这两个优点通常会带来更好的性能,以上面 Tree 结构为例,MultiArrayList(Tree) 的存储结构如下:

1
2
3
4
5
6
         ┌──────┬──────┬──────┬──────┐
age: │ age │ age │ age │ ... │
└──────┴──────┴──────┴──────┘
┌┬┬┬┬┐
alive: ││││││
└┴┴┴┴┘

结构体的每个字段都存储在单独的连续数组中。一个包含四个树的数组使用了 20 字节,相比 ArrayList(Tree) 的方案减少 37.5% 的内存占用。这忽略了多个数组指针的开销,但是随着列表中项目数量的增长,这种开销会分摊到总共减少了 37.5% 的内存需求上。

原注:为什么是 20 字节?

由于结构体被拆分,age 字段需要 4 字节,上面数组有 4 个 Tree 实例,因此是 16 字节。alive 字段不再需要 4 字节对齐,因为它不再是结构体的一部分,所以只需要 1 字节(没有浪费的字节!)。上面数组有 4 个 Tree 实例,因此 alive 字段一共需要 4 字节。这两个字段加起来总共是 20 字节。

本文不会深入介绍 MultiArrayList 的实现细节。对于理解 Zig 编译器来说,只需要知道几乎每个结构都存储为 MultiArrayList 就足够了。编译一个真实世界的程序通常会生成数万个“节点”,因此这样做可以节省大量内存,并提高缓存的局部性。

语法解析器的结构

语法解析器使用 Parser 结构体来存储当前解析状态。这不是一个公开导出的结构体,只用于管理解析操作的内部状态。调用者会得到一个 Ast 作为解析操作的结果,这将在本文后面进行探讨。

以下是本文写作时解析器的结构,我在其中插入了换行符,以将状态分成功能组:

1
2
3
4
5
6
7
8
9
10
11
12
13
const Parser = struct {
gpa: Allocator,
source: []const u8,

token_tags: []const Token.Tag,
token_starts: []const Ast.ByteOffset,
tok_i: TokenIndex,

errors: std.ArrayListUnmanaged(AstError),
nodes: Ast.NodeList,
extra_data: std.ArrayListUnmanaged(Node.Index),
scratch: std.ArrayListUnmanaged(Node.Index),
};

第一组状态包含了 gpa 和 source 两个字段。这是一个独立 Zig 文件的分配器和完整的原始源代码。

第二组状态中,token_tags 和 token_starts 是语法解析的结果。如前所述,语法解析器采用面向数据的设计,以获得更好的内存使用和缓存局部性。因此,我们有 token_tags.len == token_starts.len 的不变式,Zig 只是将结构体字段放置在单独的连续内存块中。tok_i 值是解析器正在查看的当前 Token 的索引(从 0 开始计数)。

第三组状态是语法解析器的实际工作状态。这是 Ast 部分累积的结果。第三组非常重要,因为它是语法解析器构建的核心部分:

  • errors 存储了语法解析器在进行解析过程中遇到的错误列表。大多数良定义的语法解析器在各种错误情况下都会尝试继续解析,Zig 的解析器也不例外。Zig 解析器会在这个字段中累积错误并尝试继续解析。
  • nodes 是 AST 节点的列表。初始为空,在语法解析器继续进行时逐渐构建起来。理解 AST 节点的结构非常重要,这将在下一节中介绍。
  • extra_data 是 AST 节点可能需要的附加信息的列表。例如,对于结构体,extra_data 包含了所有结构体成员的完整列表。这再次展示了面向数据的设计的例子;另一种方法是直接将额外数据放在一个节点上,但这会使整个解析器变慢,因为它会强制每个节点变得更大。
  • scratch 是语法解析器共享的临时工作空间,通常用于为节点或额外数据构建信息。一旦语法解析器完成工作,这些空间就会被释放。

AST Node 的结构

语法解析的结果是一个 AST 节点的列表。请注意,AST 在抽象定义上是一棵树,但解析器返回的结果是包含树中节点的列表,也就是上面介绍的 MultiArrayList 内存表示方式。

AST 节点结构可能会令人困惑,因为数据本身存成 MultiArrayList 结构,其中包括许多间接引用。我建议反复阅读本节,确保理解 AST 节点的结构。在理解 Zig 编译器的内部工作原理时,对这种数据模式理解不透彻会导致许多问题,因为这个模式在编译器的每个后续阶段都被重复使用。

AST 结构可以在 lib/std/zig/Ast.zig 中找到,下面是其核心 Node 结构的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub const TokenIndex = u32;

pub const Node = struct {
tag: Tag,
main_token: TokenIndex,
data: Data,

pub const Data = struct {
lhs: Index,
rhs: Index,
};

pub const Index = u32;
};

请注意,语法解析器本身将节点存储在 NodeList 中,这对应的是 MultiArrayList(Node) 类型,意味着它是 Node 结构,其中每个字段都存储在单独的连续内存块中。从概念上讲,可以将节点视为上面显示的单个结构体,但在具体实现中,字段分解存储到不同的字段数组中。

tag 是可能的 AST 节点类型的枚举。例如,fn_decl 对应函数声明,integer_literal 对应整数字面量,builtin_call 对应调用内置函数,例如 @intFromEnum 等。

main_token 字段现在并不是非常重要。它存储跟当前 AST 节点密切相关的 Token 值。例如,对于函数声明,它可能是一个 fn Token 值。该值是一个索引,指向用于构建 AST 的 Token 列表。

data 字段非常重要。它包含与 AST 节点关联的数据信息。例如,函数声明(.tag == .fn_decl)使用 data 存储函数原型和函数体。data 字段将在下一节详细介绍。

AST Node 的数据

AST Node 的数据是关联到 AST Node 的关键数据。例如,函数声明具有函数名称、参数列表、返回类型和函数体等信息。Node 结构的定义没有立即解释这些信息存储的位置。

在我们深入了解语法解析器的具体工作方式之前,理解 AST Node 的数据存储模式非常重要。对于想要了解整个编译器流程的人来说,这是一个关键的细节,因为 AST 使用了一种贯穿整个编译流程的模式。

让我们来看看给定 Zig 代码的函数声明的 AST 结构会是怎样的:

1
2
3
fn add(a: u8, b: u8) callconv(.C) u8 {
return a + b;
}

函数声明

函数声明的根 AST 节点会被打上 fn_decl 标签。

对于 fn_decl 标签的节点,data 字段的 lhs 存储了函数原型在 NodeList 中的索引(名称、类型信息等),而 rhs 字段存储了函数体在 NodeList 中的索引。这些信息目前只能通过阅读 .fn_decl 标签上面的注释或源代码才能得知。

lhs 和 rhs 中的索引值是关联到 NodeList 上。因此,你可以通过 tree.nodes[lhs] 访问函数原型信息(伪代码,并非完全正确的写法)。

data 的两个字段都用于存储 NodeList 的索引。这是 data 的一种用法,用于存储与 AST 节点相关的信息。接下来,让我们看一下函数原型,了解 Zig 如何确定参数列表、返回类型和调用约定。

函数原型

通过 tree.nodes[lhs] 我们可以访问函数原型节点。函数原型节点对应的是 fn_proto 标签。这种类型的 AST 节点存储了有关参数、调用约定、返回类型等信息。

函数原型节点以不同的方式使用 lhs 和 rhs 字段。其中,rhs 字段的用法与函数声明相同,存储了返回类型表达式在 NodeList 中的索引。Zig 除了支持直接类型标识符,还支持使用各种表达式来在编译时计算返回类型。

然而,lhs 字段并不指向 AST 节点。相反,它是指向某个 extra_data 字段的索引。该索引是附加元数据的起始索引。附加元数据的长度根据 AST 节点类型事先已知。所以,lhs 字段对应的信息不是通过 tree.nodes[lhs] 来访问,而是通过 tree.extra_data[lhs] 来访问。

现在我们知道,lhs/rhs 可能指向一个 AST 节点,也可能指向一段额外数据。

此外,extra_data 具有 []Index 类型。这可能会让人误以为 tree.extra_data[lhs] 只是指向另一个索引。这是不正确的。这种情况下的 lhs 只是指向 extra_data 中的第一个索引。要读取的后续字段数量也取决于标签。例如,fn_proto 对应的 extra_data 有六个字段。要想知道 extra_data 的具体内容,当前只能通过阅读源代码及其注释来了解:

1
2
3
4
5
6
7
8
9
10
11
12
pub const FnProto = struct {
params_start: Index,
params_end: Index,
/// Populated if align(A) is present.
align_expr: Index,
/// Populated if addrspace(A) is present.
addrspace_expr: Index,
/// Populated if linksection(A) is present.
section_expr: Index,
/// Populated if callconv(A) is present.
callconv_expr: Index,
};

因此,fn_proto 的情形下,tree.extra_data[lhs] 指向的是一个 params_start 字段。对应地,tree.extra_data[lhs+1] 指向一个 params_end 字段。依此往下,直到 tree.extra_data[lhs+5] 指向 callconv_expr 字段。

Ast 结构提供了一个 extraData() 帮助函数来完成这些解码工作:传入一个 tree 实例,你可以简单的访问其 lhs/rhs 对应的数据:

1
2
3
const fnData = tree.extraData(idx, FnProto)
fnData.params_start
fnData.callconv_expr

其中,idx 是 NodeList 中 AST Node 的索引,该 AST Node 必须是 .fn_proto 标签的。

下一个问题是,params_startparams_end 等字段仍然是一些索引,那么它们所指向的数据是什么类型的呢?答案是,它们分别对应第一个参数、最后一个参数等的 AST 节点的 NodeList 索引。这是读取有关函数原型的所有额外信息的方法。

如前所述,这里存在很多间接索引。但是现在理解这一切非常重要,否则将来的阶段将更加混乱。

函数标识符

现在,我们只剩下 main_token 字段还未讲解,这也是某些数据可能被访问的最后一种方式。

在上面函数原型的例子中,tag 和 data 等字段都不存储函数名称。实际上,.fn_proto 类型的节点使用 main_token 字段来存储函数名称。fn_proto 的 main_token 存储了指向 Token 流中 fn 关键字的索引。Zig 函数的标识符总是紧随该关键字之后。因此,你可以通过查看索引 main_token + 1 的 Token 来提取函数标识符。这正是编译器后续阶段读取标识符的方式。

小结

如果你对了解 Zig 编译器的其余工作感兴趣,深刻理解上面介绍的信息存储模式非常重要。第一次阅读时,你可能会感到很难理解(甚至第二次或第三次也是如此)。本节再次回顾 AST 的数据布局方式。

AST 节点数据可能对应到以下三个位置的内容:

  1. Token 流的数据,其中存储标识符或其他值。
  2. NodeList 即节点列表,用于查找其他 AST 节点。
  3. extra_data 列表,即额外数据列表,用于查找额外的数据结构。

后续的 AST 节点或额外数据结构通常包含额外的索引,这些索引可能指向其他节点、某个 Token 或额外的数据结构。例如,fn_decl 节点存在一个索引指向 fn_proto 节点,fn_proto 节点存在一个索引指向额外的 FnProto 数据结构,FnProto 结构存储了指向参数的一系列 AST 节点。

确定数据是否可用以及如何访问数据取决于节点标签。你必须阅读 lib/std/zig/Ast.zig 中的注释或语法解析器的源代码,才能了解数据的确切用法。

语法解析的工作原理

现在,我们对语法解析器的内部状态和 AST 构造有了坚实的理解,可以进一步讨论语法解析器实际解析 Token 流的过程了。

抽象地说,语法解析器检查 Token 流中当前 Token 的内容,并以此作为上下文来确定下一个 Token 可以是或应该是什么。例如,在解析 Token 流 fn foo 时,第一个 Token .keyword_fn 期望的下一个 Token 应该是一个标识符。如果不是标识符,则会出现语法错误。

不同于词法分析,语法解析关心 Token 的顺序是否合理。词法分析只是单纯的从文本中产生 Token 流,因此无效的语法,例如 fn pub var foo i32 } { 也能产生一个有效的 Token 流。但是,语法解析该 Token 流将产生一个错误,因为它是无意义的。

接下来,让我们具体看一下解析器如何解析一个简单的 Zig 文件:

1
2
3
4
5
var x = 7;

pub fn inc() callconv(.C) void {
x += 1;
}

解析一个 Zig 文件

语法解析器的主要入口是 parse 函数,它接受一个完整的 Zig 文件源代码。这个函数会初始化解析器状态,并调用 parseContainerMembers() 来解析 Zig 文件的成员。Zig 文件隐式地是一个结构体,因此解析器实际上是在解析一个结构体。

语法解析过程对应一个 while 循环,根据当前的 Token 确定下一个期望的内容。大致结构如下:

1
2
3
4
5
while (true) {
switch (p.token_tags[p.tok_i]) {
.keyword_var => ...
}
}

根据当前 Token 的值,上述代码确定下一个期望的内容。第一个 Token 是 .keyword_var 因为示例文件正在定义一个变量。这将进一步调用 parseVarDecl 来解析变量声明。此外,Zig 还有许多其他有效的 Token 如 comptime/fn/pub 等。

解析变量声明

示例文件中第一个被解析的对象是一个变量声明。Zig 语法解析器最终调用 parseVarDecl 方法来产生对应的 AST 节点。其代码概略如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn parseVarDecl(p: *Parser) !Node.Index {
const mut_token = p.eatToken(.keyword_const) orelse
p.eatToken(.keyword_var) orelse
return null_node;

_ = try p.expectToken(.identifier);
const type_node: Node.Index = if (p.eatToken(.colon) == null) 0 else try p.expectTypeExpr();
const init_node: Node.Index = if (p.eatToken(.equal) == null) 0 else try p.expectExpr();

return p.addNode(.{
.tag = .simple_var_decl,
.main_token = mut_token,
.data = .{
.lhs = type_node,
.rhs = init_node,
},
});
}

这个函数会“吞掉”一个 constvar 对应的 Token 值。“吞掉”意味着 Token 被消费:返回当前 Token 并使语法解析器的 tok_i 状态值递增。如果 Token 不是对应 constvar 关键字,那么函数将返回 null_node 以代表一个语法错误。因为变量定义必须以 constvar 开头。

接下来,语法解析器期望一个变量名的标识符。如果 Token 不是标识符,expectToken 函数会返回一个错误。例如,如果我们写成 var 32 那么语法解析器会在这里产生一个错误,表示语法解析期望得到一个标识符,但实际得到的是整数字面量。

如果没有遇到错误,下一步有几种可能的情况。通过分析 eatToken 的结果,我们可以确定下一步该做什么。如果下一个 Token 是冒号,那么期望的是找到一个类型表达式。例如 var x: u32 这样的源码。但是,类型表达式是可选的。上面示例中没有类型表达式,所以 type_node 将被设置为零。

然后,我们检查下一个 Token 是否是等号。如果是,我们期望找到一个变量初始化表达式。我们的变量声明确实有这个,所以这将创建一个 AST 节点并返回节点列表中的索引。我们不再往下讲解 expectExpr 的细节。

注意,这段逻辑可以接受多种有效的变量声明语法:

  • var x
  • var x: i32
  • var x: i32 = 42

但是,同样也明确了无效的语法,比如:

  • var : i32 x
  • var = 32

最后,我们调用 addNode 方法来创建节点。这将返回节点列表中的索引。在这种情况下,我们创建了一个带有 .simple_var_decl 标签的节点。

如果你查看 .simple_var_decl 的注释,其中写到其对应节点的 lhs 指向类型表达式 AST 节点的索引(如果没有类型表达式,则为零),而 rhs 指向初始化表达式 AST 节点的索引(如果没有初始化,则为零)。

parseVarDecl 函数返回 .simple_var_decl AST 节点的索引。最终,这个索引会一直返回到我们开始的 parseContainerMembers 函数,并存储在 scratch 状态中。我们将在后面解释 scratch 状态的用途。现在,while 循环继续解析下一个 Token 值,它将发现 .keyword_pub Token 并开始定义一个函数。

解析函数定义

语法解析器看到 .keyword_pub 之后,将调用 parseFnProtoparseBlock 方法继续解析。让我们重点关注 parseFnProto 的行为,因为它执行了我们尚未见过的操作。

下面是 parseFnProto 的简化且不完整的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
fn parseFnProto(p: *Parser) !Node.Index {
const fn_token = p.eatToken(.keyword_fn) orelse return null_node;

// We want the fn proto node to be before its children in the array.
const fn_proto_index = try p.reserveNode();

_ = p.eatToken(.identifier);
const params = try p.parseParamDeclList();
const callconv_expr = try p.parseCallconv();
_ = p.eatToken(.bang);

const return_type_expr = try p.parseTypeExpr();
if (return_type_expr == 0) {
try p.warn(.expected_return_type);
}

return p.setNode(fn_proto_index, .{
.tag = .fn_proto_one,
.main_token = fn_token,
.data = .{
.lhs = try p.addExtra(Node.FnProtoOne{
.param = params.zero_or_one,
.callconv_expr = callconv_expr,
}),
.rhs = return_type_expr,
},
})
}

这里出现了一些新的模式。首先,你可以看到如果没有设置返回类型,上述逻辑会存储一个警告而不是停止所有解析。语法解析器通常会在面对错误时尝试继续解析,这是其中一种情况。

接下来,fn_proto_one 类型的节点,其 lhs 值是 extra_data 的索引。上述代码展示了额外数据的写入方式。addExtra 函数接受一个包含所有值的结构体,并以类型安全的方式将其编码到 extra_data 中,然后返回 extra_data 中的起始索引。正如我们之前展示的那样,AST 节点的使用者将确切地知道 fn_proto_one 类型对应的额外数据有多少个字段以及如何读取这些信息。

完成 AST 构造

语法解析会递归地继续进行,为整个程序构建 AST 节点。在解析结束时,解析器会返回一个结构化的 Ast 结构,如下所示:

1
2
3
4
5
6
7
pub const Ast = struct {
source: [:0]const u8,
tokens: TokenList.Slice,
nodes: NodeList.Slice,
extra_data: []Node.Index,
errors: []const Error,
};

现在,我们已经理解了 Zig 的词法分析和语法解析过程,也清楚 AST 节点的结构,上面这些字段的意义也不难推断。

  • source 存储了完整的源代码,可以用于确定标识符字符串的内容或提供错误消息
  • tokens 是 Token 的列表
  • nodes 是 AST 节点的列表
  • extra_data 是 AST 节点的额外数据列表
  • errors 存储了可能的错误累积结果

在后续文章中,我们将讨论如何从 AST 生成中间表示,以及如何进一步做语义分析。

Why Async Rust

作者 tison
2023年11月6日 08:00

昨天的文章里,我介绍了 Async Rust 当前的实现以及一个实现 Async Runtime 需要了解的概念和现有的一些实践。

文章发出后,有评论称 Async Rust 创造性工作,没有可借鉴的经验。诚然,Rust 系统编程语言的定位就决定了它与其他有运行时的语言在设计时存在巨大的不同,同类语言 C 和 C++ 在异步编程方面,受限于语言的历史包袱,相关的支持往往以三方库而不是语言级的支持出现。

不过,Rust 至少可以借鉴自己一路走来的经验。Async Rust 的主要开发者之一 @withoutboats 在今年十月份的时候写了一篇 Why Async Rust 的博文,介绍了 Async Rust 一路发展的沿革和背后的设计决策。也是这一篇文章,帮助我理解了大量 Async Rust 设计的理由,从而融会贯通地写出昨天的文章。

随着软件系统越来越复杂,一个完整的复杂软件动辄几十上百万、甚至上千万行代码,提交记录少说几千,动辄几万甚至几十万。个人再也很难有足够的时间,单纯依靠读源代码和提交历史来理解系统运行的机制。这个时候,设计文档和作者现身说法讨论设计过程,尤其是失败的设计过程,就显得尤为重要。

实际上,我认为国内软件行业前沿的水平已经能跟世界前沿竞争,差的是时间积累的底蕴和人才的绝对数量。例如,全球第一波做数据库的人,到现在几乎都是三五十年经验起步,他们通过师父带徒弟甚至带出徒孙的形式,培养出了一大批领域专业人才。这一壁垒是很难走旁门左道超越的。

所幸开源运动的发展使得我们能够无国界的接触最前沿的软件设计思想和技术实践,我们于是可以对照一个完全开放的软件系统,根据核心开源开发者的介绍,理解这些智慧和经验的结晶。

以下原文,文中的“我”是 @withoutboats 的自称。

Rust 语言首次发布 async/await 语法时引起了很大的轰动。引用当时 Hacker News 的评论

这将打开新世界的大门!我确信很多人都在等待这一特性,我自己就是其中之一。

此外,这个新趋势具备一切优点:开源、高质量的工程代码、公开设计,许多贡献者为这个复杂的软件做出了巨大贡献。真是鼓舞人心!

最近,Rust 语言中的 async/await 语法变得有些褒贬不一。再次引用一条在 Hacker News 上的评论

我真的无法理解那些认为 Rust 的 async 设计很好的人。这简直是一团糟,更不用说 Rust 语言早已有着广为人知的陡峭学习曲线。

我竭尽全力尝试理解它,真的。但是,那真是一团巨大的混乱。而且它还会污染它接触到的一切。我真的很喜欢 Rust 语言,现在大部分时间都在用它编码,但每次遇到大量异步逻辑的 Rust 代码时,我都会全身紧张,视力模糊。

当然,这些评论都不能完全代表全部观点:早在四年前就有人提出了一些担忧。在同一条评论中,有人抱怨异步代码让自己全身紧张、视力模糊,但也有很多人同样热情地为异步 Rust 进行辩护。但我认为,可以说反对者越来越多,他们的口气也越来越强硬。在某种程度上,这只是炒作周期的自然进展。但是,我也认为随着时间推移,很多人未必记得或者了解异步 Rust 最初的设计过程,一些背景信息已经丢失了。

2017 年到 2019 年间,我与他人合作,在前人的工作基础上推动了 async/await 语法的设计。如果有人说,真不知道怎么会有人认为这团乱麻是个好的设计,我可能会有点不高兴。因此,请原谅我在这里用这篇不完善且过长的文章,解释异步 Rust 的诞生过程,异步 Rust 的目的,以及为什么在我看来,Rust 没有其他可行的选择。希望在这个过程中,我能够在某种程度上以更广泛、更深入的方式阐明 Rust 的设计,而不仅是重复过去的辩解。

关于术语的一些背景

在这场关于异步 Rust 的辩论中,争议的焦点之一是 Rust 决定使用所谓无栈协程(Stackless coroutine)的技术来实现用户空间并发。相关讨论充斥着大量术语,不熟悉所有术语是可以理解的。

我们首先需要明确的概念是这个特性的目的:用户空间并发。主流操作系统提供了一组相似的接口来实现并发:你可以创建线程,并在这些线程上依赖系统调用进行 IO 操作,这会阻塞线程直到操作完成。这些接口的问题在于它们存在一些不可避免的开销,如果你想要达到某些性能目标,这些开销可能成为限制因素。这些开销主要有两个方面:

  1. 在内核和用户空间之间进行上下文切换会消耗可观的 CPU 运行周期。
  2. 操作系统线程具有不可忽视的预分配线程栈,这增加了每个线程的内存开销。

这些限制在一定程度上是可以接受的。但是对于大规模并发的程序来说,它们并不适用。解决方案是使用非阻塞的 IO 接口,并在单个操作系统线程上调度海量并发操作。

开发者当然可以手动编写调度代码,但是现代编程语言通常提供现成的语法和标准库支持来简化这个过程。从抽象的角度来看,这意味着编程语言设计了一种把工作划分为多个任务,并将这些任务调度到线程上的方式。对应到 Rust 语言的设计,这就是 async/await 语法。

在这个问题的设计空间中,第一个选择的维度,是选择协作式调度还是抢占式调度,即任务是“协作地”将控制权交还给调度子系统,还是在运行过程中在某个时刻被“抢占”,而任务本身并不知道这一点?

关于这个主题的讨论中,经常被提及的一个术语是协程(coroutine),它的使用方式有些矛盾。协程是一种可以被暂停,并在稍后恢复的函数。其中一个重要的模糊之处,是有些人使用“协程”一词来指代具有显式语法以暂停和恢复的函数(这对应于协作调度的任务),而有些人则使用它来指代任何可以暂停的函数,即使暂停是由语言运行时隐式执行的(这也包括抢占式调度的任务)。我更倾向于第一个定义,因为这个定义做了一些有意义的区分。

另一方面,Goroutines 是 Go 语言的一项特性,它实现了抢占式调度的并发任务。Goroutines 具有与线程相同的接口,但是它是作为语言的一部分而不是作为操作系统原语来实现的,在其他语言中,这种特性通常被称为虚拟线程或者绿色线程。所以按照我的定义,Goroutines 不是协程。但是其他人使用更广泛的定义说 Goroutines 是一种协程,而我将这种特性称为绿色线程,因为在 Rust 中一直使用这个术语。

第二个选择的维度,是选择有栈协程还是无栈协程

有栈协程和操作系统线程一样有程序栈:当函数作为协程的一部分被调用时,它们的栈帧会被推入栈;当协程暂停时,栈的状态会被保存,以便从同一位置恢复。

无栈协程采用不同的方式存储需要恢复的状态。它通常将状态存储在一个延续(continuation)或状态机中。当协程暂停时,它所使用的栈被接替协程的操作使用;当协程恢复执行时,协程重新持有当前栈,并使用先前存储的延续或状态机,从上一次暂停的位点恢复协程。

在 Rust 和其他编程语言中,关于 async/await 语法,经常提到的一个问题是“函数着色问题”。这个问题说的是,为了获得异步函数的结果,开发者需要使用不同的操作(例如使用 .await 语法)而不是正常调用它。绿色线程和有堆协程没有这个问题,因为只有无栈协程才需要特殊语法来管理无栈协程的状态,具体特殊语法表示什么行为则取决于语言的设计。

Rust 的 async/await 语法是无栈协程机制的一个例子。异步函数被编译为返回 Future 的函数,而该 Future 用于存储协程在暂停时的状态。关于异步 Rust 的辩论的基本问题是,Rust 是否正确采用了这种方法,或者它是否应该采用更类似 Go 的有栈协程或绿色线程方法。理想情况下,开发者希望不需要显式语法来“着色”函数。

异步 Rust 的开发历程

绿色线程

Hacker News 的另一个评论很好的展示了这场辩论中我经常看到的一种言论:

开发者期望的并发模型是基于有栈协程和管道实现的结构化并发,底层实现由工作窃取执行器来优化负载。

除非有人实现这个模型的一个原型,并将之与当前的 async/await 语法加 Future 的实现相比较,否则我认为不会有任何建设性的讨论。

抛开上面提到的结构化并发、通道和工作窃取执行器(因为这是完全是不相关的问题),这种言论令人困惑的地方在于,最初的 Rust 确实实现了有栈协程,即绿色线程。但在 2014 年底,也就是 1.0 版本发布即将之前,它被移除了。了解其中的原因将有助于我们弄清楚为什么 Rust 推出了 async/await 语法。

对于任何绿色线程系统(无论是 Rust、Go 还是其他语言),一个重要问题是如何处理这些线程的程序栈。请记住,用户空间并发机制的一个目标是减少操作系统线程使用的大型预分配栈的内存开销。因此,绿色线程库往往会尝试以较小的栈启动线程,并仅在需要时进行扩展。

实现这一目标的一种方法是所谓的分段栈,其中程序栈是一系列小栈段的链表;当栈增长超出段的边界时,会向列表中添加一个新段,而当栈缩小时,该段将被移除。

这种技术的问题在于,它引入了高度不可控的将栈帧推入栈的成本。如果帧适合当前段,这基本上没什么开销。但是一旦不适合,程序需要分配一个新段。最恶劣的情况是在热循环中的函数调用需要分配一个新段,这时,该循环的每次迭代都需要进行一次分配和释放,从而对性能产生重大影响。而且,这些开销对用户来说完全不透明,因为用户不知道在调用函数时栈的深度。Rust 和 Go 都开始使用分段栈,然后因为这些原因放弃了这种方法。

另一种方法称为栈复制。在这种情况下,栈更像是一个数组而不是链表:当栈达到限制大小时,它会重新分配一个更大的栈。这样可以使初始栈保持尽量小,只根据需要进行扩展。这种技术的问题在于,重新分配栈意味着对其进行复制,且新分配的栈位于内存中的新位置。同时,任何指向栈的指针在栈重新分配后就无效了,程序需要一些额外机制来更新这些指针。

Go 使用栈复制技术。这主要得益于以下事实:Go 中指向栈的指针只能存在于同一个栈中。因此,Go 运行时只需要扫描该栈以重写指针。然而,做到这点至少需要运行时类型信息,而 Rust 并不保留这些信息。此外,Rust 也允许指向栈外的栈指针,这些指针可能在堆中的某个位置,或者在另一个线程的栈中。跟踪这些指针的问题最终与垃圾回收的问题相同,只不过这次的目标不是释放内存,而是移动它。显然,Rust 没有垃圾回收器,也不打算内置某种垃圾回收器。上面两个理由的任一个都导致 Rust 无法采用栈复制的方法。

早期 Rust 采用分段栈的方案,并通过增加绿色线程的大小来缓解频繁分配分段栈的问题,这也是操作系统线程的做法。当然,这种方案失去了绿色线程的一个关键优势:比操作系统线程更小的栈帧。

即使 Go 使用了可调整大小的栈来解决开销问题,当 Go 程序尝试与其他语言编写的库集成时,绿色线程仍会带来一定的无法避免的成本。C ABI 和操作系统栈是任何编程语言共享的最低要求。把代码从绿色线程切换到运行在操作系统线程栈上,可能会导致无法承受的 FFI 成本。Go 只是接受了这种 FFI 成本,这也成为 Cgo 饱受诟病的一个问题。最近,C# 因为这个局限性中止了绿色线程的实验

这个问题对 Rust 同样是致命的。因为 Rust 的设计目标之一,是支持将 Rust 库嵌入到用其他语言编写的二进制文件中;另一个设计目标,是能够在资源有限的嵌入式设备上运行,即使这些设备没有足够的时钟周期或内存运行虚拟线程运行时。

早期 Rust 为了解决这个问题,曾经将绿色线程运行时设置成可选的:Rust 可以在编译时选择将绿色线程编译成在原生线程上使用使用阻塞式 IO 运行的形式。因此,有一段时间,Rust 有两种变体:一种使用阻塞式 IO 和原生线程,另一种使用非阻塞式 IO 和绿色线程,并且所有代码都打算与两种变体兼容。

然而,实际结果并不理想,出于 RFC 230 的原因,从 Rust 移除了绿色线程的支持:

  1. 在绿色线程和原生线程之间的抽象并不是零成本的。执行 IO 操作时,会有无法避免的虚拟调用和内存分配,这对于特别是原生代码来说是不可接受的。
  2. 这一设计强制原生线程和绿色线程支持相同的接口,即使在某些情况下这并没有意义。
  3. 绿色线程并不完全支持互操作,因为仍然可以通过 FFI 原生的 IO 接口,即使是在绿色线程的上下文里。

绿色线程被移除了,但高性能用户空间并发的问题仍然存在。为了解决这个问题,Rust 团队开发了 Future trait 和后来的 async/await 语法。但要理解这条道路,我们需要先介绍 Rust 对另一个问题的解决方案。

迭代器

我认为 Rust 开始目前的异步功能设计,其真正起点可以追溯到 2013 年一位名叫 Daniel Micay 的开发者在邮件列表中发布的一篇帖子。这篇帖子与 async/await 语法、Future trait 或非阻塞 IO 没有任何关系,它是关于迭代器的。Micay 提议 Rust 转向使用所谓的外部迭代器,正是这种转变以及它与 Rust 的所有权和借用模型的有效结合,不可避免地将 Rust 引向了 async/await 的道路。当然,当时没有人知道这一点。

Rust 一直禁止使用变量的别名绑定来修改状态。这个常被称为 mutable XOR aliased 的约束在早期 Rust 中和今天一样重要。但是,最初它是通过不同于生命周期分析的机制来实施的。当时,引用只是某种参数修饰符,类似于 Swift 中的 inout 修饰符。2012 年,Niko Matsakis 提出并实现了 Rust 生命周期分析的第一个版本,将引用提升为真正的类型,并使其能够嵌入到结构体中。

尽管人们公认采用生命周期分析对塑造现代 Rust 的巨大影响,但是这一功能与外部迭代器的共生发展,以及这一功能对于 Rust 定位到当前领域的支柱型作用,并没有得到足够的关注。

在采用外部迭代器之前,Rust 使用了一种基于回调的方法来定义迭代器,这在现代 Rust 中大致可以表示为:

1
2
3
4
5
6
7
8
9
enum ControlFlow {
Break,
Continue,
}

trait Iterator {
type Item;
fn iterate(self, f: impl FnMut(Self::Item) -> ControlFlow) -> ControlFlow;
}

以这种方式定义的迭代器,将在集合的每个元素上调用 iterate 方法传入的回调函数。在回调函数返回 ControlFlow::Break 之前,迭代会不断产生下一个结果。for 循环的主体被编译为传递给正在循环遍历的迭代器的闭包。这种迭代器比外部迭代器更容易编写,但是这种方法存在两个关键问题:

  1. 语言无法保证当循环中断时迭代实际上停止运行。因此,语言无法依赖此功能来确保内存安全。这意味着无法从循环中返回引用,因为循环实际上可能会继续执行。
  2. 无法实现交错多个迭代器的通用组合子,例如 zip 方法,因为这种传递回调函数的设计不支持交替迭代两个不同的迭代器。

相反,Daniel Micay 建议 Rust 改用外部迭代器模式,从而完全解决了这些问题。外部迭代器的方式对应了 Rust 用户今天所熟悉的接口:

1
2
3
4
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}

原注:深入了解底层细节的读者可能会知道,Rust 的 Iterator 提供了 try_fold 的方法,该方法在功能上与内部迭代器的 API 非常相似,并且用在其他一些迭代器组合子中,因为它可以生成更好的代码。但 try_fold 不是定义迭代器的核心底层方法。

外部迭代器与 Rust 的所有权和借用系统完美地集成在一起,因为它们本质上编译为一个结构体,该结构体在自身内部保存迭代的状态,并且因此可以像任何其他结构体一样包含对正在迭代的数据结构的引用。而且由于单态化(monomorphization)的存在,通过结合多个组合子构建的复杂迭代器也编译成单个结构体,从而使优化器可以透明地优化这些结构。唯一的问题是,手动编写这些结构体更困难,因为开发者现在需要定义将用于迭代的状态机。

当时,Daniel Micay 当时写下这样一段话预示未来的发展:

在未来,Rust 可以使用像 C# 那样的 yield 语句来实现生成器。生成器被编译成一个快速的状态机,而无需进行上下文切换或依赖虚拟函数,甚至不需要闭包。这将消除使用外部迭代器模式时,手动编码递归遍历的难题。

生成器的进展不算快。不过,最近发布了一份关于 gen 语法的 RFC 提案,或许我们可能很快就会看到这个功能。

即使没有生成器,外部迭代器仍然被证明是非常成功的,人们普遍认识到了这项技术的价值。例如,Aria Beingessner 在设计字典的 Entry API 时就使用了类似的方法。值得注意的是,在 Entry API 的 RFC 中,她说这种 API 的设计“类似于迭代器”。她的意思是,Entry API 通过一系列组合子构建了一个状态机,这使得该 API 对编译器高度可读,从而能够进行深度优化。这项技术很有潜力。

Futures

在 Rust 团队寻找绿色线程的替代的过程里,Aaron Turon 和 Alex Crichton 首先复制了许多其他语言中使用的 Future/Promise 接口。这类接口基于所谓的传递延续风格(continuation passing style)设计。开发者可以向以这种方式实现的 Future 注册一个回调。这个回调被称为延续,它会在 Future 完成时被调用。

这就是大多数语言中定义 Future/Promise 的方式。这些语言会将 async/await 语法编译成传递延续风格的代码。

在 Rust 中,这类 API 大致会被定义成:

1
2
3
4
trait Future {
type Output;
fn schedule(self, continuation: impl FnOnce(Self::Output));
}

Aaron Turon 和 Alex Crichton 尝试了这种方法,但正如 Aaron Turon 在一篇具有启发性的博文中写的,他们很快遇到了一个问题,即使用传递延续风格需要为回调分配空间。Turon 举了一个 join 的例子:

1
2
fn join<F, G>(f: F, g: G) -> impl Future<Item = (F::Item, G::Item)>
where F: Future, G: Future

在这个 join 函数的定义中,注册到返回值 Future 上的回调需要被两个子 Future 拥有,因为只有后完成的子 Future 后才应该执行它。这最终需要实现某种引用计数,以及依赖内存分配来实现。而对于 Rust 来说,这些开销都是不可接受的。

于是,他们研究了 C 开发者实现异步编程的常见做法。在 C 语言的世界里,开发者通过构建状态机来处理非阻塞 IO 操作。由此,Aaron Turon 和 Alex Crichton 希望能够设计出一种可以将 Future 编译成 C 开发者手动编写的状态机的接口。经过几番尝试,他们最终采用了一种称为基于就绪状态(readiness-based)的方法:

1
2
3
4
5
6
7
8
9
10
enum Poll<T> {
Ready(T),
Pending,
}

trait Future {
type Output;

fn poll(&mut self) -> Poll<Self::Output>;
}

不同于存储延续的方案,在这个新方案中,Future 由某个外部执行器进行轮询。当一个 Future 处于挂起状态时,它会保存一种唤醒执行器的方式。当 Future 准备好被再次轮询时,执行该方式以通知执行器再次进行轮询。

译注:这里说得有点抽象。最终 Rust 的实现是 poll 方法接收一个 Context 参数,Context 持有一个 Waker 实例,如果 poll 返回 Pending 结果,那么返回前它需要以某种方式保存 Waker 实例。异步运行时需要实现一个监听状态变化的逻辑,在挂起的计算可以继续执行时,取出对应的 Waker 实例,调用 wake 或 wake_by_ref 方法通知执行器再次轮询这个 Future 计算。

通过以这种方式反转控制,Rust 不再需要为 Future 存储完成时需要调用的回调,而只需要将 Future 表示为一个单独的状态机。他们在这个接口之上构建了一系列组合子库,所有这些组合子都会被编译成一个单一的状态机。

从基于回调的方法切换到外部驱动程序,将一组组合子编译成单一的状态机,甚至是这两个 API 的确切规范:如果你是按顺序读到这一段的,所有这些都应该非常熟悉。从传递延续到外部轮询的转变,与 2013 年迭代器的转变完全相同!

再一次地,正是由于 Rust 能够处理具有生命周期的结构体,才使得它能够处理从外部借用状态的无栈协程,进而能够在保证内存安全的前提下,以最佳方式将 Future 表示为状态机。无论是应用于迭代器还是 Future 接口,将较小的组件组合成单个对象状态机的模式,是 Rust 的经典设计模式。这一模式几乎自然地融入到了语言当中。

我稍微停顿一下,强调迭代器和 Future 之间的一个区别:像 zip 这样交错两个迭代器的组合子,在基于回调的方法中甚至是不可能的,除非编程语言另外构建了某种协程的原生支持。另一方面,如果你想交错两个 Future 实例,比如实现 join 方法,基于延续的方法可以支持,只是会带来一些运行时成本。这就解释了为什么外部迭代器在其他语言中很常见,但 Rust 将这种转换应用于 Future 的做法却是独一无二的。

在最初的版本中,futures 库的设计原则是,尽可能使用户以类似构造迭代器的方式来构造 Future 实例:原语级别的 Future 库的作者将直接实现 Future trait 来定义原语级的 Future 结构,而编写应用程序的用户将使用 futures 库提供的一系列组合子,将简单的 futures 组合构建成更复杂的 Future 实例。不幸的是,当用户尝试遵循这种方法时,他们立即遇到了令人沮丧的编译器错误。问题在于,当 futures 被生成时,它们需要逃离(escape)当前上下文,因此无法从该上下文中借用状态。相反,任务必须拥有它的所有状态。

这对于 futures 组合子来说是个棘手的问题。因为通常情况下,任务状态需要在构成 Future 的一系列组合子中都能被访问。例如,用户通常会先调用一个 async 方法,然后调用另一个:

1
foo.bar().and_then(|result| foo.baz(result))

问题在于,foo 在 bar 方法中被借用,然后在传递给 and_then 的闭包中再次被借用。实际上,用户想要做的是在 await 点之间存储状态,这个 await 点是由 Future 组合子的链接形成的,而这通常会导致令人困惑和费解的借用检查错误。最容易理解的解决方案是将该状态存储在 Arc 和 Mutex 中。但使用 Arc 和 Mutex 不是零成本的,而且,随着系统变得越来越复杂,这种方法变得非常笨重和不灵活。例如:

1
2
3
let foo = Arc::new(Mutex::new(foo));
foo.clone().lock().bar()
.and_then(move |result| foo.lock().baz(result))

尽管最初的实验中 futures 展示了出色的基准测试结果,但由于这个限制,用户无法使用它们来构建复杂的系统。这个时候,我加入到 futures 的开发当中,着力改进这个状况。

async/await

在 2017 年末,由于上节末尾提到的用户体验不佳的原因,futures 生态系统并未得到好的反响。futures 项目的最终目标始终是实现所谓的无栈协程转换,即使用 async 和 await 语法的函数,可以转换为求值为 Future 的函数,避免用户手动构造 Future 实例及调用其方法。Alex Crichton 曾经开发了一个基于宏的 async/await 实现,但几乎没有引起任何关注。

是时候做出一些改变了。

Alex Crichton 实现的宏的核心问题之一,是一旦用户尝试在 await 点上保持对 Future 状态的引用,编译器就会报错。这实际上与用户在 futures 组合子中遇到的借用问题是相同的:在等待期间,Future 无法持有对自身状态的引用。因为这要求把 Future 编译成一个自引用的结构体,而 Rust 不支持这种实现。

如果把这个问题跟前文提到的绿色线程的问题进行比较,我们会得到一些有趣的结论。例如,我们说把 Future 编译成状态机的优势之一,是编译出来的状态机就是一个完美大小的栈。无论是手动实现、使用组合子或使用 async 函数构造出 Future 实例,编译后的 Future 大小都是它所需的最小大小。反观绿色线程,绿色线程的栈必须不断增长,以容纳任何线程栈可能具有的未知大小的状态。Future 状态机的大小在编译时确定后不再需要变化,因此,我们不会在运行时遇到栈增长的问题。

可以说,绿色线程的栈现在成了 Future 状态机结构保存的状态。由于 Rust 的语言约束要求移动结构体必须总是安全的,因此即使在执行期间,我们不会移动 Future 状态机,但是 Rust 的语言约束会要求它是能被移动的。这样,我们在状态机的方案里又遇到了绿色线程方案中遇到的栈指针问题。不过,这一次,我们有一个新的优势:我们不需要真的能够移动 Future 状态机,我们只需要表达 Future 状态机是不可移动的。

为了实现这一点,最初的尝试是引入 Move trait 的定义,这个 trait 用于将协程从可以移动它们的 API 中排除。不过,引入一个新的语言行为层面的 trait 遇到了我之前记录过的后向兼容性问题。关于 async/await 语法,当时有三个核心需求:

  1. Rust 语言层面就要实现 async/await 语法,从而支持用户使用类似协程的函数构建复杂的 Future 实例。
  2. async/await 语法需要支持将这些函数编译为自引用的结构体,从而支持用户在协程中使用引用。
  3. 这个功能需要尽快发布。

这三个需求的结合促使我寻找一种不对语言进行任何重大破坏性改变的替代方案。

我最初的计划比我们最终实现的方案要糟糕得多。我提议将 poll 方法标记为不安全的(unsafe),然后增加一个不变式,即一旦开始对一个 Future 进行轮询,就不能再次移动它。这个方案非常简单,可以立即实现,但是同时它也非常粗暴:它会使每个手写的 Future 都不安全,并且会施加一个难以验证的要求。更糟糕的是,编译器无法不安全的代码提供任何帮助。它很可能最终会遇到一些正确性问题,并且肯定会引起极大的争议。

所以,幸运的是,Eddy Burtescu 的几句话引导我思考另一个接口设计方向,这个 API 可以以更精细的方式强制执行所需的不变条件,也就是现在你所看到的 Pin 类型。Pin 类型本身有着一定的学习曲线,但我认为它还是显著优于当时我们考虑的其他选项,因为它是有针对性的、可强制执行的,而且,按照这个方案,我能按时发布 async/await 功能。

现在回顾起来,Pin 方案存在两类问题:

  1. 后向兼容性:一些已经存在的接口(特别是 Iterator 和 Drop 等)本应该支持不可移动类型。由于后向兼容性的要求,现在其实没有支持,这限制了语言进一步发展的可选项。
  2. 向终端用户暴露:我们的设计意图是,编写普通的异步 Rust 代码的开发者不需要处理 Pin 类型。在大多数情况下,这是正确的,但有一些典型的例外。几乎所有这些例外都可以通过一些语法改进来解决。唯一真正糟糕的问题是当前实现必须 Future 实例 Pin 住才能使用 .await 语法,这是一个不必要的错误,现在要修复它将是一个破坏性的改变(这着实让我感到尴尬)。

现在,关于 async/await 的设计决策只剩下语法的确定,我不会在这篇已经过长的文章中进行详细讨论。

组织上的考虑

我探索这段历史的原因是要证明一系列关于 Rust 的事实不可避免地将我们引入了特定的设计领域。

首先,Rust 没有运行时,所以绿色线程不是一个可行的解决方案。这不仅因为 Rust 需要支持嵌入到其他应用程序,或在嵌入式系统上运行,也因为 Rust 无法执行绿色线程所需的内存管理。

其次,Rust 的设计能够自然地把协程编译为高度可优化状态机,同时仍然保持内存安全性。我们不仅在 futures 和迭代器的设计中都利用了这一点。

但是,这段历史还有另一面:为什么我们要追求用户空间并发的运行时系统?为什么要引入 futures 和 async/await 语法?

提出这些问题的人通常有两种观点:

  1. 有些人习惯于手动管理用户空间并发,直接使用像 epoll 这样的接口;这些人有时会嘲笑 async/await 语法为“网络废物”。
  2. 另一些人只是说“你不需要它”,并提议使用更简单的操作系统并发机制,如线程和阻塞 IO 等。

在没有用户空间并发支持的语言,比如 C 语言中,实现高性能网络服务的人通常会使用手写的状态机。这正是 Future 抽象的设计目标,它可以自动编译成状态机,而无需手动编写状态机。实现无栈协程转换的目的,就是用户可以用顺序编程的方式编写代码,而编译器会在需要时生成状态转换以暂停执行。这样做有很大的好处。

例如,最近的一个 curl CVE 的底层原因就是代码在状态转换期间未能识别需要保存的状态。手动实现的状态机很容易出现这种逻辑错误,而 Rust 语言的 async/await 语法能在不损失性能的前提下避免这类逻辑错误。

2018 年初,Rust 团队决定在当年发布一个新的版本(edition),以解决一些在 1.0 版本中出现的语法问题。同时,Rust 团队计划以这个版本作为 Rust 进入主流市场的第一印象。Mozilla 团队主要由编译器黑客和类型理论学家组成,但我们对市场营销有一些基本的想法,并认识到这一版本可以成为产品广受关注的契机。我向 Aaron Turon 提议,我们应该专注于四个基本的用户故事,这些故事似乎是 Rust 的增长机会。这些故事包括:

  • 嵌入式系统
  • WebAssembly
  • 命令行接口(CLI)
  • 网络服务

这个建议成为建立领域工作组(Domain Working Groups)的起点,这些工作组旨在成为专注于特定使用领域的跨职能团队,区别于关注在具体技术或组织工作的团队(teams)。Rust 项目中工作组的概念后来有了很多改变,基本不再是最初设计的定义了,但我偏离了主题。

async/await 的工作开始于网络服务工作组,后来简称为 async 工作组并延续至今。然而,我们也非常清楚,由于 Rust 没有运行时依赖,Async Rust 也可以在其他领域发挥重要作用,尤其是嵌入式系统。我们设计这个功能时考虑到了这两种用例。

然而,尽管很少有人明说,Rust 要想成功,明显需要得到行业的采用。这样,即使 Mozilla 不再愿意资助一个实验性的新语言,Rust 也还能生存下去。而短期内行业采用的最有可能的途径,是在网络服务领域,特别是那些在当时被迫使用 C/C++ 编写的性能要求很高的系统。这个用例完全适合 Rust 的定位:这些系统需要细致的底层控制来满足性能要求,但是避免内存错误同样至关重要,因为它们面向网络。

网络服务的另一个优势是,这个软件行业领域具有快速采用新技术的灵活性和需求。其他领域当然也是 Rust 的长期发展的机会,但是它们不太愿意迅速采用新技术(嵌入式)、依赖于尚未广泛采用的新平台(WebAssembly)或者不是特别有利可图的工业应用,无法为 Rust 语言的发展带来资金支持(命令行界面)。

我以坚定的热情推动 async/await 的采用,因为我认为 Rust 的生存取决于这个功能。

在这方面,async/await 取得了巨大的成功。许多 Rust Foundation 最重要的赞助商,特别是那些雇佣开发者编写 Rust 代码的赞助商,重度依赖 async/await 编写高性能网络服务,这是他们愿意出资金支持的主要用例之一。在嵌入式系统或内核编程中使用 async/await 也是一个具有光明未来的领域。

async/await 在生态方面取得的巨大成功,甚至让开发者抱怨 Rust 生态系统滥用 async/await 而不是尽可能写“正常”的 Rust 代码。

我无法评价那些更愿意使用线程和阻塞 IO 的用户。当然,我认为有很多系统可以合理地采用这种方法,而且 Rust 语言本身并不阻止他们这样做。他们的反对意见似乎是,crates.io 上的生态系统,特别是用于编写网络服务的库,滥用 async/await 语法。我偶尔会看到一些使用 async/await 近乎船货崇拜(cargo cult)的库,但大多数情况下,可以合理地假设库作者实际上想要执行非阻塞 IO 以获得用户空间并发的性能优势。

我们无法控制每个人决定从事的工作。事实就是,大多数在 crates.io 上发布网络库的人都希望使用 async Rust 的能力,无论是出于商业原因还是出于兴趣。

当然,我希望在异步上下文以外也能方便的使用 async Rust 写成的库。这或许可以通过将类似于 pollster 的 API 引入标准库来实现。

不过,对于那些抱怨其他人发布的开源软件库不能恰好解决自己问题的人,我无言以对。

待续

虽然我认为 Rust 没有更好的 Async 解决方案,但是我并不认为 async/await 就是所有编程语言的最优解。特别是,我认为有可能有一种语言,它提供与 Rust 同等的可靠性保证,但是提供更少的对值的运行时表示的控制,同时使用有栈协程。我甚至认为,如果这种语言以一种可以同时用于迭代和并发的方式支持这些协程,那么该语言完全可以在不使用生命周期的情况下消除由于别名可变性而引起的错误。如果您阅读 Graydon Hoare 的笔记,您会发现这种语言正是他最初追求的目标。

我认为如果这种语言存在的话,有些 Rust 用户会非常愿意使用它,并且我理解他们为什么不喜欢处理底层细节的固有复杂性。以前,这些用户抱怨 Rust 语言有一大堆字符串类型,现在他们更有可能抱怨异步编程。我希望有一种提供与 Rust 同等保证的用于这些场景的语言存在,但是这不是 Rust 的问题。

尽管我认为 async/await 是 Rust 的正确演进路径,但我也同意对于当前的异步生态系统状况感到不满是合理的。我们在 2019 年发布了一个 MVP 实现,tokio 在 2020 年发布了 1.0 版本,自那时以来,Async Rust 的发展有些停滞不前。在后续的博文中,我希望讨论一下当前异步生态系统的状况,以及我认为项目可以做些什么来改善用户体验。但这已经是我发布过的最长的博客文章了,所以现在我只能到此为止了。

Async Rust 的实现

作者 tison
2023年11月5日 08:00

绝大多数第一次接触 Async Rust 的开发者所写的 Hello World 程序是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
async fn say_world() {
println!("world");
}

#[tokio::main]
async fn main() {
// Calling `say_world()` does not execute the body of `say_world()`.
let op = say_world();
// This println! comes first
println!("hello");
// Calling `.await` on `op` starts executing `say_world`.
op.await;
}

目前最流行的 Rust Async Runtime tokio 告诉你,只要在 main 函数前加上 #[tokio::main] 这个咒语,再把 main 函数定义成一个 async fn 函数,就可以在程序的任何地方用 async fn.await 语法享受到并发编程的免费午餐了。

但是,这样的解释显然不足以满足相信 Rust 尽可能显式表达所有开销的哲学的人;也就是说,到底 async fn 里的逻辑是怎么被执行的,代码中出现 .await 的时候,Rust 程序实际在执行什么动作。

本文基于具体的异步运行时和并发库代码,介绍支持 Rust 并发模型工作的核心概念。

Async Runtime

Rust 语言是一门系统级编程语言,这意味着 Rust 程序不像 Node.js 程序那样自带运行时。这不仅意味着没有全局管理内存的垃圾回收器,也意味着没有一个全局默认的异步任务运行时。

例如,以下 Node.js 代码会打印出 Hello world 字串:

1
(async function() { console.log('Hello world'); })()

而相似的 Rust 代码却不会有任何输出:

1
async { println!("Hello world") };

Rust 编译器提示:

1
note: futures do nothing unless you `.await` or poll them

如果在上述代码后加入 .await 调用:

1
async { println!("42") }.await;

编译会直接报错:

1
`await` is only allowed inside `async` functions and blocks

编译器提示 .await 只能在 async 代码块里使用,我们尝试把 main 函数改成 async 函数:

1
2
3
async fn main() {
async { println!("Hello world") }.await
}

编译器同样报错:

1
`main` function is not allowed to be `async`

我们回过头看 tokio 的示例,再添加了 #[tokio::main] 以后,似乎 main 函数就可以写成 async fn main 了,也能用 .await 触发 Future 计算了。

查看 tokio 的文档,可以知道 #[tokio:main] 实际上是一个过程宏,它会把 async fn main 展开成:

1
2
3
4
5
6
7
8
9
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
println!("Hello world");
});
}

由此,我们知道,在 Rust 程序里要想执行 async 块里的并发代码,我们需要先构造一个 Async Runtime 的实例,再调用类似 block_on 的方法来驱动并发计算执行。

Simplest Runtime

tokio 为我们提供了一个功能完备的异步运行时。然而,tokio 的实现也是极其复杂的。要想理解 Rust 异步运行时的执行逻辑,我们可以先实现一个最简单的异步运行时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct ThreadWaker(Thread);
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}

fn block_on<F: Future>(fut: F) -> F::Output {
let mut fut = pin!(fut);
let t = thread::current();
let waker = Arc::new(ThreadWaker(t)).into();
let mut cx = Context::from_waker(&waker);
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(res) => return res,
Poll::Pending => thread::park(),
}
}
}

应用代码如下:

1
2
3
fn main() {
block_on(async { println!("Hello world") });
}

这时,我们编译运行代码,可以看到屏幕正常输出 Hello world 字串。

我们来看一看上述代码实际执行了什么动作:

  1. async 代码块构造了一个并发计算闭包,返回一个 Future trait 的实例。
  2. 这个 Future 被传到 block_on 函数里,用 pin! 宏构造了符合 poll 方法要求的 Pin 实例。
  3. block_on 函数内,构造了一个 ThreadWaker 实例,持有当前线程的引用。
  4. 从 ThreadWaker 示例调用一系列构造器构造出 poll 方法需要的 Context 实例。
  5. 调用 poll 方法,在本示例中,首次调用就会执行 println 动作并返回 Poll::Ready(()) 结果,于是 block_on 函数返回。

我们把这个例子稍微写复杂一点,把异步代码块从简单打印一个字符串,改为一个类似 Timer 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
struct MyFuture {
id: u32,
start: Instant,
duration: Duration,
}

impl Future for MyFuture {
type Output = ();

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
println!("Polling {} {}", self.id, chrono::Utc::now());
let now = Instant::now();
let expect = self.start + self.duration;
let id = self.id;
if expect > now {
let duration = expect - now;
let waker = cx.waker().clone();
thread::spawn(move || {
sleep(duration);
println!("Wake up {id} {}", chrono::Utc::now());
waker.wake();
});
println!("Pending {id} {}", chrono::Utc::now());
Poll::Pending
} else {
println!("Ready {id} {}", chrono::Utc::now());
Poll::Ready(())
}
}
}

fn main() {
println!("Start {}", chrono::Utc::now());
block_on(MyFuture {
id: 1,
start: Instant::now(),
duration: Duration::from_secs(10),
});
println!("Finish {}", chrono::Utc::now());
}

输出内容如下:

1
2
3
4
5
6
7
Start 2023-11-05 09:53:03.202626 UTC
Polling 1 2023-11-05 09:53:03.203152 UTC
Pending 1 2023-11-05 09:53:03.203222 UTC
Wake up 1 2023-11-05 09:53:13.206258 UTC
Polling 1 2023-11-05 09:53:13.206370 UTC
Ready 1 2023-11-05 09:53:13.206396 UTC
Finish 2023-11-05 09:53:13.206407 UTC

可以看到,两次 Polling 之间间隔了十秒钟。实际执行的内容如下:

  1. 同上 block_on 函数第一次调用 poll 方法,这次 poll 返回了 Poll::Pending 结果,于是 block_on 阻塞在 thread::park() 上。该方法用同步原语信号量阻塞当前线程,并等待其他线程调用 unpark 唤醒内部信号量。
  2. poll 方法返回 Pending 前,从 Context 里取得 Waker 实例,并启动一个新线程,在十秒后调用 waker.wake() 方法。
  3. wake 方法调用即调用 self.0.unpark() 方法,于是第一步里阻塞的线程恢复执行,在 loop 中再次调用 MyFuture 的 poll 方法,此时时间已经超过预计等待时间,方法返回 Poll::Ready(()) 结果,block_on 方法也返回。

Simplest Runtime 在 crates.io 上有一个完整的实现 pollster 运行时。这个运行时的核心代码同样极其简短,不超过一百行。

Waker API

上面这个例子解释了在 Rust 的 Future API 中神秘的 Context 参数和它唯一传递的 Waker 实例的作用:Waker 实际上就是一个回调闭包,使用 wakewake_by_ref 执行通知异步运行时可以再次调度当前 Future 计算。

waker-fn 库提供了闭包到 Waker API 的适配器,这直观地展示了 Waker 就是一个回调闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pub fn waker_fn<F: Fn() + Send + Sync + 'static>(f: F) -> Waker {
Waker::from(Arc::new(Helper(f)))
}

struct Helper<F>(F);

impl<F: Fn() + Send + Sync + 'static> Wake for Helper<F> {
fn wake(self: Arc<Self>) {
(self.0)();
}

fn wake_by_ref(self: &Arc<Self>) {
(self.0)();
}
}

那么,为什么 Rust 要在 FnOnce 和 Fn 之外设计 Waker API 呢?我们看到 Waker 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pub struct Waker {
waker: RawWaker,
}

pub struct RawWaker {
data: *const (),
vtable: &'static RawWakerVTable,
}

pub struct RawWakerVTable {
clone: unsafe fn(*const ()) -> RawWaker,
wake: unsafe fn(*const ()),
wake_by_ref: unsafe fn(*const ()),
drop: unsafe fn(*const ()),
}

impl Waker {
pub fn wake(self) {
let wake = self.waker.vtable.wake;
let data = self.waker.data;
crate::mem::forget(self);
unsafe { (wake)(data) };
}

pub fn wake_by_ref(&self) {
unsafe { (self.waker.vtable.wake_by_ref)(self.waker.data) }
}
}

RawWaker 中的 data 字段,内容就是 Waker 本身。这跟我们常见的 Safe Rust 有很大的区别。它使用了等价于 void**const () 以及一系列方法引用构成 VTable 来组成 RawWaker 进而构成 Waker 实例,从而在类型签名中把类型参数或生命周期标记给抹掉。

这样,Rust 的 Async API 可以直接传递 struct Waker 的实例,而不是 Box<dyn Fn()>Box<dyn FnOnce()> 等。但是,后者编译时会被 Rust 编译器在生命周期和是否 Send 等类型约束上不停折磨,甚至有些代码表达不出来。

由于我们此时知道 Waker 对应的回调一定是可用的,所以 Rust 的 Async API 选择使用 VTable 和 unsafe 来实现躲过编译器检查的 Box<dyn Fn()> 的类型。

Simplest Parallel Runtime

上述异步运行时实现里,我们观察到了异步代码被 poll 的过程,但是这跟常见的通过并发实现并行,同时执行多个任务还是有所出入。

我们对 block_on 的代码稍作改动,把阻塞在当前线程的行为改为每次传入一个新的 Future 实例,就启动一个新线程来驱动执行,同时,将函数名改为 spawn 以体现出这种行为的改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn spawn<F>(fut: F) -> Receiver<F::Output>
where F: Future + Send + 'static,
F::Output: Send,
{
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut fut = pin!(fut);
let t = thread::current();
let waker = Arc::new(ThreadWaker(t)).into();
let mut cx = Context::from_waker(&waker);
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(res) => {
let _ = tx.send(res);
break;
}
Poll::Pending => thread::park(),
}
}
});
rx
}

这时,我们再编写一个同时启动两个任务的应用程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn main() {
println!("Start {}", chrono::Utc::now());
let r1 = spawn(MyFuture {
id: 1,
start: Instant::now(),
duration: Duration::from_secs(10),
});
let r2 = spawn(MyFuture {
id: 2,
start: Instant::now(),
duration: Duration::from_secs(10),
});
let h1 = thread::spawn(move || {
r1.recv().unwrap();
println!("Finish 1 {}", chrono::Utc::now());
});
let h2 = thread::spawn(move || {
r2.recv().unwrap();
println!("Finish 2 {}", chrono::Utc::now());
});
h1.join().unwrap();
h2.join().unwrap();
}

可以看到以下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
Start 2023-11-05 10:46:31.880514 UTC
Polling 1 2023-11-05 10:46:31.880631 UTC
Polling 2 2023-11-05 10:46:31.880650 UTC
Pending 1 2023-11-05 10:46:31.880659 UTC
Pending 2 2023-11-05 10:46:31.880672 UTC
Wake up 2 2023-11-05 10:46:41.881091 UTC
Wake up 1 2023-11-05 10:46:41.881091 UTC
Polling 2 2023-11-05 10:46:41.881206 UTC
Ready 2 2023-11-05 10:46:41.881232 UTC
Polling 1 2023-11-05 10:46:41.881261 UTC
Ready 1 2023-11-05 10:46:41.881276 UTC
Finish 2 2023-11-05 10:46:41.881330 UTC
Finish 1 2023-11-05 10:46:41.881377 UTC

这样,我们就实现了一个最简单的 thread per task 并发模型。

Send + ‘static

上面 thread per task 的例子里,spawn 函数里多了 Send'static 约束,这是因为我们在不同线程间传递 Future 实例,同时可能在线程间传递 Future 计算的结果。

这并不是 Rust Async Runtime 自身约束。如前所述,如果 Future 在当前线程构造,随后使用 block_on 在当前线程阻塞等待,Future 没有跨线程传递过,也就不需要满足 Send 约束。同样,异步运行时在能够保证计算一定不会跨线程进行时,异步计算的代码块即 Future 也不必满足 Send 约束。

在 tokio 的实现里,这种同一线程的计算是用 LocalSet 来抽象的:

1
2
3
4
5
6
7
8
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let local = tokio::task::LocalSet::new();
let rc = Rc::new(42);
local.run_until(async move {
println!("rc={rc}");
}).await;
});

如果把 local.run_until 换成 tokio::spawn 那么 Rust 编译器就会抱怨被 move 进去的 Rc 实例不是 Send 的。

LocalSet 的底层实现是持有一个 LocalState 实例,其中指示了持有该 LocalSet 的线程的 ThreadId 是什么。tokio 运行时调度过程里,如果拿出来的作业对应的 ThreadId 跟当前 ThreadId 不同,则不执行该作业,而是放回 LocalSet 的任务队列里。

glommio 是一个 thread per code 的异步运行时,它只提供对应到单个线程的 LocalExecutorBuilder 构造器:

1
let handle = LocalExecutorBuilder::default().spawn(|| async move { ... })?;

虽然顶层 async 块内部,可以使用 executor().create_task_queue(...) 创建任务队列,再调用 glommio::spawn_local_into(fut, tq) 把任务提交到任务队列内并发调度,但是同一个 spawn 内的所有任务,都将在同一个线程内执行。因此,所有 spawn 到 glommio 调度器上的 Future 都不必是 Send 的。

上述 LocalExecutorBuilder 的 spawn 方法最终会调用以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn spawn<G, F, T>(self, fut_gen: G) -> ...
where
G: FnOnce() -> F + Send + 'static,
F: Future<Output = T> + 'static,
T: Send + 'static,
{
std::thread::Builder::new()
.spawn(move || {
let mut le = LocalExecutor::new(...);
le.init();
le.run(async move { Ok(fut_gen().await) })
})
}

其中 LocalExecutor 的 run 方法定义如下:

1
pub fn run<T>(&self, future: impl Future<Output = T>) -> T { ... }

可以看到,这跟其他运行时的 block_on 函数非常相似。实际上,它们确实都是在当前线程里不停 Poll 来驱动异步计算进行。

RawTask

block_on 函数驱动的运行时,提交上来的 Future 实例以 local 变量的方式存储在当前线程的栈上,我们在当前线程里循环阻塞的 poll 这个 Future 实例。这种情况下,我们没有把 Future 提交到什么任务队列中,也就不需要额外的存储逻辑。

上面介绍的 thread per task 运行时只是把 block_on 放到多个 thread 里,每个 thread 拿住唯一的 Future 实例不停地 poll 获取结果。可以说,thread per task 运行时针对每个 task spawn 出来的线程的栈,就是存储该 Future 实例的地方。

但是,对于 tokio 这样有任务队列的运行时来说,情况就有所不同。这时调度器需要考虑如何存储未执行或执行后返回 Pending 的作业,以等待空闲线程取出执行。

实际上,tokio 仿照标准库用 RawWaker 封装 Waker 的方法,设计了一个 RawTask 结构用以存储待执行的 Future 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#[derive(Clone)]
pub(crate) struct RawTask {
ptr: NonNull<Header>,
}

#[repr(C)]
pub(crate) struct Header {
pub(super) state: State,
pub(super) queue_next: UnsafeCell<Option<NonNull<Header>>>,
pub(super) vtable: &'static Vtable,
pub(super) owner_id: UnsafeCell<Option<NonZeroU64>>,
}

pub(super) struct Vtable {
pub(super) poll: unsafe fn(NonNull<Header>),
pub(super) schedule: unsafe fn(NonNull<Header>),
pub(super) dealloc: unsafe fn(NonNull<Header>),
pub(super) try_read_output: unsafe fn(NonNull<Header>, *mut (), &Waker),
pub(super) drop_join_handle_slow: unsafe fn(NonNull<Header>),
pub(super) drop_abort_handle: unsafe fn(NonNull<Header>),
pub(super) shutdown: unsafe fn(NonNull<Header>),
pub(super) trailer_offset: usize,
pub(super) scheduler_offset: usize,
pub(super) id_offset: usize,
}

在 tokio multi-thread 运行时的 spawn 方法调用时,底层会依次调用方法构造 RawTask 实例:

  • src/runtime/scheduler/multi_thread/handle.rs/Handle::spawn
  • src/runtime/scheduler/multi_thread/handle.rs/Handle::bind_new_task
  • src/runtime/task/list.rs/OwnedTasks::bind
  • src/runtime/task/mod.rs/new_task
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// task::new_task
let raw = RawTask::new::<T, S>(fut, scheduler, id);

// RawTask::new
let ptr = Box::into_raw(Cell::<_, S>::new(fut, scheduler, State::new(), id));
let ptr = unsafe { NonNull::new_unchecked(ptr as *mut Header) };
RawTask { ptr }

// Cell::new
let vtable = raw::vtable::<T, S>();
let ptr = Box::new(Cell {
header: new_header(
state,
vtable,
),
core: Core {
scheduler,
stage: CoreStage {
stage: UnsafeCell::new(Stage::Running(fut)),
},
task_id,
},
trailer: Trailer::new(),
});

概括地说,就是把 Future 计算作业和调度器 scheduler 都塞到 state 里,存一个 void* 指针指向到这一整坨内容,连同取回这个状态时可以如何 poll/schedule 的 VTable 也在里面。

最后,这个 RawTask 被塞到任务队列里等待被取出后 poll 运行:

1
2
// OwnedTasks::bind
lock.list.push_front(task);

其他运行时,包括 smolyatp 乃至以上的 glommio 在向 local 任务队列 spawn 作业时,都有类似的逻辑。在相应代码库里搜索 RawTask 结构就可以了解相应的逻辑。

Future 执行

如果你分别用过 tokio 的 multi-thread 运行时和 current-thread 运行时,你就会发现前者 spawn 作业后,不用 block_on 以某种形式被调用就会自动运行,而后者 spawn 出来的作业,如果你不用 block_on 驱动,那么作业永远也不会执行。

这是因为,单纯的 spawn 部分只完成了把 RawTask 塞入到任务队列中的动作,从任务队列中取出任务执行还需要另一端逻辑来完成。

显式的 block_on 肯定是一种办法,而 tokio 的 multi-thread 运行时之所以能够“自动”运行,是因为它启动了一个多线程的线程池,线程池里的线程默认的工作就是不断从任务队列里取出待运行作业执行。

其中,Launch::launch 是初始化代码的关键节点。它会逐步调用到 Spawner::spawn_taskSpawner::spawn_thread 方法,最终启动一个新线程执行 move || run(worker) 闭包的逻辑。

run 函数在新线程里开始执行后,会逐步执行到一个 while !core.is_shutdown 的循环。这个循环的工作就是在 Runtime 被 shutdown 前不断获取任务队列中的作业驱动运行。

RawTask 重新调度

再次以 tokio 为例,如果第一次 poll Future 返回 Pending 结果,那么 Future 需要保存传入 poll 的 Context 实例,从而在可以继续运行作业时将 RawTask 再次提交到调度器上。

前面我们写到 RawTask 的 VTable 里有 poll 和 schedule 方法,这两个方法就是为了这个目的设计的。

其中,RawTask 的 poll 是由上一节提到的 run 函数驱动的:

  • src/runtime/scheduler/multi_thread/worker.rs/run
  • src/runtime/scheduler/multi_thread/worker.rs/Context::run
  • src/runtime/scheduler/multi_thread/worker.rs/Context::run_task
  • src/runtime/task/mod.rs/LocalNotified::run
1
2
3
4
5
6
7
8
9
10
11
12
// LocalNotified::run
let raw: RawTask = self.task.raw;
raw.poll();

// Harness::poll
self.poll_inner()

// Harness::poll_inner
let header_ptr = self.header_ptr();
let waker_ref = waker_ref::<S>(&header_ptr);
let cx = Context::from_waker(&waker_ref);
let res = poll_future(self.core(), cx);

raw.poll 调用的是 VTable 里的 poll 函数指针,实际就是 Harness::poll 方法。该方法从 RawTask 的 Header 里构造出 Context 并以此为参数调用 Future 的 poll 方法。这个 Context 当中的 Waker 实现是:

1
2
3
4
5
6
7
8
9
10
unsafe fn wake_by_ref(ptr: *const ()) {
let ptr = NonNull::new_unchecked(ptr as *mut Header);
let raw = RawTask::from_raw(ptr);
raw.wake_by_ref();
}

// RawTask::wake_by_ref
pub(super) fn wake_by_ref(&self) {
self.schedule();
}

即将当前 RawTask 重新提交到调度器的任务队列上。

Async IO

上面几节基本把 Rust Async Runtime 设计当中调度器部分的逻辑介绍完了。但是在这些讨论中缺少一个关键的角色,即真正把被调度作业挂起的行为。

在 Simplest Runtime 的案例里,我们简单的用 thread 的 park 方法把当前线程挂起,并在另一个线程上调用 unpark 恢复线程执行,这只是为了演示作业挂起和取回的过程。现实世界里,真正需要挂起计算的通常是两种情况:

  1. 资源暂时不可用,典型的是 IO 操作需要等待。
  2. 定时器逻辑尚未到达触发时间点。

其中 Async IO 是异步并发实际产生性能提升的的情况。无怪乎 Rust 的 Async 工作组成立的目的就是“关注 Async IO 的设计和实现”。

This working group is focused around implementation/design of the “foundations” for Async I/O.

本节以 smol 运行时的 async-io 子模块为例,介绍 Async IO 的挂起和唤醒逻辑。值得注意的是,Async IO 并不是每个运行时都必须实现的内容,例如 smol 的 async-executor 和 tikv 的 yatp 都可以调度 Future 计算,但是它们本身并不提供 Async IO 的支持。

不过,如果在此基础上,把 async-io 库的 Async IO 结构跟这两个运行时结合起来,在传给运行时 spawn 的 async 块中使用 async-io 的结构并调用 .await 读写数据,实际上就能实现 Async IO 的能力。但是 tokio 的 Async IO 实现上并不支持这种结合,我们稍后会讨论其原因。

async-io 库的代码非常简单,它的异步运行时核心是 Reactor 结构。这个名字对于熟悉 Netty 等网络库的读者应该不陌生,中文通常翻译成“反应堆”。async-io 的反应堆保存了所有等待 IO 回应的任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
pub(crate) struct Reactor {
pub(crate) poller: Poller,
/// Registered sources.
sources: Mutex<Slab<Arc<Source>>>,
events: Mutex<Events>,
}

/// A registered source of I/O events.
pub(crate) struct Source {
/// This source's registration into the reactor.
registration: Registration,
/// The key of this source obtained during registration.
key: usize,
/// Inner state with registered wakers.
state: Mutex<[Direction; 2]>,
}

pub enum Registration {
Fd(RawFd),
Signal(Signal),
Process(Child),
}

/// A read or write direction.
struct Direction {
waker: Option<Waker>,
wakers: Slab<Option<Waker>>,
}

impl Direction {
fn drain_into(&mut self, dst: &mut Vec<Waker>) {
if let Some(w) = self.waker.take() {
dst.push(w);
}
for (_, opt) in self.wakers.iter_mut() {
if let Some(w) = opt.take() {
dst.push(w);
}
}
}
}

然后,一旦你使用了 async-io 库,它就会全局启动一个 “async-io” 线程,不断地遍历反应堆,处理其上等待 IO 事件的任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
pub(crate) fn init() {
let _ = unparker();
}

fn unparker() -> &'static parking::Unparker {
thread::Builder::new()
.name("async-io".to_string())
.spawn(move || main_loop(parker))
.expect("cannot spawn async-io thread");
}

fn main_loop(parker: parking::Parker) {
loop {
reactor_lock.react(None).ok();
}
}

impl ReactorLock<'_> {
pub(crate) fn react(&mut self, timeout: Option<Duration>) -> io::Result<()> {
let mut wakers = Vec::new();
self.events.clear();
let res = match self.reactor.poller.wait(&mut self.events, timeout) {
Ok(_) => {
let sources = self.reactor.sources.lock().unwrap();
for ev in self.events.iter() {
if let Some(source) = sources.get(ev.key) {
let mut state = source.state.lock().unwrap();
for &(dir, emitted) in &[(WRITE, ev.writable), (READ, ev.readable)] {
if emitted {
state[dir].tick = tick;
state[dir].drain_into(&mut wakers);
}
}
}
}
}
}
for waker in wakers {
panic::catch_unwind(|| waker.wake()).ok();
}
res
}
}

只要有等待 IO 事件的任务加入到了 reactor.sources 上,出现 IO 事件时,其 Waker 就会被唤醒。

至于 IO 任务的挂起,则是在 poll_read 遇见 WouldBlock 错误时,调用 poll_readable 方法,注册在当前的 source 上。而当前 IO 的 source 则是在创建时就已经注册在 Reactor 的 sources 集合里:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Async<T>
pub fn new_nonblocking(io: T) -> io::Result<Async<T>> {
let registration = unsafe { Registration::new(io.as_fd()) };
Ok(Async {
source: Reactor::get().insert_io(registration)?,
io: Some(io),
})
}

// Source
fn poll_ready(&self, dir: usize, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
state[dir].waker = Some(cx.waker().clone());
}

顺带一提,async-io 库也定义了一个 block_on 函数,且这就是 smol 运行时实际用到 block_on 函数。它的实现同样是用 thread park 和 unpark 阻塞当前线程,并把 unpark 闭包作为 Context Waker 传到 poll 方法里。

接下来简单说明 tokio Async IO 的实现,以及为什么它不能直接和其他运行时结合使用。

tokio 的 Async IO 跟 async-io 的设计有两个关键不同:

第一,不同于 async-io 把 Waker 存在反应堆上,tokio 定义了一个 ScheduledIo 结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub(crate) struct ScheduledIo {
pub(super) linked_list_pointers: UnsafeCell<linked_list::Pointers<Self>>,
readiness: AtomicUsize,
waiters: Mutex<Waiters>,
}

type WaitList = LinkedList<Waiter, <Waiter as linked_list::Link>::Target>;
#[derive(Debug, Default)]
struct Waiters {
/// List of all current waiters.
list: WaitList,
/// Waker used for AsyncRead.
reader: Option<Waker>,
/// Waker used for AsyncWrite.
writer: Option<Waker>,
}

第二,它把这个 ScheduledIO 结构,仿照 RawTask 的方法,只存一个指针,且这个指针是以 u64 的形式作为 mio 中 IO 事件的 Poller 需要的 token 注册的:

1
2
3
4
5
6
7
8
9
10
pub(crate) fn token(&self) -> mio::Token {
mio::Token(self as *const _ as usize)
}

pub(super) fn add_source(...) -> io::Result<Arc<ScheduledIo>> {
let scheduled_io = self.registrations.allocate(&mut self.synced.lock())?;
let token = scheduled_io.token();
self.registry.register(source, token, interest.to_mio())?;
Ok(scheduled_io)
}

这个指针在事件循环驱动时被强转回来:

1
2
3
4
5
6
7
8
9
10
11
12
fn turn(...) {
let events = &mut self.events;
self.poll.poll(events, max_wait);
for event in events.iter() {
let token = event.token();
let ready = Ready::from_mio(event);
let ptr: *const ScheduledIo = token.0 as *const _;
let io: &ScheduledIo = unsafe { &*ptr };
io.set_readiness(Tick::Set(self.tick), |curr| curr | ready);
io.wake(ready);
}
}

此外,不同于 async-io 会启动一个 “async-io” 线程拉取 IO 事件,tokio 的 Async IO 使用的 ScheduledIO 依赖于 tokio Runtime 当中的调度器。且 IO Driver 的主逻辑 turn 函数依赖 tokio 的 Launch::launchblock_on 调用 IO Driver 的 park 系列方法驱动。这就是 tokio 提供的 IO 原语无法应用于其他异步运行时的原因。

相反,类似 tokio::sync::mpsc 这样只是使用传入的 Context 而不依赖 tokio Runtime 制造 Context 的库,就可以在其他运行时里正常使用。

Timer

最后,快速介绍 Async 运行时里 Timer 的实现。理解了 Async IO 的实现,Timer 的实现就非常自然。

同样,Timer 被创建时会被存在某个地方。async-io 实际上也支持 Timer 接口,创造出来的 Timer 同样被存在 Reactor 上。对于 tokio 运行时,则是构造一个 TimerShared 结构存到 Wheel 里。

对于 async-io 来说,驱动的逻辑是在每次反应堆的 react 接口被调用时,调用 process_timers 方法把已到时间的 Timer 的 Waker 加入到待处理 Waker 集合中。除了 IO 事件发生可以触发 react 调用,如果 “async-io” 线程发现还有注册的 Timer 待触发,那么它会在最近一个 Timer 触发的时间中断阻塞等待 IO 事件,强行再次处理一次 Timer 集合:

1
2
3
4
5
6
7
8
9
pub(crate) fn react(&mut self, timeout: Option<Duration>) -> io::Result<()> {
let next_timer = self.reactor.process_timers(&mut wakers);
let timeout = match (next_timer, timeout) {
(None, None) => None,
(Some(t), None) | (None, Some(t)) => Some(t),
(Some(a), Some(b)) => Some(a.min(b)),
};
let res = match self.reactor.poller.wait(&mut self.events, timeout) { ... }
}

这样,只要有 Timer 待触发,那么它一定会在预计时间点后的某个时间被唤醒,而不会永远等待。

对于 tokio 的情形,情况要稍微复杂一些。以 Sleep 为例,构造 Sleep 时会尝试从 tokio Runtime 当前的 Context 里取得调度器:

1
2
3
4
5
6
pub(crate) fn new_timeout(...) -> Sleep {
let handle = scheduler::Handle::current();
let entry = TimerEntry::new(&handle, deadline);
let inner = Inner {};
Sleep { inner, entry }
}

这里的 TimeEntry 对应到 Async IO 里的 ScheduledIO 结构。需要挂起任务时,调用 self.driver().reregister(...) 方法注册到 Timer 的 Wheel 里。跟 Async IO 依赖 Launch::launchblock_on 调用 IO Driver 的 park 方法驱动一样,Timer Wheel 也有一个 Driver 会在 IO Driver park 前被调用。

具体细节不再展开,可以按照这里提到的代码块和接口自行查找阅读理解。唯一值得一提的是,通常 Timer 的存储是用优先级队列或者叫小顶堆来构造,这样从数据结构里取出来的第一个 Timer 就是应当最早被触发的那一个。

结语

以上就是 Rust Async Runtime 实现的核心内容。概括地说,Async Runtime 包括两个核心角色,加上串联这两个角色的媒介:

第一个核心角色是调度器,即 tokio 里的 Runtime 或 glommio 的 LocalExecutor 等。它负责取得注册到调度器上的 Future 并调用 poll 方法触发计算。注册的方法通常是 block_on 函数或 spawn 方法,如果支持后者,则需要额外设计某种存储 RawTask 的结构。

第二个核心角色是驱动器,即 Async IO 的 Driver 或 Timer 的 Driver 等,也可以对应到 async-io 库的 “async-io” 线程。它负责监听外界信息(资源是否可用,时间过去多久等),并在外界信息显示被挂起的任务可以继续运行时,恢复任务运行。

驱动器和调度器沟通的媒介,就是经由 poll 接口传递的 Context 及其中的 Waker 实例。调度器构造出 Waker 实例,驱动器存储 Waker 并在任务可以继续执行时,调用 Waker 的 wake 方法恢复任务运行。恢复运行的常见实现,要么是解除 block_on 线程的阻塞,重新 poll 一遍,要么是把任务以 RawTask 的形式提交到调度器里,等待调度器取出作业运行。

需要说明的是,本文介绍的所有概念和实现方式,只有 Future API 和 Waker API 是标准库里的接口。其他设计和实现都是目前 Rust 社群探索出来的实践,并不是实现异步运行时的要求。

实际上,上面这些实现里至少介绍了几种不同的异步运行时风格:

  • block_on vs. spawn
  • thread per task vs. RawTask + TaskQueue
  • Async IO 和 Timer 的多种实现形式

通往罗马的路不止一条。

最后,关于并发编程的实践,我想分享以下几点:

  1. 尽量写串行代码。并发不是银弹,实际上它会增加代码复杂性。我们聊了这么多只是为了应对固有复杂性。现实编程当中,能不写就不写。
  2. Timer 必须并发,IO 几乎必须并发,独立的 Worker 需要并发跑主循环。这种情况下,绑专门的线程(池)运行并发逻辑;也就是说,不要 #[tokio::main] 全局都在同一个大池子里竞争。这个时候,不同线程组之间,必要时用 channel 传递状态。
  3. 并行计算的情形,用自动并行库(rayon)并原地等待。

参考阅读

Bonus: async/await

上面讨论中省略了 Async Rust 的一个重要组成部分,那就是 asyncawait 语法糖到底是怎么展开的。解释这个问题涉及到 Rust 编译器解语法糖的细节,这里展开讨论内容就实在太长了,而且 Rust 编译器本身的实现一直在改。

大致上,async 块会被展开成一个接受 Context 参数的闭包,回想 Future trait 唯一定义的 poll 方法也接受一个 Context 参数,async 块转成 Future 实例的过程应该不难想象。

await 的展开比较复杂,当前实现里,它会被展开成一段带 yield 的 Coroutine 代码,这是一个能保存当前运行状态的结构。例如,async { foo().await } 大概会被展开成:

1
2
3
4
5
6
7
8
9
10
11
12
match ::std::future::IntoFuture::into_future(foo()) {
mut __awaitee => loop {
match unsafe { ::std::future::Future::poll(
<::std::pin::Pin>::new_unchecked(&mut __awaitee),
::std::future::get_context(task_context),
)} {
::std::task::Poll::Ready(result) => break result,
::std::task::Poll::Pending => {}
}
task_context = yield ();
}
}

这些实现都还不是 stable 的,相关资料如下,想了解的读者可以进一步琢磨。

Rust 与 Java 程序的异步接口互操作

作者 tison
2023年7月30日 08:00

许多语言的高性能程序库都是建立在 C/C++ 的核心实现上的。

例如,著名 Python 科学计算库 Pandas 和 Numpy 的核心是 C++ 实现的,RocksDB 的 Java 接口是对底层 C++ 接口的封装。

Rust 语言的基本目标之一就是替代 C++ 在这些领域的位置,为开发者提供 Rust 具备的安全性和可组合性优势。

Apache OpenDAL (incubating) 是 Databend 工程师 Xuanwo 开发的一个 Rust 语言实现的开放数据访问层。它的核心设计支持通过相同的对象存储 API 访问不同的存储服务(Service),并提供可扩展的中间件(Layer)来支持通用的请求重试、限流和指标上报功能。目前,包括 Databend / RisingWave / GreptimeDB / mozilla sccache 在内的多个软件都选用 OpenDAL 作为其存储访问接口。

OpenDAL 架构概念图

在 Rust 核心实现的基础上,OpenDAL 提供了 Java / Python / Node.js 等不同语言的 API 绑定(Binding),以支持更广泛的生态利用 OpenDAL 已经完成的工作。例如,使用 Python 绑定,诸多大模型应用库能够在不同云厂商的对象存储服务间无缝迁移,支持用户使用任意对象存储服务。而在开发期间,则可以用内存或文件实现来模拟测试相同 API 的语义。

要在 OpenDAL 实现一个特定语言的 API 绑定,涉及到功能实现、程序库打包和发布等多个环节。本文从功能实现的角度出发,以 Java 绑定为例,讨论 OpenDAL 如何在社群力量的支持下实现 opendal-java 库。同时,重点剖析行内首个完整的 Java ↔ Rust 异步接口互操作的最佳实践。

跨语言互操作的基本知识

我的本科毕业论文《多计算机语⾔原理及实现机制分析之初探》当中讨论了三种跨语言互操作的方法:外部函数接口(FFI)、进程间通信(IPC)和多语言运行时。

最常见的是基于 FFI 的方案,即通过一套语言无关的函数调用约定,完成不同语言之间的通信。例如,opendal-java 就是使用 Java 的 FFI 方案 JNI 来完成 Java 和 Rust 之间的互操作的。CPython、Ruby 和 Haskell 等语言实现,则是通过 libffi 来完成和 Native 函数的互操作。

可以看到,FFI 方案基本都是实现了本语言与 Native 函数即遵循 C ABI 的函数之间的互操作,要想使用这样的方案实现 Java 程序调用 CPython 函数是不可能的。这不仅仅是没有人为 Java 和 CPython 之间定义一套调用规则的原因,还有只有 Native 函数才不需要运行时的缘故。要想调用一个 Java 函数,或是一个 CPython 函数,都必须先启动一个对应语言的运行时(JRE 或 CPython 解释器)。如果每次调用都启动一个新的运行时实例,那么这个性能损耗将彻底疯狂,而如果常驻一个目标运行时的进程实例,那么更加成熟的解决方案是进程间通信。

说进程间通信或 IPC 可能还有很多人不知道是什么,举一个例子就很容易理解了:Protobuf + gRPC 的解决方案就是典型的 IPC 方案。

如果说 FFI 是定义了一套语言无关的 Native 函数调用约定,那么 IPC 就是定义了一套语言无关进程接口调用约定。在 gRPC 之外,Apache Thrift / Apache Avro RPC / Apache Arrow Flight RPC 也都定义了各自的语言无关的进程接口调用约定,一般称为接口描述语言(IDL)。

这种方式下,开发者需要首先使用 IDL 定义好想要进行互操作的接口,随后使用对应方案的编译器产生调用方或被调用方语言的数据结构定义和接口存根(stub)对象,接着实现接口逻辑并在进程启动时暴露访问端口。实际调用时,调用方将接口访问及其参数结构编码为字节流,发送到接收方端口,接收方解码请求及其参数,完成请求后回传编码后的结果。

显而易见,IPC 的方式比起 FFI 的方式多了大约两轮数据编解码,加上一个来回网络字节传输的开销。

最后一种跨语言互操作的方案是多语言运行时,这个词汇可能又很陌生。同样举一个实例:JVM 就是一个跨语言运行时。

JVM 上面首先可以运行 Java 语言。然后,它可以运行 Scala / Groovy / Kotlin 等 JVM 族的语言。到这里,JVM 已经可以实现定义上的跨语言互操作了,因为 Java 和后面几个语音确实不是同一个编程语言。进一步地,JVM 上可以运行 Clojure 语言,这意味着 JVM 支持 Java 和 Lisp 之间的互操作。当然,Lisp 比较小众,所以最后我给出百分百令人信服的例子:在 JVM 上可以用 Jython 和 JRuby 实现 Java 和 Python 或 Ruby 的互操作,甚至实现 Python 和 Ruby 的互操作。虽然 Jython 项目凉凉了,但是 JRuby 仍然有很多下游使用,例如 HBase 的 Shell 是 JRuby 实现的,ELK 软件栈中的 Logstash 也是 JRuby 实现的。

此外,在多语言运行时的理论先锋 GraalVM 和 Truffle Framework 的支持下,GraalPy / TruffleRuby / FastR / Sulong (LLVM bitcode) 等等方案接连出现并活跃发展至今。这也是我在毕业论文中重点讨论和研究的对象。

OpenDAL 的多语言 API 绑定最终选择了基于 FFI 的方案。

首先,OpenDAL 根本不启动进程,它被设计为程序直接调用的软件库,所以 IPC 方案从模型上就是不适合的,更不用说调用一个基本的数据访问 API 不应该有多余的网络开销。不过,由于 Golang 自闭的跨语言生态和极力推崇 RPC 的哲学,OpenDAL 支持 Golang 调用的方式可能真的得做一个 service 然后暴露出 RPC 接口。

而多语言运行时的方案,应该说目前还没有支持 Java 和 Rust 或 Native 函数互操作的多语言运行时方案。最接近的是 GraalVM 上的 Sulong 运行时,但是它和它所依赖的 GraalVM 都还不算成熟甚至还未大规模生产使用,且 Sulong 支持的是执行 LLVM bitcode 代码,采用这个方案,就要解决 Rust ↔ LLVM bitcode ↔ Java 三方的沟通和版本适配问题。一言以蔽之,这个方案技术上就很难实现。

opendal-java 的实现

Java 通过 JNI 约定调用 C ABI 函数的一般实现流程如下:

  1. Java 侧定义一个 native 方法;
1
2
3
4
package org.apache.opendal;
public class BlockingOperator extends NativeObject {
private static native long constructor(String schema, Map<String, String> map);
}
  1. C ABI 侧定义一个符合方法编码规则的函数,这里以 opendal-java 中的定义为例;
1
2
3
4
5
6
7
8
9
#[no_mangle]
pub extern "system" fn Java_org_apache_opendal_BlockingOperator_constructor(
mut env: JNIEnv,
_: JClass,
scheme: JString,
map: JObject,
) -> jlong {
// ...
}
  1. Java 程序启动时,调用 System.loadLibrary(libname)System.load(filename) 方法加载 native 库,后续对 native 方法的调用便会转为在 native 库中查找经过编码后的对应 native 函数的调用。

知道了基本的方法映射模式,我们就可以分点来讨论 opendal-java 中的设计要点和技术难点了。

Native Object

从简单的不涉及异步接口互操作的 Blocking Operator 开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class BlockingOperator extends NativeObject {
// ...

public BlockingOperator(String schema, Map<String, String> map) {
super(constructor(schema, map));
}

public String read(String path) {
return read(nativeHandle, path);
}

public Metadata stat(String path) {
return new Metadata(stat(nativeHandle, path));
}

@Override
protected native void disposeInternal(long handle);
private static native long constructor(String schema, Map<String, String> map);
private static native String read(long nativeHandle, String path);
private static native long stat(long nativeHandle, String path);
}

public class Metadata extends NativeObject {
// ...

protected Metadata(long nativeHandle) {
super(nativeHandle);
}
}

public abstract class NativeObject implements AutoCloseable {
// ...

protected final long nativeHandle;

protected NativeObject(long nativeHandle) {
this.nativeHandle = nativeHandle;
}

@Override
public void close() {
disposeInternal(nativeHandle);
}

protected abstract void disposeInternal(long handle);
}

这个代码片段介绍了 Java 侧的主要映射策略:

  1. 每个对应到 Rust 侧结构的类都继承自 NativeObject 类,它持有一个 nativeHandle 字段,指示 Rust 侧对应结构的指针。
  2. 这个指针通过 constructor native 方法获得,通过 disposeInternal native 方法释放。
  3. 每个方法,例如上面的 read 方法,在内部都会被转成 methodName(nativeHandle, args..) 的 native 方法调用,前面可能有一些必要的 marshalling 工作。
  4. 每个返回 Rust 结构的方法,例如上面的 stat 方法,其 native 方法返回对应结构指针的整数,在 Java 侧方法返回前包装成继承自 NativeObject 的类。

NativeObject 包括了一段动态库加载的 static 逻辑,这是一个独立且复杂的话题,这里不做展开。

对应到 Rust 侧,native 方法实现的模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#[no_mangle]
pub extern "system" fn Java_org_apache_opendal_BlockingOperator_constructor(
mut env: JNIEnv,
_: JClass,
scheme: JString,
map: JObject,
) -> jlong {
intern_constructor(&mut env, scheme, map).unwrap_or_else(|e| {
e.throw(&mut env);
0
})
}

fn intern_constructor(env: &mut JNIEnv, scheme: JString, map: JObject) -> Result<jlong> {
let scheme = Scheme::from_str(env.get_string(&scheme)?.to_str()?)?;
let map = jmap_to_hashmap(env, &map)?;
let op = Operator::via_map(scheme, map)?;
Ok(Box::into_raw(Box::new(op.blocking())) as jlong)
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_BlockingOperator_disposeInternal(
_: JNIEnv,
_: JClass,
op: *mut BlockingOperator,
) {
drop(Box::from_raw(op));
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_BlockingOperator_read(
mut env: JNIEnv,
_: JClass,
op: *mut BlockingOperator,
path: JString,
) -> jstring {
intern_read(&mut env, &mut *op, path).unwrap_or_else(|e| {
e.throw(&mut env);
JObject::null().into_raw()
})
}

fn intern_read(env: &mut JNIEnv, op: &mut BlockingOperator, path: JString) -> Result<jstring> {
let path = env.get_string(&path)?;
let content = String::from_utf8(op.read(path.to_str()?)?)?;
Ok(env.new_string(content)?.into_raw())
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_BlockingOperator_stat(
mut env: JNIEnv,
_: JClass,
op: *mut BlockingOperator,
path: JString,
) -> jlong {
intern_stat(&mut env, &mut *op, path).unwrap_or_else(|e| {
e.throw(&mut env);
0
})
}

fn intern_stat(env: &mut JNIEnv, op: &mut BlockingOperator, path: JString) -> Result<jlong> {
let path = env.get_string(&path)?;
let metadata = op.stat(path.to_str()?)?;
Ok(Box::into_raw(Box::new(metadata)) as jlong)
}

这里有三个要点。

第一,虽然 Rust 的 FFI 理论上可以直接对接 JNI 的标准,但是我还是使用了 jni-rs 库来简化开发。这个库的质量很不错,其主要工作是在 FFI 接口上封装了一套 JNI 领域模型的 Rust 结构。例如 JMap 这样的结构在 JNI 里是不存在的,JString 提供的接口也非常方便。注意 String 在这个传递过程中是有可能产生 marshalling 开销的。

第二,每个 JNI 接口函数都实现为调用对应的 intern 函数,然后用一段 unwrap_or_else(|e| {e.throw}) 的模板处理可能的错误。这是因为 JNI 的接口不能返回 Result 类型,所以做了一个错误处理的集中抽象。具体设计实现下一段会谈,这里主要说明的是可以最大程度的避免 unwrap 或对等方法的调用,把错误传递到 Java 侧用 Exception 来处理,而不是 Rust 侧 panic 即等价与 C++ core dump 来处理失败。后者显然是所有 Java 用户都不想处理的问题,也无法在 Java 侧捕捉处理。

第三,可以注意下如何返回 Rust 结构的指针,以及 disposeInternal 时如何释放指针。这是 Rust 内存安全的边界,理解这里面的逻辑对编写内存安全的 Rust FFI 有很大的帮助。

这里有一个潜在的优化点:Metadata 其实是个记录结构(record),如果能做好 marshalling 对应,可以直接编码返回,这样 Java 拿到的就是一个完全自己管理生命周期的数据对象,后续也不用走 JNI 去访问 Metadata 的数据。

错误处理

opendal-java 的一个创新价值是实现了一套 Rust ↔ Java 的错误处理范式。

在 Rust 侧,我们在 intern 系列方法里完成调用 Rust 函数的工作,回传 Result 到外层 FFI 接口处理。如果 Result 是错误结果,那么会走一个 throw 的过程抛出异常。这个过程会从 Rust 侧的错误提取出错误信息和错误码,然后构造 Java 侧的异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
pub(crate) struct Error {
inner: opendal::Error,
}

impl Error {
pub(crate) fn throw(&self, env: &mut JNIEnv) {
if let Err(err) = self.do_throw(env) {
env.fatal_error(err.to_string());
}
}

fn do_throw(&self, env: &mut JNIEnv) -> jni::errors::Result<()> {
let exception = self.to_exception(env)?;
env.throw(exception)
}

pub(crate) fn to_exception<'local>(
&self,
env: &mut JNIEnv<'local>,
) -> jni::errors::Result<JThrowable<'local>> {
let class = env.find_class("org/apache/opendal/OpenDALException")?;
let code = env.new_string(...);
let message = env.new_string(self.inner.to_string())?;
let exception = env.new_object(...);
Ok(JThrowable::from(exception))
}
}

对应 Java 侧 OpenDALException 定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class OpenDALException extends RuntimeException {
private final Code code;
public OpenDALException(String code, String message) {
this(Code.valueOf(code), message);
}
public OpenDALException(Code code, String message) {
super(message);
this.code = code;
}
public Code getCode() {
return code;
}
public enum Code {
// ...
}
}

运用这个范式,我把整个绑定 Rust 侧的 panic 调用控制在了 10 个以内,且全部是在异步接口互操作的范畴里的。其中大部分在 Load 和 Unload 的逻辑里,这是整个程序启动和终止的地方。其他的调用在 Rust 侧完成 Futrue 后回调的上下文里。这两者的共同点是:它们都对应不到一个用户控制的 Java 上下文来抛出异常。

异步接口互操作

opendal-java 的另一个创新价值,也是业内首创的方案,是实现了 Rust ↔ Java 异步接口互操作。

opendal-java 的第一版异步接口互操作实现是基于 Global Reference 的。但这个方案有一个缺陷,那就是 Global Reference 上限是 65535 个。所谓基于 Global Reference 的方案,就是把需要异步完成的 CompletableFuture 对象注册为 JNI 的 Global Reference 并跨线程共享,这意味着整个程序的 API 调用并发上限一定不超过 65535 个。

虽然这个数量对于大部分场景已经够用,但是毕竟是个无谓的开销,且 Global Reference 的访问没有经过特别的优化,很难估计重度使用这个特性会带来怎样的不稳定性。

我曾经构思过基于全局 Future Registry 的解决方案,或者演化成一个类似于跨语言 Actor Model (Dispatcher + Actor with Mailbox) 的方案,但是最终都没有成功写出来。

这里面主要的难点是 JNI 调用所必须的 JNIEnv 不是线程安全的。而要想真正实现 Java 调用 Rust 的异步接口,并在 Rust 异步动作完成后回调,而不是原地阻塞等待,调用过程一定会经历从 JNI 调用线程转移到 Rust 的后台异步线程。Global Reference 能够把 Java 对象提升到全局空间,进而跨线程共享,但是这其实也不解决 JNIEnv 不能移动到另一个线程的问题。

opendal-java 的第一版异步接口互操作实现解决了这个问题,其核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static mut RUNTIME: OnceCell<Runtime> = OnceCell::new();
thread_local! {
static ENV: RefCell<Option<*mut jni::sys::JNIEnv>> = RefCell::new(None);
}

#[no_mangle]
pub unsafe extern "system" fn JNI_OnLoad(vm: JavaVM, _: *mut c_void) -> jint {
RUNTIME
.set(
Builder::new_multi_thread()
.worker_threads(num_cpus::get())
.on_thread_start(move || {
ENV.with(|cell| {
let env = vm.attach_current_thread_as_daemon().unwrap();
*cell.borrow_mut() = Some(env.get_raw());
})
})
.build()
.unwrap(),
)
.unwrap();

JNI_VERSION_1_8
}

#[no_mangle]
pub unsafe extern "system" fn JNI_OnUnload(_: JavaVM, _: *mut c_void) {
if let Some(r) = RUNTIME.take() {
r.shutdown_background()
}
}

unsafe fn get_current_env<'local>() -> JNIEnv<'local> {
let env = ENV.with(|cell| *cell.borrow_mut()).unwrap();
JNIEnv::from_raw(env).unwrap()
}

unsafe fn get_global_runtime<'local>() -> &'local Runtime {
RUNTIME.get_unchecked()
}

其中,RUNTIME 的启动、关闭和获取是常规的使用 tokio 异步框架的方式:虽然可能更多人是简单的 #[tokio::main] 解决,但是其实 tokio 底下大概也是这么一个全局共享的 RUNTIME 的实现。

真正值得注意的是 JNI_OnLoad 传进来了一个线程安全的 JavaVM 对象,我们基于它在每个 tokio RUNTIME 的线程里 attach 了一个 JNIEnv 实例。

上面提到,JNIEnv 不是线程安全的,但是我们现在是在每个 tokio 线程池的线程里各自创建了一个本地的 JNIEnv 实例,这些实例在各自的线程里存活,并不跨线程共享。

JNI_OnLoad 方法就是这里破解难点的关键,它在本动态库被加载(通过 System.load 或者 System.loadLibrary 方法)之后被调用,传递当前 JavaVM 实例以供使用。由于运行当前程序的 JavaVM 全局只有一个,它是线程安全的,并且有一个 attach_current_thread_as_daemon 方法可以把当前线程注册到 JVM 上,获取 JNI 操作必须的 JNIEnv 对象。

突破这个问题以后,我们其实完全就不需要用 Global Reference 来传递 CompletableFuture 对象,而是可以实现我设想过的全局 Future Registry 方案了。其主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private enum AsyncRegistry {
INSTANCE;
private final Map<Long, CompletableFuture<?>> registry = new ConcurrentHashMap<>();
private static long requestId() {
final CompletableFuture<?> f = new CompletableFuture<>();
while (true) {
final long requestId = Math.abs(UUID.randomUUID().getLeastSignificantBits());
final CompletableFuture<?> prev = INSTANCE.registry.putIfAbsent(requestId, f);
if (prev == null) {
return requestId;
}
}
}
private static CompletableFuture<?> get(long requestId) {
return INSTANCE.registry.get(requestId);
}
private static <T> CompletableFuture<T> take(long requestId) {
final CompletableFuture<?> f = get(requestId);
if (f != null) {
f.whenComplete((r, e) -> INSTANCE.registry.remove(requestId));
}
return (CompletableFuture<T>) f;
}
}

public class Operator extends NativeObject {
// ...

public CompletableFuture<Metadata> stat(String path) {
final long requestId = stat(nativeHandle, path);
final CompletableFuture<Long> f = AsyncRegistry.take(requestId);
return f.thenApply(Metadata::new);
}

public CompletableFuture<String> read(String path) {
final long requestId = read(nativeHandle, path);
return AsyncRegistry.take(requestId);
}

private static native long stat(long nativeHandle, String path);
private static native long read(long nativeHandle, String path);
}

这次,所有的 native 方法都返回一个 long 值,它是一个从 AsyncRegistry 中获取结果对应的 CompletableFuture 的凭证。

Rust 侧通过 JNI 调用 AsyncRegistry#requestId 方法注册一个 Future 并取得它的凭证,随后这个凭证(整数)被传递到 tokio RUNTIME 创建的后台线程里,完成 API 调用后,通过后台线程的 JNIEnv 调用 AsyncRegistry#get 方法取得 CompletableFuture 对象,调用 CompletableFuture#complete 方法回填结果,或者 CompletableFuture#completeExceptionally 方法回调异常。

其主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
fn request_id(env: &mut JNIEnv) -> Result<jlong> {
Ok(env
.call_static_method(
"org/apache/opendal/Operator$AsyncRegistry",
"requestId",
"()J",
&[],
)?
.j()?)
}

fn get_future<'local>(env: &mut JNIEnv<'local>, id: jlong) -> Result<JObject<'local>> {
Ok(env
.call_static_method(
"org/apache/opendal/Operator$AsyncRegistry",
"get",
"(J)Ljava/util/concurrent/CompletableFuture;",
&[JValue::Long(id)],
)?
.l()?)
}

fn complete_future(id: jlong, result: Result<JValueOwned>) {
let mut env = unsafe { get_current_env() };
let future = get_future(&mut env, id).unwrap();
match result {
Ok(result) => {
let result = make_object(&mut env, result).unwrap();
env.call_method(
future,
"complete",
"(Ljava/lang/Object;)Z",
&[JValue::Object(&result)],
)
.unwrap()
}
Err(err) => {
let exception = err.to_exception(&mut env).unwrap();
env.call_method(
future,
"completeExceptionally",
"(Ljava/lang/Throwable;)Z",
&[JValue::Object(&exception)],
)
.unwrap()
}
};
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_Operator_read(
mut env: JNIEnv,
_: JClass,
op: *mut Operator,
path: JString,
) -> jlong {
intern_read(&mut env, op, path).unwrap_or_else(|e| {
e.throw(&mut env);
0
})
}

fn intern_read(env: &mut JNIEnv, op: *mut Operator, path: JString) -> Result<jlong> {
let op = unsafe { &mut *op };
let id = request_id(env)?;

let path = env.get_string(&path)?.to_str()?.to_string();

unsafe { get_global_runtime() }.spawn(async move {
let result = do_read(op, path).await;
complete_future(id, result.map(JValueOwned::Object))
});

Ok(id)
}

async fn do_read<'local>(op: &mut Operator, path: String) -> Result<JObject<'local>> {
let content = op.read(&path).await?;
let content = String::from_utf8(content)?;

let env = unsafe { get_current_env() };
let result = env.new_string(content)?;
Ok(result.into())
}

fn make_object<'local>(
env: &mut JNIEnv<'local>,
value: JValueOwned<'local>,
) -> Result<JObject<'local>> {
let o = match value {
JValueOwned::Object(o) => o,
JValueOwned::Byte(_) => env.new_object("java/lang/Long", "(B)V", &[value.borrow()])?,
JValueOwned::Char(_) => env.new_object("java/lang/Char", "(C)V", &[value.borrow()])?,
JValueOwned::Short(_) => env.new_object("java/lang/Short", "(S)V", &[value.borrow()])?,
JValueOwned::Int(_) => env.new_object("java/lang/Integer", "(I)V", &[value.borrow()])?,
JValueOwned::Long(_) => env.new_object("java/lang/Long", "(J)V", &[value.borrow()])?,
JValueOwned::Bool(_) => env.new_object("java/lang/Boolean", "(Z)V", &[value.borrow()])?,
JValueOwned::Float(_) => env.new_object("java/lang/Float", "(F)V", &[value.borrow()])?,
JValueOwned::Double(_) => env.new_object("java/lang/Double", "(D)V", &[value.borrow()])?,
JValueOwned::Void => JObject::null(),
};
Ok(o)
}

可以看到,我构建了一个实现 API 接口绑定的模式:

  1. 外层 JNI 映射函数和阻塞接口一样,调用 intern 方法并串接 throw 回调,处理同步阶段可能的异常。这主要来自于 String marshalling 和参数合法性检查的步骤。
  2. intern 方法处理参数映射,从 AsyncRegistry 里取得 Future 的凭证,随后调用 unsafe { get_global_runtime() }.spawn(...) 把 API 请求发送到后台线程处理,并返回 Futrue 凭证。Java 侧的 native 方法返回,取得凭证。
  3. do 方法在后台线程执行,得到结果。该结果由 complete_future 方法处理回调 CompletableFuture 的方法回填结果或异常。

其他的细节可以读源码分析,这里再提一下对异常的处理。

可以看到,只要是在 Java 侧调用 JNI 线程里的异常,我都压在 intern 方法的 Result 里抛出去了。JNI Onload 和 Unload 过程没有用户能处理的线程,tokio RUNTIME 的后台线程调用 complete_future 方法的时候也不在用户能处理的线程上,所以这些地方我都用了 unwrap 来处理错误。一方面是用户根本处理不了,另一方面也是这些调用是可以确保一定成功的,如果不成功,一定是代码写错了或者底层的不变式被破坏了,即使用户可以捕获这些异常,也不可能有合理的处理方式。

当然,如果未来发现其中某些异常可以恢复,可以在 Rust 侧从错误里恢复。技术上,do 方法返回的 err 会被 complete_future 回传到 CompletableFuture 的错误结果里,这也是一种不 panic 的 tokio RUNTIME 中的错误处理方式。

社群驱动的开发方式

虽然当前版本的 opendal-java 主要是我的设计,但是它的第一版并不是我写的。

项目作者 Xuanwo 首先开了 Java 绑定的 Issue-1572 提出需求,随后 @kidylee 很快表达了兴趣。由于我此前尝试过构建基于 TiKV Rust client 的 Java client 绑定,我分享了我做过的尝试。

不过,我没能实现一个符合自己期望的 TiKV Java client 绑定,所以在我想清楚之前,我并没有动力去做一个自己不满意的实现。

但是这个时候 @kidylee 很快做出了第一版 blocking operator 的实现。一个月后,来自 RocketMQ 社群的 @ShadowySpirits 也加入了进来。他想实现异步接口的支持,而这就是我之前没想通所以不愿意动手的卡点。

@ShadowySpirits 很快做了一个基于我放弃的 Global Reference 的解决方案,虽然 Global Reference 有上面我提到过的缺陷,但是他构建的 JNI Onload 方法及其全局线程池共享的方式给了我启发,Thread loacal 共享 JNIEnv 的方案打通了我之前面临的 JNIEnv 不 Sync 的难题,我于是得以实现自己就差最后一个技术难点的基于全局 AsyncRegistry 的解决方案,彻底绕过了 Global Reference 的限制。

功能实现以后,出于没有发布的软件就得不到严肃使用的认知,我着手解决了基本的项目打包和发布逻辑问题(Issue-2313)和发布前的其他功能、测试和文档工作(Issue-2339)。

这些工作完成以后,opendal-java 就正式发布到 Maven 中央资源库了。

昨天 @luky116 上报的另一个问题验证了我对软件发布重要性的认知。他凭着直觉使用 opendal-java 库,马上撞上了一个构建问题。这使得我重新思考了之前打包方式对下游用户的不方便之处,并记录了对应的 Issue 追踪。

我的计划是复刻 rocksdbjni 的发布方法,在不同平台编译动态库,最后合并不同平台编译出来的库到 resources 目录下发布,加载逻辑对应处理好平台架构的命名和发现逻辑。这个同时要修改 NativeObject 里的动态库加载逻辑,Maven 的打包逻辑和 GitHub Actions 的构建和发布逻辑。如果你了解 RocksDB 的打包发布方式,可以参与进来。不过这样的人应该很少,所以如果你感兴趣,也可以订阅这个问题,等我下个月找到时间演示一下解法。

此外,我在绕过 @luky116 遇到的构建问题以后,还发现了 opendal-java 对 OpenDAL features 打包的问题,可能会影响下游用户的使用预期。这个问题是个产品问题,我也记了一个 Issue 来讨论。基本上,用户可以自己打包动态库并指定动态库发现路径,这是最终兜底方案。但是这个方案目前没有直接的文档,只是我这个实现的人心里清楚。而且作为上游,有些 features 是适合一揽子打包出去,提供更好的开箱体验的。

最后,如果你也想体验一下开发 OpenDAL 多语言 API 绑定的过程,可以参与到我做了一半的 C# 绑定上来:

基本的项目框架我已经定好了,后续工作的参考材料也列出来了。如果你有足够的背景,我提供的材料应该已经足够作为直接实现的参考。

C# 绑定相较于 Java 绑定的优势在于它有原生的 C ABI repr 支持,这能减少一部分 marshalling 的开销。但是这些技术使用的人比较少,或者说整个 .NET 技术栈的用户都显著少于 JVM 技术栈,更不用说国内几乎没有 .NET 技术栈的企业,也就没有什么中文材料,所以学习新知识的门槛可能会有一些。

Git 分支管理与版本发布

作者 tison
2023年5月19日 08:00

Git 版本管理系统由 Linux 的作者 Linus Torvalds 于 2005 年创造,至今不到二十年。

起初,Git 用于 Linux Kernel 的协同开发,用于替代不再提供免费许可的 BitKeeper 软件。随后,这一提供轻量级分支的分布式版本管理系统得到了开源开发者的广泛喜爱,在大量开源项目中投入使用。如今,Git 几乎是版本管理系统的同义词。

Git 最大的创新就是轻量级的分支实现,鼓励分布式的开发者群体创建自己的分支。每个分支都是平等的,只是原始作者的分支或者实现最好的分支会被社群公认为上游,其他分支被认为是下游。下游分支的修改通过邮件列表发送补丁或 GitHub 发起 Pull Request 的方式向上游申请合并,最后大部分用户从上游分支取得源代码使用。

在这个模型下,如何协同不同分支的开发,当上游发布了多个版本,尤其是并行维护多个发布版本时,如何管理分支,就是一个亟需解决的问题。Git 自身的设计不解决这个问题,也不对此做建模。它只提供分支创建和合并等基本功能,而把具体的分支管理策略留给开源软件的开发者。

基础软件的分支策略

一路向前的 Curator

对于绝大部分的开源软件来说,既没有维护多个版本的需求,又没有重量级的发版检查,最适合自己的分支策略就是唯一上游分支,一路向前。

Apache Curator 采用了这种策略:主分支 master 是唯一的上游分支,版本号一路向前,没有在以前的功能版本发新的补丁版本的说法。

所有的下游修改,一般也是一个小修改一个分支,做完以后迅速提交到上游评审合并,几乎所有用户获取的版本都是从 master 分支上打 tag 得到的。

这种简单的策略被广泛使用,甚至可以做成自动化的流水线:

  • spotless 用一个专门的发布流水线来手动触发从 master 分支分析 changelog 并发布新版本的工作。
  • griseo 的流水线则是在推送新的 tag 的时候就触发把 tag 关联的新版本发布到 GitHub Release 页面和 PyPI 仓库上。这参考了 githubkit 的方案。
  • setup-zig 依赖 npm 生态的 semantic-release 工具集,实现了彻底的自动化:每次主分支合并代码后,自动分析出 changelog 并根据 changelog 的语义判断是否应该发布新版本,应该发布什么版本。

齐头并进的 Flink

Apache Flink 是一个典型的并行维护多个发布版本的开源软件。

Flink Release Management 提到,Flink 上游社群维护最近的两个特性版本,而其过往发布记录大致如下:

Flink 版本发布历史

值得注意的是,其版本发布时间并不随着语义版本号单调递增,例如 1.16.0 的发布日期(2022-10-28)就早于 1.15.4 的发布日期(2023-03-15)。

根据语义化版本的定义,patch releases 只包含必要的修复,而不应该包含新功能。如果仍然采取一路向前的分支策略,那么在发布了带有新功能的 1.16.0 版本后,再发布 1.15.4 版本,难道还能 revert 所有功能变更吗?这不现实。

所以 Flink 采取的是和并行维护发布版本线对应的分支策略:

  1. master 分支是不稳定的开发分支,对应 X.Y-SNAPSHOT 的快照版本,其中 X.Y 是下一个即将发布的功能版本。例如,现在最新的功能版本是 1.17 版本,那么 master 上的版本号就是 1.18-SNAPSHOT 了。
  2. release-X.Y 分支是 X.Y 系列版本的基础分支,该分支将接受 bug fix 类的提交。

分支是不稳定的 Git 引用,不同时间 check out 同一个分支可能得到不同的结果。Flink 在实际做版本发布的时候,选择的是 tag 的形式来发布不可变的版本:

  1. release-X.Y.Z-RCn 是一个标签版本,是一个静态的版本,对应 X.Y.Z 的第 n 次预发版本。由于 Flink 系统复杂,发布周期内需要进行大量测试,很可能有多次预发,为了避免强行覆盖 tag 导致破坏 tag 引用内容不可变的语义,Flink 用这一 tag 命名模式来给预发版本起名。
  2. release-X.Y.Z 是一个标签版本,是一个静态的版本,对应 X.Y.Z 的发布版本。实现上,它就是最后一个 release-X.Y.Z-RCn 标签,两者应该有相同的 commit hash 和完全一致的历史。

其他并行维护多个发布版本的开源软件也大多采用这种策略,Apache Pulsar 就是其中之一。除了把 release-X.Y 的分支名模式改成 branch-X.Y 和 release-X.Y.Z.RCn 的标签名换成 vX.Y.Z-candidate-n 以外,两者没有任何差别。

即使没有实际维护多个并行版本,使用这种分支策略仍有一个好处。那就是在发布周期较长的情况下,以切出发布分支的形式来完成 feature freeze 的工作。

例如 Apache Kvrocks 也维护了不同特性版本的分支,但是自进入 Apache 孵化器之后并没有发布 .0 以后的补丁版本。尽管如此,从 2.2 分支的历史可以看出,切出发布分支以后,主分支可以继续正常进功能代码,而 release manager 可以按需 cherry-pick 需要进入到本次发布的变更。这样就不会因为当前有个版本正在发布,而被迫推迟所有不适合进入正在发布的版本的 Pull Request 的评审和合并。

特性分支的 OpenJDK

从上面两种分支策略我们可以看到,大部分的开发分支都是一个轻量级的下游分支,其生命周期自开发功能始,终于合并到上游。上游实际要处理的分支策略是与自己的版本发布和管理策略对应的分支管理需求。

不过,对于大型项目来说,有一个变体需要专门介绍,那就是特性分支的分支策略。

特性分支之所以存在,是由于一个特定的功能提案涉及相对复杂的代码修改。为了加速分支内的开发迭代,同时避免把半成品的功能合入上游,提案实现团队拉出一个特性分支进行独立开发,定期合并上游的变更,在功能稳定后提交到上游进行发布。

可以看到,这个模式的特点主要有三个:

  1. 独立快速迭代,分支内部评审合并
  2. 定期合并上游的变更
  3. 稳定后提交到上游

OpenJDK 是特性分支的成熟实践者,他们甚至会为特性分支创建单独的代码仓库。

OpenJDK 的特性分支仓库

上图中,loom / amber / valhalla 都对应到一个或多个 JDK 功能提案,最终都是会以合入 JDK 主分支结束自己的生命周期。

Implementation of Foreign Function and Memory API (Third Preview) 是一个特性分支在稳定后提交到上游的例子。可以看到,上游 Reviewer 对特性分支进行评审,特性分支的开发者在分支上共同开发,并定期合并上游的变更。最终,整个 Thrid Preview 完成后一次性合入上游

+6,924 -8,006

从 feature branch 这种模式来看,实现这一模式的前提是软件代码的模块化。

上面强调的模式特点“定期合并上游的变更”,是特性分支在迭代过程中避免最终提交时和上游冲突,从而需要花大精力调整甚至重做的关键。如果代码的模块化很差,开发者都争先恐后地把自己的提交早点塞进上游以求不要过一会儿就 conflict 又要重新写,那么 feature branch 的模式是不能成功的。

另一方面,稳定后提交到上游其实可以用迭代的方式来看待。类似上面提到的 FFM 提案,其实也不是等到做出一个最终 GA 的版本才合入,而是分成 Incubator 阶段和 Preview 阶段。把通用的接口抽象和必要的重构做在上游,把预览的功能提供给用户试用,这也是 feature branch 能够持续和上游整合并得到用户反馈的关键手段,而不是闭门造车。

从 OpenJDK 的版本策略来看,在切换到三年一个 LTS 版本,其他版本后一个发布前一个即停止维护的策略以后,OpenJDK 上游实质上也只维护了一个 master 分支。对于 LTS 的 8 / 11 / 17 三个版本,OpenJDK 创建了三个单独的维护仓库来合并补丁:

这三个仓库的每一个都只维护一个分支,patch releases 一路向前。

今年新的 LTS 版本 21 发布后,应该会变成只维护 8 / 17 / 21 三个分支仓库。

Nightly 到 Stable 的 Rust

虽然实现形式略有出入,但是 OpenJDK 针对版本的分支策略其实还是 Flink 齐头并进策略的变种,只不过把 release-X.Y 分支变成了一个仓库,而且引入了 LTS 的概念定义了自己的长期维护分支策略。Python 则是完全符合齐头并进策略,只是分支名和标签名略有出入。

同样作为编程语言,Rust 选择的分支策略略有不同:从 Git branches 页面上看,它维护了 master / beta / stable 三个分支。

rust-branches

这一版本和分支策略的详细说明可以从 Rust Forge 页面上查到,或者从下游 rustup 的 Channels 说明做补充。

简单来说,master 对应 nightly 版本,每天定时构建后发布。beta 是 stable 前的缓冲。beta 版本发布后六周会发布对应的 stable 版本,然后从 master 切出新的 beta 版本。

Rust 开发者通常要么使用 nightly 版本,要么使用 stable 版本。beta 版本更像是 Rust 团队在发布 stable 前的一个六周的发布测试周期,也就是上面提到的其他软件的 release candidate 时间。

nightly 版本和 stable 版本的另一个重要不同是只有 nightly 版本可以开启 feature flag 启用没打上 stable 标签的功能,所以即使 stable 跟 nightly 版本在代码上仅差大致两三个月,但是很多不稳定的功能可能几年间都只能在 nightly 版本上使用。

Rust 除了直接对应到分支的 Nightly / Beta / Stable 版本以外,还有另外两个版本策略。

其一是语义化版本,目前最新的 Nightly 版本是 1.71.0 版本,最新的 Stable 版本是 1.69.0 版本。Rust 在版本号上其实是一路向前的。即使偶尔发布 >0 的补丁版本,那也是在没有新的功能版本需要发布的情况下,在当前的 X.Y.Z 版本的基础上发的 X.Y.Z+1 版本,而从未出现过版本号回退的现象。

其二是 Edition 策略。语义化版本的约束下,目前所有 1.x 版本的 Rust 都应该是后向兼容的,这种兼容性对于程序开发语言来说尤为重要。但是,保持大方向上的兼容如果变成教条,导致一些小的比如默认值的修改不能调整,那么反而可能是一件坏事。因此 Rust 通过 Edition 来指定一系列默认值和行为的集合,以在实际上保持后向兼容的情况下,优化软件开发的体验。

这个需求由于 Rust 解决的早,可能不这么做的恶性后果不太明显。Perl 7 的提案是一个很好的参考。在 Perl 5 稳定的二十几年后,为了保证后向兼容,即使你用的是今年新发布的 5.36.1 版本,为了用上很多“现代功能”,你需要手动调整一大堆默认行为:

1
2
3
4
5
6
7
use utf8;
use strict;
use warnings;
use open qw(:std :utf8);
no feature qw(indirect);
use feature qw(signatures);
no warnings qw(experimental::signatures);

不过,新版本的 Perl 可以用 use v5.36 一类的办法来实现类似的缩短样板代码的需求,所以 Perl 7 的提案也就被搁置了。

业务代码的分支策略

业务代码和基础软件最大的不同,就在于业务代码提交后是立即要运行在生产环境的,而基础软件发布后一般有比较长的采用周期,甚至业务代码可以锁定基础软件在某个稳定的早期版本上。

这一重要不同导致业务代码的分支策略是部署驱动的,其最新的技术探索应该是 GitOps 一类的方案。

不同于基础软件不同分支对应不同版本线,业务代码的不同分支对应的是不同的环境。一个典型的业务开发发布平台会有测试、预发和生产环境,业务代码仓库也分成对应的分支:

  1. master 分支或者 product 分支就是生产环境的代码,业务流量都会打到这个代码编译运行的应用上。
  2. staging 分支对应预发环境的代码。预发环境和生产环境形态一致,但是规模较小,有独立的域名,业务流量不会过来。
  3. 研发自己的开发分支可以部署到测试环境,测试环境大致仿照生产环境建立,但是数据库等资源是研发自己可以调整的,基本没有权限限制,所以可能和生产环境有出入。

某一服务/模块只有一个人负责的情况下,预发环境也可能没有一个专门的分支,而是发布一个开发分支的代码;测试环境有多人一起修改的情况下,也有可能临时拉出一个协同测试分支,保证共同开发的变更都在环境里。

可以看到,在业务自己就是代码的最终消费者的情况下,不同的生产版本或者不同的标签基本是不需要的。

不过,如果是微服务的情况,发布微服务接口定义的 contract 包的时候,还是有版本号的问题的。但是由于访问的流量都可以在企业内监控到,所以一般也不需要维护多个版本,而是在向后兼容的情况下一路狂奔,或者在需要做破坏性改动的情况下依据监控把所有下游服务的负责人都拉到一起讨论升级方案。

GitHub 的分支协同

最后,介绍一下不同分支策略在 Git / GitHub 上实际操作的一些协同技巧。

分支保护

第一个是 GitHub 支持分支保护,避免误删关键分支。

例如,我在 Zeronos 项目里就保护了 main 分支和归档以前尝试的 archive- 分支。

zeronos-protect-branches

ASF 项目的 committer 没有 GitHub 上 admin 的权限,不过 ASF INFRA 提供了一个 .asf.yaml 的配置文件支持指定保护分支。

Pulsar 保护了所有版本发布分支

Pulsar Site 保护了生产环境分支

合并策略

第二个是合并策略。GitHub 支持三种合并 Pull Request 的按钮:

  1. Create a merge commit
  2. Squash and merge
  3. Rebase and merge

我的个人倾向是参考 Flink 社群的经验,禁用 merge commit 的方式,大部分情况下采用 Squash and merge 的方式,少数情况下使用 Rebase and merge 合并。

GitHub 最佳配置推荐

Squash and merge 大于 Create a merge commit 是为了保持主分支相对简洁。很多 Pull Request 尤其是被 GitHub 合并展示 changeset 以后,很容易出现各种 “fix” “save” “tempsave” 的 commit 历史,这些内容极度干扰检索 Git 历史发现问题的效率。Squash and merge 能够把一个逻辑单元以单一的 commit 合到上游,避免了 commit 膨胀。

Rebase and merge 的价值就在确实一个事情需要几个逻辑步骤,但是又是同一个高内聚的主题的场合下,避免 Reviewer 来回 review 多个 Pull Request 和作者反复解决冲突,而是就在一个 Pull Request 里解决,但是把 commit 拆分清楚。最后合并的时候还是线性的历史,但是每一步的 commit 仍然是单独的,而不是 squash 以后的。

其实,在邮件列表时期,这些问题都会被自然解决。因为提交到邮件列表上的就是一个个补丁文件。那么为了能正常的 review 补丁,作者是习惯于自己整理好 commit 的。如果 commit 都是好好写和拆分的,其实 Create a merge commit 也没太大问题。GitHub 在降低了参与门槛的同时,也带来了一些额外的显性教育成本。

参考资料

本文其实起源于一位朋友凭借旧文《Git 分支整合与工作流》的印象,问我关于保护分支治理的问题。保护分支的动作好做,主要是分支管理的策略,推出哪些分支需要保护,以及推出分支策略背后的版本策略或业务部署策略。

关于 Git 的使用和协作,推荐两本必看好书:

  1. Pro Git
  2. Git 团队协作

我的 Linux 开发机

作者 tison
2023年2月11日 08:00

首先说一下结论:最终我选择了基于 Arch LinuxGaruda Linux 发行版作为基础来搭建自己的 Linux 开发机。

Neofetch 时刻

发行版的选择

在上周末的这次折腾里,我一共尝试了 Garuda Linux 发行版,原教旨的 Arch Linux 发行版,以及众所周知的 Ubuntu 发行版这三个选择。虽然最后做了逃兵直接摆烂 Garuda Linux 配好的图形桌面环境,算不上符合 Arch 之道的玩法,但是该走的过场还是走了的。

机器的选择

我想配置一个纯粹的 Linux 环境的想法大概酝酿了一年多了。从接触 io_uring 相关功能的代码,到其他依赖 Linux 内核版本或生态工具的功能,我从前同事 @w41ter 那里接受了一个理念:“开发机的环境最好跟生产环境是一致的这样也才好调试问题。”

配置一个原生的 Linux 环境(相较于虚拟机),最舒服的其实是公司提供高配的开发机。例如在某司时用上的 256 GB 内存 96 核 NVMe 盘的机器。毕竟对 Linux 的执念也不过是一个开发环境,而不是必须要一个桌面环境。可惜公司的机器跟特定公司绑定,而且不太适合开展一些未必跟公司工作紧密相关的工作和共享文件,更麻烦的是,往往访问公司机器还得连接内网,这就让“完全使用 Linux 开发”的目标难以实现。

另一个被否定的做法是个人购买云服务器。这个思路我在读书的时候就试过了,最近也还考察过目前的行情。结论就是:太贵。一台上面提到的公司级开发配置的机器,云厂商对个人是卖不到这个配置的。大约打个五折以上可以起卖,每个月少说五千块钱,多了上万。还别说这个机器只是租给你,上面的数据不在本地,而且机器在哪个区域联网都有不同的问题。个人装机花个小几万能配得比这配置好不少,还是自己的资产,用个几年不在话下。这么一看,个人用买云服务器纯属冤大头。

最后,办法就是自己买台机器装上 Linux 系统。由于国内几乎所有机器都预装了 Windows 系统,弃之可惜,所以只能麻烦点装双系统。硬件配置我自己是不会配的,找的好兄弟一揽子解决,这里就不介绍了。

装双系统这件事我在大学时候就做过,当时还干过替换内核和开发内核模块,还有利用显卡写 Lua Torch 之类的事情,所以做起来其实是轻车熟路的。但是一直以来都觉得同一块盘上分区逼死强迫症,也确实对使用多少有点影响,这次我就干脆加了一块 2TB NVMe 的盘来折腾 Linux 系统。

Garuda Linux

我一开始选的就是 @Manjusaka_Lee 在推特上介绍过的他为自家 HomeLab 选型的 Garuda Linux 发行版。在一个非常清晰的教学视频的帮助下,我很快就完成了安装。

How to Dual Boot Garuda Linux and Windows 10 SAFELY

然后就发现 N 卡不愧是 Linux 的两大噩梦硬件之一。

你知道我要说什么

我使用的是 GTX 4090 Ti 的显卡,毕竟这次是一揽子计划,估计一劳永逸个五年起步,而且买新不买旧嘛。但是 4090 确实是太新了,而黄老爷自然是不太在乎开源拖拉机能不能用上他的新卡的。Garuda Linux 在我的机器上,如果选择安装开源驱动,Live USB 都起不来;如果选择安装专有软件驱动,则会在安装完成后进入系统的时候起不来。

所幸 @Manjusaka_Lee 哥哥也遇到了这个问题,所以这个问题是可解的,咒语全文如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 首先,选择专有驱动安装系统。此时,直接启动系统会进入 Color buffer .. 卡死
# 此时,重新进入 Boot 页面,选择从 Live USB 登录,在 Live USB 环境中完成修复:

# 1. chroot
# Find your system disk by `lsblk -f`
# https://forum.garudalinux.org/t/how-to-chroot-garuda-linux/4004
sudo mkdir -p /mnt/broken
sudo mount /dev/<your system disk partition (e.g. nvme0n1p1)> /mnt/broken
sudo garuda-chroot /mnt/broken/@

# 2. Remove nvidia-dkms
pacman -R garuda-nvidia-config
pacman -R nvidia-dkms

# 3. Install nvidia-open-dkms
pacman -S nvidia-open-dkms

# 重启系统,应该就可以正常登录了。

“咒语”这个词概括了我配置 Linux 开发机早期阶段的主要心情:一下子接触到一大堆 Linux 图形桌面操作系统的知识,这些以前都是厂商搞定的,现在是个问题都得我自己理解解决,很多时候我是似懂非懂念完找到的咒语能灵就赶紧跑路了。

从这个角度讲,Garuda Linux 基于 Arch Linux 的基本工具和软件库,把一些关键的应用选择全做了,然后开发出了非常漂亮的图形桌面,连 N 卡驱动这种事情都有舒适的路径,细节处用好了 btrfs 和 linux-zen 内核,确实是一个优秀的面向极客的发行版。

这些体会,也是我写《黑客与顾客:开源软件能商业化吗?》的背后动因。

Ubuntu

那么为什么我还会尝试 Ubuntu 呢?直接原因是一个很小的事情:绘文字(emoji)在 Garuda Linux 上显示有问题😅

具体技术上怎么出这个问题的后面再谈,总之在输入法提示框和 JetBrains 系 IDE 上不能正确显示 emoji 逼死强迫症,加上一开始尝试时乱安装了一堆依赖早就想清清了的动机下,反正机器上也没啥大不了的数据(甚至我数据全都丢一个分区摆大烂),我就开始了堵死自己逃生路口之旅,验证一下其他发行版确实不行,免得真过两天个人数据记录起来了心里总觉得可能还有更好的沉没成本太高。

兄弟姐妹们,下载 Ubuntu 安装镜像记得从国内镜像站下载,直接从官网下载慢死个人。

Garuda Linux 在官网提供了 Garuda Installer 轻松愉快的点两下就能制作 Live USB 介质,Ubuntu 还得自己搜索工具准备。从这点看,Garuda Linux 的用户体验确实是做得相当不错。不过话说回来,Ubuntu 的 Live USB 网店有人做,Garuda Linux 这种小众货色还得自己买 USB 设备,从这个角度看,是 Ubuntu 赢了。

放弃 Ubuntu 的速度比闪电战还快:它还是我熟悉的配方,每个操作都充满了一卡一卡的摸鱼气息。别说 GNOME 的美工和 UI 确实不如 KDE 了,Ubuntu 这老小子居然也对 4090 水土不服,N 卡果然是噩梦。花了几十分钟试图解决 N 卡驱动问题未果,直接选择和解跑路。

梅开二度

其实不选 Ubuntu 这种超重量级的发行版是有原因的。

Arch Linux 系的发行版,遵循所谓 Arch 之道的情况下,整体比较简洁,没有太多不需要的东西。虽然这样很多内容是用户作为开发者自己去调配的,但是到底怎么出的问题,用户一般还调试得了。

Ubuntu 走的是 Windows 和 Mac OS X 的道路,也就是虽然用了 Linux 内核,但是试图提供的是开箱即用的用户体验,可惜我只能称之为其他两者的低配版。这样的产品完成度下,极客或黑客玩家的体验就是:我开箱即用的效果不好,改还不知道咋改,麻了。

不过,抛开 Ubuntu Desktop 的问题不谈,Canonical 公司为 Ubuntu 上软件的正确性测试投入还是很值得肯定的。对于追求稳定可靠版本的 Linux 环境和生态完善经过验证的 apt 系软件库的场景,比如找个合适的基础 Docker 镜像摆烂的情况,Ubuntu 还是个不错的选择的。但是 Ubuntu Desktop 确实大可不必。

Arch Linux

人不 Arch 枉少年!在歌单名《中二少年的轨迹》的加成下,年过半半百的小 tison 还是打算炫一把 Arch Linux 发行版。毕竟可以肯定的是 Garuda Linux 就是基于 Arch Linux 配出来的,emoji 的问题肯定也是做了啥不该做的定制才出现的。我遵循 Arch 之道从头来一把,总能折腾好这个环境。

后来的事实证明确实是做多了事情导致的 emoji 不能在 Garuda Linux 上正常显示,但是从裸的 Arch Linux 上配环境确实也挺折腾人。

有一说一,装一个基础的 Arch Linux 系统并不难。Arch Linux Wiki 的安装指南加上坊间指南对一些关键细节的补充和选择困难症的终结,装一遍 Arch Linux 应该不到三十分钟就能搞定。

整个安装过程完成后的感想就是:@Xuanwo 哥哥说得对,走完一遍 Arch Linux 的安装,Linux 操作系统的引导、磁盘分区和挂载、网卡的交互和基本的用户模型就都懂了。推荐新玩家在玩过编译替换 Linux 内核的游戏以后也来整一次 Arch Linux 发行版的安装。

然后这个过程就卡死在装好系统以后设置了 KDE 桌面却打不开了。KDE 桌面的安装和 SDDM (Simple Desktop Display Manager) 服务应该都是好的,还是 N 卡拉了胯。

帽子戏法

试过几个驱动方案无果后和解。

过程里几次通过 Arch Linux 的 Live USB 环境使用类似前文解决 Garuda Linux 驱动问题的手段 arch-chroot 到主系统关闭 systemd 上的 SDDM 服务,这是一个避免机器变砖的逃生出口。

另外 os-prober 安装后必须运行一遍以发现原系统的引导信息,不然你刷了 GRUB 可能突然就看不到启动 Windows 的选项了,万一手忙脚乱出点啥错 Windows 系统丢了,修起来也是个麻烦事。

Emoji 问题的解决

Emoji 已经被收录到 Unicode 编码里了,所以 emoji 的显示问题其实就是字体问题。

不过无论是 Garuda Linux 还是 Ubuntu 都有图形化界面管理字体,所以整体配置环境跟纯粹 Linux 最小集是不一样的。这也是我前面评价 Ubuntu 是低配版 macOS 的一个佐证,这种额外的封装和开箱不即用的体验会伤害黑客玩家的信心。当然在这点上,Garuda Linux 也没好到哪里去。

解决问题的办法隐约中跟 Arch 之道也是呼应的:少即是多。最后查明问题的触发点是我在成功安装 Garuda Linux 以后,按照系统提示的定制化建议勾了几个输入法和语言字体的选项,其中安装了 ttf-google-font-git 这个包,而这个包和系统的 adobe 字体包冲突,导致了一些字体加载、解析和回退方面的问题。不做这个安装就能解决问题,也不会引入新问题。

此外,解决这个问题期间我还试过 Garuda Linux 发行版的其他图形桌面主题。结论是只能说旗舰就是旗舰,其他版本就是大学生课程作业的水平。如果要我一句话评价,那就是:别用。

输入法和字体

先说结论:输入法我选择了 Fcitx5 + Rime 的解决方案,字体我在全局用了 Noto fonts 解决中英文加 emoji 的所有问题,代码显示和终端环境里则是用 Source Code Pro 字体。

字体的问题不用多说,各有各的偏爱。我用 Source Code Pro 的历史开始于大二实验室实习期间学到了 CTF 世界冠军 @Atum 老板的配置。

输入法我在 macOS 和 iOS 上七年来都是搜狗拼音摆烂,然而搜狗拼音自己顾不上兼容 Fcitx5 新框架。强迫症让我拒绝回退到旧版本,这也不是 Arch Linux 这种滚动更新天天最新的玩家的做法,我也没那个时间去折腾,尤其是搜狗代码还是闭源的,这基本表明你除了祈祷以外什么也做不了(RMS 打印机的翻版)。

所以入乡随俗,我确实对输入法的定制还是有点需求的,就用了 Rime 中州韵输入法,配置过程大抵可以参考 Arch Linux Wiki 上的 Fcitx5 页面。

第一步,安装一大堆相关的库,比如:

  • fcitx5
  • fcitx5-rime
  • fcitx5-chinese-addons
  • fcitx5-configtool

这几个搞完,其他的基本都在依赖里也装上了。然后最重要的就是告诉 X 窗口管理器记得用上 Fcitx5 做输入法,配置方式是在 $HOME/.xprofile 里写上:

1
2
3
export GTK_IM_MODULE=fcitx
export QT_IM_MODULE=fcitx
export XMODIFIERS=@im=fcitx

随后就是在 Fcitx5 的默认配置目录 $HOME/.local/share/fcitx5 里定制了。直接改配置文件容易出错,所以我是下载了 Rime 的管理工具 plum 来做这件事的。

随便找个地方下载好 plum 工具,然后执行以下命令配置双拼和 emoji 候选词:

1
2
3
rime_frontend=fcitx5-rime bash rime-install double_pinyin
rime_frontend=fcitx5-rime bash rime-install emoji
rime_frontend=fcitx5-rime bash rime-install emoji:customize:schema=double_pinyin_flypy

再到配置目录下做一些微调。

添加 $HOME/.local/share/fcitx5/rime/default.custom.yaml 文件:

1
2
3
4
5
6
7
8
patch:
# 去掉其他不需要的候选输入法
schema_list:
- schema: double_pinyin_flypy
# 候选词界面支持 Tab 键翻页
key_binder:
bindings:
- { when: has_menu, accept: Tab, send: Page_Down }

原地修改 $HOME/.local/share/fcitx5/rime/double_pinyin_flypy.schema.yaml 文件:

  1. 把 switches 西文的部分 reset 改成 1 以默认启用英文输入。主要工作是开发,所以默认英文会方便很多。
  2. 把 switches 汉字的部分 reset 改成 1 以默认启用简体字输入。

我也趁着这次换工作机器的契机把输入法切换到了小鹤双拼,确实第一天对着键位图打个几小时就能记住了,打了几天以后虽然不算流畅,但是双拼本身键程短,误输入概率低,所以我的输入速度已经恢复到原来的 80% 有余。误输入的典型案例是,全拼很容易把“时间”(shijian)输入成“时间爱你”(shijain),由于双拼多韵母也是一个键输入,就不会出现这种多韵母字母错位带来的误输入,从而提高打字效率。

双拼键位图

Dotfiles

技术上,图形桌面系统也是以 Linux 操作系统为内核的。而 Linux 的特点就是一切皆文件,而且配置也都是一堆文件。

一旦用上 Linux 系统,你就会知道为啥 GitHub 上那么多人分享或者说单纯是备份自己的配置文件。比如上面对 Fcitx5 和 Rime 的这一通配置,不记下来下次再来一遍还得累死人。

我从 macOS 时期就一直需要带着的配置文件是 Git 的配置和 Vim 的配置,所幸我不做复杂的配置,所以迁移起来不怎么费时间。这也是因为越少的配置越能跨平台的缘故,否则定制化太多,真的除了自己的机器,换个环境生产力就跌零了。

Git 可公开配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[user]
email = wander4096@gmail.com
name = tison
signingkey = ...
[pull]
rebase = true
[core]
excludesfile = /home/tison/.gitignore
editor = vim
pager = less --mouse
[tag]
forceSignAnnotated = true
[init]
defaultBranch = main
[gpg]
program = /usr/bin/gpg
[commit]
gpgsign = true

主要是支持自动 GPG 签名的问题,以及引用一个全局的 gitignore 规则:

1
2
3
4
5
6
7
8
9
10
11
12
# JetBrains
.idea
*.iml

# Visual Studio Code
.vscode

# Apache Maven
.mvn

# macOS
.DS_Store

Vim 的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
syntax on
syntax enable

filetype plugin indent on

set nocompatible
set number
set ruler

set tabstop=2
set softtabstop=2
set expandtab
set backspace=indent,eol,start

inoremap jk <ESC>

其中最后一行是减少 ESC 键的键程。同样的道理,我还会总是把 CapsLock 即大写锁定键映射成 Control 键。高级点的键盘在硬件层面就可以设置,macOS 修饰键配置可以改,Windows 有 Ctrl2Cap 工具,而 Linux 就得根据环境各显神通了。Garuda Linux 发行版的配置下,可以通过在 /etc/profile 里加这一个指令来实现:

1
2
# Map CapsLock to Control
setxkbmap -option caps:ctrl_modifier

另外,我把默认的 shell 从 Garuda Linux 发行版配的 fish 程序换成了 zsh 程序,不过没有用 oh my zsh 终端,它跟发行版已经给 zsh 配的默认风格不是特别搭。但是 oh my zsh 在 ls 命令上的一些 alias 还是不错的,我给 pick 了过来:

1
2
3
# User-defined alias
alias ls='ls --color=auto'
alias l='ls -lah'

应用软件

Garuda Linux 发行版默认使用了以下 AUR 源:

  • garuda
  • core
  • extra
  • community
  • multilib
  • chaotic-aur

其中 garuda 包括了发行版的桌面环境、定制化配置和专用软件。接下来三个是基础源。multilib 包含了一系列兼容模式的 32 位软件。chaotic-aur 则是闻名的二进制应用源。

因此,大部分应用软件在 Garuda Linux 上也可以直接从 AUR 上安装。我的主要应用软件是:

  • Google Chrome
  • Visual Studio Code
  • JetBrains Toolbox
  • LogSeq
  • QQ
  • Telegram

另外,Notion 和 Slack 也很重要,不过能用的 Desktop 应用体验很差,所幸网页版功能是全的,直接遁入网页版完事儿。JetBrains 全家桶走 JetBrains Toolbox 这个门户应用全都能安装下来。其他一些基本应用比如 Konsole 和 Latter Docker 这些都在 Garuda Linux 发行版的预装里。

国民应用微信,可以通过 Wine 使用 Windows 的版本,或者直接安装 AUR 上基于统信 UOS 版魔改的 Linux 原生版本,但是使用体验均不佳,遂放弃。微信移动端以外做得最好的还得是 macOS 平台,而直接下线了网页版则完全体现出了垄断巨头的不讲理。

最后,祝各位每天 yay -Syyu 都顺利 :)

生成版本信息正确解析的 POM 文件

作者 tison
2022年12月12日 21:23

本文成文于 2019 年。最近 Apache StreamPark (Incubating) 项目要做第一个 Apache 版本的发布,遇到了类似的发布多 Scala 支持版本时如何正确生成对应 POM 文件,又尽可能复用流水线的问题。由于过往发布记录都被删除,故重新发布。

近日在阅读 FLINK 代码时发现 FLINK 有一个 force-shading 模块,关于这个模块的作用注释在其使用点 maven-shade-plugin 的配置中是这样写的

现在这个模块已经移动到 flink-shaded 仓库下,详见 pom.xml 文件。

1
2
3
4
5
6
7
8
9
10
11
<artifactSet>
<includes>
<!-- Unfortunately, the next line is necessary for now to force the execution
of the Shade plugin upon all sub modules. This will generate effective poms,
i.e. poms which do not contain properties which are derived from this root pom.
In particular, the Scala version properties are defined in the root pom and without
shading, the root pom would have to be Scala suffixed and thereby all other modules.
-->
<include>org.apache.flink:force-shading</include>
</includes>
</artifactSet>

从注释中我们可以看到 force-shading 的作用是强制触发 maven-shade-plugin 的执行,并且提到了这样会生成所谓的 effective pom 文件。这究竟是怎么一回事呢?我们先从一个实例中理解这个问题。

先创建一个任意 MAVEN 工程,将它的 pom.xml 文件中 dependencies 替换为以下内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>moe.tison</groupId>
<artifactId>eden_${scala.binary.version}</artifactId>
<version>0.1-SNAPSHOT</version>

<properties>
<scala.binary.version>2.12</scala.binary.version>
</properties>

<dependencies>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>force-shading</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_${scala.binary.version}</artifactId>
<version>2.5.24</version>
</dependency>
</dependencies>
</project>

这里省略了可以配置 scala.binary.version 属性的 profile 部分,我们的意图是根据不同的 profile 来打出适应不同 Scala 版本的 jar 包,这一点可以在 mvn clean install -P<profile-name> 里指定。但是我们看一下在默认 profile 下发出来的 pom 文件的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>moe.tison</groupId>
<artifactId>eden_${scala.binary.version}</artifactId>
<version>0.1-SNAPSHOT</version>

<properties>
<scala.binary.version>2.12</scala.binary.version>
</properties>

<dependencies>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>force-shading</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_${scala.binary.version}</artifactId>
<version>2.5.24</version>
</dependency>
</dependencies>
</project>

可以看到,${scala.binary.version} 的部分并没有被解析。这是因为 MAVEN install 的策略是直接复制工程对象的 pom file 字段对应的文件,在这里它直接复制了项目下的 pom.xml 文件。

这样会有什么问题呢?基于简单的复制策略 MAVEN 并不会解析 pom 文件中的 properties,这会导致我们基于不同的 profile 打出来的包的项目描述 pom 文件都是一样的。即使我们分别为 Scala 2.11 和 2.12 版本打了两个不同的 jar 包,由于 ${scala.binary.version} 未解析,在下游应用中引用的使用属性永远是以 <scala.binary.version>2.12</scala.binary.version> 为准,也就丧失了原本分开打包兼容不同版本的初衷了。

明白了问题以后,我们来看一下 force-shading 是怎么解决这个问题的。

我们先往 pom.xml 中添加一个 artifactSet exclude 所有依赖的 maven-shade-plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>shade-eden</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<excludes>
<exclude>org.apache.flink:force-shading</exclude>
<exclude>com.typesafe.akka:akka-actor_*</exclude>
</excludes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

可以看到打出来的 jar 包的 pom 文件依旧不解析相关的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>moe.tison</groupId>
<artifactId>eden_${scala.binary.version}</artifactId>
<version>0.1-SNAPSHOT</version>

<properties>
<scala.binary.version>2.12</scala.binary.version>
</properties>

<dependencies>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>force-shading</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_${scala.binary.version}</artifactId>
<version>2.5.24</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>shade-eden</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<excludes>
<exclude>org.apache.flink:force-shading</exclude>
<exclude>com.typesafe.akka:akka-actor_*</exclude>
</excludes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

我们试着把 force-shading 像 FLINK 那样 inlcude 到最终的 uber-jar 中。

1
2
3
4
5
<artifactSet>
<includes>
<include>org.apache.flink:force-shading</include>
</includes>
</artifactSet>

可以看到这次打出来的 jar 包中的 pom 文件已经解析了 properties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>moe.tison</groupId>
<artifactId>eden_2.12</artifactId>
<version>0.1-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>shade-eden</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>org.apache.flink:force-shading</include>
</includes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_2.12</artifactId>
<version>2.5.24</version>
<scope>compile</scope>
</dependency>
</dependencies>
<properties>
<scala.binary.version>2.12</scala.binary.version>
</properties>
</project>

这样,在不同的 profile 下,MAVEN 会把 properties 的使用点全部替换成运行时的值,打出来的包即依赖运行时的值,这个值可以由 profile 指定,就达到了我们打不同的兼容包的需求了。

那么,为什么使用 force-shading 就能达到这样的效果呢?

我们看到 MAVEN install 的时候的一行日志。

1
[INFO] Installing /path/to/eden/dependency-reduced-pom.xml to /home/user/.m2/repository/moe/tison/eden_2.12/0.1-SNAPSHOT/eden_2.12-0.1-SNAPSHOT.pom

可以看到我们是把 dependency-reduced-pom.xml 作为最终安装时复制的 pom file 来使用的,这个文件由 maven-shade-plugin 在对比模块原有依赖和经过 shade 之后的依赖有区别是解析产生,即 FLINK 中注释提到的 effective pom,它会在运行时基于依赖 diff 产生,由于运行时的 properties 本就是被设定的值,因此它巧合的就完成了这个解析 properties 的任务。

这里,依赖的 diff 由 pom.xml 中依赖 force-shading 而在 uber-jar 中打入 force-shading 因此不含这个依赖来达到。由于这个 diff 出现在 flink-parent 中,所有的子模块都会经历这个过程,所以所有子模块都使用了 effective pom 作为最终的 pom 文件。由于 force-shading 本身是一个空模块,只是为了触发 maven-shade-plugin,因此打入 uber-jar 中也不会有问题。

此外还有一点值得一提,即 maven-shade-plugin 在不指定 artifactSetartifactSet/includes 为空时,默认是将所有依赖打入 uber-jar,即不选=全选。force-shadinginclude 恰巧避免了这一出乎意料的情况的发生,保证 shade 时所有的 inlcudeexclude 都显式声明,客观上也减少了潜在的难以分析的漏洞。

关于 FLINK 和 SPARK 使用 force-shading 手段的讨论:

关于 MAVEN 安装时不解析 properties 的讨论:

Spring Boot + Next.js 端口唯一代码分离方案

作者 tison
2022年10月22日 08:00

上周开发报表的时候实现了一个 Spring Boot + Next.js 前后端代码分离,但是端口唯一的 WebApp 方案。这个方案既能利用好各自的生态,也能避免要起两个 server 占用两个端口的繁琐构建部署逻辑。

本文从选型到实现做一个分享,主要面向熟悉 Spring Boot 技术栈,又想跟进 React 系前端组件化方案的开发者。

选型对比

基本的目标是要做一个数据展示的网页应用。应用后端负责采集和分析数据,提供 API 接口获取特定报表需要的数据。应用前端需要利用各种 UI 组件库和 Chart 绘图库把从后端取得的数据良好地展示出来。

Next.js 全栈方案

数据展示是报表系统的一个重要部分,不管后端数据处理得再好,如果没有做好数据可视化的工作,只是简单的把数据记录全部返回,决策人员很难高效的从报表中获取信息。

基于这一点,我们的方案选型需要高度重视前沿前端技术栈的兼容性。比如使用 Next.js 框架搭建报表页面,很自然地就会考虑是否可以直接用一个框架全栈解决问题。

然而,事与愿违。Node.js 的生态比起传统做数据处理的 Java/Python 生态还是非常有限,很多关键的依赖库都是缺失的。我们的主要目标是快速搞定报表系统,而不是为生态添砖加瓦。实际上,我一开始写的是 Golang 应用,后来因为要处理非结构化的 JSON 数据,Golang 没有好的 JSONPath 库,而手动定义层层嵌套的 JSON 结构体又非常折磨,所以我才转向了 Java 生态。Node.js 就更不用说了。

除了生态以外,还有我个人的问题。我接触前端开发不到一年,做数据处理这种非常结构化的后端逻辑,用静态类型带类型系统的 Java 要顺手得多。

个人熟悉程度还带来一个考量点,那就是打包的复杂性。我之所以一开始选择 Golang 实现,也是因为要搞一个 Java 项目其实还是很麻烦的,如果能快糙猛的糊完应用,Golang 还是非常舒服的。Java 的打包对于新人来说或许还很复杂,但是我已经经历过好几个大型项目嵌套模块和 Maven/Gradle 插件开发等等需求的洗礼,基本处理一个 Spring Boot 还是手到擒来。JavaScript 全栈我遇到的第一个问题就是经常需要判断自己的环境是在浏览器还是在 Node.js 上,这两个环境需要的依赖库和能做的事情的假设是不一样的。第二个问题是基于 webpack 的打包体验,我只能说懂得都懂。每个框架和工具都有自己的一套配置逻辑,一个项目配置逻辑的复杂度几乎是依赖项的乘积,重重叠加的。第三个问题是数据分析应用的开发非常依赖类型系统,而有 JavaScript 的基础在,哪怕使用 TypeScript 来开发,各种开发工具支持还是不够。

Spring Boot 全栈方案

另外一个非常常见的方案是基于 Spring Boot 的方案。如果去掉全栈的限制,我估计 Java 生态当中开发应用和微服务的程序员,超过半数都是基于 Spring Boot 来开发的。

不过,大部分用 Spring Boot 开发的应用或微服务,只是对外提供 API 接口,另有前端开发团队来设计开发页面,调用这些 API 接口获取数据渲染页面。然而我们现在是要一个人搞定整个报表系统,出于康威定律,很自然地会考虑 Spring Boot 的全栈方案。

Spring Boot 的全栈方案其实已经很成熟了。这条技术线往上追溯,能追溯到 JSP/ASP 年代。也就是说,后端应用处理服务逻辑,加上一个模板引擎读取和 HTML 文件格式差不多又有点不一样的文件,通过扩展的方便后端填充数据的语法,在收到请求,生成好数据以后,把数据填入到模板当中,最终生成一个 HTML 页面返回给浏览器。Golang 自带的 web 方案也是这个套路,Python 生态的 Flask + Jinja2 也是这个套路。可以说,这是从后端走向前端的全栈的经典思路。

不过,这个思路会面临一个致命的问题:前端生态目前的前沿技术,依靠 Vue.js 和 React.js 的发展已经生长出一套独立的组件化开发方案,这套方案跟传统模板引擎要整合起来非常的困难。换句话说,现在已经不是用 Spring Boot 框架 + Thymeleaf 模板引擎,再从 CDN 上捞一下 Bootstrap 的 .min.css 文件和 .min.js 文件就能搞好的年代了。

要想真正用上 Vue.js 或者 React.js 的生产力,一套前端的脚手架是必不可少的。虽然最终方案其实也是一个 Spring Boot 应用加上编译出来的静态前端网页,某种意义上也可以说是 Spring Boot 的全栈方案,但是跟以往搞搞模板,或者搞搞模板加上几个手动挡的 CSS 文件、JS 脚本,还是有很大的差别。

前后端分离双进程方案

既然传统的模板 + Spring Boot 应用后端的方式不好走,那么是不是可以考虑就像组织内有团队分工那样,把前后端两个项目分成两个进程,做一个前后端分离的方案呢?

实话说,这个方案对于真的有团队分工,前后端团队是各自领域专家,同时还有人专门负责搞定部署和服务连通性的情况来说,是最佳的解耦方案。然而,做这个报表的只有我一个人,最多加上一两个搭把手的好兄弟。真的去做前后端分离,起两个进程的方案,部署起来其实还是相对麻烦的。

你要给 Node.js 的应用绑一个端口,然后再给 Spring Boot 应用绑一个端口,配置一下前端全栈的应用在访问 API 接口的时候转发到 Spring Boot 应用的端口。另外 Node.js 既然是一个全栈应用,比如你用 Next.js 来开发,那么其实就容易把后端逻辑割裂在两个应用之间,因为前端其实不完全是前端,它有一部分逻辑还是跑在服务端的,这部分前端的内容其实是全栈。从控制复杂性的角度出发,同一个团队或者说同一个人用这样的技术栈还是引入了太多知识负担。

最终方案

最终我选择的方案,如同标题和开头所描述的,是一个基于 Spring Boot + Next.js 的,代码分离但是端口唯一的方案。

具体一点说,我会用 Java 生态搞定后端数据处理的工作,基于 Spring Boot 框架完成一个 Web 应用;再仅使用 Next.js 框架前端部分的功能,利用现代化的组件化开发方案做出页面展示逻辑以后,编译出页面对应的一系列静态文件,再由 Spring Boot Web 应用把编译产生的文件作为静态资源提供给浏览器访问。

这样,Next.js 框架开发的页面不包括任何依赖 Node.js 的逻辑,自然也就不需要起一个 Node.js 进程和占用一个额外的端口来暴露服务。这些页面要想拉取数据,只需直接从 /api/xxx 路径访问同一个端口上由 Spring Boot Web 应用定义的 Controller 组件暴露出来的 API 端口,就可以实现了。

详细的实现方案请看下一节。

方案实现

完整方案实现已发布到 GitHub 平台,可以访问 tisonkun/springboot-nextjs-demo 仓库阅读。

Step 1. 初始化 Spring Boot 项目

本文假设读者熟悉 Spring Boot 技术栈,这一部分就不做过多过程介绍。典型的初始化 Spring Boot 项目的方法包括:

  • IntelliJ IDEA 新建项目选择 Spring Initializer Generator 直接创建模板项目。
  • 从网站 start.spring.io 基于 Spring Initializer 创建模板项目。
  • 安装 Spring Boot CLI 后运行 spring init 创建模板项目。

今年初调研 r2dbc 的时候我还给 Spring Initializer 提过一个补丁

这个项目里我们选择使用 Spring 2.7.5 版本,Java 17 + Maven + Jar 打包方式,并且包含以下依赖:

  • configuration-processor
  • devtools
  • lombok
  • web

其中 web 是必须的,有了它我们才能启动一个 Spring MVC 服务。其他三个都是提升开发体验的依赖,个人推荐都选上。

出于简洁考虑,我把测试部分删除,Application 入口类重命名成 DemoApplication 类。这样,第一步就完成了。

Step 2. 编写一个 API 接口

简单编写一个产生随机数据的 API 接口。

通常来说,后端返回的数据都是一个封装类转换成的 JSON 对象,这样方便迭代过程中通过增减字段来调整接口语义。哪怕是明确的返回列表的接口,也会再加一层应对可能追加的类似分页等会传输额外元数据的需求。出于简单考虑,而且在这个应用里前后端都是同一个人开发,我们对数据 payload 做一个封装,但是接口本身就直接返回数据封装类的列表了。

首先,定义封装数据的 DataEntry 类:

1
2
3
package org.tisonkun.springnext.demo;

public record DataEntry(String payload) { }

再简单写一个 Controlller 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package org.tisonkun.springnext.demo;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(path = "/api")
public class DemoController {
@RequestMapping(method = RequestMethod.GET, path = "/random-data")
public List<DataEntry> randomData() {
final var result = new ArrayList<DataEntry>();
for (int i = 0; i < 42; i++) {
result.add(new DataEntry(UUID.randomUUID().toString()));
}
return result;
}
}

从命令行启动 Spring Boot 应用:

1
./mvnw spring-boot:run

在另一个 shell 会话中检查 API 接口正常工作:

1
2
3
4
curl localhost:8080/api/random-data

# OUTPUT:
# [{"payload": "..."}, ...]

Step 3. 初始化 Next.js 项目

我们在 src/main/frontend 目录下初始化 Next.js 项目。这个路径并不是约定的,可以选择任意路径,不过放到 src/main/frontend 下是相对常用的做法。

1
yarn create next-app --typescript # 交互界面中输入 frontend 作为项目名

如前所述,我们使用静态网页生成的技术来生成前端页面。因此,我们需要在 package.json 文件当中添加一个 export 命令:

1
2
3
4
5
{
"scripts": {
"export": "next build && next export"
}
}

next export 生成的静态页面无法使用 Next.js 依赖 Node.js 运行的相关功能,可以阅读静态网页导出文档了解详细内容。对于我们的项目来说,需要在 next.config.js 文件中添加一个设置禁用图片优化功能:

1
2
3
4
5
6
7
8
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
images: { unoptimized: true } /* >> 添加这一行 << */
}

module.exports = nextConfig

此外,src/main/frontend/api 目录代表的 Next.js 提供的 API Routes 功能依赖 Node.js 环境,导出后也不可用,可以删掉。

src/main/frontend 目录下执行 yarn export 命令,检查导出功能正确无误。

1
2
3
4
5
6
yarn export

# OUTPUT:
# ...
# Export successful. Files written to /path/to/demo/src/main/frontend/out
# ✨ Done in 6.38s.

Step 4. 添加导出页面为 Spring Boot 项目静态资源

Spring Boot 项目在默认配置下会自动添加 classpath:/static/ 路径下的文件作为可以访问的静态资源。

我们通过 yarn export 导出的文件存在于 src/main/frontend/out 目录下,需要一个构建逻辑来拷贝输出内容。此外,上面我们是手动运行 yarn export 命令,可以借助 frontend-maven-plugin 把这一前端构建步骤整合到 Maven 的构建步骤当中。

总的来说,添加 frontend-maven-pluginmaven-resources-plugin 的相关配置到 pom.xml 文件的插件配置中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.12.1</version>

<configuration>
<nodeVersion>v18.11.0</nodeVersion>
<yarnVersion>v1.22.19</yarnVersion>
<workingDirectory>${frontend.basedir}</workingDirectory>
<installDirectory>${project.build.directory}</installDirectory>
</configuration>

<executions>
<execution>
<id>install-frontend-tools</id>
<goals>
<goal>install-node-and-yarn</goal>
</goals>
</execution>

<execution>
<id>yarn-install</id>
<goals>
<goal>yarn</goal>
</goals>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>

<execution>
<id>build-frontend</id>
<goals>
<goal>yarn</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<arguments>export</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>position-react-build</id>
<goals>
<goal>copy-resources</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<outputDirectory>${project.build.outputDirectory}/static</outputDirectory>
<resources>
<resource>
<directory>${frontend.basedir}/out</directory>
<filtering>false</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>

frontend.basedir 属性定义在 pom.xml 的属性配置中:

1
2
3
4
<properties>
<java.version>17</java.version>
<frontend.basedir>${project.basedir}/src/main/frontend</frontend.basedir>
</properties>

从命令行启动 Spring Boot 应用:

1
./mvnw spring-boot:run

打开 http://localhost:8080/ 页面,确认导出页面已经被 Spring Boot 应用正确识别为静态资源:

springboot-nextjs-index

Step 5. 从 Next.js 页面访问 API 接口

接下来,我们实现使用这套技术栈最初也是最终的目的,从 Next.js 页面访问 Spring Boot 后端提供的 API 接口。

为了体现前端 UI 组件化开发的优势,我们在 src/main/frontend 目录下执行以下命令,引入 Material UI 依赖:

1
yarn add @mui/material @emotion/react @emotion/styled

然后,根据 Next.js 框架对创建页面的设计,在 src/main/frontend/pages 目录下新建 my-awesome-page.tsx 文件。随后,编写页面逻辑访问 Spring Boot 后端提供的 API 接口。最终文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import {useEffect, useState} from "react";
import {Alert, Box, Checkbox, CircularProgress, List, ListItem, ListItemButton, ListItemText} from "@mui/material";
import styles from "../styles/Home.module.css";
import Head from "next/head";

type DataEntry = { payload: string }
type DataEntryProps = { data: DataEntry[] }

export default function MyAwesomePage() {
const [data, setData] = useState<DataEntry[] | null>(null)
const [isLoading, setLoading] = useState<boolean>(false)

useEffect(() => {
setLoading(true)
fetch('/api/random-data')
.then(res => res.json())
.then(data => {
setData(data)
setLoading(false)
})
}, [])

const content = renderContent(isLoading, data)

return <div className={styles.container}>
<Head>
<title>My Awesome Page</title>
<meta name="description" content="Generated by create next app"/>
<link rel="icon" href="/favicon.ico"/>
</Head>

<main className={styles.main}>
{content}
</main>
</div>
}

function renderContent(isLoading: boolean, data: DataEntry[] | null): JSX.Element {
if (isLoading) {
return <Box sx={{display: 'flex'}}><CircularProgress/></Box>
}

if (!data) {
return <Alert severity="error">Error fetching data!</Alert>
}

return <CheckboxList data={data}/>
}

function CheckboxList({data}: DataEntryProps) {
const [checked, setChecked] = useState<DataEntry[]>([]);

const handleToggle = (entry: DataEntry) => () => {
const currentIndex = checked.indexOf(entry);
const newChecked = [...checked];

if (currentIndex === -1) {
newChecked.push(entry);
} else {
newChecked.splice(currentIndex, 1);
}

setChecked(newChecked);
};

return (
<List dense sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
{data.map((entry) => {
const labelId = `checkbox-list-secondary-label-${entry.payload}`;
return (
<ListItem
key={entry.payload}
secondaryAction={
<Checkbox
edge="end"
onChange={handleToggle(entry)}
checked={checked.indexOf(entry) !== -1}
inputProps={{'aria-labelledby': labelId}}
/>
}
disablePadding
>
<ListItemButton>
<ListItemText id={labelId} primary={`Line item ${entry.payload}`}/>
</ListItemButton>
</ListItem>
);
})}
</List>
)
}

可以看到,在 fetch('/api/random-data') 一行我们使用 JavaScript 的 Fetch API 从后端 API 接口 /api/random-data 获取数据,随后使用 Next.js (React.js) 支持的 JSX 语法组件化的开发前端 UI 页面。

从命令行启动 Spring Boot 应用:

1
./mvnw spring-boot:run

打开 http://localhost:8080/my-awesome-page.html 页面,最终效果如下图所示:

springboot-nextjs-page

页面样式没有针对 Dark Mode 适配配色,如果系统配色选择 Dark Mode 的话,可以把 src/main/frontend/public 目录下的 CSS 文件里标记 @media (prefers-color-scheme: dark) 的片段删除,以获得和上图相同的渲染结果。

Bonus. 自动补全 html 扩展名

可能有读者已经发现,我们访问最终效果页面的时候,使用的 URL 是 http://localhost:8080/my-awesome-page.html 而不是 SSR 形态的 Next.js 应用或者 Spring Boot Web 应用常见的不带 .html 扩展名的地址。

如果使用 http://localhost:8080/my-awesome-page 尝试访问页面,将会被重定向到 Spring Boot 应用默认的 error 页面:

springboot-nextjs-error

这是为什么呢?

对于 SSR 形态的 Next.js 应用来说,根本就不存在 my-awesome-page.html 文件,服务端直接解析 /my-awesome-page 路径请求,返回对应的页面内容。如果你用 http://localhost:8080/my-awesome-page.html 访问,甚至会得到一个 404 页面。

对于不带模板引擎 Spring Boot Web 应用来说,默认 .html 为 view 的扩展名。当请求最后要返回视图,而视图路径没有扩展名的时候,Spring MVC 的逻辑会加上 .html 后缀搜索对应的视图。

然而,在我们项目中访问 /my-awesome-page.html 并不是访问一个 Spring MVC 语境下的视图,因为我们根本没有为 /my-awesome-page 路径注册 HandleMethod 组件。对比我们访问 API 端口的时候,访问路径是 /api/random-data 而不需要任何后缀名,这是因为我们注册了 DemoController#randomData 这个处理方法。

实际上,访问 /my-awesome-page.html 最终会 fallback 到对静态资源的访问。又因为我们从 next export 的导出产物拷贝到默认的静态资源路径 classpath:/static/ 下的文件,只包括 my-awesome-page.html 文件,所以寻找名为 my-awesome-page 的文件自然会失败。

当然,就大部分应用场景,告知用户要加上 .html 访问也不会是一个大问题,不过这确实不是现在常见的网页访问方式。

Spring Boot 框架极大简化了以往需要大量配置文件或注解的 Spring MVC 开发模式,但是它只是基于原来的配置能力,以约定大于配置的理念,减少了必须手动填写所有配置的负担。这意味着我们仍然能够直接修改高度可配置的 Spring MVC 框架,通过拦截 Servlet 请求处理的过程,来优化访问体验,让用户也可以使用 http://localhost:8080/my-awesome-page 地址来访问我们的页面。

具体而言,我们需要添加这样一个配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package org.tisonkun.springnext.demo;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.io.FilenameUtils;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class DemoWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HandlerInterceptor() {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
final var isApiHandle = handler instanceof HandlerMethod;
final var path = request.getServletPath();
if (FilenameUtils.getExtension(path).isEmpty() && !"/".equals(path) && !isApiHandle) {
request.getRequestDispatcher(path + ".html").forward(request, response);
return false;
}
return true;
}
});
}

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}
}

为了使用 FilenameUtils.getExtension 方法,需要引入 commons-io 依赖:

1
2
3
4
5
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>

当然,读者如果不想引入额外的依赖,也可以自定义方法赖判断。

从命令行启动 Spring Boot 应用:

1
./mvnw spring-boot:run

输入 http://localhost:8080/my-awesome-page 打开页面,得到和上一步相同的页面渲染结果。

基于 ClickHouse 的 GitHub 事件数据库

作者 tison
2022年8月16日 08:00

缘起

ClickHouse 社群指标模型》一文里提到了 ClickHouse 社群基于自己的软件 ClickHouse 制作社群指标的探索。由于遇到了公开数据集表模式缺列,查询执行内存限制,以及数据库只读模式限制等问题,我在过去一周里试着按照 ClickHouse 官方博客的介绍,搭建起了一个属于自有的基于 ClickHouse 的 GitHub 事件数据库。

简单介绍下结果,自建数据集确实解决了上面列举的三个问题。然而,我选择的 16 核 64 GB 内存版本实例在查询性能上还不如 Playground 的性能,只是内存占用大的聚合查询 Playground 可能由于 Quota 限制无法执行。另外,我选择的阿里云上的 ClickHouse 数据库在追上游版本和一些使用体验上还是有所欠缺。最后,日常数据同步的脚本可以在 korandoru/github-adventurer 取用。

当然,我会去做这件事,还有一个不可忽视的原因是我所在的公司本身商业模式就是云上售卖数据处理服务,我希望能够基于云上的服务搭建自己的业务,体验这个流程可能遇到的各种问题。因此,本文不是一个指导手册或者技术指南,而是实现过程中每个环节的杂谈。

Metabase 连接 ClickHouse 数据库

从做这个数据集的出发点开始谈起,ClickHouse 官方文档里有详细的介绍如何连接 Metabase 的文档。然而,实操过程中碰到了两大问题。

  1. 前面提到的权限问题。Metabase 的 ClickHouse Driver 会向服务器发送设置查询参数的请求,由于 Playground 的服务器是 READONLY=1 的配置,因此 Metabase 无法连接。

这个问题是我陆陆续续花了一周时间准备自建数据集的出发点。技术上要么是 Driver 本身避免对这类语句的使用,因为在查询里带上查询相关的 SETTINGS 还是可以的;要么是 Playground 自己开启 READONLY=2 来允许这类查询。我也在上游提出了请求,这个确实只能由数据库管理员评估操作。

Is it possible to set read_only=2 for playground dataset?

  1. Metabase 的插件问题。上一点提到,Metabase 想要连接 ClickHouse 是需要专门的 Driver 的。metabase-clickhouse-driver 是由社群成员提供的,而 Metabase 云服务只支持官方的插件,这就导致哪怕我想花钱采购 Metabase 云服务,也因为无法连接数据库只能作罢。

这其实侧面揭示了云服务的一个缺点,也就是用户的选择权实际上被云厂商所剥夺,云厂商提供什么能力,用户就只能用什么能力。回到中台战略的年代,那些得不到中台关照的小型业务往往只能自己挣扎求存。

面对这种形势,一方面是凸显出云中立的技术厂商的价值,以及原厂能够最大限度地提供新版支持和旧版兼容的优势,另一方面也让我想起了之前看过的一篇文章 Local-first software 里面提出的“你拥有自己的数据,而不是云厂商”的理念。

诚然,云服务能够在很多场景下避免投入开发的成本,也能提供相对优质的服务。但是如果你真的很看重拥有自己的数据,或者对于核心数据,不妨考虑一下数据所有权的问题。当然,反过来说,到底自己能不能做得比云厂商更可靠,也是需要慎重斟酌的,在特定的核心领域里做出投入重点保障,可能也是必要的成本。

话说回来,最终我是用了本地的 Metabase 实例 + 配置 Driver 跑起来了整体应用。借助 Metabase 的 BI 能力,探索出了诸如分时最活跃的参与者这样的一些指标。不过我的可视化功底非常差,做出来的图表不好意思见人。如果实在想看,可以打开这条推文

购买云上 ClickHouse 数据库实例

没有找到御三家的 ClickHouse 服务,国内云厂商倒是不少。由于其他云厂商不允许外网访问,看起来技术支持也非常值得怀疑,于是选择了阿里云 ClickHouse 社区兼容版

总的来说,还算能用,也确实解决了开篇提出的三个问题,在 ClickHouse.com 出云服务并且试用稳定之前,可能还是会勉强续费。实际使用过程里有这么些问题和体验。

  1. 工单客服还是不错的。

大周末的值班,跟我这种要命的夜间生物一起排查问题…我绝对不鼓励加班,过程里也表达了对同行的理解。只能说做服务业的,大家都不容易。整体解决问题的速度和能力值得赞同。

  1. 外网访问需要技巧绕过。

由于众所周知的原因,本地机器 IP 不固定,服务部署的环境 IP 也不固定。我也不需要对这份数据集做特别极致的安全保护,于是尝试允许任意 IP 鉴权访问。不过,阿里云的产品显然有不一样的想法。它禁止了 0.0.0.0 的配置,又把 127.0.0.1 映射成仅允许本地访问。

不过广大人民群众的智慧是可靠的,这条推文里 @ImperiusDs 大佬想出了 127.0.0.1/1 的绕过方案,真是个天才。我希望阿里云不要不识好歹把这个方案也禁了,那我只能提前放弃续费了。

  1. 内核版本不足。

众所周知,ClickHouse 冲版本非常快,以至于 Yandex 当初自己都跟不上上游版本。或许业务对版本的要求是稳定就行,但是我只是一个个人业务,版本不足会带来一些实际的问题。

第一个,client 每次链接的时候都会有 warning 提示。这个还算好的了,只是比较烦人。

第二个,无法使用时间窗口等新功能。有些分析还是能用上会比较好,不是不能接受,稍微麻烦点。

第三个,不能支持跨域访问,这个就要命了。上游在去年底的时候才以 Add CORS support 解决了这个问题。没有跨域访问支持,我在制作开源小镇网站的时候就没办法用自己的数据集了。可以说为了解决 Metabase 连接,我要用自己的数据集,网站上固化 Dashboard 展示,又得换回 Playground 的数据源,一来一去让我感觉花的钱真的是血亏。

为此,一方面我给阿里云提了工单,另一方面给 ClickHouse 上游也提了我三个具体 blocker 的问题清单,希望两者之间有一个能够解决问题,我就切到能解决问题的那一方去。自己维护一个 ClickHouse 服务器还是太要命了,不予考虑。

最后介绍一下价格。不得不说云上的存储是真的便宜,扩容了 300GB 的存储每个月只多花几十块钱。目前的配置是单机 16 核 64GB 内存 + 500GB 云盘,每个月两千多块钱。这还是一笔不小的开销,目前的计划是利用 Metabase 尽量探索出有价值的指标,在网站上直接查 Playground 的数据出图表,一段时间后废弃数据集算逑。

GitHub 事件全量数据概览

ClickHouse 官方博客写成的时候,数据是从 2011 年到 2020 年,压缩前数据集大概 1.2TB 大小,压缩后导出文件不到 100GB 大小。压缩比还是很夸张的。

我制作的数据集从 2015 年到 2022 年实时更新,总事件数 47 亿条,运行时占用磁盘空间 400GB 左右,原数据量没有记录,应该也是几个 TB 大小。

很明显,GitHub 的增长是飞快的,每年事件数也呈明显上升趋势。实际下载数据时候也能感受到逐年向前的数据下载压力。

Count Events by Year

由于 GitHub Events 只有公开仓库的数据,所以 public 之前私下开发的活动是不被记录的。此外,大家都知道 GitHub 的可靠性非常感人,实际上有一些时间段的数据是缺失的。

  • 2016-01-28 01:00:00 ~ 02:00:00 数据缺失
  • 2016-10-21 18:00:00 ~ 19:00:00 数据缺失
  • 2018-10-21 23:00:00 ~ 2018-10-22 02:00:00 数据缺失
  • 2019-05-08 12:00:00 ~ 14:00:00 数据缺失
  • 2019-09-12 08:00:00 ~ 2019-09-13 06:00:00 数据缺失
  • 2020-03-05 22:00:00 ~ 23:00:00 数据缺失
  • 2020-06-10 12:00:00 ~ 22:00:00 数据缺失
  • 2020-08-21 09:00:00 ~ 2020-08-23 16:00:00 数据缺失
  • 2020-10-30 损坏一条数据 id=14032425374
  • 2021-08-25 17:00:00 ~ 2020-08-27 23:00:00 数据缺失
  • 2021-09-11 损坏一条数据 id=17943409164
  • 2021-10-22 05:00:00 ~ 23:00:00 数据缺失
  • 2021-10-23 02:00:00 ~ 23:00:00 数据缺失
  • 2021-10-24 03:00:00 ~ 23:00:00 数据缺失
  • 2021-10-26 00:00:00 ~ 2021-10-29 18:00:00 数据缺失

除了这些整段的数据缺失以外,部分数据缺失也是可能的。CNCF 的 devstats 项目有一系列的补偿逻辑来修复数据,这里不做展开。

总的来说,GitHub 事件数据集没有单个事务级别的完整性,也就是因果性无法完全保证,只适合做一些粗略的倾向分析和大数统计,无法做特别精确的因果分析。

GitHub 事件的数据模型

ClickHouse 官方博客 How to choose the structure for the data? 章节已经讲清楚了数据模型的选型。前文提到的 devstats 和 PingCAP 的 OSSInsight 都是关系型数据库打底,基本是多个具体表分发处理不同事件类型,查询时走 JOIN 查询来出结果的。ClickHouse 作为列存数据库,则更加倾向于一张大表多个列储存所有数据,也是因为这种模式,才能做到数据极高的压缩比和查询时的过滤效率。

我所采取的建表模式和官方博客的模式略有不同,这也是我选择自建数据集的关键原因之一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
CREATE TABLE default.github_events
(
`file_time` DateTime,
`event_id` UInt64,
`actor_id` UInt64,
`repo_id` UInt64,
`event_type` Enum8('CommitCommentEvent' = 1, 'CreateEvent' = 2, 'DeleteEvent' = 3, 'ForkEvent' = 4, 'GollumEvent' = 5, 'IssueCommentEvent' = 6, 'IssuesEvent' = 7, 'MemberEvent' = 8, 'PublicEvent' = 9, 'PullRequestEvent' = 10, 'PullRequestReviewCommentEvent' = 11, 'PushEvent' = 12, 'ReleaseEvent' = 13, 'SponsorshipEvent' = 14, 'WatchEvent' = 15, 'GistEvent' = 16, 'FollowEvent' = 17, 'DownloadEvent' = 18, 'PullRequestReviewEvent' = 19, 'ForkApplyEvent' = 20, 'Event' = 21, 'TeamAddEvent' = 22),
`actor_login` LowCardinality(String),
`repo_name` LowCardinality(String),
`created_at` DateTime,
`updated_at` DateTime,
`action` Enum8('none' = 0, 'created' = 1, 'added' = 2, 'edited' = 3, 'deleted' = 4, 'opened' = 5, 'closed' = 6, 'reopened' = 7, 'assigned' = 8, 'unassigned' = 9, 'labeled' = 10, 'unlabeled' = 11, 'review_requested' = 12, 'review_request_removed' = 13, 'synchronize' = 14, 'started' = 15, 'published' = 16, 'update' = 17, 'create' = 18, 'fork' = 19, 'merged' = 20),
`comment_id` UInt64,
`body` String,
`path` String,
`position` Int32,
`line` Int32,
`ref` LowCardinality(String),
`ref_type` Enum8('none' = 0, 'branch' = 1, 'tag' = 2, 'repository' = 3, 'unknown' = 4),
`creator_user_login` LowCardinality(String),
`number` UInt32,
`title` String,
`labels` Array(LowCardinality(String)),
`state` Enum8('none' = 0, 'open' = 1, 'closed' = 2),
`locked` UInt8,
`assignee` LowCardinality(String),
`assignees` Array(LowCardinality(String)),
`comments` UInt32,
`author_association` Enum8('NONE' = 0, 'CONTRIBUTOR' = 1, 'OWNER' = 2, 'COLLABORATOR' = 3, 'MEMBER' = 4, 'MANNEQUIN' = 5),
`closed_at` DateTime,
`merged_at` DateTime,
`merge_commit_sha` String,
`requested_reviewers` Array(LowCardinality(String)),
`requested_teams` Array(LowCardinality(String)),
`head_ref` LowCardinality(String),
`head_sha` String,
`base_ref` LowCardinality(String),
`base_sha` String,
`merged` UInt8,
`mergeable` UInt8,
`rebaseable` UInt8,
`mergeable_state` Enum8('unknown' = 0, 'dirty' = 1, 'clean' = 2, 'unstable' = 3, 'draft' = 4),
`merged_by` LowCardinality(String),
`review_comments` UInt32,
`maintainer_can_modify` UInt8,
`commits` UInt32,
`additions` UInt32,
`deletions` UInt32,
`changed_files` UInt32,
`diff_hunk` String,
`original_position` UInt32,
`commit_id` String,
`original_commit_id` String,
`push_size` UInt32,
`push_distinct_size` UInt32,
`member_login` LowCardinality(String),
`release_tag_name` String,
`release_name` String,
`review_state` Enum8('none' = 0, 'approved' = 1, 'changes_requested' = 2, 'commented' = 3, 'dismissed' = 4, 'pending' = 5)
)
ENGINE = ReplacingMergeTree
ORDER BY (event_type, repo_name, created_at, event_id)

可以看到,表格列添加了 event_id / actor_id / repo_id 三列,这是因为需要 event_id 去重,后两者可以较好的应对用户和仓库改名的情况。

同时,表引擎选择的是 ReplacingMergeTree 引擎而非 MergeTree 引擎,并多制定了 event_id 作为排序键,同时也是 ReplacingMergeTree 的去重基准。

数据集的具体制作

可以在 korandoru/github-adventurer GitHub 仓库上获取制作数据集的所有相关脚本。

目前,我通过 GitHub Actions 每个小时增量从 GHArchive 拉取新数据插入到数据库里。对于全量的导入,只需要把 download.sh 的逻辑替换成全量下载即可。由于没有云服务器可以直接用,购买云服务器和带宽我血亏了几百块钱以后发现还不如本地机器来的靠谱,所以最后是用自己的机器分批次导入全量数据的。GHArchive 数据下载不用梯子就可以访问,速度也很快。

增量数据下载上游的脚本是全量存储了所有 .json.gz 的原始文件,所以可以直接以当前时间为基准框出前后十二个小时的数据集文件名后过滤。我没有那么大的存储空间,只能从当前最新的数据往后连续数一天了。

具体的脚本逻辑不逐行讨论,只说几个值得一提的点。

  1. 导入 GitHub 事件数据的过程确实就是一个 ETL 的过程,为此我直接把分阶段的处理文件叫做 extract / transform / (up)load 了。

这当然不止是因为强迫症。全量导入数据的时候,下载完成以后从 flatten.sh 入口驱动批量导入,由于网路和数据质量等原因有些数据可能导入失败,这个时候就只需要从错误输出里找到失败的数据,传参给 transform.sh 重新导入即可。

这里也可以以小见大看出软件开发过程里模块化的好处来,能够分离关注点避免同时处理多个事情对人的生理挑战进而引入缺陷,每个阶段的逻辑可以单独复用而无需重复编写。

  1. 处理 JSON 数据时用的是 jq 这个实用工具,强烈推荐。

不过 jq 处理的速度也不算快。数据导入的时候我按照本地核数做数据并发,10 个并发的情况下最后数据库导入效率也就每秒 15MB 左右,升配了也上不去,属于本地处理的瓶颈了。

  1. ClickHouse 对数据输入和输出的格式支持做得非常到位。

JSONCompactEachRow 输入和 HTTP 的 FORMAT JSON 输出,跟后端程序还有网页对接起来不要太爽。导入数据再也不用跟复杂的 INSERT 语句打架了。

  1. dotenv 对统一开发环境和生产环境部署有很大的帮助。

可以看到,自建数据集需要配置自己购买的数据库实例。很多应用都会包含敏感的配置信息,过去都是八仙过海处理不同环境下的配置。无论是不同的处理逻辑,还是命令行传参,都不是那么舒服。

如今,dotenv 越来越多得到用户的青睐,不是没有道理的。因为理念足够简单,要么从 .env 文件读取配置,要么从环境变量读取,所以绝大部分集成开发环境和语言都支持和 dotenv 标准协同工作。本地走一个 .env 文件并从 VCS 里 ignore 掉,生产环境配置环境变量,就能解决逻辑不一致和命令行传参仍然有泄露风险的问题。

当然,dotenv 对复杂数据类型的支持一般。对于复杂的配置需求,还是需要专门的配置系统来处理。但是连接到配置系统的地址和用户信息又可以用 dotenv 管起来。总之和 jq 一样,属于新一代生产力利器。

还有一些 bash shell 和 GitHub 的小技巧就不一一罗列了,从 korandoru/github-adventurer 的源码里都能看出端倪。

回顾一下这轮工作的成果,自建数据集确实解决了一开始列举的三个问题。但是阿里云版 ClickHouse 并不能解决我遇到的所有问题,我现在需要同时连接两个数据库完成 Metabase BI 探索和网页端查询固化的工作。如果 ClickHouse.com 上线了云服务,我会再尝试一下。另外,现在对云服务的使用也算一回生二回熟了,如果有精力我可以看看用 cockroachdb 平替 PostgreSQL 以每种 event 一张表的形式来组织数据。目前至少也有一些用户详情的元数据想存到一个关系型数据库上的需求。

NoSQL Revolution

作者 tison
2022年6月13日 08:00

从本世纪初谷歌的三篇论文发布以来,数据处理领域在大数据的方向上探索了将近二十年的时间。从三篇论文的开源实现 Apache HadoopApache HBase 开始,到打破传统关系型数据库的分布式数据处理系统如雨后春笋般接连诞生,NoSQL 系统回应了移动互联时代的数据爆发式增长的挑战。

诚然,传统的数据库专家对 NoSQL 也有像 MapReduce: A major step backwards 这样的批评,不过 NoSQL 系统本身也在向传统数据处理领域当中被证明有效的特性靠拢,向 Not Only SQL 系统转变。

本文首先从移动互联时代数据增长和数据模型演进带来的实际问题出发,讨论 NoSQL 系统在现在企业数据处理生态当中的定位和价值,然后介绍 NoSQL 系统靠近 Not Only SQL 定位的过程中遇到的硬核诉求,最后分析新时代 NoSQL 的发展方向。

数据量的增长带来的挑战

NoSQL 系统崛起的主要原因就是移动互联时代数据的爆发式增长。

起初,企业经营过程中产生且需要运维的数据并不多,单机数据库应对就绰绰有余。尤其是在摩尔定律尚未失效的硬件主导技术升级的年代,数据量级增长的速度未曾超过硬件升级的速度。关系数据库赢下单机数据库战争以后,几乎每家企业的数据处理生态都被 Oracle 数据库、IBM 的 DB2 数据库和微软的 SQL Server 数据库所占据。

随着移动互联时代的到来,计算机全面进入到民用阶段。几乎人人手持一部甚至多部终端设备,这些设备逐渐占领了每个人生活的绝大部分时间。全域搜索、社交媒体、在线游戏、电商购物、网络直播……提供此类服务的企业所要处理的数据的量级,不再是商业场景下的 B2B 订单、客户关系管理和运维的量级,而是全民参与的 B2C 或 C2C 的用户行为的量级。换句话说,这时企业所要处理的数据,从一部分企业及其行为的量级增长到了全体民众及其行为的量级。

另一方面,硬件的升级也遇到了摩尔定律的瓶颈,硬件的升级不再能够满足用户数量增长的需求。阿里巴巴在 2008 年前后开始的“去 IOE 运动”就是这一趋势的一个注脚。原本,阿里巴巴在应对用户数据快速增长的时候,采取的也是传统的硬件技术升级的手段,采购商业级 Oracle 数据库、特殊定制硬件的 IBM 小型机和 EMC 高级存储设备来支持。然而,一方面受到技术自主可控的驱动,另一方面也是出于企业经营成本控制的要求,阿里巴巴转向了 MySQL 数据库以及后续一系列开源或自研的分布式数据处理系统的解决方案。

当然,单机 MySQL 也无法抗住全网用户每天源源不断产生的行为数据。因此,在阿里巴巴等互联网公司当中就诞生了以分库分表技术为核心的数据库中间件解决方案,即通过分拆业务到不同数据库实例中,同一业务选择分片键分拆到不同数据库实例中,再于业务和数据库实例集群之间设置一个解析查询和转发查询的中间件,来实现以多台廉价计算机和运行其上的 MySQL 数据库,抗下用户行为数据的解决方案。

严格来说,这一方案产生的软件不是 NoSQL 系统。NoSQL 系统的一个重要特征是用户能够像对待单一系统那样与整个 NoSQL 分布式系统交互,而分库分表的数据库中间件往往要求用户知悉底下数据分片的模型,从而针对性的写出不会导致全表扫描的查询。另一方面,即使采用了分库分表的解决方案,系统所能处理的数据量仍然是有限的。目前主流的分库分表方案,最多能够应对 TB 级别的数据。这对于用户账户数据、商户和商品概要数据以及最近一段时间的订单数据或许是足够的,但是对于历史订单数据、商品详情数据、用户历史足迹和社交网络活动记录来说,则远远不够。或者说,即使能够扩容分库分表的数量来支撑更大的数据量级,底下运行的 MySQL 实例产生的开销,也不如 NoSQL 系统底下只是需要一个普通的数据节点更有性价比。

NoSQL 系统当中,除去主打内存缓存的 Redis 以外,诸如 HBase 和支持 Redis 协议的数据持久化 NoSQL 系统 Apache Kvrocks (Incubating) 都能支持 PB 级别甚至以上的数据存储和访问。这得益于从谷歌三篇论文一脉相承的 scale out 策略,藉由简化系统复杂度,以硬件技术的新增长点网络性能抵消单机处理的延时优势。这样,企业当中的数据处理系统可以用增加成本可控的节点数,而非对抗摩尔定律购买价格不支持商用的高端硬件的方式,在延时可接收的范围内应对更大的数据量。

前面提到,NoSQL 系统对于用户来说是一个整体,而分库分表在扩缩容时却未必能够像传统数据库使用体验那样流畅。由于分片键与实例数相关,分库分表分出来多少个库表,这个知识会成为整个系统的一个固有限制。如果想要增加数据库实例,这个过程并非简单地上线新实例就可以开始服务,而是需要整个逻辑数据库在新的库表数下重新分片。我在某司操作过 32 库乘以 128 表到 128 库乘以 128 表的迁移过程,这个迁移的数据同步阶段总共花了两天半的时间,在线上几乎没有感知的情况下以深夜一分钟左右的闪断为代价切换成功。然而,这还是建立在公司有足够强的研发实力支持从头开发一套数据中间件以及数据迁移系统的前提下的开销。而无论是哪种典型的 NoSQL 系统,几乎都支持用户无感知的扩容和缩容动作。

分库分表的数据库中间件实质上操作的是底下不同的数据库实例,传统数据库支持的事务一致性、多表联合操作和存储过程等功能,几乎都受限于实际上数据存在于多个数据库实例的物理限制而无法支持。NoSQL 系统可以认为是在这样的 baseline 上,基于整体考虑设计出一个能够最大化数据处理吞吐和尽可能降低数据延迟,并且尽可能使得用户像对待一个统一系统那样操作的解决方案。

对于定位在支持传统数据库的语义和功能,同时又要满足数据增长需求的 NewSQL 系统,这些系统能够处理的数据量级上限,实际上也没有超过分库分表方案 TB 级别的水平。同时,在数据量超过一定水平时,这些系统会面临严重的功能挑战,例如大事务延迟不可接受,选择 TSO 作为中央授时的系统中心节点不堪重负,或者 Aurora 会提示用户关闭 Binary Log 以保证用户读写的时延。相比起 NoSQL 系统所能处理的数据量级,这些 NewSQL 系统还是不太够看。从它们支持的数据库功能来看,往往与传统数据库也有明显的差别,比如存在微妙差异的事务一致性,不支持存储过程,不支持外键,等等。

数据模型贴近业务的价值

NoSQL 系统崛起的另一个主要原因是打破了关系模型对数据处理领域的垄断。

严格来说,在业务逻辑开发这一块,关系模型并没有统治开发者的心智。虽然不少业务逻辑是写在存储过程或者触发器当中的,这些代码自然深深地被搭上了关系模型的印记,但是尤其在互联网业务开发的领域当中,开发人员并非直接面向数据库编程。在开发人员编写的业务代码到底下的数据库系统中间,经常有一层对象关系映射框架(ORM)的存在。

这就是关系数据库始终绕不过的“对象关系阻抗失配”问题。

现代程序设计语言的主流是面向对象的程序设计,即使并非“一切都是对象”的信徒,大部分语言也都支持数据结构的嵌套。而在关系模型当中,所有的数据都以元组的形式存储,想要表达列表或者嵌套数据结构,要么需要冗余数据,要么需要设置多张表并藉由外键关联来查询。

前者不仅会造成空间的浪费,还会在数据结构趋于复杂,尤其是存在 option 和 either 这样的结构的时候,列的碾平生出非常难以查询和写入的表结构。后者更不必说,原本是同一个逻辑对象的数据,如今散落在多张表上,无论是更新时需要注意的级联变更和完整性约束,还是查询时需要依靠 JOIN 来聚合数据,都是非常麻烦的事情。

反观 Redis 的主要特点之一就是支持丰富的数据结构,例如开发者熟悉的 List/Hash/Set/ZSet 以及方便的 HyperLogLog/GeoHash/Bitmap 等等。对于接受经典数据结构培养的研发人员来说,Redis 这种丰富数据结构上手成本很低,开发者对于基本的数据结构都会使用。反观关系数据库的模型,要在其中实现 List Push/Pop 这样的操作还是有些麻烦。

MongoDB 的数据模型文档将支持灵活的数据模型放在了第一位,Apache Cassandra 的数据模型文档则进一步点明了这种数据模型价值观与关系数据库的不同——如果说关系数据库的数据模型是表驱动的,那么 NoSQL 系统的数据模型就是查询驱动的。

传统的数据库开发流程,往往是由 DBA 或架构师定义出一系列的表及表的模式,藉由关系数据库系统支持的特性和约束来保证数据的完整性和一致性,以这些表及表的模式为基础,上线数据处理系统支持业务需求。如果业务迭代需要引入新的字段或者添加新表支持嵌套数据结构,这些改动都需要送交 DBA 和架构师审批,甚至对于核心数据表的改动,还需要送交研发高管审批。这一过程和认识直到今天仍然没有什么大的改变。基本上,关系数据库在企业当中的定位就是持久化数据资产。

然而,移动互联时代业务的需求有着很强的时效性,需求经常变化,为了应对某个活动需要临时增加某个字段,过后即可废弃。这样的使用场景遇上层层审批的变更流程,必然激发出剧烈的矛盾。NoSQL 系统此时就扮演了一个在企业关键数据资产和业务经常变化且时效性强的需求之间的润滑剂。

一方面,核心数据资产例如用户账户数据、用户信息数据、订单交易数据等等,仍然由关系数据库来支撑运转,保证数据的完整性、一致性和足以应对容灾的持久化,并且借助几十年来发展得相当成熟的数据平台生态进行冷数据归档,以及数据订阅、数据同步等等,作为业务系统的核心数据来源支撑。另一方面,NoSQL 系统存储非核心的经营数据或者衍生、冗余数据,用以支持业务高速迭代的需求。查询驱动的含义就在于此:业务查询是什么样的,底下的数据模式就可以是什么样的。例如使用 Redis 存储用户账户与手机号的对应关系,使用 HBase 存储全国地图上的兴趣点以支持基于位置的用户服务,将业务数据导入 ElasticSearch 当中提供搜索功能,使用 Apache Pulsar 接受采点上报数据。

这种职责分层实践在过去十几年当中不断地被传播和应用,证明了 NoSQL 在企业当中足以赢得自己生存的空间。从数据模型的角度看,贴近业务的数据模型天然适合应对业务的经常性迭代。灵活的数据模式能够快速适应数据模型变更的需求;丰富的数据结构符合开发者的心智模型,能够更快的完成业务代码开发;而对于消息队列、倒排索引系统和图数据库,则是各自领域当中最贴合的建模方式。例如 XLab 分析 GitHub 全域开源协同数据的时候,就自然选择了图数据库来分析人与人、人与项目、项目与项目之间形如社交网络的关系和行为数据。

随着 NoSQL 系统逐渐成熟,尤其是在数据一致性和存储可靠性上面的突破,越来越多的企业也结合自身业务的特性,尝试把核心业务及其数据也假设在 NoSQL 系统上。国外基于 MongoDB 发展出一套 MEAN 应用开发栈,就是这一实践的注脚。虽然业务稳定以后,数据模型变更减少,表驱动的关系数据库能够带来多年积累的软件成熟度和生态繁荣度的优势,但是对于创业公司或者新团队新业务来说,采用 NoSQL 来快速启动自己的业务,并且能够灵活地调整数据模型,或许是个更好的选择。

Not Only SQL 的诉求

NoSQL 系统一开始得名就是因为它的设计理念和数据模型都是反(NO)关系数据库(SQL)的。

这种反叛的极致体现在谷歌的三篇论文当中完全无视数据库领域二三十年的积累,以一种非常土味的方式用廉价机器拼凑起来一个分布式存储系统 GFS 和仅仅支持 MapReduce 这样简单算子的计算引擎。Bigtable 作为初代 NoSQL 引擎,不支持跨行跨表事务,不支持严格的表模式,没有关联查询,没有索引,没有存储过程。

这些“离经叛道”的创举自然引来了数据库大佬们的批评,比如本文开篇引用的 MapReduce: A major step backwards 博客文章。这些批评主要就集中在上面提到的这些“不支持”和“没有”上,以及与数据库生态的不兼容。

一开始,尝到了堆砌大量廉价机器就能解决业务问题甜头的开发者和公司对这些批评自然是不屑一顾的。只是随着业务越长越大,复杂性越来越高,人们面临着数据杂乱无章的失序的风险,以及缺乏传统数据库约束和索引带来的性能退化的痛点,逐渐开始认真考虑数据库领域一直以来的研究的价值。

事务

第一个被提出的议题就是事务,或者说其所代表的数据一致性问题。单机数据库能够保证简写为 ACID 的事务一致性,而分布式系统受到 CAP 理论的限制,往往无法实现单机关系数据库能达到的数据一致性。

关于 CAP 理论的理解,在实际业务取舍的过程中,并不是简单的一致性、可用性和分区容忍性三选二,而是在分布式系统本来就需要能够做到分区容忍,以及业务必须保证服务可用的前提下,看看能够做到多少一致性。当然,有些一致性是以服务短暂不可用或者时延升高为代价的,但是业务绝对不会接受服务一直不可用。

这种情况下首先被提出的解决方案是所谓的 BASE 性质,即基本可用、柔性状态和最终一致,或者我喜欢借用一个说法,叫做啥也不保证。BASE 性质基本已经被扫进历史的垃圾堆里了,不会再有系统标榜自己符合所谓的 BASE 性质。但是它确实提供了数据一致性上的一条基线,即最终一致性。也就是说,对于给定的有限的输入集合,NoSQL 系统当中的数据最终会收敛一个稳定状态,但是这个稳定状态下数据的值是否还有业务意义,不保证。

一般来说,NoSQL 系统在此之上能够做到对自己数据模型下单个数据单元的基本操作是原子性的。比如说,KV NoSQL 系统当中 Put 一个字符串是原子的,不会出现两个 Put 操作的结果是值一部分由第一个操作提供,一部分由第二个操作提供的情况。不过,业务要求显然远远不止这点。对于业务来说,常见的一致性或者叫事务需求,是保证对一行数据的多个操作的原子性,乃至多行数据多个操作的原子性。例如单行数据的 CAS 操作,或者多行数据原子写乃至事务性的读后写的支持。

HBase 和 Bigtable 都支持单行事务,这是因为它们的数据模型里单行数据一定存在单台机器上,保证同一台机器上操作的原子性是比较简单的。大部分系统根据自己物理数据分布的特性,也会向用户保证这类数据存储在同一台机器上的情况下事务能力的支持。

对于跨多台机器的事务支持,则要牵扯到分布式事务的话题。对于 Pulsar 这样数据仅追加的消息系统来说,可以通过批量提交及该操作的幂等性来实现生产消息的事务支持。对于存在删改的系统来说,要么选择放弃隔离性,实现复杂的数据补偿逻辑来支持 Sagas 式的分布式“事务”,要么是采用 Raft 这样的共识算法加上某种形式的两阶段提交算法来支持分布式事务。例如 TiKV 采用了 Raft + Percolator 算法来实现分布式事务,Percolator 本质上还是两阶段提交,但是在生产上会有一系列的优化,并且在某些特定条件满足的情况下可以简化成一阶段提交。

一般来说,启用分布式事务会导致数据吞吐的下降和其他性能影响,因此大部分 NoSQL 系统都提供了用户自己调节数据一致性的选项,来保证只在需要对应级别的数据一致性的情况下,才付出相应的开销。

模式

前文提到,NoSQL 的一个优势是灵活的数据模式能够响应业务的高速迭代。不过,随着业务日渐复杂,开发团队人员更迭,维护 NoSQL 系统上存储的数据的质量就成为了一个难题。

如果所有的数据都是无模式的,或者数据模式没有被良好的记录和检验,那么杂乱无章的数据就可能带来极大的存储空间浪费并阻碍业务开发。

关系数据库和 SQL 当中有专门的数据定义语言(DDL)来描述表模式,通过定义清楚字段的类型和约束来保证数据是结构化的。虽然一旦这种约束过于繁琐和严格,且由于企业流程难于变更时,会影响业务开发的效率,但是清晰的类型约束和唯一性约束是有助于开发人员理解字段的属性和检验业务逻辑正确性的。

这种思路体现在 NoSQL 的演进之路上就是渐进式模式定义。

例如,MongoDB 就支持数据模式校验,Pulsar 也支持定义消息的模式

再以 Cassandra 为例,虽然一开始它对外暴露的是稀疏列簇式大宽表的接口,但是也逐渐地转向建议用户以 CQL 和 Cassandra 交互,同时也保留直接操作底下稀疏列簇式大宽表的手段。

对于现有系统本身不支持数据模式定义的,也有其他系统来支持。例如 Apache Hive 支持为 Hadoop 上的数据定义模式,Apache Phoenix 支持为 HBase 定义数据模式。

索引

对于直截了当的查询来说,NoSQL 的性能优势是明显的。例如 HBase 上已知 rowkey 查询值,这样的操作是系统设计之初就考虑到的情况,属于舒适区。

然而,随着业务发展逐渐复杂,各种新的查询维度也纷至沓来。例如,不再是以 rowkey 查询值,而是以某一列的值为筛选条件来查询匹配的所有行。比如一个用户表,一开始将用户 ID 作为主键存储,现在要根据用户所在地筛选出所有在某地的用户。由于 HBase 没有索引,这种查询只能扫全表后过滤。可想而知,每次查询都需要遍历全表数据,查询的性能肯定好不到哪去。

关系数据库当中也有一样的问题,MySQL 每一行的主键是固定的,要么是创建表模式时指定,要么由插入行时自动生成的 rowid 取代。关系数据库当中可以针对某张表创建索引。一方面,唯一键索引可以施加键值唯一的约束;另一方面,创建索引通常会在存储系统当中额外创建出一个从索引列到主键的映射。实际以索引列为过滤条件查询的时候,会先从索引映射当中找到对应主键的集合,然后直接挑选出小部分相关行做后续操作。

基本上现在的 NoSQL 系统都会实现一定的索引机制。例如业内前沿的数据湖存储 Apache Hudi 系统,一开始只是一个按照直觉写出的读取 Hadoop 上的文件,应用对给定记录的变更并写回 Hadoop 的 Apache Spark 程序。但是在后来投入生产之后,越来越多的开发人力加入和生产环境对性能无止境的追求,为 Hudi 添加了基于元数据文件的、基于 HBase 外存的,以及在选择 Apache Flink 处理引擎的情况下基于 Flink 内置 State 存储的多种索引方案。

上一节提到的能为 HBase 定义数据模式的 Phoenix 项目,也支持为 HBase 创建索引。

从这一系列 NoSQL 系统的转变来看,索引确实是其走向 Not Only SQL 的一个性能上的硬核需求。

新时代 NoSQL 的发展方向

NoSQL 系统的范畴非常广,具体到每个细分领域面临的业务环境演化和技术需求都不尽相同。

对于整个 NoSQL 生态发展的角度来说,未来的发展方向是发挥在应对大数据量上无需全面兼容传统数据库约束的优势,直面海量数据和全球分布式系统的挑战,并且结合具体业务领域对数据模型的要求,根据对应假设设计出在不同业务场景下最优化的数据处理系统。

对于具体的 NoSQL 系统来说,我想 KV NoSQL 这个细分领域值得关注。字典映射是构建复杂数据结构的基础构建块,无论是应对什么场景特化的 NoSQL 系统,最终映射到持久化存储的数据结构,几乎都是某种 KV 的形式。如果 KV 引擎能够从现在 RocksDB 占据单机引擎半壁江山的状况,发展到有一个分布式 KV NoSQL 系统能够支持其他特化的 NoSQL 系统基础的存储需求,那么这或许会是分布式数据处理系统下一次革命的开端。

对于有望成为这个方向解决方案的系统,它至少能够可选地支持上面提到的 Not Only SQL 所需要的硬核特性,也就是事务、模式和索引。在此基础上,如果能够在低延迟、可扩展性和稳定性上实现突破,比如引擎的创新带来的性能提升,利用云原生时代的基础设施和硬件的迭代支持全球规模的集群管理,工程打磨实现生产可靠的稳定且方便运维的系统,那么这样的一个软件将是有价值的。

对于现有系统来说,实现这样的转型并不容易,但也绝非不可能。例如 Datastax 公司全力投入支持以 Cassandra 为基础的 Astra DB 在云上的应用,HBase 社群也在投入存储上云的开发。对于新系统来说,历史包袱会轻松一些,能够基于现在的情况做针对性的设计。但是在增量市场逐渐萎缩的环境下,做好现有系统 API 的兼容让就是生产系统采用的一个关键考量了。例如,ScyllaDB 采用了 thread-per-code 的线程模型来试图革新 KV NoSQL 的性能,但是在面向用户的接口上选择兼容 Cassandra 的 API 以帮助存量用户平滑过渡。

进一步地,如果某个 KV NoSQL 系统在 KV 领域打开了局面,那么它就可以借助协议层抽象来支持不同场景的数据存储需求。

例如,TiKV 是一个支持分布式事务的 KV NoSQL 系统,Titan 通过实现 Redis 协议层来支持 redis-cli 对 TiKV 系统的访问;TiDB 可以认为是在 TiKV 之上实现了一个支持 SQL 访问的协议层。

总结一下,新时代 KV NoSQL 的发展方向,一方面是需要支持前面提到的 Not Only SQL 的硬核诉求,并且需要和现存的数据处理生态保持良好的兼容性。另一方面,这些系统可以在低延迟、可扩展性和稳定性等方向上寻求突破。最后,如果某个 KV NoSQL 系统足够成熟,那么它可以借助协议层了解 KV 之上具体场景下的数据结构信息,知道用户想要存的是什么数据,从而在复杂场景下允许用户直观的表达自己的业务数据,同时让数据处理系统理解相应场景的的语义,帮用户做场景优化。

CMake 是怎么工作的?

作者 tison
2022年4月15日 08:00

关于开源软件的发布相关的内容还在构思当中,先摆烂重发部分以前讨论过的关于构建系统的文章。构建和发布密不可分,可以认为都是持续交付流水线的一环。

我会分多篇文章讨论软件发布和开源软件发布的各个方面,文章内容再加以提炼放到编撰当中的《开源指南》里。

构建系统是软件开发的重要组成部分,生产环境中的绝大多数软件都由多个组件所组成,由一系列依赖和分散的编译单元聚合而成,而自动化这些组件的集成的系统,就是构建系统。

笼统地说,构建系统负责除了应用代码编写以外的,所有从代码到可执行文件的步骤的自动化。其中,查找、编译和链接等具体执行由其他工具支持。构建系统本身处理的内容分为两大部分,第一部分是构建过程各个步骤的编排,第二部分是包管理或说第三方依赖管理。这两者的区别可以参考 Java 生态中 Ant 和 Ivy 的区别和联系。狭义的构建系统仅包括第一部分,因为狭义的构建过程只关心有某种方法可以取得依赖并将其引入构建,而不关心依赖本身是怎么管理和获取的。

那么,本文主角 CMake 是哪一种或者两者都是呢?

CMake 是构建系统生成器

CMake 两者都不是,作为 C/C++ 生态构建系统事实标准的 CMake 其实是一个构建系统生成器。CMake 的主要功能是描述项目结构,表达模块依赖,从而以人类友好的方式表达构建过程步骤编排的需求,最终从声明式的 CMake 代码中生成实际的构建系统,比如生成一系列 GNU Make 构建需要的文件。

举个例子,假设我们有一个 hello world 代码文件 main.cpp 和对应的 CMake 项目描述文件 CMakeLists.txt 如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ (root) tree
.
├── CMakeLists.txt
└── main.cpp
$ (root) cat CMakeLists.txt
cmake_minimum_required(VERSION 3.19)
project (hello_cmake)
add_executable(hello_cmake_exe main.cpp)

$ (root) mkdir build && cd build && cmake .. && make
$ (root/build) ls
CMakeCache.txt Makefile hello_cmake_exe
CMakeFiles cmake_install.cmake
$ (root/build) ./hello_cmake_exe
Hello CMake!

可以看到,CMakeLists.txt 里关键的内容是 add_executable 一行。这行表达了我们希望在项目中创建一个名为 hello_cmake_exe 的可执行文件,它的构建要素是 main.cpp 文件。

我们通过 cmake 命令在当前目录下创建了 GNU Make 构建需要的 Makefile 文件及其依赖,随后执行 make 命令实际完成构建。

CMake 围绕构建目标声明

刚开始接触 CMake 的同学往往会被它茫茫多的指令吓到,尤其是神秘的结构化语法、没有命名规律的声明、不知所云的参数列表和复杂的命令行参数,让人不知道从何入手。

其实,CMake 尤其是现代化的 CMake 核心是围绕构建目标来组织声明,上一节例子中 add_executable 一行里的 hello_cmake_exe 就是一个构建目标。此外,add_library 定义的库也是一种构建目标。可执行文件和库是构建最终对用户可见的产物,CMake 对项目模块依赖的声明就是围绕着这两种目标来展开的。

add_executable 和 add_library 的第一个参数说明了构建目标的名称,它必须是全局唯一的符号,并且随后全局可访问;紧跟着是各自专有的控制参数,例如可以指定库是静态链接还是动态链接等;再之后是一个可变长度的源文件数组,用于描述直接参与构建当前目标的源文件。

CMake 的声明参数是一系列空格隔开的符号或字符串,同时每个命令都可以有自己的参数列表,这些参数列表没有统一的风格,同时仅通过位置区分而没有命名参数,可以说是 CMake 最为用户不友好的一点了。

定义了构建目标之后,就是描述构建目标的各种特性。上面两个命令列出了构建目标所需的源文件,但是还有链接时的库依赖,这就需要 target_link_library 来声明目标构建依赖的库。构建目标可以是 add_executable 或 add_library 声明的,库可以是 add_library 声明的构建目标,也可以是库文件等。

此外,构建目标可能还需要包含头文件等 include 预编译命令依赖的文件,除了在 add_executable 和 add_library 中加入以外,还可以随后通过 target_sources 逐个包含,或者更常见的,通过 target_include_directories 包含目录下的文件。

最后,构建目标在编译时还可能有一系列编译选项和宏定义等,可以通过 target_compile_options 和 target_compile_definitions 来声明。

可以看到,链接、文件包含和编译选项的命令都以 target_xxx 的形式出现,第一个参数是 add_executable 或 add_library 声明的构建目标。这就是现代化的 CMake 围绕构建目标声明的含义。

值得一提的是,旧版的 CMake 不支持 target_xxx 形式的命令,而是通过不带 target_ 前缀的命令来表达相似的含义,但是旧版的命令是通过修改全局状态和获取全局状态来声明的,而非按照构建目标所构成的命名空间区分开的。举例来说,include_directories 命令将导致之后及子项目中的所有构建目标在编译时都带上此命令引入的路径下的文件。显然,这很容易导致声明的泄漏,尤其在项目层次复杂,引入其他子项目或作为子项目被其他项目引入时,容易出现难以排查的非预期构建结果,也就限制了项目规模的扩大。

现代 CMake 的最佳实践是牢记围绕构建目标组织声明,不仅能避免意外的声明泄漏,还能获得更好的表达力。

CMake 灵活地支持三方库

一开始我们就提到,广义的构建系统包括三方库的依赖管理。即使是狭义的只处理构建步骤编排的构建系统,也需要有某种方式引入三方库的依赖。

程序设计实践发展至今,引入三方库依赖的方式无非是 systemwise 安装依赖,vendor 方式携带依赖和利用依赖管理系统的接口与专门的依赖管理系统协作。CMake 支持以上三种形式的三方库依赖引入方式,并且抽象了自己统一的 find_package 接口。

我们先介绍 find_package 接口,它的主要使用形式如下。

1
find_package(<PackageName> [version] [REQUIRED] [[COMPONENTS] [components...]])

后面都是可选项,最基础的使用形式就是 find_package 加一个 PackageName 信息。CMake 会在约定路径和通过选项指定的路径下搜索名为 FindPackageName.cmake 的文件,并执行其中的逻辑以设置一些关键的变量。约定的 PackageName_FOUND 标识是否找到对应的依赖以进行差别处理逻辑,其他的变量则根据不同 FindPackageName.cmake 的策略有所不同,通常包括该依赖暴露的头文件信息,可供链接的库的信息,以及库的构建目标等等,并可能按照模块进行划分以获得更细粒度的导出控制。

不少成熟的三方库都得到了 CMake 的原生支持或者提供了可移植的 Find 脚本,例如 Protobuf 和 Boost 等。这些 Find 脚本通常被写成仅搜索系统级路径的形式,也就是原生支持了查找 systemwise 安装的第三方依赖的方法。换个角度看,也就是把 /usr//usr/local/ 等经典安装路径等同于 Maven 当中的 .m2 路径来处理。

然而,systemwise 的方式对运行环境的侵入性明显,很容易影响 PATH 环境变量和实际使用的三方库,同时容易产生难以解决的版本冲突问题。通常只在临时开发或者隔离性较强的容器环境中才考虑采用这种方法。

为了避免 systemwise 的安装依赖,另一种方案是 vendor 方式携带依赖,也就是常说的 third party 或 contrib 或 submodule 等等。ClickHouse 重度使用了这种模式,几乎 vendor 了它的所有依赖。C / C++ / Scheme 广泛流行的库通常也是通过此种形式来组织的,Go 语言在 go mod 面世之前乃至现在都广泛使用了 vendor 的依赖引入模式。

具体地说,vendor 的含义就是依赖库和根项目在源代码层面一起分发,从而在下载根项目源代码时同步就绪了所有依赖库的代码。前面提到原生的 Find 脚本通常只搜索系统级路径,因此 vendor 通常也需要手动编写 CMake 脚本引入依赖项的问题。

最理想的情况是三方库已经充分考虑了作为子项目被外部引入的情况,例如 GoogleTest 或 gRPC 等。它们在项目的根目录下有 CMakeLists.txt 文件来定义和导出项目的构建目标和头文件、静态链接库等信息,只需要在父项目中通过 add_subdirectory 声明引入三方库,就可以在 CMake 的解析框架下导出所有需要的符号。

然而,现实往往是更复杂的。有些三方库未曾想过跟其他项目协作,有些三方库并不支持 CMake 或者实现的 CMake 脚本有问题,例如典型的 Poco 项目的 ENABLE_TESTS 选项命名很容易冲突,又未启用相应的 CMake Policy 以允许上层临时屏蔽选项。诸如此类的细节问题常常引出难以排查的非预期构建结果,所以富余人力的项目研发团队倾向于撰写一个轻量级的 CMake 层来管理三方库。

典型的例如 ClickHouse 项目,它几乎对所有的项目都配备了一组 CMake 脚本,并且精心裁剪了依赖库的文件,仅保留构建相关的文件。在根项目的 CMake 文件中,通过 include 执行来执行这组配置三方库依赖的 CMake 脚本,从而达到和 find_package 或者丝滑的 add_subdirectory 类似的符号导出效果。

不过,上面提到的手写 CMake 脚本跟直接调用 find_package 的方式并不冲突,从导出符号的角度看,效果是一样的。但是,我们还能做得更加一致,即利用 find_package 提供的机制,将 vendor 的依赖作为 systemwise 依赖的前置或后置候选被挑选和导入。通过自定义 Find 脚本的逻辑,兼容 systemwise 的引入方法,再修改 CMake 查找 Find 脚本的配置,就可以实现 CMake 脚本里一致的使用 find_package 声明来引入三方库依赖了。

最后讨论的引入三方库依赖的方式是与其他专门的依赖管理系统协作,例如 vcpkg 或 conan 等。这两者都有自己的中央仓库,类似于 Maven Central 或 JCenter 等,来管理三方库,包括版本、平台、名称和库的具体内容等。

vcpkg 通过 CMake 原生的 TOOLCHAIN 机制 Hook 了 CMake 执行前的阶段,以配置好 CMake 随后 find_package 的环境从而能够正确的找到依赖。conan 则在 CMake 对构建系统的一层抽象的基础上再做一层抽象,支持生成 CMake 生成构建系统所需要的文件,加一层套娃,在生成 CMake Binary 路径的内容的时候把依赖库的内容也拷贝过去支持 CMake 索引到。

可惜由于 C++ 的跨平台构建太过复杂,并且一直以来的习惯都是 vendor + 魔改三方库,因此这两种已经是最流行的专门的依赖管理系统并没有大范围的获得采用。

顺带一提,CMake 本身还提供了 ExternalProject 和 FetchContent 等内容来支持 vendor 以外的模式,在生成构建系统期间拉取或者根据配置寻找三方库依赖并复制到构建路径下,最终以相对路径生成构建系统文件。不过这两个方案非常难用,而且没有类似于 .m2.ivy 这样全局的依赖管理目录,其实每次构建的时候还是要每项目的重新拉依赖,比起 vendor 来说并不少多少力气,反而失去了 vendor 魔改的灵活性。实际采用的人少之又少。

CMake 新时代的最佳实践

上面只讨论了 CMake 的两个最为关键且值得的内容,其他诸如指定 CMake 版本、指定 C++ 标准兼容、设置 CMake Policy 开关、添加变量和定义宏及函数等等内容,要么非常显而易见,要么比较少见,需要的时候自然可以弄懂,不做过多展开。

文字的讨论只是帮助厘清概念和建立感性认识,CMake 作为构建系统还有丰富的细节,熟悉实际系统也需要从实践出发。CMake 介绍的最后附上我在接触这个构建系统的过程中最受启发的几份最佳实践的材料。

这两个演讲和 Slides 是近年来比较有代表性的讲 Modern CMake 的材料。不多说,主要也是围绕构建目标和模块化项目来介绍的。

简练又恰到好处的 CMake 实例,新手跟着实践一遍 01 和 02 就差不多了,其他的可以按需阅读。

CMake 实现上的原理,包括解析声明和生成构建系统的步骤以及一些执行细节,富有黑客精神的同学可以看一看。

Protobuf Gradle Plugin 的用例

作者 tison
2022年3月8日 08:00

近日尝试利用 Apache Ratis 这个项目包装一个 Raft 协议驱动的状态机的时候,遇到了需要用 Protobuf 传输数据的场景。由于 Gradle 构建工具的门槛和 Java 语言项目的某些惯例碰到了使用上的问题,这里记录一下我在这个玩具项目当中的用例。

首先介绍一下整个项目的主要目录结构,这里只包含最小复现需要的集合

1
2
3
4
5
project/
project/proto/
project/proto/RMap.proto
project/build.gradle
project/settings.gradle

其中 settings.gradle 只有一行默认生成的 rootProject.name = 'dryad' 信息,RMap.proto 是一个普通的不包含 gRPC 定义的 proto 文件。RMap.proto 文件内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
syntax = "proto3";
option java_package = "org.tisonkun.dryad.proto.rmap";
option java_outer_classname = "RMapProtos";
option java_generate_equals_and_hash = true;

package dryad.rmap;

message GetRequest {
bytes key = 1;
}

message GetResponse {
bool found = 1;
bytes key = 2;
bytes value = 3;
}

message PutRequest {
bytes key = 1;
bytes value = 2;
}

message PutResponse {
}

主要使用 Protobuf Gradle Plugin 的逻辑都在 build.gradle 文件里,文件内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
plugins {
id 'java'
id 'com.google.protobuf' version '0.8.18'
id 'com.github.johnrengelman.shadow' version '7.1.2'
}

repositories {
mavenCentral()
mavenLocal()
}

sourceCompatibility = 17
targetCompatibility = 17

dependencies {
implementation 'com.google.protobuf:protobuf-java:3.19.2'
implementation 'org.apache.ratis:ratis-thirdparty-misc:0.7.0'

protobuf files("proto/")
}

protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.12.0'
}
}

shadowJar {
configurations = []
relocate 'com.google.protobuf', 'org.apache.ratis.thirdparty.com.google.protobuf'
}

编译和构建工具采用的版本信息如下

1
2
3
4
5
6
7
8
9
10
11
12
------------------------------------------------------------
Gradle 7.4
------------------------------------------------------------

Build time: 2022-02-08 09:58:38 UTC
Revision: f0d9291c04b90b59445041eaa75b2ee744162586

Kotlin: 1.5.31
Groovy: 3.0.9
Ant: Apache Ant(TM) version 1.10.11 compiled on July 10 2021
JVM: 17.0.2 (Eclipse Adoptium 17.0.2+8)
OS: Mac OS X 10.15.7 x86_64

这个用例当中有两个注意点。

第一个注意点是 protobuf 的配置方式。

可以看到在 dependencies 配置块中声明了 proto 文件的路径。我不记得是不是有默认的查询路径比如 <project>/src/main/proto 这样的,但是建议还是明确写出来为好,毕竟业界也没有什么公认的标准,每个插件工具的假设不一定采用同一套约定。

另外就是 protobuf 配置块中声明了 protoc 工具的版本。Protobuf Gradle Plugin 的官方文档当中还介绍了如何整合 gRPC 等插件等控制 protoc 编译过程的方式。玩具项目当中不需要,因此略过。

最后是 protocprotobuf-java 的版本不一样,如果还要引入 gRPC 的插件和 JAR 包依赖,还会有其他不一样的版本。这是因为 Protobuf 生态并不是整体同步发布的,而是各个组件很大程度上自主开发和发布的缘故。具体的兼容矩阵我没有研究过,但是一般来说锁定了某个版本就不太会轻易升级了。比如 Apache Hadoop 的 Protobuf 版本一直停留在 2.5.0 版本上。印象中 3.0 版本以后的兼容性还是比较好的,3.10+ 版本之间的升级还算顺滑。

第二个注意点是 Gradle Shadow Plugin 插件的使用。

Gradle Shadow Plugin 很大程度上是 Maven Shade Plugin 的同位替代。也就是说,服务于需要把依赖项一起打成一个大 JAR 包的场景。

通常来说,Maven 或 Gradle 项目打包的时候,依赖项都不会进入到最终产物当中。因为打包就只是对你写的这些代码编译出来的 class 文件打包,而不是像 C / Rust 这种产生二进制可执行文件的思路。Java 语言程序运行起来,是需要程序员把所有的依赖项都写进 classpath 里,再指定要运行的类,执行其 Main 方法启动的。这种情况下打包不需要把依赖项都搭进去。

这种方案对于企业自己管理所有依赖,大部分软件是自包含少依赖的大型软件的场景是比较合理的。但是随着互联网的兴起和合作开发效率提升,一个项目依赖大量其他项目的情形越来越多,这些其他项目也有自己的开发周期,往往会产生多个版本的 JAR 包发布产物。这种情况下再要求程序员自己去管理依赖项,管理 classpath 的内容,在生产上就是既繁琐有不可靠的了。

因此,Gradle Shadow Plugin 和 Maven Shade Plugin 解决的问题就是把所有依赖在打包的时候也打进构建产物当中,产生一个 project-all.jar 文件。用户可以直接把这一个 JAR 包加入 classpath 就能保证所有的依赖都已经就绪。甚至在 MANIFEST 文件中写好默认的 MainClass 信息,就能通过 java -jar 命令将大 JAR 包以一种形如二进制可执行文件的方式运行起来。

不过,我们这里用上的不是打一个大 JAR 包的功能,而是在这个大需求下解决 package relocation 问题的功能。

Java 语言程序依靠全限定名来识别一个类,每个 ClassLoader 都对每个全限定名都只会加载一个类实例。如果 classpath 当中存在两个相同全限定名的类,那么根据 ClassLoader 的实现策略,可能会加载其中任意一个,或者报错。

对于服务端应用例如 Apache Flink 和 Apache Ratis 而言,它们自己需要依赖 protobuf 或 akka 等三方库,同时它们自己的用户也有可能依赖这些三方库,那么用户内部逻辑使用的三方库版本,跟用户逻辑需要跟服务端打交道时使用的三方库版本,就有可能在 classpath 当中同时存在。如果这两个版本不兼容,就会出现运行时错误。

由于服务端应用往往受众更广,通常来说解决方案是用户应用程序采用跟服务端相同的依赖版本。但是如果用户不是直接依赖跟服务端可能冲突的三方库,而是间接依赖,那么这个版本对齐的工作往往就很难做了。

另一种形式是形如 akka 生态当中的 play 框架,直接暴露操作 akka 底层数据结构的接口,用户自己不依赖 akka 而是通过 play 提供的接口使用 akka 的能力。但是这种形式只对 akka 和 play 这样由同一个团队开发的软件是比较合适的,放在更加复杂的开源软件生态当中就很难配合了。

因此从服务端的角度出发,为了避免用户遇到这一难题,一个彻底的解决方法就是 package relocation 更改自己依赖的三方库的全限定名。

比如上面 build.gradle 里配置项显示的

1
2
3
4
shadowJar {
configurations = []
relocate 'com.google.protobuf', 'org.apache.ratis.thirdparty.com.google.protobuf'
}

这意味着把所有 com.google.protobuf 的文本都替换成 org.apache.ratis.thirdparty.com.google.protobuf 的字样,也包括字符串当中的情况,以应对动态加载的用例。

这样,服务端最终打出来的 JAR 包里,使用的类全限定名就不是 com.google.protobuf.Message 而是 org.apache.ratis.thirdparty.com.google.protobuf.Message 了,这也就跟用户依赖的 com.google.protobuf.Message 不同,从而不会起冲突。

当然,这种 package relocation 不仅仅在服务端的使用上会改掉全限定名,也需要类的实现本身也是以新的全限定名来提供的。因此 Apache Ratis 项目提供了 ratis-thirdparty-misc 库,Apache Flink 项目提供了 flink-shaded 库。其中的内容就是把服务端依赖的软件以 relocate 之后的名称重新发布。

对于这个玩具项目来说,它需要的是保持跟 Apache Ratis 服务端一样的 protobuf 依赖的全限定名,保证能够嵌入到 Apache Ratis 的服务端实现当中。对于其中的 proto 定义部分,它并不需要真的把依赖项也打进自己的 JAR 包里,这个打大 JAR 包的工作会交给最终的 dist package 完成。所以我们还需要把 Gradle Shadow Plugin 默认打入所有运行时依赖的行为变掉。这就是 configurations = [] 一行起的作用,把打入最终 JAR 包的依赖项置空,这样就只会包含 proto 文件编译出来的 class 文件了。这样的用例,其实与 Maven Shade Plugin 的惯用法有较大的差别,更像是 Maven Replacer Plugin 的用法。

最后作为小 tip 值得一提的是,上面提到 package relocation “也包括字符串当中的情况,以应对动态加载的用例”。这其实导致了 akka 项目很难利用常规的 package relocation 插件来完成这个工作。惯例上,Java 语言项目的全限定名以域名开头,形如 com.google.protobuforg.apache.ratis 等等。一般而言这种形式的字符串只会出现在类的全限定名当中。然而,akka 作为一个 Scala 项目采用了 akka.actor 形式的全限定名前缀。不幸的是,这种前缀模式跟 akka 的配置项是重叠的。这就导致 package relocation 会同时改变配置项的名称。这其实不是我们想要的,因为这样用户也要跟着改配置项的名称才能跟 relocate 之后的 akka 库交互,这通常来说是非常难做到并且与大部分开发者的直觉和生态项目的假设是冲突的。

20220626 更新

  1. 这样 relocated 以后的结果,只会体现在本仓库再次被依赖时。因为 shadowJar 作业发生在打包阶段,因此如果在同一个包内使用 Protobuf Plugin 生成的类,此时依赖的还是 relocated 前的全限定名。
  2. 多模块的 Gradle 项目中,一个子项目依赖 shadowJar 产生的另一个子项目需要形如 implementation project(path: ':foo-proto', configuration: 'shadow') 的语法。
  3. 如果使用 Intellij IDEA 来开发,需要在 build.gradle 里加载名为 idea 的 Gradle Plugin 才能正确索引 Protobuf Plugin 生成的文件。

具体可以参考在线的完整案例 Dryad 仓库。

❌
❌