普通视图

发现新文章,点击刷新页面。
今天 — 2024年11月22日夜天之书

开源软件有断供的风险吗?

作者 tison
2024年11月17日 08:00

近期,Linux 上游因为受美国出口管制条例的影响,将移除部分开发者的 MAINTAINER 权限,引起了新一轮对开源依赖的重新评估。

关于其中开源精神和社群治理的讨论,卫 Sir 的两篇文章已经讨论得比较清楚(见尾注)。本文主要从软件供应链的角度出发,回应对“开源软件的断供风险”的担忧。

简言之,开源协议只是向用户授权自由使用特定版本源代码。除此以外,大部分开源协议都明确声明不提供维保,更不承诺有一个长期迭代的上游分支

开源协议提供了什么样的开源软件?

大部分人对开源协议的了解是不准确的。人们往往望文生义的认为开源软件只意味着源代码公开,至于软件的用途和使用限制,都是可以商议的。

严格意义上的开源定义由开源促进会(OSI)提供,它定义了开源软件的分发条款必须符合的若干个标准,此外才是可以商议的部分。我们常见的 MIT License 为例,先读一遍协议原文,看看 MIT License 到底以什么形式分发软件。

我们采用开放原子开源基金会“源译识”项目的中英对照翻译成果:

Copyright [YEAR] [COPYRIGHT HOLDER]

版权所有 [年份] [版权持有人]

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

特此向获得本软件及相关文档(合称“本软件”)副本的任何人免费授予不受限制地利用本软件的许可,包括而不限于:使用、复制、修改、合并、发布、分发、分许可和/或销售本软件副本,并允许本软件的接收者也获得前述许可,但须遵守以下条件:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

以上版权声明及本许可声明应包含在本软件的所有副本或主要部分中。

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

本软件系“按原样”提供,不包含任何形式的明示或默示保证,包括但不限于适销性、特定目的适用性及不侵权的保证。在任何情况下,无论是在合同、侵权或其他案件中,作者或版权持有人均不对因本软件、或因本软件的使用或其他利用而引起的、引发的或与之相关的任何权利主张、损害赔偿或其他责任承担责任。

MIT License 的条款包含了所有常见开源协议都具备的两大特征。第一个是授予获得以本协议许可的软件的任何人几项基本权利,主要是任意使用、任意修改和任意分发。第二个是强调该软件“按原样”(AS IS)提供,没有任何维护保障服务,也不承担任何用户使用软件带来的责任。

这两个条款所基于的开源精神,是软件作者已经无偿将代码公开发布并允许你自由使用、修改和重新分发了,那么用户也不应该再强求软件作者承担额外的责任,包括持续维护软件,或必须处理用户使用软件产生的问题。

此外,不同于朴素理解下,“一个软件是 MIT License 许可的开源软件”,意味着它将会以 MIT License 持续发布新版本,实际上,软件作者有权以任何协议发布其代码,而开源软件协议也只绑定在软件的特定发布版本上。

因此,我们可以看到,开源协议本质上只是绑定在特定版本软件上的一系列分发条款,或者说,开源协议本身就是对一个代码快照进行许可。在这种情况下,被“提供”的软件就是这个快照本身,而开源协议授予的一系列基本权利是不可撤销的。在这个维度下,“开源断供”从未发生过。以多次变更协议的 Redis 为例,今天全球用户仍然能自由使用其最后一个以 3-Clause BSD License 许可的版本(7.2.5)。

如何自己维护开源依赖?

既然从开源协议授予用户的权利,以及开源协议中大多包含的免责协议角度看,“开源断供”从未发生,那么甚嚣尘上的对于“开源软件的断供风险”的担忧到底指的是什么呢?应该说,这种担忧主要是对开源软件过度期待带来的误解。

今天,任何现实世界的应用软件都不可能脱离开源软件实现。换句话说,任何你实际使用的软件,都或多或少的依赖了开源软件。例如,只要你的应用涉及 TLS 网络通信,最常用的 OpenSSL 软件库就是一个开源软件;任何 Java 应用只要有输出日志的需求,很大概率就会依赖 Apache 软件基金会的开源软件 Log4j 等。

从软件供应链的角度来看,开源软件充满了终端应用的整个供应网络。上面提到的两个开源软件,分别出过 Heartbleed 安全漏洞Log4Shell 安全漏洞,影响了数以千万计的在线应用。

好在开发这两个软件的开源社群对安全漏洞都能做到快速响应,第一时间就提供了修复补丁。所有用户企业得以在第一时间自检是否收到漏洞影响并打上补丁修复。

现在,我们考虑到这些修复补丁是以新版本发布来提供的,按照上面的讨论,这一新版本其实有可能并不以开源协议来提供。举一个实际的例子,如果一个企业用 Akka 开发了核心业务系统,今年 Akka 归属的商业公司 Lightbend 变更了 Akka 的协议,后续 Akka 的安全漏洞补丁许多就只以新的专有协议发布。

这里就出现了“开源断供”的风险,即用户使用了开源软件,一旦该软件出现问题,上游有能力修复的情况下,出于商业考量或法律约束,不向特定用户提供修复版本。换句话说,开源软件的用户不能想当然地认为开源软件必定有人维护,且该软件的后续版本一定会以开源协议提供

因此,企业开发和使用软件时,必须厘清软件的开源依赖,判断哪些核心依赖需要维护保障,进而采取相应措施。

鉴于开源软件供应链无处不在的影响,市场上出现了一系列分析软件依赖的工具和标准:

这些工具大多提供为应用软件生成软件物料清单(Software Bill of Materials, SBOM),即罗列出软件所有依赖项的名字、协议属性和代码数字签名等等。这应当会成为未来软件供应链一个必然的趋势,欧盟今年出台的《网络弹性法案》就有此要求,从制造业的发展来看,物料清单也是产业发展成熟的一个最佳实践。

SBOM 示例片段

如何获得有维保的开源软件?

能够分析清楚自己开发和使用应用软件所依赖的开源软件之后,下一步就是如何获得关键依赖的维护保障。

前文已经说明,常见的开源协议都是不提供维护保障的,甚至这些协议都会有专门的免责声明和责任限制条款。因此,单纯祈祷开源软件的开发者持续保持热情,响应问题发布版本,是不可靠的。

另一方面,今天任何人都不可能完全不依赖开源软件开发一个新应用,甚至任何新应用当中绝大部分依赖都是开源依赖。因此,为了避免所谓“开源断供”的风险而选择由公司承担所有依赖代码的编写工作,是绝无可能的。没有任何一家公司能够负担得起这样的研发成本。

因此,使用开源软件,并确保核心依赖得到某种形式的维护保障,就是企业用户必须考虑的问题。我们可以把开源依赖分成四类:

  1. 稳定的依赖。依赖库本身并不复杂或完成度极高,在可预见的未来没有任何迭代需求。例如 Hash 算法的实现或特定数据结构的软件库等。这类依赖只需下游用户固定住一个版本即可高枕无忧。甚至可以说最担心的就是上游没事找事胡乱迭代,下游激进跟进版本以后出现故障。例如各种 npm 生态种的迷你库曾经引发的互联网风暴。
  2. 可靠的依赖。例如前文提到的 OpenSSL 和 Log4Shell 等,虽然它们都出现过严重的安全漏洞,但是软件开发总是要有漏洞的,这两个社群能够即时发布开源的补丁以供下游使用,这样的依赖就是可靠的。基石性的开源软件往往需要十分可靠才能得到大范围应用,例如 Linux 和 Kubernetes 等等。当然,依赖是否可靠也是动态变化的,例如维护人员的变动或者去世,还有维护组织经营状况和所属环境的改变等等。
  3. 可替换的依赖。如果一个开源依赖既不稳定,也就是需要不断迭代以适应需求或使漏洞尽量收敛,又不可靠,也就是不存在一个可持续的上游社群维护,那么企业可以放心使用这一依赖的唯一出路,就是确保该依赖是可以替换的。换句话说,一旦这个开源依赖出现问题,可以替换成另一个没有问题的开源软件,或者由公司员工制作一个替代软件,或者向供应商采购替代软件。
  4. 风险。除了以上三类依赖,其余的软件都是有风险的。它们既不稳定,也不可靠,一旦出现问题,公司也没有任何替换的预案。

这样分类过后,我们可以清楚的看到,公司要想获得有维保的开源软件,主观能动的解决方案,就是确保雇佣的员工能够兜底核心依赖,或者从供应商采购维保服务或替代软件。无论哪种方式,都需要企业支付对应的成本。

因此,企业要想安心使用开源软件,首先需要建立起来的认识就是,开源依赖总是存在于软件供应链上,保障开源依赖的供应链安全是有成本的

在此基础上,根据自身应用的实际形态和复杂度,衡量成本,相应地选择(1)雇佣软件工程师维护(2)采购供应商的服务或软件;或者,也可以(3)直接跟开源软件的作者签订协议或提供赞助,确保或促进上游的可靠性、可持续性。

Linux 基金会近期事件中的“开源断供”风险

其实,前段时间 Linux 基金会移除 Linux 项目中特定开发者的 MAINTAINERS 权限,跟上面分析的“开源断供”风险还有一些不同。

首先,根据目前的信息,Linux 基金会实际做的操作是根据一份美国政府的制裁名单,确保被制裁的个人或为被制裁企业提供服务的个人不出现在 MAINTAINER 列表上。这在规则上,并不影响 Linux 项目接收这些人提交的代码补丁。此外,被制裁的个人和企业实际上仍然可以自由地使用 Linux 软件。从供应链角度上看,没有直接的“断供”风险。

但是,这样的行为对特定下游用户的使用仍然是有实际影响的。例如,移除特定 MAINTAINERS 可能会导致 Linux 的某些模块现在事实上处于无人维护的状态,因此,原本将这些模块视为可靠依赖的下游,将不得不重新评估如何处理对相关模块的依赖。此外,企业由于受到制裁,而无法将任何员工培养成关键开源项目的维护者,可能会严重影响企业采用该开源项目的成本评估。这些就是更加复杂而大部分企业暂时遇不到的情况了,本文不做展开讨论。

尾注

卫 Sir 关于 Linux 基金会移除 MAINATINER 事件的相关评论文章如下:

此外,大部分人对于开源理解的种种问题,实际上都可以通过阅读开源协议、开源定义来解决。我在日常交流中发现,绝大部分开发者和软件使用者根本没读过开源协议的原始文本,全靠各种二手信息、一图流懒人包,甚至一句话、一个词概括来了解某个开源协议的内涵。这不仅会导致“开源断供”式的误解,也可能导致近期另一个热点事件上出现的关于开源与营利,开源的产权保护方面的一系列误解。

建议各位读者在讨论开源问题时都先尝试阅读理解相关材料的原文。卫 Sir 出品的一系列人话解读开源协议文章就是一个很好的起点。

昨天以前夜天之书

开源嘉年华纪实

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

上周在北京参与了开源社主办的 2024 中国开源年会。其实相比于有点明显班味的“年会”,我的参会体验更像是经历了一场中国开源的年度嘉年华。这也是在会场和其他参会朋友交流时共同的体验:在开源社的 COSCon 活动上,能够最大限度地一次性见到所有在中国从事开源相关工作的人。

我在本次会场上有两个演讲,分别是 Rust 分论坛上分享的《高性能 Rust 编程:如何减少数据拷贝和并发开销》,以及开源运营分论坛介绍的《商业开源如何重塑社群信任》。本文将简要介绍我的分享内容,以及参会的见闻纪实。

开源社的午后香茗

开源社的所有成员都是志愿者。也就是说,开源社没有任何一个全职员工,所有成员都是依靠对开源的热爱自愿贡献的。我从 2022 年开始志愿参与开源社的工作和活动,从 2023 年开始参与顾问管理委员会提供建议。

今年的 COSCon 正好也是开源社成立十周年的嘉年华,在活动现场作为组织成员有幸收到了开源社准备的一盒普洱茶:

午后香茗

茶客基操

作为一个典型潮州人和饮茶爱好者,这是我本次活动最满意的收获。可以看到,从会场回来三天,每天一颗普洱茶,正好。

主题分享:高性能 Rust

第一天下午我分享的主题是《高性能 Rust 编程》。

我从今年初开始把 Rust 语言作为我的主力编程语言。这主要是我观察到我长期投入的数据系统领域,出现了一个由于云基础设施的崛起带来的创新机会,而这个创新机会的探索者们大多采用 Rust 语言编程和构建生态。于是,我也加入其中。

我的 Rust 码力值

可惜的是,截止我演讲的前一刻,我的 Rust 代码量仍然以 1% 的微弱差距落后于我早年编写 Java 大数据系统时生产的代码。

不过,在过去的一年里,我充分体会到了 Rust 生态的勃勃生机,全面投入到生态共建当中,包括:

本次分享的内容就是在这些工作当中积累的经验,每一页 PPT 都是朴实无华地贴代码讲代码(bushi)。主要介绍的两个核心论点:一个是在 Rust 所有权系统下,如何规避常见的由于绕过所有权挑战带来的拷贝开销;另一个是破除 Async Rust 的性能迷思,介绍其远不及完善的应用现状。

目前,关于 Rust 编程的最佳实践,包括工程组织、接口设计和性能优化,还处于一个激烈讨论的环境中。整个 Rust 生态的繁荣和完整程度,我经常把它比作 Java 1.5-1.7 时代,即当今统治生态的类库和软件尚未诞生或方兴未艾。应该说,Rust 生态的广阔天地,大有可为。任何想要赶上 Rust 主导的软件发展浪潮的人,现在上车还为时未晚。

主题分享:商业开源

第二天下午,我在开源运营和开发者关系论坛分享了《商业开源如何重塑社群信任》的主题。




这个话题其实不太应该出现在“开源运营和开发者关系”论坛,而是比较适合一个独立的“商业开源”论坛,即讨论在商业活动中如何利用开源要素创造优势,还有营利性组织如何做出开源贡献以及背后的原因。

我在做这个分享的时候更多像是对自己近年来的思考做一个总结,从开源项目的分类学,商业开源的方法,到商业开源和“开源商业化”的思辨。这大概会导致信息量太大而听众难以接受。

我目前在实践自己分享里提到的商业开源理念,也颇感有趣的看到我对“开源商业化”的批判接连被印证。或许以后我再讲这个话题的时候,就不会像这次一样做一个总纲式的分享,而是抽取一两个典型的案例和瞬间来传播。

承接上一个主题讲的这一张 PPT 实际不在演讲的 PPT 里🤣

开源社的下一个十年

这个话题其实我讲不了(笑)。只是在这里介绍一下我所认识的开源社,它是做什么的以及未来可能会做什么。

近年来,开源社除了一年一度举办 COSCon 以外,还联合其他社群伙伴推出了《中国开源年度报告》中国开源先锋中国开源码力榜等报告和评选活动,丰富了开源社作为一个公益团体在开源主题上传递出的观点。

但是,相比起开放源代码促进会(OSI)TODO Group 这样,在通用开源和企业开源领域能够提出见解和倡议的组织,开源社在掌握开源话语权这个议题上显得比较弱势。随着中国开源力量的崛起,如何引导各方参与者正确认识开源,在开源环境当中高效协作创新,将是一个无可回避的问题。我热切期望开源社能够发挥自己所在生态位的优势,联合开源社的志愿者,向社会不断输出批判性观点,帮助中国开源茁壮成长。

此外,开源社从去年开始,成立了一系列开源社城市社区(KCC),包括北京、上海、广州、深圳、成都、杭州、长沙、大连、南京和新加坡等。在一年一度的开源嘉年华之外,日常组织起中华开源的 Meetup 活动,日积月累的提高开源理念在群众当中的影响力。如果任何个人或组织想要在本地聚集开源人办活动,都可以考虑联系 KCC 合作落地。

开源嘉年华:交个朋友

最后,我想以“交个朋友”作为本次开源嘉年华参与体验的总结。

这一次 COSCon 的绝大部分分论坛,都是由开源社和一个或多个合作社群共同举办的,所以才能攒成 21 个论坛的盛况。

在主会场上,我能看到开源公益的从业者分享他们的故事,看到青少年开源开发者介绍自己的作品,看到开源人展现自己多彩的生活。

开源从来都是一个广阔的概念,秉持着开放和合作的基本理念在不同的细分领域创新价值。但是,开源也只是每个开源人生活当中的一部分。本届开源嘉年华的主题“开源新生活(Open Source, Open Life)”,传递出的是开源社一直以来的人文关怀。

希望所有参与开源的贡献者,都能在开源共同体当中认识新朋友,开心开源,在开源生活中收获快乐 :D

从参与 Rust 标准库开发看开源贡献的源动力

作者 tison
2024年8月9日 08:00

首先介绍一下我在 Rust 标准库当中做的两个微小的工作。

第一个是从去年 8 月 14 日发起,今年 4 月 6 日合并,历时约 8 个月,目前仍在等待 stabilize 的为 OnceCellOnceLock 增加新接口的提案:

第一次提交贡献

第一次贡献成功合并后,马上第二个工作是从今年 4 月 8 号开始,7 月 6 号合并,历时约 3 个月,同样还在等待 stabilize 的为 PathBufPath 增加拼接扩展名新接口的提案:

第二次提交贡献

可以看到,这两次贡献的内容可算是同种类型的,第二次提交从发起到合并的时间比第一次缩短了一半以上。本文先介绍 Rust 标准库提案的基本工作流程,然后介绍这两个贡献背后的故事,最终讨论开源贡献的源动力从何而来。

Rust 标准库提案的工作流程

标准库的贡献有很多种,上面我所做的两个贡献都是扩充现有数据结构的接口,也就是 API Change 类型的提案。

Rust 社群为 API Change Proposal (ACP) 设计了专门的流程,流程文档记录在 std-dev-guide 手册中。

简单来说,一共分为以下三个步骤:

  1. rust-lang/libs-team 仓库中创建一个新的 ACP 类型的 Issue 开始讨论;
  2. 讨论形成初步共识以后,修改 rust-lang/rust 仓库中标准库的代码并提交 PR 开始评审;
  3. 针对实现达成共识以后,在 rust-lang/rust 仓库中创建对应的 Tracking Issue 并由此获得标注 unstable feature 需要的参数(Issue 编码);PR 合并后,由 Tracking Issue 记录后续的 stabilize 流程

不过,其实我所做的两个简单改动并没有严格遵循这个流程。因为我首先不知道这个流程,加上实际改动的代码非常少,我是直接一个 PR 打上去,然后问一句:“老板们,这里流程是啥?我这有个代码补丁还不错,你看咋进去合适。”

开源贡献的源动力从何而来

为什么要给 OnceCell 和 OnceLock 加接口?

真要说起来,给 OnceCell 和 OnceLock 加接口的起点,还真不是直接一个 PR 怼脸。上面 PR 截图里也能看到,首先是我在原先加入 once_cell 功能的 Tracking Issue 提出为何没有加这两个接口的问题:


进一步地,之所以会提出这个问题,是我当时在实现 Apache Kafka API 当中 RecordBatch 接口,其中涉及到一个解码后缓存结果的优化。因为 Rust 对所有权和可变性的各种检查和契约,如果想用 OnceCell 来实现仅解码(初始化)一次的效果,又想要在特定上下文里取得一个可变引用,那就需要一个 get_mut_or_init 语义的接口。

实际上,这个接口的实现跟现有的 get_or_init 逻辑几乎一致,除了在接收者参数和结果都会加上 &mut 修饰符以外,没有区别。所以我倾向于认为是一开始加的时候没有具体的需求,就没立刻加上,而不是设计上有什么特殊的考量。

于是,开始讨论后的下一周,我就提交了实现功能的代码补丁。很快有一位志愿者告诉我应该到 rust-lang/libs-team 仓库走 ACP 的流程,我读了一下文档,尝试跟提出这个要求的志愿者 confirm 一下具体的动作。不过马上就是两个多月的毫无响应,我也就把这件事情暂时搁置了。

经典开始等待……

11 月,两位 Rust 语言的团队成员过问这个补丁。尤其是我看到 Rust 社群著名的大魔法师 @dtolnay 表示这个补丁确实有用,应该合进去以后,我感觉信心倍增,当周就把 ACP 的流程走起来了。

随后就是又两个月的杳无音讯……

今年 1 月,@dtolnay 的指令流水线恰巧把这个 PR 调度上来,给我留了一个 Request Changes 让我修一下编译。发现我凭直觉写出来的合理代码遇到了另一个 Rust 编译器的 BUG 导致编译不过:

解决以后,社群志愿者 @tgross35 指导我给新的功能添加 unstable feature 的属性注解,也就是:

1
#[unstable(feature = "once_cell_get_mut", issue = "121641")]

另一位社群志愿者 @programmerjake 帮我指出几个文档注释的问题,为标准库添加新接口不得不写好接口文档,摆烂不了。

在此过程中,我给 OnceLock 也同样加了 get_mut_or_initget_mut_or_try_init 接口,因为它和 OnceCell 本来就是同期进来的,接口设计也一模一样。我想那就一起加上这个接口,免得 OnceLock 后面有同样的需要还要另走一次 ACP 流程。

这悄然间又是两个月过去了……

终于,在 @programmerjake Review 完之后一周,@dtolnay 出现并 Appove 后合并了代码。

第一次 Rust 主仓库参与贡献至此告一段落,唯一剩下的就是等 libs-team 主观认为合适之后启动功能 stabilize 的流程。

上个月,有人发现之前写的文档注释还是有错误,于是提交 PR 修复。这就是某种开源协同让代码质量向完美收敛的过程。

为什么会想到给 Path 加接口?

这个动机要追溯到我用 Rust 重写 License Header 检测工具 HawkEye 的时候了。

Rust 重写 HawkEye 的动机,则是为了支持调用 Git 库跟 gitignore 的机制做集成。虽然原先 Java 的实现可以有 JGit 来做,但是 JGit 的接口很脏,而且加上 JGit 以后就不能 Native Image 了导致分发产物体积显著变大。虽然都不是什么大事,但是工具类软件本来就是强调细节处的开发者体验,正好我逐渐掌握了 Rust 编码的技巧,就拿上来练练手。

今年 3 月重写 HawkEye 的时候,我发现了 Rust 标准库里 Path 结构缺一个接口:

1
2
3
4
let mut extension = doc.filepath.extension().unwrap_or_default().to_os_string();
extension.push(".formatted");
let copied = doc.filepath.with_extension(extension);
doc.save(Some(&copied))

我在写上面这段代码的时候,目的是给文件名加一个 .formatted 后缀。但是,Path 的 with_extension 接口是直接替换掉扩展名,也就是说,原本我想要的是 file.rs 变成 file.rs.formatted 的效果,如果用 with_extension 接口,就会变成 file.formatted 这样不符合预期的结果。

最终,只能用上面这样的写法手动绕过一下。显然代码变得啰嗦,而且这段代码是依赖 extension 的底层实现细节,某种程度上说是 brittle 的。

遇到这个问题的时候,我就想给标准库直接提一个新的 PR 了。不过当时 OnceCell 和 OnceLock 的 PR 还没合并,我拿不准 Rust 社群的调性,也不想开两个 PR 都长期 pending 下去。

好在上一个 PR 在几周后顺利合并了,我于是同时创建了 ACP 和 PR 以供社群其他成员评审。因为改动面很小,比起口述如何实现,不如直接在 ACP 里链接到 PR 讲起来清楚。

这次,提案很快得到 Rust 团队成员,也是我的前同事 @kennytm 的回应。不到两周,我就按照上次的动作把 PR 推到了一个可以合并的状态。

然后又是两个月的杳无音讯……

最后,我根据其他社群成员的指导,跑到 Rust 社群的 Zulip 平台上,在 libs-team 的频道里几次三番的问:“这个 ACP 啥时候上日程啊?”

终于,在今年 7 月初,libs-team 在会议上 appove 了 ACP 的提议,并在一周内再次由 @dtolnay 完成合并。

开发者的需求是开源贡献的源动力

可以看到,上面两个贡献的源动力,其实都是我在开发自己的软件的时候,遇到的 Rust 标准库缺失的接口,为了填补易用接口的缺失,我完成了代码开发,并了解社群流程以最终合并和发布。在最新的 Rust Nightly 版本上,你应该已经能够使用上这两个我开发的接口了。

在开源共同体当中,一个开发者应该能够发现所有的代码都是可以更改的。这是软件自由和开源定义都保证的事情,即开发者应该能够自由的更改代码,并且自由地使用自己修改后的版本。

那么,开发者在什么时候会需要修改代码呢?其实就是上面这样,自己的需求被 block 住的时候。小到 Rust 标准库里接口的缺失,大到我之前在 Apache Flink 社群里参与的三家公司合作实现 Application Mode 部署,甚至完全重新启动一个开源项目,说到底核心的源动力还是开发者真的对这样的软件有需要。

因此,开发者的需求是开源贡献的源动力。一旦一个开源软件不再产生新需求,那么它就会自然进入仅维护状态;而一旦一个开源软件不再能够满足开发者实际的需求,甚至开发者连直接修改代码的意愿也没有,那么它的生命也就到此为止了。

Logforth: 从需求中来的 Rust 日志功能实现

由此,我想介绍一个最近在开发 Rust 应用的时候,由自己需求出发所开发的基础软件库:

Logforth: A versatile and extensible logging implementation

Rust 生态的日志组件,由定义 Logging 接口的 log 库和实现 Logging 接口的各个实现库组成。其中,log 库可以类比 Java 日志生态里的 SLF4J 库,而日志实现则是 Logback 或 Apache Log4j 这样的库。实际上,Logforth 的名字就来自于对 Logback 的致敬。

编写 Logforth 的动机,是我在选择 Rust log 实现的时候,现有所有实现都不满足我的灵活定制需求。生态当中实际定位在完整 log 实现的库,只有 fern 和 log4rs 这两个。其中,fern 已经一年多没有新的发布了,一些显而易见的问题挂在那里,内部设计也有些叠床架屋。而 log4rs 则是对 Log4j 的直接翻译,也有许多非常诡异的接口设计(底层问题是 Java 的生态和设计模式到 Rust 不能直接照搬)。

于是,我花了大概两个小时的时间,梳理出 Rust 和 Java 日志生态里日志实现库的主要抽象:

  • Appender 定义日志写出到目标端的逻辑;
  • Filter 定义日志是否要打印的逻辑;
  • Layout 定义日志文本化的格式。

然后花了一天的时间,把 log4rs 的主要 Appender 即标准输出和(滚动)文件输出给实现了,同时完成了基本的基于日志级别的 Filter 和纯文本以及 JSON 格式的 Layout 实现,就此发布的 Logforth 库。

经过两周的完善,目前 Logforth 库已经具备了全部日志实现所需的基本能力,且可以自由扩展。接口都非常干净,文档也都补齐了。除我以外,一开始一起讨论需求的 @andylokandy 也极大地帮助了 API 的设计跟多种常用 Appender 的实现和优化。应该说,目前的 Logforth 已经是超越 fern 和 log4rs 的库了。

而回到本文的主题,之所以我能在一天时间内就写出第一个版本,我跟 @andylokandy 能目标明确、充满动力地实现 Logforth 的功能,就是因为看到了自己和整个 Rust 生态的需求,并且我们清楚应该怎么实现一个足够好的日志库。

Rust 生态的诸多潜在机会

最后,为坚持到这里的读者分享几个我看到的 Rust 生态潜在的机会。

总的来说,目前整个 Rust 生态接近 Java 1.5 到 1.7 时期的生态,即语言已经流行开来,核心语言特性和标准库有一些能用的东西,整体的调性也已经确定。但是,距离 Java 1.8 这样一个全面 API 革新的版本还有明显的距离,核心语言特性的易用性有很大的提升空间,标准库的 API 能用但算不上好用,开源生态里开始出现一些看起来不错的基础库,但是还远远没有达到 battle-tested 的状态。

第一个巨大的缺失点就是 Async Rust 的实用工具。我找一个 CountdownLatch 的实现,找了半天才发现去年底发布的 latches 库符合我的期待。要是去年我有这个需求,就真没人能搞定了。后来我要找一个 Async 版 WaitGroup 的实现,找来找去没有一个合适的,只能自己 fork waitgroup-rs 改改来用。

Crossbeam 是个很不错的高质量库,可惜它提供的是同步版本的并发原语,不能在 Async Rust 里使用。上面 latches 和 waitgroup-rs 单看实现得还可以。但是这种单文件库,真的很难长期维护,而且像 latches 这样一个结构硬整出各种 feature flags 的做法,其实是反模式的,没必要。

所以一个 async-crossbeam 可能是目前我最想看到的社群库,或许它可以是 futures-util 的扩展和优化。这些东西不进标准库或者事实标准库,各家整一个,真的有 C++ 人手一个 HashMap 实现的味道了。

第二个缺失点,顺着说下来就是 Async Runtime 的实现。Tokio 虽然够用,但是它出现的时间真的太早了,很多接口设计没有跟 Async Rust 同步走,带来了很多问题。前段时间 Rust Async Working Group 试图跟 Tokio 协商怎么设计标准库的 Async Runtime API 最终无疾而终,也是 Tokio 设计顽疾和社群摆烂的一个佐证。

async-std 基本已经似了,glommio 的作者跑路了,其他 xxx-io 的实现也有种说不出的违和感。这个真没办法,只能希望天降猛男搞一个类似 Java ExecutorService 这样的体系了。而且最好还要对 Send + Sync + 'static trait bound 做一些参数化,不用全部都强行要求……

IO 和 Schedule 虽然有关系,但还是不完全一样的概念。总的来说 Rust 生态里暂时没有出现跟 Netty 一样 battle-tested 的网络库,也没有能赶上 ExecutorService 生态的 battle-tested 的异步调度库。不过好的调度原语库还是有一些的,比如 crossbeam-deque 和 soml-rs 里的相关库,等待一个能合并起来做高质量 Runtime 的大佬!

第三个缺失点还是跟 Tokio 有关。应该说 Tokio 确实做得早而且够用,但是很多设计到今天看就不是很合适了,然而社群里用得还是很重,就变得更不好了。比如上面的 log 库,Tokio 生态里搞了个叫 Tracing 的跟官方 log 库不兼容的接口和实现,也是一大堆槽点。

这里想说的是 bytes 库。很多 Rust 软件闭眼睛就用 bytes 处理字节数组,这其实有很大的性能风险。例如,基于 bytes 搞的 PROST 就有一些莫名其妙的的多余拷贝,可参考《Rust 解码 Protobuf 数据比 Go 慢五倍?》。值得一提的是,PROST 也是 Tokio 生态的东西。

Apache OpenDAL 内部实现了一个 Buffer 抽象,用来支持不连续的字节缓冲区。这个其实也是 Netty 的 ByteBuf 天生支持的能力。如果现在 Rust 有一个 Netty 质量的网络库,有一个 ExecutorService 的接口跟一些基本可用而不像 tokio 一样全家桶的实现,再把文件系统操作搞好点,我想整个 Rust 生态的生产力还能再上一个台阶。

Rust 的 Async FileSystem API 实现现在没有一个能真正 Async 的,这个其实 Java 也半斤八两。不过 Rust 的 io::copy 不能充分利用 sendfile syscall 这个,就被 Java 完爆了。这点是 Apache Kafka 实现网络零拷贝的重要工程支撑。

最后一个,给国人项目做个宣传。Rust 的 Web 库也是一言难尽,同样是 Tokio 生态的 Axum 设计让人瞠目结舌,整个 Rust 生态对 Axum 的依赖和分发导致了一系列痛苦的下游升级体验,可参考 GreptimeDB 升级 Axum 0.7 的经历,至今仍然未能完成。

国内开发者油条哥搞的 Poem 是更符合 Web 开发习惯的一个框架,用起来非常舒服,也没有奇怪的类型挑战要突破。Poem 的最新版本号是 3.0.4 而不是像 Axum 的 0.x 系列,这非常好。

不过,Poem 的接口文档、回归测试跟一些细节的易用性问题,还需要更多开发者使用慢慢磨出问题跟修复。希望 Poem 能成为一个类似 Spring 的坚固 Web 框架,这样我写 Rust 应用的时候,也能省点心……

举个例子,我的一个 Poem 应用里有 query handle panic 的情况,就踩到了 Poem 一个并发对齐的设计没有处理 panic 的缺陷。当然,我顺手就给修了,所以新版本里应该没这个问题。

至于文档不全的例子,主要是错误处理的部分,应该需要更多最佳实践。

无论如何,目前 Rust 生态整体的生产力和生产热情,还是比 Java 要高出太多。长期我是看好 Rust 的发展的,短期我只能安慰自己 Netty 是一个在 Java 1.6 才出现,1.8 才开始逐渐为人熟知使用,5.x 胎死腹中,发展时间超过 15 年的项目,生态要发展并不容易了。

CommunityOverCode Asia 2024 参会纪实

作者 tison
2024年7月30日 08:00

CommunityOverCode Asia 2024 于 7 月 26 日到 28 日在杭州举行。其前身是 Apache 软件基金会一年一度的 ApacheCon 活动,通常会在多地分别举办。今年已经举行的是本次亚洲大会,以及 6 月在斯洛伐克举行的欧洲大会。10 月,北美大会将在丹佛举行,我也会在北美的会议上分享两个主题:

  • Mobilize Your Community Army: A Commercial OpenSource’s Perspective
  • Equip the Community with Test Suite: Best Practice from Apache OpenDAL and more

欢迎届时关注。

本文分享我在刚刚过去的 CommunityOverCode Asia 2024 大会上的见闻和体会。

会场体验

本次活动现场,主要分成以下四个部分:

  1. 主会场
  2. 各个分论坛的会场
  3. Workshop
  4. 开源集市等自由活动区域

Workshop 我没进去,不知道具体什么情况。

各个分会场的空间今年是有点狭小了,即使全部坐满,估计也就四十个人的水平。同时,狭小的空间里开猛烈的空调,相对来说更容易犯困或身体不适。我在各个分会场里都没有待太久的时间。

Community 分会场由于有几位海外讲师,并且确实到场的外国友人比较多,是唯一有文字同传的分会场。但是文字同传需要视线在讲师、PPT 和翻译软件上来回切换,且翻译有几秒钟的明显延迟,现场的外国友人普遍反应体验一般。

主会场是整个会场当中体验最好的一部分,屏幕比去年的屏幕看起来要大上很多。我想如果我在上面讲 Keynote 演讲,应该也不得不好好准备了(笑)。

巨大的主会场屏幕

最后,开源集市布置在一个狭小的过道上,整体体验上比较局促。不过参与展览的项目和组织倒是不错,我除了在 GreptimeDB 摊位上不定时刷新,也在 Apache 基金会摊位上客串大半个小时的 NPC 跟不少参会者做了交流。

Greptime 的文创广受好评

商业、开源以及出海

这次大会上我除了出品 Community 分论坛以外,主要参与分享了两个主题,分别是:

  • 第一天主会场的圆桌讨论《国际化的机遇和挑战》
  • 第二天 Community 分会场的主题分享《动员你的社群大军:商业开源视角下的开发者关系》

可以看到,两者围绕了商业、开源和出海等主题展开。

其中第一天的圆桌,本来我以为要讲一些比较 tough 的话题,比如开源社群国际化当中的挑战,商业开源出海如何取得成功,结果讨论的都是比较柔和的话题,包括应该什么时候做国际化,国内和海外参与者的不同,社群如何建设之类有定论的主题。整体气氛轻松欢乐。

社群成功的关键是及时响应;参与开源社群可以是轻松的

第二天的主题分享,由于事前我没有做好排练,外加分会场冷气 debuff 和我的演讲时间被压缩到了 15 分钟,整体分享效果应该说非常糟糕。我实际想说清楚的是:

  • 开源社群不同分类的动机及可持续的根基;
  • 商业开源的模式和商业与开源的共生关系;
  • 面向商业开源模式的开发者关系如何实施。

其中,我目前主要的观点是,持续的开源创造,要么是个人自己所需,自产自销;要么有其他收入来源,支付开源开发的成本。后者对于社群团队来说,就是募资和加入企业就职;对于商业团队来说,需要商业盈利,反哺上游开源软件,核心基础能力走开源孪生构建生态的同时帮助大众。大公司支持的企业开源,是特定的标准化工作能规避的巨大成本风险。

商业开源的理论蓝图

这个主题我会在 10 月北美的大会上再做一次英文演讲,应该到时候做好排练调整一下内容顺序,效果会更好一些。敬请期待到时的分享和专项说明文章。

此外,作为 Community 分论坛的出品人,本应由我担任的分论坛主持人一职,实际最后是 Ted Liu 和 Yu Liu 客串完成的。感谢两位的支持 :D

现场与场外的交流

虽然本次会场没有 Unconference 一类专门交流的节目,但是参会者在开源集市、会场休息处和其他场外地点做的交流可一点都不少。应该说,这样一场行业瞩目的开源峰会,来到现场的人在主题分享之外的交流,也是会议价值不可缺少的组成部分。甚至以其创造的价值总量而言,它是超过主题分享的。

夜话 ASF Culture

得益于来自中国的 ASF 董事姜宁老师亲自前往北美和欧洲的邀请,本次亚洲大会有很多外国友人参会和做分享。

由于我跟外国讲师恰巧都住在会场附近的酒店,在第二天晚上 Track Chair 聚会结束以后,我跟几位外国讲师回到酒店大堂边吃夜宵边聊起了在 ASF 的经历和各地文化的差异。

几位讲师都是游历各地的老江湖了,他们分享起各地的风俗和自己的人生经历,十分有趣。尤其是他们面对跟自己儿子乃至孙子同辈的我解释笑话里面的梗的时候,连比带划的介绍然后又想要跳过黄暴的部分,非常迫真。

线下交流会让每个人都发现,其实线上看起来一板一眼,只知道规则只关心编码的人,也是一个活生生的有自己生活的人。大家对生活的热爱和基本的品质是共通的,由此在线上产生矛盾的时候,也更容易站在对方的角度考虑其动机,而不是直接假定对方是故意搞破坏的恶人。

除了交流各地的文化和趣闻,作为资深的开发者和常年参与 ASF 事务的基金会成员,我们也交流了不少软件研发过程当中的经验、近期软件发展的趋势,以及对 ASF 文化、议事规则跟近期冲突争议事件的看法。我想,这就是 ASF 纪录片里提到的,ASF 最大的价值是其成员之间的共识,而这种共识是由每次具体的讨论反复达成和演进的吧。

在跟国内某开源软件基金会的朋友交流的时候,谈及 ASF 的流程和议事规则,我们都很惊讶 ASF 大量的步骤依赖于一个看起来很不可靠的“达成共识”。例如项目如何进入孵化器?只要孵化器的项目管理委员会(PMC)成员达成共识即可。进入孵化器的导师如何寻得?答案是只要项目足够有趣,不少 PMC 成员会志愿担任导师。孵化项目版本发布如何保证合规?几乎每时每刻都有导师志愿帮助检查,而且这种检查可能是非常严格的。

这种依赖志愿者的做法,并且数百名孵化器志愿者有着相似的共识,上万名 Committer 主观接受的理念,才是 ASF 有别于其他组织的核心竞争力。

虽然出于这次夜话 private 的性质,我没有留下合影。不过会场内单独的合影还是有的:

Craig L Russell

前几周去湾区的时候跟 Craig 吃了一顿泰国菜,听他吹牛在 IBM 和 Oracle 的工作经历,吹得太厉害忘记合影留念,这次可算补上了。

Justin Mclean

Justin 跟我介绍了几个他的拿手好菜。希望下次发版或者过提案的时候,别把我当成菜炒了 >~<

后起之秀的开源项目

大会现场有位中国开源的老玩家问我,是不是感觉每年参与开源大会的都是同一批人。我说或许主导国内开源方向,跟参与社群运营的主力军,这些年的中流砥柱都没有发生太大的变化,但是我作为 ASF 孵化器导师,不停地能看到新项目进来,还有新的开发者投身开源,从这个角度来讲,其实中国开源的新面孔还是非常多的。

今年让我印象深刻的后起之秀有这么三位。

第一位是在主会场做主题演讲的 Apache Fury 原作者杨朝坤(Shawn)。虽然他在分会场讲序列化框架的技术细节讲得那叫一个深入,但是在主会场上还是很好的把握了大众的需求,分享了一个 Side Project 如何从蚂蚁集团内部孵化,经历一次申请开源失败以后,通过一段时间的內源运营以后成功开源并进入 ASF 孵化的故事。

Apache Fury 的开源之旅

项目成功的故事是群众喜闻乐见的。Shawn 富有感染力的分享打动了现场的听众,Apache Groovy 的 PMC Chair Paul King 投喂了一个明知故问的技术问题,让 Shawn 有机会进一步展开作为跨语言 IDL-Free 的序列化框架 Fury 的设计优势。

Paul King 抛出“诱饵”问题

我客串了一把技术翻译

第二位是在主会场做闪电演讲,在 Community 分会场也做了分享的 Apache StreamPark Committer 张超(@VampireAchao)。作为一名 00 后程序员,他非常外向且思维活跃,编程技巧过关,假以时日应该会成为强大的开源开发者。

闪电演讲:Chrome 的 Debug 技巧

第三位是满会场宣传 Apache Amoro 的陈政羽。开源运营是一个富有挑战的领域,尤其是并非自己开发且独占主导权的项目,如何协调不同参与方的需求,跟核心开发者商量项目的定位和演讲方向,是非常不容易的。

经过本次会场讨论,Apache Amoro 当前的实质定位是增强 Apache Iceberg 的自动调优服务。一言以蔽之,Iceberg++ 是其核心价值点。不过这跟部分核心开发者希望兼容 Hudi 的负载,想开始超融合所有数据集成生态的目标有出入。我们可以拭目以待这个项目后续的发展,以及陈政羽在其中协调结果的成色🤣。

SelectDB 乱入 O_O

未来的开源峰会

CommunityOverCode Asia 2024 圆满结束。今年,国内还尚未开幕的开源峰会当首推开源社将于 11 月举办的中国开源年会(COSCon)。

定档 11.2-3,COSCon’24 第九届中国开源年会暨开源社十周年嘉年华正式启动!

巧合的是,今年是 Apache 软件基金会 25 周年,也是开源社成立的 10 周年。或许从未来回头看,今年会是中国开源继往开来的关键年😇。

此外,今年的 CommunityOverCode 大会还有 10 月的北美大会尚未举办。我将于 10 月到达丹佛会场做两个主题分享:

  • Mobilize Your Community Army: A Commercial OpenSource’s Perspective
  • Equip the Community with Test Suite: Best Practice from Apache OpenDAL and more

欢迎届时关注。

最后,去年和今年的 CommunityOverCode Asia 承办单位几乎都是贴钱办会,以至于活动组织者专门写了一篇文章“化缘”:

正在消亡的社区会议:呼吁更多伙伴持续支持阿帕奇亚洲大会的举办

我想,作为面向开发者的大会,最希望看到 CommunityOverCode Asia 顺利举办的,是每一位热爱开源的开发者。我向组织者建议,明年筹款的时候,其一可以实时公布筹款进度,让潜在的赞助者知道要想会议成功举办,还需要自己的多少支持;其二可以开通个人赞助的渠道,我想参与开源的开发者们众志成城,并不难凑齐这样一场年度盛会所需要的小几十万元人民币。

我在这里承诺,如果明年开通了个人赞助的渠道,我将以本人的名义或届时所在公司的名义,捐赠不少于十万元人民币以作支持。期待明年 CommunityOverCode Asia 越办越好!

Apache DataFusion 湾区线下聚会纪实

作者 tison
2024年7月15日 08:00

6 月 24 日,我在北美湾区参与了一场线下的 Apache DataFusion 聚会活动。

其实我是 6 月 21 日才到的旧金山,6 月 18 日才发现湾区有这样一场线下活动。不过或许得益于我在今年在 DataFusion 做过几次贡献,GreptimeDB 是 DataFusion 在行业顶级的应用标杆,会议组织方很干脆的就增加了一个 Speaker 席位,让我能够做在聚会上做题为《Boosting a Time-Series Database With Apache DataFusion》的演讲。

会议风格

本次 Meetup 一共报名 80 人,实际到场超过 50 人,绝对算是小型 Meetup 里的大型聚会了。现场是一位联合主办人的办公室,堪堪容纳所有人或站或坐待在室内。

我印象最深的是海外 Meetup 更加注重 Get Together 的风格,与国内 Meetup 基本是讲师从头讲到尾的风格的差异。

一般来说,国内的 Meetup 一个主题可以讲 30-40 分钟,4-6 名讲师讲完一个下午也就结束了。往往参与者也不怎么听完主题,而是逮到相关的到场专家,或者听到感性的主题以后,就拉着专家或讲师直接另开小会去了。

我刚报上名参加湾区 DataFusion Meetup 的时候,得到的消息是演讲时长 15-20 分钟,临了又改成 12-15 分钟。基本上一共六名讲师,从六点开讲,到七点半就完全讲完了。剩下的时间留给现场的观众自由交流,当然也有人拉着看对眼的与会者出去开小会,但是总体所有人都听完了所有主题,并且也还毫不疲倦。

这是国内不少活动最近有在参考的一个趋势。不管是年初 OSPO Summit 的 unconforence 环节,还是接下来几场 Apache 软件基金会的国内会议筹备的形式,都更加注重 Get Together 与会者自由交流,而不再是发布会形式的讲师从头讲到尾。

此外,海外的组织者对这样的小型聚会明显更有热情,他们有 lu.ma 和 meetup.com 等等丰富的宣传渠道,也乐于在湾区举办各种会议交流前沿进展。这点是国内近几年来声音比较弱的方面。

活动预告

在进入演讲内容回顾之前,先预告将在未来两周举办的两场 Apache 生态线下活动。

第一场是在 7 月 21 日下午杭州蚂蚁 A 空间举办的 Apache DataFusion Meetup 活动。GreptimeDB 核心工程师,同时也是 DataFusion 社群 PMC 成员夏锐航将会做深入的技术分享;蚂蚁集团工程师和 eBay 工程师也都将分享他们基于 DataFusion 构建出来的多样系统,参与 DataFusion 社群的故事,以及对 DataFusion 未来的看法。

欢迎点击链接( https://www.huodongxing.com/event/5761971909400?td=1965290734055 )报名,或扫描以下二维码报名。

第二场从 7 月 26 日开始持续 7 月 28 日。Apache 软件基金会年度活动 Community Over Code Asia 2024 今年也将在杭州举办。我将作为 Community 分会场的出品人参与活动并做演讲。GreptimeDB 的核心工程师,同时也是 OpenDAL 社群 Committer 的徐文康也将在 IoT 分会场上做技术分享。GreptimeDB 在现场另有展位介绍,我会全程在场(たぶん)。

欢迎通过这个链接( https://asia.communityovercode.org/zh/ )报名。

演讲内容

聚会上一共有八名讲师做了六个主题演讲,分别是:

  • LanceDB 的 CEO Chang She 分享了他们利用 DataFusion 实现向量计算的实践;
  • Cube.js 的 CTO Pavel Tiuvov 分享了基于 DataFusion 实现的查询缓存层;
  • DataFusion 的现任 PMC Chair Andrew Lamb 介绍了社群发展的近况和未来展望;
  • Denormalized 的创始人 Matt Green 分享了使用 DataFusion 实现流计算引擎的实践;
  • DataFusion 的原作者 Andy Grove 和他的同事分享了在 Apple 实现了 DataFusion Comet 做 Spark 查询加速实践;
  • 我介绍了 GreptimeDB 使用 DataFusion 和整个 FADOP 技术栈快速实现了一个高完成度的时序数据库的实践。

出于演讲时间限制,我的核心内容浓缩在了 GreptimeDB 如何使用和定制化 DataFusion 的实现上:

  • 每位分享嘉宾介绍的实践,都包含了在 DataFusion 这个执行引擎库之上实现的一个分布式计算框架。虽然 DataFusion 自己有一个子项目 Ballista 某种程度完成了这项任务,但是无论是成熟度还是它为大数据生态做的许多特化设计,都不适合上面这些使用场景的具体需求。

GreptimeDB 的分布式计算框架

  • GreptimeDB 从入口处直接重新实现的整套 PromQL 查询支持。DataFusion 主要支持的是 SQL 查询的解析、优化和执行,但是 GreptimeDB 为兼容 Prometheus 的生态,借助 DataFusion 封装的框架,将 PromQL 当做一种新的方言,通过实现 DataFusion 高度插件化的接口集成到了 DataFusion 的执行引擎当中。

值得一提的是,GreptimeDB 将 promql-parser 完全开源之后,已经成为所有想要实现 Prometheus 接口的 Rust 项目的统一选择。

GreptimeDB 实现了超过 80% 的 PromQL 接口

  • 另一项 GreptimeDB 突出的技术实现,是核心工程师钟镇炽主导开发的一个高度可扩展的索引框架。GreptimeDB 在此之上实现了 MinMax 索引和倒排索引的能力,并将在 0.9 版本里推出针对文本列的全文索引。我们计划将这一框架回馈给 DataFusion 上游,共同演进丰富查询引擎的共享代码。

GreptimeDB 强大的索引框架

  • 演讲之初,我介绍了 GreptimeDB 在时序数据领域上的关键理念创新:时序数据不仅仅是指标(Metrics),同样带有时间戳和上下文信息的数据,例如事件(Events)、日志(Logs)和追踪(Traces),都可以统一在一个时序数据库下进行处理。不仅能够同时优化负载和性能,降低成本,还能通过联合分析获得更加丰富的数据洞察。

统一 MELT 的时序数据处理

  • 最后,我实际超时了大概两分钟介绍了 GreptimeDB 得以快速实现一个高完成度时序数据库所依赖的技术栈:

FADOP Data Infra 技术栈

其中 FAD 都是原先 Apache Arrow 项目群的项目,分别是:

  • Apache Arrow
  • Apache Arrow Flight
  • Apache (Arrow) DataFusion

P 对应的是 Apache Parquet 项目,实际上大量的 Parquet 实现包括 Rust 实现,都包含在 Arrow 项目仓库里。

O 对应的是 Apache OpenDAL 项目。这是由来自 Databend 的工程师漩涡实现的统一数据访问层。接上它,你的项目就可以无痛对接上百个存储后端,包括几乎所有主流云厂商的对象存储服务,都可以用统一的 API 进行读写:用过的都说好。

社群发展

其他讲师大体也介绍了如何应用 DataFusion 的故事,而 DataFusion PMC Chair Andrew Lamb 则站在社群角度分享了 DataFusion 过去一年的进展,包括:

  • 超过 1000 个项目使用 DataFusion 构建自己的软件;
  • 正式从 Apache Arrow 的一个子项目,成为 Apache 软件基金会的顶级项目;
  • 遍布全球的用户会议,包括上面提到的杭州 Meetup 活动
  • 在 SIGMOD 2024 上发布了 DataFusion 的主题论文
  • 全球开发者合作实现的若干项功能,例如查询优化加速、索引加速和内存占用改进等等,其中不少功能是由中国开发者主导或参与实现的。

Andrew Lamb 确实是 10x 工程师

我在今年开始给 DataFusion 做一些实际的贡献,在此过程中深刻感受到了 Andrew Lamb 的热情和生产力。虽然他经常过分乐观的合并别人贡献的代码,导致一些回退(regression)跟接口需要重新适配,但是他 10x 甚至 100x 的生产力能够快速再次解决这些问题。我想这才是在开源社群协同当中真正 distinguished 的能力。正如 Linus 一样,他未必要独立写出 90% 的代码来推动项目前进,而是能每天合并来自全球各地的数十个 pull request 并保证它们相互之间没有实际冲突,也不会导致回退。

对于 DataFusion 这个项目,我的看法与其官方描述一致:

DataFusion is great for building projects such as domain specific query engines, new database platforms and data pipelines, query languages and more. It lets you start quickly from a fully working engine, and then customize those features specific to your use.

它有抽象层级非常高的 SQL 和 DataFrame 接口,能让你直接获得查询计算能力;对于数据库或其他查询系统而言,它也提供了高度可定制化的框架抽象。从纯粹技术理论上说,针对一个特定的数据系统,定制一个查询引擎总是能有最高的优化上限。但是很多时候,这未必是你的系统最核心的竞争力。

因此,就像 GreptimeDB 采用 DataFusion 的历史一样,我们发现这个软件库是一个很好的起点,扩展定制的旅程也很流畅。绝大部分情况下,我们对查询优化的投入程度和生产力是不如 DataFusion 上游的,我们会将这些时间和精力更多放在 GreptimeDB 作为一个统一时序数据库的业务逻辑实现,用户体验优化,以及从云到端的协同方案落地上。或许在未来的某一天,使用 DataFusion 的项目团队拥有足够多的开发者,能够针对自己的系统设计实现一个更加优秀的查询引擎;但是更有可能的是,终其软件一生,DataFusion 都是那个更好的框架和库实现。

改良 SQL Interval 语法:一次开源贡献的经历

作者 tison
2024年7月9日 08:00

本文是 GreptimeDB 首位独立 Committer Eugene Tolbakov 所作。

在上一篇文章《GreptimeDB 首位独立 Committer Eugene Tolbakov 是怎样炼成的?》当中,我从社群维护者的视角介绍了 Eugene 的参与和成长之路。这篇文章是在此之后 Eugene 受到激励,从自己的角度出发,结合最近为 GreptimeDB 改良 SQL Interval 语法的实际经历,分享他对于开源贡献的看法和体验。

以下原文。


开发者参与开源贡献的动机不尽相同。

开源贡献本身是一种利用自身技能和时间,回馈社群和造福更广大受众的方式。开源社群是一个绝佳的环境。参与贡献的人在其中能够与高水平开发者自由交流并建立联系,甚至可能找到可靠的导师或合作者。对于寻求职业发展的开发者,开源贡献可以作为个人技能和经验的展示。当然,也有许多开发者参与贡献只是出于个人的热爱。这很好!当你真正投入到一个开源项目当中后,你可能会发现软件的缺陷或缺失的功能。通过提交补丁修复问题或实现功能,不仅可以消除挫败感,还能让每个使用该软件的用户都受益。

成功参与开源贡献的秘诀不仅仅是编写代码。强烈的学习欲望才是內源动力,由此推动开发者才会主动了解本不熟悉的代码库,并应对其中出现的种种挑战。社群的及时响应和其他资深成员的支持,是这种学习欲望转换为真正参与贡献的重要保障。

社群的及时响应让开源开发者感到宾至如归;其他资深成员提供的指导和反馈,帮助新成员改进自己的贡献水平。理想情况下,你应该可以测试自己的补丁,并将修改版本应用到工作或个人项目当中。这种来自真实世界的需求,尤其是本人的需求,为开源贡献提供了重要的使用场景支撑,确保你的贡献是真的有用,而不是闭门造车的产物。只有如此,通过开源参与做出的贡献才能对整个社群产生持久的影响。

回到我本人的例子上来。虽然我已经花了不少时间锻炼自己的软件开发技能,但事 Rust 对我来说仍然颇有挑战。这种“新手”的感觉可能会让一些人不愿做出贡献,担心他们的代码不够好。然而,我把愚蠢的错误当作提高技能的垫脚石,而不是气馁的理由。

GreptimeDB 社群一年多的参与贡献经历,是一段不断学习并获得丰厚回报的旅程。今天,我会向你介绍其中的一次具体的贡献。让我们亲自动手吧!(或者我应该说,爪子?🦀)

动机和背景

这次贡献主要的目的是支持一个 SQL Interval 字面量的简化语法

1
select interval '1h';

SQL 标准定义了 Interval 的语法:

1
select interval '1 hour';

这个语法相对冗长,我们希望支持上面展示的简化语法,让 select interval '1 hour'select interval '1h' 返回相同的结果。

深入研究代码后,我发现处理转换的核心功能已经存在。为了实现上面的效果,只需针对 Interval 数据类型添加一条新规则:把简化语法格式的 Interval 将自动扩展为标准语法。让我们仔细看看代码中执行相关逻辑的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn visit_expr(&self, expr: &mut Expr) -> ControlFlow<()> {
match expr {
Expr::Interval(interval) => match *interval.value.clone() {
Expr::Value(Value::SingleQuotedString(value)) => {
if let Some(data) = expand_interval_name(&value) {
*expr = create_interval_with_expanded_name(
interval,
single_quoted_string_expr(data),
);
}
}
Expr::BinaryOp { left, op, right } => match *left {
Expr::Value(Value::SingleQuotedString(value))=> {
if let Some(data) = expand_interval_name(&value) {
let new_value = Box::new(Expr::BinaryOp {
left: single_quoted_string_expr(data),
op,
right,
});
*expr = create_interval_with_expanded_name(interval, new_value);
}
// ...

代码评审

上面是我第一次尝试写成的代码。GreptimeDB 的资深 Rust 开发者 Dennis 很快发现了改进空间

Dennis 发现了不必要的复制

代码评审是一个优秀的学习渠道。

除了简单地接受建议(因为“减少复制”的理由很明确!),我决定深入研究。通过分析建议并尝试自己解释,我巩固了对 Rust 代码和最佳实践的理解。

避免不必要的 Clone 和所有权转移

起初,我直接在 interval.value 上调用 clone 来取得一个具有所有权的结构:

1
match *interval.value.clone() { ... }

这里的 clone 每次都会创建一个新的数据实例,如果数据结构很大或克隆成本很高,那么这可能是一个性能的影响因子。Dennis 建议通过使用引用来避免这种情况:

1
match &*interval.value { ... }

匹配引用(&*interval.value)避免了 clone 的开销。同样的手法可以用在对二元运算符 left 的匹配逻辑里:

1
match &**left { ... }

这个稍微复杂一些:它使用双重解引用来获取对 Box 内部值的引用。因为我们只需要读取数据结构中的部分信息,而不需要转移所有权,因此只获取引用是可行的。这也减少了 clone 的开销。

清晰的模式匹配

在模式匹配中使用引用可以强调仅读取数据而不是转移所有权的意图:

1
match &*interval.value { ... }

只在需要所有权的时候进行克隆

在第一版代码当中,opright 总是被复制一份:

1
2
3
4
5
let new_value = Box::new(Expr::BinaryOp {
left: single_quoted_string_expr(data),
op,
right,
});

但是,其实克隆只需发生在匹配左侧变量是 Expr::ValueSingleQuotedString 变体,同时 expand_interval_name 成功的情况下。Dennis 建议把 clone 调用移动到 if let 块内,从而只在需要所有权的时候进行克隆:

1
2
3
4
5
6
7
if let Some(data) = expand_interval_name(&value) {
let new_value = Box::new(Expr::BinaryOp {
left: single_quoted_string_expr(data),
op: op.clone(),
right: right.clone(),
});
// ...

直接引用

在第一版代码当中,我用 expand_interval_name(&value) 显式借用了 value 的值。

然而,valueString 类型的值,它实现了 AsRef<str> 特质,所以它可以被自动解引用成 &str 类型。修改后的版本直接写成 expand_interval_name(value) 不需要再手动 & 取引用。

译注:

这个说的不对。其实是因为 String 实现了 Deref<Target=str> 所以不 clone 以后 &String 可以被自动转成 &str 类型,但是之前 clone 的时候传过去的是带所有权的 String 类型结构,这个时候 &value 取引用而不是把带所有权的结构整个传过去是必要的。

Deref 的魔法可以查看这个页面

总结

在这次贡献当中,代码的“效率”体现在以下三个方面:

  • 避免不必要的克隆,减少运行时开销;
  • 让借用和所有权转移的模式更清晰和安全;
  • 提升整体的可读性和可维护性。

最终版本的 visit_expr 大致如下:

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
fn visit_expr(&self, expr: &mut Expr) -> ControlFlow<()> {
match expr {
Expr::Interval(interval) => match &*interval.value {
Expr::Value(Value::SingleQuotedString(value))
| Expr::Value(Value::DoubleQuotedString(value)) => {
if let Some(normalized_name) = normalize_interval_name(value) {
*expr = update_existing_interval_with_value(
interval,
single_quoted_string_expr(normalized_name),
);
}
}
Expr::BinaryOp { left, op, right } => match &**left {
Expr::Value(Value::SingleQuotedString(value))
| Expr::Value(Value::DoubleQuotedString(value)) => {
if let Some(normalized_name) = normalize_interval_name(value) {
let new_expr_value = Box::new(Expr::BinaryOp {
left: single_quoted_string_expr(normalized_name),
op: op.clone(),
right: right.clone(),
});
*expr = update_existing_interval_with_value(interval, new_expr_value);
}
}
_ => {}
},
_ => {}
},
// ...

开源贡献是我找到的加速 Rust 学习的绝佳方式。参与贡献 GreptimeDB 只是一个例子,说明了我如何通过开源贡献获得知识。根据读者反馈,我很高兴在未来的帖子中分享更多这些学习经验!

非常感谢整个 Greptime 团队,特别是 Dennis 提供的帮助和指导,感谢他们在我贡献过程中的支持和指导。让我们继续贡献和学习!

分析时序数据:从 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]'

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

Rust 程序库生态合作的例子

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

近期主要时间都在适应产品市场(Product Marketing)的新角色,不少想法还在酝酿和斟酌当中,于是文章输出没有太多时间来推敲和选题,只能保持每月发布相关的进展或一些零碎的思考。或许我可以恢复最早的模式,多做更新但是文章内容可能不会太过完整。

原本这一期想讨论的是 ASF 开源项目代码的所有权,以及开源软件变更协议的具体含义与操作方式。但是这个话题稍显枯燥,而且要把相关细节讲清楚,还需要继续斟酌。所以我改为采取把最近开始全职投入 Rust 开发,并接触相关生态发展和合作的经历做一点梳理,分享个人在其中的所见所闻。

我会从 Rust HTTP 库的生态切入,从最近一个大事件出发,即 Rust 采用范围最广的 HTTP 库 hyper 和 http 前后发布 1.0 版本,讲述其导致的整个 Rust 应用开发上下游牵一发而动全身的变化。

起因是我在整蛊 GreptimeDB HTTP 相关代码的时候,发现项目依赖的 axum 库是 0.6 版本,而上游是 0.7 版本。这天降的升级闲手,不升有点对不起自己了。通过解决升级过程中的问题,也能帮助摸清 GreptimeDB HTTP 模块的逻辑。

不过,我显然是小看了 Rust 生态荼毒甚广的 ZeroVer 文化的威力。

ZeroVer 是一个揶揄的说法,即在采用语义化版本(SemVer)的前提下,因为各种原因,项目迟迟不愿发布 1.0 版本。

0ver 的魅力时刻

在语义化版本中,0.x 版本是在项目正式发布或说进入稳定期前,一个相对动荡的快速迭代阶段。语义化版本的核心价值是告诉用户升级版本可能面临什么变化:

  • 升级补丁版本(Patch Version):应当只有缺陷修复和性能提升等,不会破坏用户程序的改动。
  • 升级小版本(Minor Version):可能包含新功能,应当向后兼容,用户应用应当可以顺滑升级。
  • 升级大版本(Major Version):可能包含破坏性变更,用户需要做好应对逻辑甚至数据迁移的准备。

当然,在实践当中,语义化版本并不那么严格执行。尤其对于大型项目的实验性功能,是可能有一个独立的可靠机制的。但是无论如何,进入 1.0 之后就意味着项目对用户做了一个向后兼容的保证,除非升级大版本,否则用户会假设软件升级是可以非常激进的。

ZeroVer 方案的反面极端是 Apache Arrow 和 Apache DataFusion 每次发版都升级大版本的做法,很难评价。

话说回来,axum 0.6 到 0.7 的版本升级是一个巨大的变更,基本把核心的类型设计做了一个颠覆,即 Body 不再是泛型了。

这其实也是一个槽点。国内开发者油条哥做的 Poem 就不搞这些花里胡哨的泛型,直接用胖指针抹掉底下的差别,提供更好的开发体验。你说我都 HTTP 了,搞应用层接口开发了,我是跟你抠这点性能的人吗?

一个屏幕都写不完的 breaking changes 列表

Rust HTTP 生态泛型的魅力时刻

于是我着手升级 axum 的版本,一上来就是好几个屏幕的编译错误。没事,Rust 开发者的日常而已。

第一个小问题,我们依赖了 axum-test-helper 这个库,它没跟上 axum 0.7 的版本。我先试着给上游提 PR 升级:

未果。

自己维护比较头疼,其实只有一个文件,最后我在 GreptimeDB 里 vendor 掉了:

这里我又要吐槽了。axum 是不是哪里有问题,居然不提供测试套件,还要下游自己 embedded 然后去掉 (crate) 修饰词,好玩吗?虽然其实也是可以用 reqwest 套一个 TestClient 解决,按照上游的说法:“这只是很薄的一层”,但是这么简单提升使用体验的事情,为何不做呢?

反观 Poem 就提供了 TestClient 工具,开发起来舒服多了。不管是不是我一个文件就能解决,这不是下游应该解决的事情。

紧接着,发现 axum 自己的 TestClient 有落后,以及一个 Rust Nightly toolchain 的兼容问题,提 PR 解决:

Rust Nightly toolchain 的兼容问题是一个很奇妙的问题。因为 Nightly 顾名思义就是最新的 Rust 开发版本,不提供语义化版本保证,只是在 Rust 1.x 的时间线上大体向后兼容。但是结合上 Rust Stabilize 的流程,以及打开 feature gate 如果找不到 feature flag 就会编译失败等等细碎的问题,经常会导致生态在跟进 Nightly 之前有一个无法编译的窗口。

这不算是绝对的坏事,甚至推着生态跟新版本是合理的。但某种程度上其实是 Rust Stabilize 太慢,导致系统开发比如 GreptimeDB 不得不用 Nightly 版本,而出现的新问题。Rust 开发很多用 Nightly 版本,跟 0ver 可能也有某种互相呼应的巧合。在这种环境下,开发公共库并保持多平台多版本兼容,其实是一件非常困难的事情,怪不得都 0ver 了。

然后我就遇到了本次升级最大的大魔王。Rust 生态的 gRPC 库 tonic 闪亮登场!

这里的依赖关系大概是这样的。

首先,axum 0.7 除了接口变化,还有一个关键的依赖变化是把 hyper 和 http 给升到了 1.0 上。因为 Rust HTTP 生态都依赖 hyper 和 http 这两个库,这就导致如果你的接口开始交互,那么所有的结构都要同步到同一个版本。

这个问题并不那么致命。因为如果你合理的 re-export 了依赖的接口,那么同一个 crate 的多个版本是可以共存的,就像我最近在升级 Apache OpenDAL 的时候连带需要升级 reqwest 到 0.12 版本,它依赖了 hyper 1.0 和 http 1.0 但是跟 axum 的 server 端代码关系较小,所以我可以切割开:

注意以上 PR 里修改 use 语句以选择正确的 re-export 符号的变更。

不过,tonic 的情况就比较幽默了。tonic 依赖了 axum 0.6 版本,而且 tonic 和 axum 都用了 tower 作为中间件的第三方库,而且都没有 re-export 而是标榜自己能无缝接入 tower 丰富的中间件生态。

由于 tonic 尚未完成 axum 0.7 和 hyper 1.0 的升级,这下就连环爆炸了:你找不到一个合适的 tower 版本,或者说你找不到一个合适的 axum 版本,来作为 GreptimeDB 被传递关联起来的版本约束。

于是,直到今天,GreptimeDB 的升级还是未完成的状态:

不过,在三月底,tonic 上游就出现了一位大英雄开了一个升级的 PR 完成了主要的工作:

这个 PR 我看到的时候还需要两个 hyper-util 的改动,我给帮忙推着合并了:

这里又岔开一下。虽然 tonic 在 hyperium 组织下,跟 hyper 和 http 一样,但是 hyper 和 http 的作者,Rust 生态真正负责人的英雄 @seanmonstar 并不怎么看 tonic 这个库。tonic 主要是 tokio 的作者 @LucioFranco 和另外两位志愿者 @tottoto 和 @djc 维护的。他们都有自己的本职工作要做,所以并没有太多时间 Review tonic 的变动。实际上,很多项目就用着 tonic 0.11 和 axum 0.6 万年不动也还行的(有没有发现 tonic 也是 ZeroVer 流派)。

不过,经过两个月当中的空闲时间累积,这个 tonic 的升级 PR 终于看着要走进尾声,希望能顺利。开源项目能有这个效率,小几个月跟进一个大型重构,虽然比不上 @seanmonstar 和其他活跃维护项目的响应速度,也绝对算是能及时更新的了。

这个 tonic PR-3610 有很多经典的开源贡献者跟维护者之间的交流和争论,我这里就不展开了。但是我非常建议各位关心开源的人去看看,了解真实的开源世界,未来或许参与进去做出自己的贡献,而不是自己想象或者冷眼旁观。

最后,贴两张图说明 Rust 生态 ZeroVer 的严重程度:

GreptimeDB 的依赖 ZeroVer 有 782 个

GreptimeDB 的依赖非 ZeroVer 有 278 个

在 GreptimeDB 的依赖里,ZeroVer 的占比约莫七成。

其实,GreptimeDB 很多升级都很有工程上的说法,欢迎各位关注发现。我可能也会在今年的某些 Rust 会议上分享相关的经验。

文末放两个我在 Reddit 上跟这次 HTTP 生态大更新相关的讨论链接,在 Rust channel 上还算有一些有趣的讨论。

应当在 ASF 孵化器中帮助项目

作者 tison
2024年4月28日 08:00

由于 Apache 软件基金会(ASF)过去十年在国内的文化传播,许多开源软件的创作者都有一个将自己的软件捐赠到 ASF 并最终成为顶级项目的梦想。我所接触到的 Apache Fury / Apache OpenDAL / Apache StreamPark 都有这样的背景。

按照目前 ASF 的章程和惯例,开源项目要想成为 ASF 顶级项目,绝大多数情况下需要经过 ASF 孵化器孵化。

这个 ASF 孵化器本身是一个 ASF 的顶级项目,与其他项目一样由项目管理委员会指导,通常称为 IPMC 即孵化器(Incubator)项目管理委员会(Project Management Committee)。IPMC 成员规模应当是所有 ASF 项目中最多的,截至今天 IPMC 共有 285 人,项目 Committer 有 3927 人(所有新的孵化项目,孵化期间产生的 Committer 都会自动成为 Incubator Committer)。

本文主要分享我最近在孵化器邮件列表上发布的一封倡议,以及近期帮助孵化项目所做的一些工作。

首先放一下倡议原文,可以从 https://lists.apache.org/thread/h7tblpghtyhlsfolt14jnpylj1cygmxf 公开访问和进行回复。

应当帮助孵化项目的倡议

这封倡议主要是面向 IPMC 成员也就是孵化器导师们的。但是,我认为这样的讨论应该公开进行,因为不涉及安全问题或个人评价,而是孵化器的共识和行为准则。

一言以蔽之,我建议在孵化项目积极回应或解决 IPMC 成员提出的问题或挑战之后,IPMC 成员应当给予积极反馈,认可孵化项目做出的努力,并鼓励项目主动了解和践行 ASF 文化和规则。

一个基本的前提是,在 ASF 的社群模型和定义里,所有成员都是志愿者。这意味着他们不是被雇佣参与开源社群贡献和创造的,也没有上下级关系,而是因志同道合聚集在一起的平等的同侪。

平心而论,大部分孵化器导师不是孵化项目的第一作者,也很少参与到孵化项目的开发贡献当中,甚至对项目的历史和当前社群成员组成都不甚了解。在孵化器导师看来,某个孵化项目出现显而易见的问题,大概率存在其历史原因,或是项目团队其实正在想办法解决。

我对隔着网线能造成的误解深有体会,因此总是尝试假设对方是一个正常人而不是一个弱智,做某件事情肯定有其原因而不是故意破坏,只要能把事情说明白总能取得进展而不是对面就是个恶棍。这些听起来都很平常,但是如果你仔细观察网络空间当中的文字交流,你会发现但凡冲突出现且无法收敛的地方,很多是一方或双方都有前面提到的预设立场。

目前孵化器当中由于沟通能力产生的问题并不鲜见。其中直接触动我的,是一个孵化失败的项目退出孵化器时写下的一段话:

The incubator spends more energy on failing us than helping us.

它说:在孵化过程中,孵化器像是在努力阻止我们成功毕业,而不是帮助我们融入到 ASF 的文化和规则当中。

这很像开发者在一开始 Review PR 的时候,总是执着于找到 PR 当中的错处,把理论上协同开发、合作贡献的流程搞成了大家来找茬。IPMC 成员在审查孵化项目的问题时还有一个额外的问题,就是前面提到的 IPMC 成员往往很少参与项目本身的发展,因此甚至从《大教堂与集市》中提到的 Loken 所有权理论出发,都不那么有权利来评判孵化项目的好坏。

诚然,IPMC 成员大多是经验丰富的老 ASF 玩家。我相信并且从看到的情况看也确实是,每个 IPMC 成员提出挑战的时候,其动机都是为了保证孵化项目符合 ASF 的文化和各种政策要求。但是,我也确实看到 IPMC 成员由于种种原因,草率地给出挑战,或是以一种上级对下级的口吻颐指气使。当挑战的内容本身是个误报,甚至从开发者的角度来说就是无厘头的折腾的时候,IPMC 成员和孵化项目之间的摩擦就会急剧上升。在《全票通过?同侪社群无须整齐划一》里我列举过几个具体的例子。

这些提出挑战的 IPMC 成员往往不是被挑战的孵化项目的导师。因此,他们对这个项目而言更是一个志愿者。我非常尊重这些成员有如此多的时间,来检查大量孵化项目可能存在的问题,而且现在的我也能看清楚他们志愿者的身份,心平气和地按照事情本身的是非曲直来沟通。

不过,对于很多孵化项目来说,遇到直觉上应当经验老到的 IPMC 成员的挑战,还是会很有心里压力。尤其是当孵化项目的导师不能及时处理摩擦(项目导师也是志愿者,时常出现不能及时响应的情况),项目成员很容易感受到孵化器在阻止自己成功,而不是帮助自己更好地成长。

因此,我鼓励 IPMC 成员 follow 其他项目已经广泛验证的经验,在孵化项目做出改进时积极给予肯定,如有可能,自己上手解决发现的问题,或者提供解决问题的具体解决办法。这样,孵化项目能够认识到 IPMC 是帮助自己在孵化器中成长,最终成为 ASF 顶级项目,我相信大部分有志于经历 ASF 孵化的项目,都是有动力配合 IPMC 的指导改进的,只要这些说辞站得住脚。

两个典型的例子。

第一个,@sebb 最近狂盯孵化项目下载页面的合规问题,包括发版投票时候邮件的格式和链接是否指向可靠的地址等等。他提出问题都是非常具体可以解决的,而且在项目解决之后往往会感谢孵化项目及时处理。同时,他还维护了用于 ASF 项目管理的 Whimsy 工具,把这些检查和常见的管理操作尽可能自动化发现和自动化处理。这种从根本上改进流程,又针对每个具体问题提出可以操作的解决方案的做法,为他赢得了相当的声誉。

不过 @sebb 上次跟我扯 ASF 网站模板组织形式的时候,因为被 GitHub UI 显示误导,跟我 battle 了一周,最后发现是他看错了,搞得我也心很累。

第二个,我把自己发现的 StreamPark 距离毕业最大的阻碍,文档的正确、合规和流畅等问题,提出来之后,不仅是 StreamPark 社群的活跃成员积极响应开始改进,还有一位身为专业文字工作者的 IPMC 成员 Andrew Wetmore 伸出援手,仔细改进文档不符合英文表述习惯的地方。这无疑为他赢得了项目成员的尊敬。如果他再对项目的内容提出挑战和改进意见,项目成员也更容易接受。

最后这点我想再强调出来,它代表的是一种同情心或同理心。很多 IPMC 成员对自己辅导的项目都是非常宽容的,经常能够找到通融、网开一面的制度支撑,从根本上是了解体谅项目团队遇到的问题跟当前阶段的困难。相反,当审视其他项目时,往往由于缺乏同情心,容易光是挑战项目的各方各面,而不愿意花时间好好沟通。

比如,孵化器主席 Justin Mclean 在过去几个月里就让我几度血压飙升。

在 HoraeDB 进入孵化器评审的时候,因为原创 CeresDB 团队用新名称捐赠核心代码,Justin 就怀疑人家图谋不轨,搞到最后原来是说代码里很多 CeresDB 的文本还没改过来。如果他一开始就说这些遗留的 CeresDB 文本是问题,马上项目团队就可以开始改,然后我作为 champion 把提案改改,误会就说清了。

在 OpenDAL 进行毕业投票的时候,项目成员没有顺着他的意去处理几个很小的品牌问题,而是就合理性做了一些反问,他就从根本上质疑团队在品牌保护问题上的严谨性,拿放大镜挑各种各样的刺(《Apache OpenDAL 毕业随感》)。最后我找到 ASF 品牌办公室的事务官帮忙评估,才收敛到若干个可实操的改进措施。而即使 OpenDAL 项目成员解决了一系列此前没有细究的品牌问题,在 API Docs 上的品牌合规水平秒杀 90% 的 ASF 项目,Justin 还是不依不挠,沿着预设立场认为你人品有问题。这真是没话说。

在 Fury 刚刚进行的上一轮投票,Justin 稍微表现的正常一点,虽然还是出现了以下经典画面,但是我再三追问之下还是把自己使用的检查工具说出来了,一下子项目成员就能自检出……全都是误报。其中有两个误报只要稍微看一眼就知道是误报,但是 Justin 有功夫回邮件 battle 也不乐意去验证报告的成色。这我只能说是傲慢作祟。至于另外两个,作者能不能多次捐赠代码,Java ArrayAsList Wrapper 这种朴素代码有没有抄袭可能,这搞不清楚只能说是人水平不行。

无言以对

应当说,Justin 自认作为孵化器主席,有责任充当守门员来保证毕业项目质量,同时也会在前几天某个毕业项目出现品牌问题时,被其他人追责孵化期间怎么没有发现,而上火着急自我辩解,肯定还是对 ASF 的角色有认同。而且 Justin 乐意花这么多时间检查这些确实重要的合规问题,客观来说是非常不容易的。

但是他的表达、语气和措辞真的很容易产生摩擦。@sebb 挑战项目的次数跟他不相上下,孵化项目体感上完全不同。而且 Justin 真的特别闲,所以过往孵化器里想争论道理的人,也没有那么多时间跟他吵这些说白了就是官僚主义的东西。加上 Justin 是 ASF 董事会成员,而董事会成员只有他全力关注孵化器,可能再加上姜宁老师和 Craig 偶尔看看,就导致 Justin 实质上成为孵化器里几乎唯一强势的声音。

中国人是最务实的,现在国内项目要进孵化器,第一时间就是找 Justin 来当导师,然后你总不能自己输出自己,把这个“难关”直接自己消解掉。对于我来说,现在我发现我之前也落入到情绪主导思考的坑里,只要自己别着急上火,冷静按照事情本身的是非曲直来讨论,无理的意见就忽略掉,不自己把任何一个社群成员特殊化,沟通还是可以进行下去的。

回到项目孵化的话题上。

虽然 ASF 一直强调社群重于代码(Community Over Code),但是我的理解一直都是活跃的社群才能一直演进代码,死代码不会自己向前走,而不是说“社群优于代码”,或者“社群才重要,代码不重要”。

因为 ASF 的组织目标明确写了:

The Apache Software Foundation (ASF) exists to provide software for the public good.

然后才是:

We believe in the power of community over code, known as The Apache Way.

所以 ASF 存在的意义是向公众提供好的开源软件,Community Over Code 是手段。

而在孵化器当中,IPMC 成员和项目导师往往不怎么参与到软件研发本身去,大多是帮助项目了解 ASF 文化和制度,成为社群形式上合格的 ASF 顶级项目。实际上,项目的价值还是由软件解决的问题本身所定义的。如今 ASF 代码仓库最热门的六个项目,都是充分解决了某个领域的问题,而不是因为它们合规。

所以,作为孵化器导师,我个人做得多的是参与到项目发展中去,这样才能真正站在项目成员的立场上看待问题,而不至于缺少关键评判视角。对于每位 IPMC 成员来说,至少应该对努力创造软件价值的人保持同情心或同理心,认识到他们是 ASF 长期存在且保持影响力的重要贡献者。这样,才能基于平等的同侪对话共同创造价值。

如何处理 Good First Issue

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

我在《GreptimeDB 社群观察报告》当中提过,GreptimeDB 的 good-first-issue 流转速度极快,大部分容易上手的工作往往在一周甚至两三天内就会有人认领,并且完成的情况也还不错。这个体验很难得。

在最近一些 Good First Issue 的流转过程中,我重新发现了一些典型的模式。正好同大家分享一下我对于如何处理 Good First Issue 这个问题的看法。

还是先看几个案例。

第一个案例算是标杆:

example

example-pr

可以看到这里有两个要点。

  1. Issue 上仔细说明了问题是什么,为什么要解决这个问题,相关代码在哪里,可能的实现思路是什么。
  2. Pull Request 里作者积极的提问,并且说明他做了什么改动,有哪些 alternative 是考虑过但是不可行的。
饱和沟通是开源协同的一个秘诀。

这位 contributor 后来也开始参与到其他 issue 的解决中。

第二个案例介绍了一种处理 stale assign 的方法:

stale

注意这里我并没有直接 assign 给这位新出现的开发者,而是在他提交 PR 之后才 assign 给他表示这个 issue 已经有人开始工作。当然,如果他能在 issue 中表达任意有效信息推动 issue 前进,我也会 assign 给他。

第三个案例是我相对担心的:

concern

因为 issue 的描述我读完以后都觉得,可能需要耗费相当时间梳理清楚相关逻辑。这个时候一个没有过任何开源项目 contribution 经验的新人贸然说要开始接手这个工作,且不说明他的计划,也没有任何问题要追问。在我的记忆里,这种 assignee 很容易回头发现搞不定。如果能主动说自己搞不定,unassign 还好,但是更多人是默默离开,一言不发。

于是我追加问了一下他的计划:

concern-follow

嗯……虽然有点抽象,但是至少多产生了一些有用的信息,暂且信一回。

关于这个问题,我有一段相关的论述:

Assign contributor 的时候可以做个背调,或者直接问一下 TA 的实现计划,除非 issue 确实是闭着眼睛都能做的。另一方面,这也跟 good first issue 有没有相关代码链接,有没有写清楚需求有关。比如上面的典范写清楚了,就容易让 contributor 上手做起来。

Contribution 不只是上来写代码,能经过交流把 issue 往前推进一步,对项目都是有帮助的。因此 issue 也不一定每个都要写得那么详细,毕竟大佬可以自己脑补很多内容。只是很多新手,他不知道可以问问题,或者就模糊地问一句“你有什么可以帮我的吗”。

不用害怕劝退潜在的参与者,如果正常交流都不能回答,大概率不能解决问题,或者做出 PR 来很抽象,review 起来想死。当然,我也见过不用太多交流也能做得很好的,那种大佬会一个 PR 拍你脸上,也不会被劝退。

最近比较像的比如这位:

demo-scene

demo-scene-pr

不过无论如何都有可能出现 assignee 出于各种原因无法完成 issue 的情况。从维护者的角度说,兜底可以问一下进度然后 unassign 掉。这个是可以自动化的。我在 GreptimeDB 上记了一个 Good First Issue 来实现这个自动化:

其他相关的话题这里就不展开了,如果各位有兴趣听,可以回复我来讲。

  • 不同文化背景的 Assignee 的倾向;比如这位是比较典型的。
  • Good First Issue 怎么设置比较合适?
  • 企业开源项目如何在 Issue 上与跨越组织边界合作?

对于关注 GreptimeDB 想找机会参与的读者,现在主仓库还有一些未解决的 Good First Issue 可以上手,即使有人 Assign 了,如果过去数周没有什么进展,也可以由你来接手推进。

contribute

如果你不是 Rust 开发者,GreptimeDB 也有各个语言的客户端正在开发。对于 DevOps 开发者,GreptimeDB 的命令行工具 gtctl 正在准备发布第一个版本,我和项目作者都会及时处理上面的信息。对于前端开发者,GreptimeDB 的 Web UI 也是一个独立的开源项目。

总的来说,GreptimeDB 是一个值得参与的开源项目。其软件产品目标是成为一个高效、实惠,且可以处理分析大规模时序数据的云数据库。目前已经可以替换 InfluxDB 和作为 Prometheus API 的存储后端。

GreptimeDB 社群观察报告

作者 tison
2024年2月8日 08:00

GreptimeDB 是格睿科技(Greptime)公司研发的一款开源时序数据库,其源代码在 GitHub 平台公开发布。

https://github.com/greptimeteam/greptimedb

我从 2022 年开始知道有 GreptimeDB 这个项目。2023 年,我注意到他们的 Community Program 是有认真写的,不是无脑复制所谓成功项目的大段规则,于是开始跟相关成员探讨开源治理和社群运营的话题。后来,我读过 GreptimeDB 的源代码,发现他们的工程能力很不错,于是就开始参与贡献

经过这几个月的参与,我对 GreptimeDB 的社群有了初步的了解。我认为,这是一个值得参与的拥有巨大潜力的开源社群。于是写作这份社群观察报告做一个简单介绍和畅想。

GreptimeDB 的社群量化情况

两年前,曾有人半开玩笑地说 Rust 和时序数据库都快成开源世界的一个梗了,因为当时有大量的 Rust 语言写作开源项目和定位在时序数据库的开源项目出现。GreptimeDB 也算其中一员,它同样是用 Rust 语言写成的。

不过两年过去,回过头看能够坚持下来不断发展的项目,GreptimeDB 就是为数不多硕果仅存的一员。哪怕跟主流时序数据库项目社群相比,GreptimeDB 的活力也可圈可点。

上面两张图展示了 2023 年,即 GreptimeDB 开源运营第一年,从 OpenDigger 数据集生成的每月 OpenRank 和活跃指数(Activity)数据折线图。

可以看到,从 OpenRank 的维度看,GreptimeDB 显著超越了近年来发展乏力的 InfluxDB 项目,跟 TimescaleDB 和 Prometheus 不分伯仲,相比起战斗民族出品的商业开源产品 VictoriaMetrics 仍有差距。从项目活跃指数的维度看,GreptimeDB 则与 InfluxDB 之外的主流项目同属第一梯队。

OpenRank 是同济大学赵生宇博士定义的一个开源价值流分析指标。相比于容易受先发优势影响的 Star 数和 DB Engines 分数等指标,上面展示的每月 OpenRank 和 Activity 变化情况更能体现出项目当前的发展情况和未来趋势。

GreptimeDB 的社群运营情况

前面提到,我真正开始关注 GreptimeDB 社群的契机是发现他们的 Community Program 并非船货崇拜,而是明显经过思考,有一定可行性的。事实证明,确实如此。2023 年 GreptimeDB 按照 Community Program 的设计发展了两名公司之外的 Committer 新成员:

这两位 Committer 都是通过代码贡献被提名的,提名前都提交了大约二十个代码补丁,且质量被项目团队所认可。此外,这两位 Committer 从开始参与项目发展到成为 Committer 都经过了若干个月的持续投入。可以看到,这个标准下筛选和认可的两位 Committer 在上个月仍然有活跃参与。

应该说,目前 GreptimeDB 的项目功能已经初具规模,能够达到线上交付的标准。这也意味着开荒阶段的大量初创工作已经完成,新加入的社群成员可以在一个坚实的工程基础上发挥自己的创造力。同时,GreptimeDB 在实现优化上还有很大的进步空间,倒排索引、WAL 和存储引擎等技术方向上还有很多未解决的设计实现问题。现在仍然是参与 GreptimeDB 成为 Committer 的机遇期。

不过,GreptimeDB 的 Community Program 距离成为一个商业开源标杆还有不少可以改进的地方。

例如目前邀请新的 Committer 只在 Biweekly 上简单提及。Community Program 设计的结构上并没有即时体现出它正常运转,以及社群存在公司之外的 Committer 的事实。对于活跃参与者和 Committer 的介绍和成功经验分享,也尚有欠缺。目前社群基本处于给代码写得好的人一份权限的朴素运营阶段。

此外,Community Program 虽然已经相比其他船货崇拜的同行删减了许多内容,以保证它能够务实地运作,但是仍然存在一些空洞的组织结构。例如设计出的 Steering Committee 做技术和社群发展决策,但是实际上当前阶段大部分工作就是公司团队商议决定后公开;例如还是定义了 SIG 乃至 OSPO 的组织,但是根本没有人力填充运营这些机构。

我认为,Community Program 应该继续依托当前社群实际运行的状态,结合期望达成且有能力达成的下一个状态,来做修订。例如,提高成为 Committer 的标准和路径的透明性,积极分享案例和邀请 Committer 说出自己的故事。例如,精简冗余和虚假的组织架构的同时,保留在社群征求意见和决策结果向社群公开的关键动作。例如,强调社群成员参与渠道的多样性,鼓励在不同渠道帮助他人使用 GreptimeDB 和参与贡献。这部分是 Ambassador/Advocate 的核心。

除了这个堪称开拓性探索工作的 Community Program 之外,GreptimeDB 社群还有两件事情让我印象深刻。

第一个是 GreptimeDB 社群积极参与开源之夏这样的务实的开源活动,今年释放的三个挑战项目都实现了不错的开源导师传帮带效果:

第二个是 GreptimeDB 的 good-first-time issue 流转速度极快,大部分容易上手的工作往往在一周甚至两三天内就会有人认领,并且完成的情况也还不错。实际认领实现过程中,只要你能够主动更新进展和提问,项目团队成员大多能及时回复。这个体验还是很难得的。

GreptimeDB 的未来发展期望

前面介绍 GreptimeDB 的时候,提到了开源、Rust、分布式、云原生、时序数据库等关键词。虽然这些 buzzword 确实也是 GreptimeDB 能力或特点的一部分,但是从注重实效的程序员(The Pragmatic Programmer)的角度来说,我们还可以做一个具体的展开。

即使当初市面上已经有“恒河沙数”的时序数据库存在,GreptimeDB 的创始团队还是发现了这些现存解决方案没能处理好的问题。对于时序数据当中重要的三个分类:指标(Metrics)、事件(Events)和日志(Logs),大多数时序数据库都只能最优化其中一到两种分类的存储和访问。

GreptimeDB 的创始团队认为,这三类数据可以共用同一套查询层和对象存储层能力,只需要针对各自的数据特性实现各自的存储引擎即可。其中大部分 DB 的架构和能力,例如数据分片、分布式路由,以及查询、索引和压缩等都可以共享。这样,GreptimeDB 最终能够成为同时提供所有时序数据最优化的存储和访问体验的单一系统。

开源应用监控项目 Apache SkyWalking 自研数据库 BanyanDB 也是基于相似的挑战和思考,不过它作为一个监控项目的子项目,更多是以相当特化的方式在实现。但是这反应了时序数据可以统一存储逐渐成为业内共识,所有的通用主流产品都将朝这个方向发展。

在仰望星辰大海的期许之外,GreptimeDB 也有脚踏实地的挑战。

例如,虽然我前面夸赞 GreptimeDB 的工程化水平不错,工程师做功能扩展和代码重构都能找到一个相对整洁的切面,但是软件工程是一个即使知道了原理和最佳实践,真正做出来还是有相当长的必要劳动时间的领域。在快速原型迭代的过程中,GreptimeDB 对内存和抽象的使用是相对奔放的。随着线上应用逐渐增多,GreptimeDB 团队也能收到用户上报的各种性能问题。这就要求重新关注到在快速开发过程里被刻意忽略的细节,精打细算关键路径上的内存使用,针对性能修改抽象以充分利用机器资源。这部分工作都是细致工作,讲究一个 benchmark 发现性能瓶颈并逐个优化。目前的 GreptimeDB 应该有相当多这样的优化机会。

例如,之所以过去时序数据库没能同时服务前面提到的三种不同数据,除了数据建模上的差异,更主要还是因为在数据量暴涨之后,特定数据类型的特定访问形式的读写性能会骤然降低。目前针对此类问题,业界提出了一系列索引方案进行改良。GreptimeDB 目前正在实现其中倒排索引的方案,也将探索结合倒排索引、基于 Cost 的查询优化器和 MPP 查询引擎的自适应方案。这些工作存在许多参与机会,目前 GreptimeDB 团队成员也有不少精力投入在此。

例如,系统层面数据一致性和性能之间的取舍依赖 WAL 模块的实现。目前,GreptimeDB 仅提供了本地的 RaftEngine 实现和 Kafka Remote 的实现,其中 Kafka Remote 的实现发布还不足三个月。这部分工作现在跟进来,参与到现有实现的完善和优化,以及可能的自研 WAL 设计实现过程当中,对任何数据系统开发者而言都将是一段宝贵的经历。

例如,GreptimeDB 在部署形式上支持云端同构部署,时序数据从设备端到云端都是同一套技术栈在处理。这时,如果 GreptimeDB 能够支持一些高级的分析能力,那么时序数据分析的成本将大大降低,体验也将进一步提高。目前,GreptimeDB 已经支持通过 SQL / PromQL / Python 脚本等形式执行分析,正在设计实现基于 Dataflow 技术的分析功能。分析的需求无穷无尽,这一部分对于熟悉数据分析的开发者来说,是一个很好的切入点。

核心数据库系统代码之外,GreptimeDB 还开源了完整的 Dashboard 方案和多语言客户端。再加上本身 GreptimeDB 就支持 SQL 和 PromQL 等业内通用接口,从 GreptimeDB 与生态集成的角度入手参与到 GreptimeDB 的发展,也是一条不错的道路。就在几天之前,我还看到有位同时使用 EMQX 和 GreptimeDB 的开发者向 GreptimeDB 的 Erlang 客户端提交补丁

软件开发参与之外,Greptime 社群维护的两个重要渠道:GitHub Discussions 主题讨论平台Slack 即时通信工作空间都欢迎任何对 Greptime 开源和商业产品感兴趣的人加入。

GreptimeDB 的商业与可持续

我曾经表达过一个观点:商业化不是开源项目可持续的必要条件。因为许多开源软件是个人开发者兴趣所为,这些个人开发者可以有其他经济收入。即使不基于其创造的开源软件做商业变现,也不影响这些开源项目持续维护和发展。

不过,GreptimeDB 是 Greptime 公司研发的开源软件,而公司要想存续下去,就必须以某种形式取得盈利。Greptime 公司投入了不少资本和人力在 GreptimeDB 的研发上,那么 GreptimeDB 总要为 Greptime 的商业成功创造价值。

Greptime 公司目前有两条商业产品线:

  • GreptimeCloud 提供了全托管的云上时序数据库服务,其内核是 GreptimeDB 系统。这个服务可以免费试用,其 Playground 和 Dashboard 做的技术品味都很好。

Greptime Playgorund

Greptime Dashboard

  • GreptimeAI 是为 AI 应用提供可观测性的服务。不同于其他数据库在赶上 AI 浪潮时采用的 PoweredBy AI 增强自身产品的思路,GreptimeAI 是 For AI 增强 AI 产品的思路。其实本轮语言大模型带动的 AI 浪潮对 Database 服务本身的提升还十分有限,反而是这些 AI 应用自身产生的数据需要 Database 来存储和管理。

这两个产品的底层都是 GreptimeDB 的开源代码提供的核心能力,而云控制面、企业管理、安全集成等功能,则是商业代码实现的。

至于为什么要开源 GreptimeDB 数据库核心代码,而不是干脆全部都是私有的商业代码,前几天 Meta 的财报上介绍的 Llama 开源的理由帮我省去了很多口水:

LLaMa 开源的理由

应用在 GreptimeDB 的情况,在 Greptime 团队决心做这个产品的时候,先发的主流时序数据库已经取得极大的优势,且它们几乎全是开源的。这种情况下,就算 GreptimeDB 存在没有历史包袱的优势,直接朝着正确的方向飞奔,但是软件工程的固有复杂度和必要劳动时间并不能无限减少,所以开源是追赶现有主流产品和赢得用户信赖的必选项。

当然,开源软件允许任何用户免费使用,因此构建商业价值不能直接基于开源软件本身。关于 Greptime 如何设计开源模型,或许我会另写一篇文章做对比介绍。目前而言,其开源模型接近 Databricks 的策略。虽然 GreptimeDB 是从头开始写的,不像 Databricks 直接基于开源的 Apache Spark 构造解决方案,但是其核心功能实现重度复用了已有的开源软件:

  • Apache Arrow DataFusion
  • Apache OpenDAL
  • TiKV RaftEngine
  • Apache Kafka

而且,Greptime 团队对于什么功能应该开源是谨慎的,而不是 by default 开源。只有存在这样一个踌躇推敲的过程,才有可能做商业可持续的战略开源。

DISCLAIMER

在社群参与过程中,我跟 GreptimeDB 的核心社群成员有深入的交流,并于近期加入了 Greptime 团队,因此我的观察和评价可能存在一定的主观误差。欢迎各位留言或私信交流意见。

开发者关系的指标与价值

作者 tison
2024年1月26日 08:00

随着软件行业持续发展,企业构建软件系统的复杂度日益上升,系统不同层次和不同方面的分工日益精细。许多公司不再完全自己生产所有需要的软件,而是转向大量采购技术产品来满足自己的软件需求。

除了核心业务逻辑需要独立实现以外,支持业务逻辑的软件平台和服务都可以甚至应该采购,开发业务逻辑本身也能够藉由采购开发工具和平台来进行加速。前者的例子包括传统商业软件和云服务等,后者的例子有 Copilot 和 Retool 等。

这个潮流当中,开发者已经成为公司购买技术产品决策过程中的重要参与者。他们既影响了技术的发展,也是技术产品的使用者和创造者。于是,开发者经济蓬勃发展,开发者本身成为重要市场客户,企业面向开发者的一系列工作应运而生。这就是 Developer Relationship (DevRel) 即开发者关系发展的背景。

关于开发者关系的定义和详细论述不是本文要涵盖的内容,可以参考我此前的文章《开发者关系简明指南》和《开发者体验的基础设施》,以及 Richard 翻译的《开发者关系:方法与实践》

本文讨论的是开发者关系工作,作为商业公司的一个职位,可以采取的工作成果衡量指标。

虚荣指标

在循序渐进的讨论可行的衡量指标之前,我先介绍一下最常见的错误:虚荣指标。

虚荣指标是《精益创业》提出的概念,指的是反馈表面数据的指标。这些指标往往数据量级很大,看起来效果很好,但是唯独不能告诉企业指标对应的具体价值。

典型的虚荣指标包括点击量和下载量,放在如今开源运动盛行的开发者关系工作上,还有软件代码仓库的 star 数等等。

这些指标共同的实际问题在于信息量太少。例如要做 star 数的指标,我们做过去几年中反复看到,被分配此项任务的运营人员用小礼物在各式活动现场以扫街地推的方式引诱开发者点击 star 按钮。对于单纯的下载量指标,我很清楚自动化流水线会对此产生多大的噪音,以至于使用这一指标的团队完全无法从一个每月下载几万到几十万的数据当中得到任何有用的信息。

信息量太少的原因是行为太简单或者说成本太低。任何一个路人,即使不是开发者,也可能为了小礼物而点击 star 按钮,或许他点完 star 拿了礼物,还会顺手再按一次取消。不加区分的页面点击量和下载量也是如此,除了作为某种谈资,很难指导开发者关系工作的开展。

Star 数这个指标没什么额外的变化空间,唯一能想到的价值是在做广告宣传时跟同类产品做比较,给到一个虚假的直观印象。但是,页面点击和下载行为是可以通过一些精细化的分析来增强的。

针对页面点击行为,简单的有 Google Analytics 分析点击来源的不同地区、不同源网站,分析各个页面的跳入跳出率。复杂一点的有 ReadMe 做的访客全路途分析,甚至集成到 API 页面调用和结果反馈。在数字指标以外,类似 Vercel 和 GitHub 的官方网站尤其是文档,都会添加交互反馈的小组件。这些指标或组件的目的都是优化网站内容的组织呈现,改善用户访问体验。

vercel-docs-widget

github-rest-api-widget

针对下载行为,主要增加信息量的途径是区分下载的来源和目的,尤其是:

  • 具体下载了什么构件?
  • 下载来自什么地区?哪些公司?
  • 手动下载还是自动化流水线下载?

Scarf.sh 试图提供基于下载量的数据分析,不过这个工作还在探索当中,并没有被证明是实际有效的。同时,Scarf.sh 增强下载量数据的方式,需要引导用户通过 Scarf.sh 提供的 Gateway 下载构件,这一点并不容易做到。

实际上,Google Analytics 能够采集到丰富的信息,依靠的是页面访问 URL 中带上 utm 系列参数等。这个方向往下做,总会涉及到需要用户配合提供信息的种种问题,例如用户拒绝提供、信息伪造等等,甚至可能触犯某些地区的信息安全法规。

市场声量

虽然上一节的后半段我讨论了改造“虚荣指标”使得它们产生某种价值的基本方法,但是这些简单行为组成的指标仍然不适合作为开发者关系工作开展的北极星指标。北极星指标也叫唯一关键指标,应当牵引整个开发者关系工作的开展方向。

开发者关系工作含义广泛,某种程度上是目前面向开发者应当开展的工作尚未进入分工而产生的一个笼统的指代。它可以涉及开发者营销、开发者布道、开发者技术推广、开发者技术支持、开发者体验、开发者培训、开发者成功、开发者社群运营和开发者关系项目管理等等。

这其中很大一部分工作跟市场工作相关。例如,公司推出的软件服务或开发工具应当被开发者所认知,应当促进开发者的使用。这就需要有足够多的人谈论公司推出的技术产品,从而引出技术产品的市场声量这一衡量指标。

然而,市场声量指标的一大难点是缺乏统一的定义,如果把它又定义成为单纯的点击量或阅读量,就落入了虚荣指标的陷阱当中,而且容易牵引出变形的运营动作。

最简单的一个市场声量数据就是 Google 指数,但是在如今的自媒体多媒体传媒时代,单纯看 Google 指数很容易掉进坑里,尤其是当项目刚刚起步的时候,很少有开发者是通过 Google 进入到你的范围的。

某些细分领域有成熟的市场声量定义,例如数据库领域的 DB Engines 排行榜。它详细地说明了分数的计算因子,同时提供了细分领域的排名。重要的是,数据库领域内部对比和用户选型时,真的会把 DB Engines 作为一个参考指标。对于一个新兴的数据库软件来说,可以先确定自己所处的细分领域,主要的竞争对手,在多长的时间内要超越哪些对手或者进入到前几名的位置。

其他领域如果没有类似的市场声量指标,可以参考上面提到的计算因子列表自己定制和对比主要竞争对手。这其中我认为最有价值和能够牵引其他工作的渠道,是 Twitter 的提及次数或 HashTag 引用次数。因为这个指标的生成方式对应到了具体人的具体行为,从而具有产生化学反应的可能。这一点在介绍另一个指标模型时会展开。

除了跟竞争对手横向比较,市场声量指标还可以在私域做纵向比较。

目前,市面上有着 OrbitCommonRoom 等社群工具能够收集技术产品相关社群的私域行为数据,拿到数据以后可以自定义市场声量算式并做不同时间点的纵向比较。同样,这些指标背后的每一个行为在这个模型下是可以溯源的,根据聚合指标的变化情况,可以开展影响对应原始行为的开发者关系工作。

最后提醒两点,市场声量衡量的目标,不一定只是技术产品本身。很多时候,市场声量的评估要跟具体的市场营销动作相结合,尤其是跟今年主推的市场概念相关联的声量。进行横向对比时,也不一定是只在竞争对手产品这个粒度上做全域比较,进一步细分领域和平台能够更精确地衡量工作效果。

成员数量

开发者关系工作本身是面向开发者的,其核心工作方式是帮助开发者利用技术产品取得成功,从而实现技术产品自身的成功。这项工作围绕开发者开展,自然也应该是以人为本的。于是,定义好技术产品相关社群后,社群成员数量就是一个很好的北极星指标。

这个指标在几年前就被 Apache SkyWalking 的作者吴晟使用。他当时在不同场合使用 SkyWalking Contributor 的数量来替代 Star 数介绍项目的健康情况和发展情况。

当然,对于商业公司当中的开发者关系工作来说,开源软件代码仓库上的 Contributor 很可能不是工作的重心。这主要是因为公司技术产品的核心代码未必开源,或者开源核心后主要开发工作实际由公司员工负责,在企业软件工程的模型上套一个开源协同的模型,且不说实现起来并不容易,实际上对公司商业成功带来的价值也很难周期性地衡量。

当然,这并不是说商业开源公司都应该完全放弃开源协同的模型。实际上,良好运作的协同模式和生态发展,长期看来是公司产品技术增长和用户增长的巨大杠杆。但是作为开发者关系工作的北极星指标,它并不合适。直白点说,一旦 Code Contributor 数量或参与度成为北极星指标,很容易发生揠苗助长的运营行为,并且很容易跟企业软件工程模型发生摩擦,最后两败俱伤。

商业公司当中的开发者关系工作,作为其北极星指标的社群成员数量,应该是更广阔的相关社群当中的参与者。这里的参考模型仍然可以从 Orbit 和 CommomRoom 这样的社群工具当中获取灵感。

  • Slack
  • Discord
  • Discourse
  • Redit
  • Stack Overflow
  • YouTube
  • Twitter
  • LinkedIn
  • GitHub

当然,这里一定不是说技术产品的社群建设要覆盖上面所有这些渠道。实际上,对于一个刚起步做开发者关系的产品来说,先在少数几个关键渠道上取得突破,再把经验和内容传播到其他渠道上,尤其是配套的扩大开发者关系队伍来应对渠道增加后的工作量,才是一个比较健康的模式。

所谓关键的渠道,首先是即时通讯工具选定一个,论坛看人力选择一个或放弃。Twitter 必须运营,LinkedIn 和 Stack Overflow 看情况覆盖。其他单方面发布内容的渠道,视目标开发者主要获取信息的渠道和团队的内容生产能力决定。

不同类型的渠道有不同的参与者。例如 Slack Workspace 和 Discord 是加入的成员,Discourse 是注册会员,各种社交平台是关注的人和参与讨论创作内容的人。在定义北极星指标时,可以先把一些低成本的行为排除出去,计算一个笼统的社群总体人数。然后再看各个渠道进来的人具体的行为,划定一个基准定义活跃社群成员。

在一刀切的统计全渠道社群成员数量之外,进一步改良北极星指标的方式就是从这些社群成员当中发现真正可以促进公司商业成功的人。这就是下一节要讨论的指标。

DevRel Qualified Leads

我没有为 DevRel Qualified Leads (DQL) 找到一个合适的翻译,直译下来这是“开发者关系认证的潜在客户”,但是不如英文直观地表达它的意思。

DQL 的概念大致是 DevRel Qualified Leads: Repurposing A Common Business Metric To Prove Value 博文提出的。下面讨论的时候我会大量引用这篇文章的内容。

首先说明一点,在技术产品的社群刚起步的时候,实际上社群成员的人数非常少,如果你在这时使用 DQL 指标,就会进入跟使用 Code Contributor 作为指标类似的问题。不是说他们不好,而是在社群起步阶段,这些人可遇不可求。早期能够成为某种“潜在客户”的人,一定有某种特殊性或巧合性,而不是某个策略带来规模化效果的一部分。

直白点说,社群规模小的时候,定这个目标可能完不成,或者为了完成牵强附会。即使在开发者关系工作顺利开展的情况下,往往几个季度也只有个位数 DQL 出现,而且其出现往往很不规律,把它作为北极星指标会打乱开发者关系工作的方向。只有社群初具规模以后,DQL 才能通过一定的内容生产和运营动作来产生。

当然,即使在社群规模小的时候,也可以把 DQL 作为某种补充指标来引导社群人数增长时具体关注哪些人,实行哪些后续动作。

我们先阐明 DevRel Qualified Leads 的含义。

所谓的 Leads 在市场营销领域内是被广泛理解的。在一次市场营销活动中,目标对象往往会被邀请填写某种表单或者登记自己的相关信息。一旦你在这种场合登记了自己的信息,甚至有一些具体的期望或反馈,那么你就是这家公司的市场合格潜在客户。换句话说,市场团队制作了有意思的内容,吸引潜在客户登记信息。然后,他们审核这些信息,确保潜在客户符合公司的标准或期望,然后将这些信息交给销售团队。销售团队会在未来与这些潜在客户联系。市场团队由此完成了他们填充销售流水线的工作。他们不负责确保潜在客户真的成为客户,这是销售团队的责任。

把这个概念移动到 DevRel 工作当中来。

当然,这不是说 DevRel 团队要有销售指标。前面提到过 DevRel 的核心工作方式是促进开发者成功,这是一种真诚的人际关系。如果直接跟销售指标挂钩,那么 DevRel 的工作会一下变得非常混乱难以开展。因为已经有市场营销团队定位在填充销售流水线上,这不是 DevRel 的工作,而将金钱关系直接带入到开发者关系当中是不持久的:某些公司在过去几年中实际上是在期待用几个小礼品换取开发者的技术选型偏好或大量时间投入,这是非常荒唐的。

Leads 这个概念在 DQL 指标模型当中指的是能够以某种方式为公司做出贡献的人,而不一定是潜在客户。

再次回到 DevRel 的核心工作方式定义上来:帮助开发者利用技术产品取得成功,从而实现技术产品自身的成功。商业公司要从这个模式当中获益,需要有人从开发者角度考虑问题,理解什么是开发者成功,达成这个目的需要如何动员公司各个职能部门的力量。而在现有商业公司架构当中,通常没有一个部门会以这种方式考虑问题。

这就是 DQL 引入的契机:DevRel 团队从开发者角度考虑问题,告诉各个职能部门如何与产品社群当中的开发者合作,最终“以某种方式”为公司带来利益。

下面举几个具体的例子。

市场部门

面向市场部门的常见 DQL 是案例或用户创造的内容。

华为云是 Kafka on Pulsar (KoP) 的早期使用者和合作者。KoP 是 StreamNative 推出的一款基于 Apache Pulsar 系统的兼容 Apache Kafka API 的技术产品。在华为云成功使用 KoP 实现自身需求之后,StreamNative 与华为云开发者共同创作并发布了 From Kafka to Pulsar: Creating A Comprehensive Middleware Platform to Power HUAWEI Mobile Services 博文。此文向大量观望 KoP 技术的开发者证明了这项技术确实是可行的,为该产品的市场营销做出了直接的贡献。

类似地,StreamNative 在 FlipkartDiscord 顺利上线之后,也分别推出了专题分享,证明公司的技术产品在电商领域和在线学习技术方向上是可行的(Discord 的场景是泛化的在线学习,而不是核心业务逻辑)。

如果缺少与开发者的深入沟通,这些用户故事很可能沦为一个简单的证言,即“我们用了这个产品挺好的”,但是说不出具体的技术挑战和应用场景。开发者们看到“相互认证”式的市场营销,很难对技术产品本身产生信心。

产品部门

面向产品部门的常见 DQL 是使用反馈和 Beta 测试。

TiDB 的用户论坛 AskTUG 上每天都会有很多用户使用 TiDB 相关产品的反馈。当然很多反馈本身是使用姿势问题,论坛在发展出良好运作的版主制度以后成为一个志愿的 TiDB 支持前线。但是也确实有一些产品反馈是产品本身的设计缺陷或者存在优化空间的地方。TiDB 相关产品大多有从用户论坛上反馈出来的问题。

PingCAP 的社群团队建立起了版主制度,让 AskTUG 成为一个能自服务解决问题的论坛,而不用大幅占用内核研发的时间解答用户问题。这是用户能够提供足够使用反馈的前提。否则一个论坛问问题没人回,那么根本就不会再有用户参与,遑论从中得到有价值的产品反馈。进一步地,该团队可以建立起用户反馈问题的报表,以及确认是产品部门待解决问题的清单,推动用户开发者实际参与到改良产品的流程中来。

当然,PingCAP 的社群团队也不会忘记可以引导开发者参与 Beta 测试,例如 TiDB v7.1.0 荣誉体验官活动就是一个例子。

同样有送礼品的环节,PingCAP 送出的奖品比起换赞的量级所能承受的小礼品要高级许多。与之相对应,作为这个“荣誉体验官”所要做的工作也比点个 Star 要复杂,但是确有指导手册和技术讲解的支持。PingCAP 没有期待参与测试体验的开发者此后就一定会选型 TiDB 到线上环境,也没有期待他们会投入额外的时间做测试指导手册要求以外的工作。因此,奖品和交换而来的产出是相对应的。而这个过程当中参与的开发者总之是花了时间了解 TiDB 等产品,那么从长期主义的角度来说,他们会投入更多时间就是有可能的,同时选型时也至少对 TiDB 有更全面的了解。

tidb-beta-test

研发部门

面向研发部门的常见 DQL 是报告缺陷和修复问题。

如果是开源软件,这个问题可能有 Contributor 会直接解决。否则至少用户能够提供出现问题时的环境配置和复现步骤,这些对于研发部门来说都是重要的输入。

一般而言,软件产品刚开始开发的时候,研发部门的开发者们自己就能够处理外部开发者进来的输入。此时往往也不会有太多的报告缺陷和志愿修复问题的人。但是随着软件产品走向销售轨道,研发部门需要花更多的时间解决客户问题,这种来自于社群开发者的声音就很容易被忽略。而且早期由研发部门经验丰富的开发者亲自指导问题掩盖的信息沟通问题会快速显现,尤其是随着软件变得日益复杂,所有想要报告缺陷和修复问题的人都缺乏相关的知识正确推进事情解决。

DevRel 团队能够改善这个状况。我帮助了来自丹麦的开发者完成 Apache Pulsar C# 客户端的自动发布,完善了 API 文档和发布文档。我帮助了 pulsar-rs 的开发者取得 StreamNative 维护者的代码评审,并最终合并和发布他们提交的补丁。

这个过程当中主要的挑战是 DevRel 团队需要有建立起信赖的研发团队合作者,或者本身就了解一些具体的研发知识。否则只是充当一个传声筒,像个监工一样催促研发部门的开发者“处理”这些输入,而不能说明这些输入具体做了什么,有什么价值,需要他们提供什么帮助,那么他们就会像忽略开发者的邮件提醒一样忽略掉 DevRel 团队传来的同样没有额外附加值的信息。

devrel-notify-challenge

商业拓展

面向商业拓展的常见 DQL 是集成和伙伴关系。

TiDB 的 Flink CDC Connector 维护在 Ververica 的仓库上,StreamNative 维护的 pulsar-spark 集成得到了 Databricks 开发者的参与。这些合作伙伴开发集成的主要动力,是他们的用户希望能够将这两个应用结合起来用。

Airbyte 的成功高度依赖第三方开发者创造的集成。一个 DevRel 团队能够跟集成开发者保持沟通,确保软件提供的扩展点是易于实现的,相关文档是齐全的,甚至有开箱即用的测试套件来支持集成开发。所有这些事情并不都需要 DevRel 团队的成员实施,但是 DevRel 团队能够将这些工作的必要性和优先级说清楚,以推动它们得到公司的投资并最终能够交付,从而帮助集成开发者和依赖集成的应用开发者成功。

airbyte-develop-connector

人才招聘

这个不用多说了。尤其常见于商业开源公司,从开源社群当中接触到的完全懂这一行的开发者,如果他认可这项技术,那么就很有可能会加入公司一起创造。许多有名的商业开源公司都是由此聚集起来最早的一批员工。

潜在客户

回到 Leads 在市场领域当中的含义,DevRel 团队当然也能从开发者关系工作中发现潜在的销售线索。实际上,我工作过的某公司的相当部分订单,就是开始于甲方开发者从某个社群渠道接触到公司的技术产品。

结语

上面介绍了几个可用于开发者关系工作的衡量指标。应该说,目前 DevRel 的工作缺少一个行业范围内通用的单一指标。从事这一工作的人要么过分强调长期主义,以至于表现出来的就是在任意期限内都没有可交付的成果;要么模糊的说 DevRel 的指标要看情况,实际是做了什么就说是什么。这都对 DevRel 工作取得广泛认可和推广产生了阻碍。

上面除了作为反面教材的虚荣指标,我所罗列的也不是某个单一指标。但是这些指标之间是有联系的。从 DevRel 工作的独特价值来说,DevRel Qualified Leads 是可以作为最终的北极星指标的。但是从我实际开展 DevRel 的经验来看,如果社群刚开始建设,为了保证 DQL 准确定义了为公司做出贡献的阈值,使用社群成员作为北极星指标是一个比较好的替代。而市场声量是特定在开发者营销方向上一个能够用于横向比较的指标,如果当前公司的优先事项是提高开发者的认知度,提高公司技术产品和理念的触达,那么可以使用这个指标来作为牵引。

相比较而言,DQL 着重体现了 DevRel 工作者在催化公司各个部门与开发者的连接上能够为公司带来收益的独特价值。DevRel 的工作经常与社群工作联系在一起,因为帮助开发者成功,催化开发者之间、开发者与公司部门之间的链接,本身就是在建立一个开发者社群。

关于这项工作开展的具体细节,除了上面提到的《开发者关系:方法与实践》,还可以参阅 Jono Bacon 的《People Powered》,以及关注 Jono Bacon 的 Youtube 频道。如果有可能,我也会在接下来的时间里详细介绍上述 DevRel Qualified Leads 指标的具体实现方式和其中的细节。

Apache OpenDAL 毕业随感

作者 tison
2024年1月18日 08:00

Apache OpenDAL 简介

Apache OpenDAL 是一个以软件库形式提供的数据访问层。它允许用户通过统一的 API 简单且高效地访问不同存储服务上的数据。你可以把它当作是一个更好的 S3 SDK 实现,也可以通过统一的 OpenDAL API 来简化配置访问不同的数据存储服务的工作(例如 S3 / HDFS / GCS / AliyunOSS 等)。

OpenDAL 以库形式提供,因此使用 OpenDAL 无需部署额外的服务。OpenDAL 的核心代码用 Rust 写成,因此它原生的是一个 Rust 软件库。在项目孵化和成长的过程中,社群也开发出了 Java / Python / Node.js / C 等语言的绑定,以支持在其他语言程序中方便地集成 OpenDAL 的能力。

下图列举了 Apache OpenDAL 多语言实现的线上用户:

real-users

OpenDAL 核心的统一 API 设计,其使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async fn do_business() -> Result<()> {
let mut builder = services::S3::default();
builder.bucket("test");

let op = Operator::new(builder)?
.layer(LoggingLayer::default())
.finish();

// Write Data
op.write("hello.txt", "Hello, World!").await?;
// Read Data
let bytes = op.read("hello.txt").await?;
// Fetch Metadata
let meta = op.stat("hello.txt").await?;
// Delete Data
op.delete("hello.txt").await?;

Ok(())
}

可以看到,实际读写数据的 API 是经过精心设计的。用户想要访问存储在不同服务上的数据,只需修改 Operator 的配置构造,所有实际读写操作的代码都不用改动。

Apache OpenDAL 孵化

OpenDAL 起初是 @Xuanwo 在 DatafuseLabs 为 Databend 项目开发数据访问层时创造的软件库。再往前追溯,Xuanwo 在青云工作时就开发过 BeyondStorage 这一目标相近的软件。不过,由于种种原因,BeyondStorage 最终夭折了

不同于 BeyondStorage 遇到的挫折和经历的歧路,OpenDAL 在一个明确目标的指引下快速成长:

  • 2021 年 12 月,Xuanwo 在 Databend 的代码库中开始开发后来作为 OpenDAL 核心代码的数据访问层逻辑
  • 2021 年 12 月,Xuanwo 同步开始起草 OpenDAL 的定位和目标
  • 2022 年 2 月 14 日情人节,OpenDAL 的核心代码从 Databend 代码库中抽离,开始作为一个独立开源项目运作。

2022 年 8 月,Xuanwo 找到我讨论进入 ASF Incubator 孵化的可能性。彼时项目发展才大半年,几乎所有代码都是 Xuanwo 一个人开发的,也没有 Databend 之外的用户。我发给 Xuanwo 一份 ASF 孵化项目提案的模板并给出项目发展的一些建议,并告诉他如果能在用户增长上做一些工作,主动集成其他知名软件和打造示例场景,过程中发觉合作开发契机招徕开发者,年底应该项目就能成长到进孵化器标准的水平。

今天再次看到 OpenDAL 的提交历史,实际上这个时候 GreptimeDB 应该已经开始调研采用 OpenDAL 的方案。可以看到在 v0.11.0v0.11.4 两个版本的发布里都有 GreptimeDB 的主创人员的参与贡献。

2023 年 1 月,春节前我正好跟 Apache Kvrocks 的成员讨论年后开始准备孵化毕业的事情,想起来之前跟 Xuanwo 交流过 OpenDAL 进入孵化器的意向,于是拉着 Xuanwo 一起起草了孵化提案,并在 2 月初开始孵化讨论

由于项目定位清楚,并且潜在地寄托了替代行将就木的 Apache jclouds 项目的希望,孵化提案顺利地“全票通过”

这段时间的经历可以补充阅读这两篇博文:

接下来的一年,Apache OpenDAL 孵化项目社群高速发展,在功能开发、版本发布和社群成长等各个方面全面取得可观的成绩:

incubation-status

上图所显示的是,孵化期间 OpenDAL 发展了 10 名新 Committer 和 3 名新 PPMC 成员,并有 8 名不同的 Release Manager 发布了 11 个符合 Apache 标准的版本。

上图未显示的是,孵化期间 OpenDAL 发起并实现了 23 个技术提案,处理了超过 1000 个问题报告,合并了 2000 多个代码补丁。

早在 2023 年 8 月,我就判断 OpenDAL 已经接近孵化毕业的标准。2023 年 10 月,在进行过一些沟通之后,项目导师之一吴晟将他发现的毕业前需要完成的工作列了一份清单,正式开始推进毕业工作:

graduation-todos

这份清单上的工作对于 OpenDAL PPMC 来说并不全是简单易懂的,甚至有很多项内容颇有挑战。后面展开讨论 OpenDAL 毕业面临的挑战时,你将看到一些挑战解决起来是很有难度的。于是,在部分简单易懂的工作完成后,清单上的项目有四五周的时间没有任何推进。

2023 年 11 月底,我在准备发起两个新的孵化提案的同时,也决定同时推进 OpenDAL 毕业的工作,免得这些理论上可以一次性完成的工作越拖越久,反而日后需要彻底重新做一遍。

事后,我在跟 Xuanwo 的讨论中得知,这些事务性工作对于开发者来说还是有一定的门槛,看到很多个一眼不知道如何开始的工作项,下意识搁置是第一反应(俗称摆烂)。我作为项目导师,把这些颇具官僚主义色彩的事务性工作用通俗的语言向 PPMC 成员解释,并拆解成一些实际可执行的工作,才能推动项目往毕业的方向前进。

2023 年 12 月,OpenDAL 项目社群内部达成了毕业共识。随后,毕业提案提交到 ASF Incubator 列表上讨论。经过一个月的激烈探讨和继续处理项目存在的问题,2024 年 1 月,项目成功通过毕业投票并在 Board Meeting 上由董事会审核通过:Apache OpenDAL 正式成为 ASF 的顶级项目

PMC Member 的标准

ASF 词汇体系下,PMC Member 即项目管理委员会成员,大致上相当于开源项目的维护者。所有 PMC Member 都是 Committer 并且额外具有对项目发展议题投有效票(binding vote)的权力。

项目在 ASF Incubator 孵化期间也有项目管理委员会,称为 Podling 项目管理委员会(PPMC),其中 Podling 意即孵化中的项目。最初的 PPMC 通常由孵化提案中的 Initial Committers 组成。随后就是上文图中显式的走提名投票流程邀请新的 PPMC 成员。

项目毕业时,毕业提案需要说明最终形成的顶级项目的 PMC 由哪些人组成。通常,原先 PPMC 的成员包括项目导师会加入到顶级项目 PMC 中。此外,孵化阶段邀请的 Committer 也是潜在成为顶级项目 PMC 成员的候选。

万事皆有例外。比如 Apache Doris 毕业时,原先 PPMC 成员部分加入了现在的 StarRocks 公司,并且在孵化期间持续损害 Doris 的品牌。这些成员在毕业时就没有被包含在 PMC 中,甚至不是顶级项目的 Committer 了。

如同我在《Maintainer 的标准》中提到的,我倾向于给予做出贡献的社群成员更高的权限以减少他们参与的门槛,因此在发起 OpenDAL PMC 成员讨论时,我先抛出了一个极端的所有 PPMC 成员和 Committer 都加入 PMC 的提案。

这个提案遭到了 Xuanwo 和其他 PPMC 成员的挑战。他们认为因为“项目毕业”这个契机将 Committers 加入到 PMC 当中,这个说法是行不通的。

随后,项目导师吴晟回应说到 ASF 文化崇尚积极引入 Committers 和项目维护者,Committer 和 PMC Member 在代码权限上是相同的,只是 PMC Member 具有额外关注项目管理的责任,例如处理安全问题、响应 Board 的提问和要求、参与版本发布和投票决议等等。

在后续的讨论中,OpenDAL PPMC 成员表现出把 PMC Member 和 Committer 差别对待,以至于类似等级制度的表述。不过这更多的是一个表达和语境的差异,我在推特上提到

在国内的开源宣传和讨论语境下,确实经常会有一个升级甚至权限交易的 mindset 在。甚至有人就是说开源参与像打游戏一样打怪升级。不同流派和认识存在是很正常的,只是这确实不是 ASF 倡导的方式。

最后,OpenDAL 采取的做法是在包括 PPMC 的成员和项目导师的基础上,询问所有 Committers 参与项目管理成为 PMC Member 的意愿。如果 Committer 都不看邮件列表不回这个邮件,显然跟有意愿参与项目管理事务还是有一定距离的。最终有两位 Committer 回应了轮询,他们也在近期积极参与了项目发展的讨论和组织版本发布。

另一方面,在轮询以外,直到完成毕业流程的两个月间,OpenDAL 也按照以往的流程和标准提名了一位 PPMC 成员。最终毕业成为顶级项目的 Apache OpenDAL 共有 14 名 PMC 成员:初始 4 位,孵化期间提名 3 位,轮询增补 2 位,以及 5 名孵化导师。

从我个人的标准来看,至少愿意花时间做 Release Manager 或是作为项目某个模块的 CODEOWNER 的社群成员,都应该是 PMC 成员。按照这个标准看,PyO3 的核心开发者和 OpenDAL Python 绑定的原始作者 @messense 还不是 PMC 成员,这点应该再 Review 一下。

官方网站和文档

homepage

OpenDAL 的官方网站并不算非常“惊艳”。这一方面是由于核心开发者大多缺乏前端开发技能,另一方面也是作为一个被应用集成的软件库,OpenDAL 不需要独立部署服务,自然也就没有一个独立服务配套的管控页面相关的需求可以展示。大部分情况下,OpenDAL 的使用方式是在软件当中以代码的形式被调用。

在上面的首页信息展示中,可以看到 OpenDAL 主要设计了三个扩展点。

  1. OpenDAL 的核心代码是 Rust 库,但是提供了多语言的绑定,从而可以在诸多语言写成的程序中调用。提供新语言的绑定是一个扩展点。
  2. OpenDAL 的核心价值是屏蔽不同存储服务后端,从而使得用户可以用统一的 API 访问不同位置的数据。提供新的存储后端集成是一个扩展点。
  3. OpenDAL 设计了 Layer 抽象,以在统一 API 的访问链路上提供不同切面的增强,包括重试、日志、监控、超时等功能。

docs

紧接着文档导航页几乎就展示了所有文档内容:OpenDAL 的设计理念,以及跳转到 QuickStart 页面的如何配置四个已经正式发布的语言的软件库。侧边栏的 Services 与其说是文档,不如说是已经支持的部分存储后端的参考手册。

community

相反,关于如何参与 OpenDAL 开发和作为 Committer 或 PMC Member 如何处理事务性工作的文档,由于有实际需要,是相对完整的。

其余页面,博客截至目前只发了四篇,且已经有小半年没有新发布。API 页面除了 Rust 文档,其他语言的 API 文档主要是参考手册性质的。Downloads 和 ASF 相关的页面主要是为了符合 ASF 的要求,对于项目本身基本没什么价值。

项目导师吴晟在毕业自检清单中提到了文档的问题,主要关注的是文档的版本化和避免露出未正式发布的软件库的临时文档。大体上,这是在以 Apache SkyWalking 多语言集成和多模块功能文档的标准给 OpenDAL 提建议。

OpenDAL PMC 成员之一 @suyanhanx 初步完成了文档版本化的调研工作,但是没有彻底完成,也没有更新开发和发布文档以包括相关操作。

我认为,在文档上,OpenDAL 还是有很大的提升空间的。不过在做毕业检查时,我采用了以下的标准:

  • 官网大致能用起来,谈到项目概念时引用链接要齐备。
  • 至少需要让想用 OpenDAL 的人知道如何用起来,整个内容阅读路径是清楚的,就还算可以了。至于版本化的问题,OpenDAL 还没到 1.0 版本,可以先只提供 nightly 版本的文档,这也是目前用户实际的用法。

其实,关于 OpenDAL 的内核设计和各个服务后端的使用方式,Rust 核心实现中已经包括了详细的版本化文档

我认为,OpenDAL 接下来的文档优化方向,除了继续完成所有语言的版本化发布以外,应该注重阐明概念的定义和常见的设计、使用模式,以及不同语言之间的翻译定式。在这一前提下,把实际的文档内容用引用链接导向 Rust 核心实现当中伴随代码的活文档,就可以把存在于 Rust API 文档中的完整文档给利用上。

rust-docs

用户实际的阅读路径,首先从设计、使用模式的文档中确定自己要到 Rust API 文档中查看哪部分模块的具体文档,了解相应的接口契约之后,对应自己使用的语言,查看不同语言直接接口翻译的定式,完成逻辑开发。

如果能做到这个程度,从软件产品角度说,OpenDAL 的产品力才算堪堪能打。

多语言软件库的开发与合规

如前所述,OpenDAL 的一大特色就是在 Rust 核心软件库的基础上,提供了不同语言的绑定,以支持在各种语言写成的程序中利用 OpenDAL 的能力。这也是为什么 OpenDAL 能被寄托替代 Java 写成的 jclouds 库的原因。

目前正式发布的四种语言的 OpenDAL 库的绑定方式如下:

其余开发中的语言绑定包括:

这其中 C Binding 已经有线上用户直接拿去用了,而其他语言的绑定则还未发布或者甚至就只有一个占位符。

在开发多语言绑定的过程中,OpenDAL 总结了一套最佳实践:

  1. 暴力开发出 Hello World 示例;
  2. 重构完成基本的工程化编译和测试流程;
  3. 重构完成基本的 API 映射设计;
  4. 跑通语言对应的发布形式。

这其中最麻烦的其实是工程化的部分和搞清楚最终要怎么发布到目标平台。目前 C Binding 的设计开发已经相对完善,但是由于 C 生态没有一个发布的定式,因此导致了 C Binding 迟迟未能正式发布。

又或者说其实 C 生态就是直接拷贝源文件,所以实际上也已经“发布”。

相反,Rust / Python / Node.js 这样有官方背书的发布平台的语言生态,OpenDAL 可以很轻松的创建对应的 GitHub Actions 工作流来完成自动发布。

值得一提的是,虽然 Java 软件库大多发布到 Maven Central 上,但是 ASF 软件对应的 Repository 不是其他项目常用的 Sonatype 资源库,而是 ASF 自己的资源库。考虑到 Maven 也是一个 ASF 项目,这一点并不奇怪。不过,这就导致支持 OpenDAL Java Binding 自动发布需要 ASF INFRA 介入。OpenDAL Java Binding 是 ASF 第二个支持自动发布的 Java 库,也是第一个自动发布 JNI 原生共享库的 Java 库。相关的工作如下:

此外,OpenDAL PMC 积极与 ASF 的品牌官员合作,探讨在 @apache scope 下发布 OpenDAL NPM 包的方案:

Apache Airflow 的 Jarek Potiuk 正在与 PyPI 团队合作探讨创建一个 ASF 账号的方案。OpenDAL PMC 密切关注进展并随时准备集成 OpenDAL Python Binding 到这个账号下:

可以看到,OpenDAL 认真对待软件发布工作,通过平台提供的机制,以及与 ASF INFRA 密切合作,切实提高了所发布软件包的可靠性。

最后,ASF 在技术合规方面还非常看重所发布的软件的依赖项采用的软件协议是否符合 ASF 对软件协议的政策。OpenDAL 为每个发布的制品都提供了 DEPENDENCIES 文件来披露这一消息。同时,由于大部分其他语言的绑定都是 Rust 核心库的一个翻译层,OpenDAL 开发者们尽可能减少不必要的第三方依赖,以降低下游使用时的合规负担。

技术上,由于需要对接多个存储服务后端,并且存在提供不同语言绑定的愿景,OpenDAL 高度重视代码工程化。

查看 OpenDAL 的 GitHub Actions Workflows 就可以发现,OpenDAL 开发了一套可重用的测试框架,任何新语言绑定或新存储服务后端都能快速具备现有的测试覆盖范围。不过这也不算新奇,同样提供多语言支持和多模块分散开发的 SkyWalking 也研发了适用于自身情况的 SkyWalking Infra E2E 测试框架。

就语言绑定技术而言,Rust 本身支持 C FFI 决定了 C Binding 的实现是非常流畅的。大部分语言也会提供访问 C API 的集成方式,于是通过 C Binding 可以产生其他语言的绑定。这也是 OpenDAL Haskell / Lua / Zig 等一众绑定的实现方式。

在这种大量利用现有技术的方案之外,上面提到的 jni-rs 和 napi-rs 等技术,则是在已有的 C API 集成方式之上,封装了一层符合 Rust 习惯的接口,从而在开发层面只需要涉及 Rust 语言和绑定目标语言。PyO3 更进一步,为这个开发过程研发了一套脚手架,中间打包和配置对接的工作也全部简化了。应该说,这是 Rust 生态主动向绑定目标语言靠拢。底层技术上,两边仍然是基于 C ABI 在通信。

于是这些技术统统可以归类到 FFI 的框架下,跨语言通信的主要成本就产生于数据拷贝和线程模型同步上。可以阅读我的另一篇技术博客《Rust 与 Java 程序的异步接口互操作》了解 OpenDAL 做过的实践。我想 OpenDAL 应该会活跃在 Rust 与其他语言深度集成的前沿。如果生态中有人想要改进 Rust 与某个目标语言的互操作体验,不妨在 OpenDAL 上实践你的想法。

ASF 的政策、官僚主义与基金会发展

上文提到,在毕业讨论进入孵化器邮件列表后,截至顺利毕业前经过了一个月的激烈探讨和继续处理项目存在的问题。

绝大部分毕业前需要处理的问题,其实都包括的项目导师吴晟罗列的清单当中。在处理清单列表的过程中,文档版本问题、依赖合规问题和最终 PMC 人选问题花了一些时间研究讨论,剩下的基本都是按部就班顺利完成。

不过,在清单当中忽略了一个重要问题,那就是 ASF 项目的 PMC 要遵守品牌政策,保护项目品牌和 ASF 的品牌。这其中主要且基础的一条就是以 Apache Xxx 正式名称来引用项目。

OpenDAL 的捐赠没有经历过改名,所以大部分材料和项目核心成员在捐赠过后仍然沿用原来的称呼习惯用 OpenDAL 来指代项目,并认为既然已经捐赠到 ASF 了,那么项目归属 ASF 的事实会随着时间推进被不断强化,因此也没有特别在意。

实际上,ASF 当中明确违反品牌政策的行为主要是 DorisDB 这样直接占用品牌宣传竞争产品,或者在商业公司中用某某项目商业版来称呼自己的产品等等。OpenDAL 虽然脱胎于 DatafuseLabs 公司,但是跟商业化可以说是一点关系也没有。其核心开发者也大多是个人身份参与,所以我认为只要大家没有损害 ASF 品牌的行为也就差不多了。

但是 IPMC Chair Justin Mclean 不这么认为,他在 OpenDAL 毕业提案的讨论里抛出了品牌问题的挑战。

现在回头看,其实一开始 Justin 的表达是 “I found a few minor issues where some name and branding work needs to be done.” 并不十分强烈。但是在 Xuanwo 首次回复没有做到 Justin 期望的完美符合 ASF 政策之后,他表示 PMC 应该要“好好学习相关政策”。

随后,在 PMC 成员完全一头雾水不知道 Justin 所指的到底是什么问题的情况下,项目导师吴晟表达了不同看法,大致跟我上面说的类似,即 OpenDAL 项目成员没有损害 ASF 品牌的动机,实际指出的问题也并不是什么明显的问题,只是没有达到完美主义的标准而已。

这个回复让 Justin 彻底破防,认为 PMC 目无政策,在主观遵守规定的意愿上有严重的缺陷,于是对毕业提案投了 -1 票,并在接下来的一个月时间里充分发挥轴的特性不停地全方位质问。

这个过程给 OpenDAL 项目成员带来了非常不好的体验。不是说不能投 -1 票,而是在主观认定 OpenDAL PMC 不配合、不愿意解决问题之后,连续的挑战都不是奔着具体解决问题去的,而是为了证实 OpenDAL 的项目成员就是一群坏人,且就算 OpenDAL PMC 成员读过品牌政策做过一些 nice to have 的改进以后,得到的也不是认同和进一步改进的建议,而是“你做得还不够好”的持续 -1 批评。

我于是专门写了一篇文章《全票通过?同侪社群无须整齐划一》批评这种行为。实际上,最后 OpenDAL 的毕业提案确实没有“全票通过”

opendal-graduation-result

不过,Justin 本人是 Board Member 之一,哪怕孵化项目在 IPMC 中如上图决策通过,最终是否可以建立顶级项目,还需要 Board 核准。

所以从 Justin 开始挑战,直到最终 Board 一致通过了 OpenDAL 成为顶级项目这一个月间,我跟 ASF 的商标品牌官员 Mark Thomas 以及其他 Board Member 就这个问题进行了全面的讨论。最终我们发现,实际上很多顶级项目都没有严格按照品牌政策来落实自己发布的内容,甚至一些 ASF 基金会层面官方渠道发布的内容,严格按照品牌政策来“审查”,也会有做得不够完美的地方。

不过,这并不是大家可以一起摆烂的原因。相反,这揭示了 ASF 项目在品牌保护上孱弱的现实。我做这些相关内容的讨论,也从来不是为了争个对错,而是带着相关人员重新审视一下目前 ASF 品牌政策的执行情况,从而能够用建设性的目光来评判 OpenDAL PMC 在过去和近一个月来处理品牌政策问题的行为到底做得怎么样。

在 ASF 孵化器里,一般而言顶级项目是不能作为参考的。从务实的角度出发,这是因为很多顶级项目都并不完全合规,也就是这里提到的问题。

但是我仍然坚持应该在孵化器中讨论顶级项目的做法,至少对于做得好的地方应该予以传播和认可。对于做得不好的地方,也不应该局限于孵化器的范畴,而是站在基金会的角度统一协调解决。

这是因为我清楚地知道大多数开源项目进入孵化器,或多或少受到了其他顶级项目的影响,而且进入孵化器明面上的目的就是毕业成为顶级项目。如果顶级项目都在摆烂,都没有按照 ASF 政策行事,孵化项目如何能理解它们被要求符合的政策规定?

探讨的过程中,我们发现了 OpenDAL 和其他顶级项目存在的各种品牌问题,所有已知被发现的问题都被积极解决了。这些工作被总结发送到上面提到的毕业提案结果讨论串上。

Justin 仍然认为 OpenDAL PMC 做得不像他为另一个有志于捐赠到 ASF 孵化的项目做的那样“完美”。但是在我通过对话将他的挑战彻底转化为主观担忧以后,由于我本人问心无愧,说 OpenDAL PMC “不愿意解决问题,只是被动反应,并且习惯性向外甩锅”更是无稽之谈,所以这些挑战在我列举事实的回应之下也就烟消云散了。

在交涉探讨的过程中,我认为有以下几个片段是值得注意的。

第一个是关于孵化器当中指出问题的方式

我用了两组对比。第一个是某个孵化项目对 ASF 执行政策时体现出的官僚主义失望,从而主动退出孵化器时,提到孵化器的维护者们并不是以帮助它们的姿态出现,而是表现得像是要通过项目的失败来证实自己的权威。如前所述,Justin 在 OpenDAL 的案例上最后完全是走向“我对你错”的模式,而不是我如何帮助你一起变得更好。

这未必是 ASF 结构性的问题。对于某个孵化项目来说,主要给予它们这个印象的就是孵化器主席 Justin 本人,有一个“权威”不断地否定你,这种挫败是非常明显的。

第二个是作为一个开源社群,指出问题最好的方式是提交补丁来修复,并在此过程中传达自己的理念,再不济也是提供一个可复现的问题报告,而不是说我认为你有点问题,你要自己发现问题并解决。我用的一个类比,是现在如果有个从未参与过项目开发,也不实际使用项目软件的人,跑过来说我感觉你代码写的方式会出现一些性能问题,你最好自己测一测改过来,这种莫名其妙的报告是无法得到项目维护者的注意的。

这两组对比用英文表达会更加对仗:

  • Helping us rather than failing us
  • Correcting with contributions rather than instructions

第二个是关于政策的文档和实践的问题

ASF 一个做得非常好的地方是它的社群规则和工作方式都有相应的文档记录:

  • 基金会官网 apache.org 记录了基金会的目标,核心定义和各个角色的职责,以及发布、品牌和投票等相关政策。
  • 社群发展网站 community.apache.org 是政策实际执行时的最佳实践参考。
  • 孵化器官网 incubator.apache.org 包括了孵化全过程的指南。
  • 基础设施官网 infra.apache.org 说明了主要资源的位置和使用方式。

但是,这些网站上的内容很是年久失修。

基金会官网的内容东一块西一块,除非很有经验的老成员,否则大多很难快速找到相应的材料。

社群发展网站的最佳实践基本都是十几年前的实践。号称目前开源世界最泛用的成熟度模型,对本次毕业讨论时被挑战的品牌问题只字未提。

孵化器网站内容也是极其杂糅,且有部分“指南”实际是 Justin 个人的偏好,虽然更新时流程上也是经过讨论的,但是大部分审阅的人也很久没有参与孵化,很难对指南落实的时候实际产生的问题有直观的感受。

基础设施官网也是内容稀碎,除非很有经验的老成员,否则大多很难快速找到相应的材料。而且,ASF 在过去二十几年里,基本都在 Maven Central 上发布 Java 代码库,在 SVN 仓库上发布源代码压缩包,对于新时代的不同语言不同软件的发布形式有很大的落差。

Apache OpenDAL 一方面遇上了 Justin 近期跟品牌问题较劲的点子上,另一方面由于它多语言多平台想做自动化发布的工作直接挑战了 ASF INFRA 常年的舒适区,所以在孵化和毕业时相比起其他项目,跟 ASF 的各个机构打交道的次数和时间都要多得多。

这也算是一件好事。毕竟只有新鲜血液的加入,才能促进基金会不断向前发展。只要挑战被正确引导、合作解决,那么遇上问题就不是一件可怕的事情。

第三个是关于基金会本身发展的问题

从另一个角度考虑,为什么 Justin 总是不断地在孵化器当中投 -1 票呢?其实这也反映出孵化器人才梯队建设的问题。由于太多人并不在乎 ASF 政策和 The Apache Way 到底要以什么形式建设出一个什么样的社群和发布什么样的开源软件,所以这些违反政策和文化冲突的问题才会不停挤压到 Justin 这里处理。

久而久之,比起费尽心力去了解项目社群发展的来龙去脉,到底是什么人出于什么动机做了这些事情,人类懒惰地天性就会促使有严格要求的人先直接一个 -1 拍脸,你自己反省。对我来说,我是有足够的动力来处理合规和文化问题的,所以这种处理方式对我来说很冒犯,我会认为原本好好谈合作解决就能行。但是对于某些项目来说,确实你不 -1 我就不管你了,也不是没有这样的案例。

一个更高抽象层次的问题是,ASF 的社群发展和孵化项目的形式,迄今为止仍然是某种“作坊式”的做法。ASF 起源于几个志同道合的开发者聚在一起成立的 Apache Group 并延续了它“一小部分人掌握一系列部落知识运作起一个开源社群”的模式。

在我直接指出作为孵化毕业标准之一的成熟度模型中根本没有关于品牌的论述之前,Justin 宁愿地低效逐个讨论他发现的品牌问题,不断评价 PMC 到底是听话还是不听话,主动还是不主动,也没想到其实这个可以在孵化的必经之路上做提示,最好能做成现在官网基本合规的检查器来提升整个社群的合规水平。

The ASF is well past the point where a small number of folks who have huge “tribal knowledge” can guide the number of projects and podlings that we now have.

我在探讨这些问题的时候,推动和主动修复了一系列文档的缺失,促进了最佳实践的产生和归纳,并且思考到底我们怎么把这些政策、理念和文化传播给更多的人,让他们主动的承担起裂变传播的责任。我想这是 ASF 在走过 25 年之后,面对新的开源社群形势和软件开发方法,应该要考虑和改进的问题。实际上,这也是从参与 ASF 项目接触 Apache 社群理念和方法论的人,成长为基金会成员的一条康庄大道。

全票通过?同侪社群无须整齐划一

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

近几年,国内开源项目捐赠到 Apache 软件基金会(ASF)的案例很有一些。几乎每个在进入孵化器和从孵化器当中毕业时发通稿的项目,都会选择在标题中加入“全票通过”的字样。

诚然,大部分项目在 ASF 孵化器中茁壮成长,实际上投票结果也是没有反对票,使用这一标题无可非议。然而,对于把同侪社群(Community of Peers)作为社群核心价值之一的 ASF 来说,追求全票通过并不是必须的。

在 ASF 孵化器当中,近些年来由于孵化器主席 Justin Mclean 个人风格的原因,许多项目遭受了无端的审查压力。我认为有必要在国内营造出人人都可以,甚至都应该“全票通过”的氛围时,阐明 ASF 同侪社群的理念和工作方式,以减少项目在面临不合理的挑战时遭受的挫败,尤其是当它来自于某个看起来权威的成员时。

理念与制度支撑

The Apache Way 当中即包括同侪社群的理念:

  • ASF 的参与者是个人,而不是组织。ASF 的扁平结构决定了无论职位如何,角色都是平等的,投票权重相同,贡献是基于志愿的(即使有人因为 Apache 代码的工作而获得报酬)。Apache 社区期望成员之间相互尊重,遵守我们的行为准则。我们欢迎领域专业知识,但不允许有终身仁慈独裁者。

也就是说,ASF 当中所有人在原则上都是平等的,所有的 PMC 成员在投票表决议案时具有相同的权重。

进一步地,ASF 关于投票的专门文档中写到:

  • 对于流程问题的投票,遵循多数原则的常见格式:如果赞成票多于反对票,该问题被认为已通过,而不考虑赞同或反对的具体票数,即反对票不构成否决。
  • 对于代码补丁的投票,反对票构成否决。为了避免否决权被滥用,投票人必须在行使否决权时提供技术理由,没有理由的否决是无效的,没有影响力。
  • 对于版本发布的投票,要通过至少需要三票有效赞同票,且有效赞同票多于有效反对票。反对票不构成否决。

实际操作中,行使技术否决时,如果其他 PMC member 不认同否决者提出的理由,否决也不成立。

因此,全票通过当然是一件值得开心的事情,但是 ASF 的运作方式并不要求需要全票通过。

面对反对意见

我在指导 ASF 孵化项目的过程中遇见过多次反对意见。

先看一个压力没那么大的。StreamPark 的孵化提案在提交表决时,最终是以 8 票有效赞成票,12 票其他赞成票,两票其他反对票通过的。

两票反对票来自 Apache StreamPipes 的项目成员,他们没来由地觉得 StreamPark 跟他们的项目“很像”,所以不应该进入孵化器。

且不说 ASF 并不禁止定位相似的项目进入孵化器,例如复数个消息队列和功能相似的大数据软件,StreamPipes 定位是物联网的工具箱,而 StreamPark 是为流计算系统 Flink 打造的作业管理平台(现在也部分支持管理 Spark 作业)。

所以,这种反对意见,既不是有效票,更没有什么可靠的理由,忽略即可。

再看一个比较搞笑的。Doris 的毕业提案在 2022 年 4 月 27 日以 12 票有效赞成票,13 票其他赞成票“全票通过”。但是孵化器主席 Justin Mclean 在 5 月 15 日找了一下存在感发了一个反对意见

显然,时间已经过去了,而且赞成票远多于反对票,因此 Doris 毕业是既定事实。

面对傲慢的审查

既然是同侪社群,那么允许不同的意见存在就是合理甚至必要的。有人提出反对意见,有人行使投票权投有效反对票,这都是正常的。我在本文开篇所反对的,是通过投反对票带给项目无端压力的傲慢的审查。

上面 Justin 给到 Doris 连续的负面意见,虽然对毕业结果没有影响,但是实际上作为 Doris PMC 整体处理起来的负担并不小。Justin 不停地抛出各种链接,要求 PMC 对此做出解释,其中各种无厘头或者过分的要求层出不穷。

例如,他提到,搜索 Baidu Doris 或者 DorisDB 会出现可能模糊 Apache Doris 品牌的内容,这些内容都需要 Doris PMC 去处理解决。

这根本就是扯淡的。

今天,你主动搜索 Baidu Doris 或者 DorisDB 还是会有各种导向非 Apache 品牌的内容,难道 PMC 整天啥正事儿不干,就陪你做因特网警察?这还是在 Doris PMC 对当时的品牌侵占大户,如今的 StarRocks 有较大影响力,且 Doris PMC 中不少成员受雇投入时间解决这些问题的情况下。

另一个例子来自于几乎全员志愿者的 OpenDAL 项目。

OpenDAL 自进入孵化器以来已近一年,在这段时间里,OpenDAL 提名了 9 位新 Committer 和 3 位新 PPMC 成员,发布了十几个版本,且分别由近十位 Release Manager 主导,不同语言的版本被多个下游软件所依赖。以任何开源社群的标准来看,这都是一个蓬勃发展且做出成绩的项目社群。OpenDAL 的作者 Xuanwo 信任 ASF 的社群发展理念,把 OpenDAL 捐赠到 ASF 当中,其本身就是对 ASF 品牌的认同。

那么好了,在上面链接对应的孵化毕业讨论中,OpenDAL 遭受了怎样的审查呢?

第一次回复,Justin 表示 OpenDAL 的一些引用最好改成 Apache OpenDAL 并带上商标标记,一些第三方的网站提到 OpenDAL 的时候也没有 Apache 的品牌。Xuanwo 看到以后及时的处理,甚至到第三方项目中提交 PR 将 OpenDAL 改成 Apache OpenDAL 的字样。

一般来说,到这里我们就可以认为 OpenDAL PMC 认真对待商标问题,尽力展现 Apache 商标,这已经很足够了。

足够吗?Justin 认为还不够呢。

Justin 进一步提出,按照 ASF 品牌政策的字面意思,所有 OpenDAL 网站的页面,都要用 “Apache OpenDAL” 来指称项目,而且都要带商标名称。最为离谱的是,这个要求连带要执行到 API 文档的每个页面上。

这个真的是保护 ASF 品牌吗?我要打个大大的问号。且不论 OpenDAL 的网站明晃晃的是在 opendal.apache.org 域名下的,根本就没有任何一个 ASF 项目,能够做到在所有网页和材料里都用 Apache ProjectName 指称项目,还要带上商标名称。还是那句话,PMC 整天啥正事儿不干,就陪你搞这些?

说到“任何一个 ASF 项目”,就不得不提 ASF 孵化器讨论里某些人的 360° 立体防御体系。其运作方式如下:

  1. 顶级项目不能作为参考,原因不明反正就是不行。你说某个顶级项目也是如此,他们不会解释为什么顶级项目那么做是有问题的,甚至为什么很多顶级项目都没管这些破事,只会说顶级项目不能作为参考,其回答模式就像低水平 AI 一样。难道孵化器项目毕业,不是为了成为顶级项目?怎么顶级项目反而没这么多破事,到你这就有了?
  2. 其他孵化项目不能作为参考,因为它们反正也没毕业,有问题是正常的。
  3. 基金会以外的项目不能作为参考,因为我们是 ASF 孵化器,别人爱咋咋地。

你发现了吗?这样一套操作下来,一个孵化项目要 argue 自己的做法的时候,不能援引任何其他项目做参考,建设性讨论几乎无法进行。

不能参考其他项目,那怎么界定合理性呢?那就要回到 ASF Policy 及其解释了。

例如,Justin 援引 ASF 品牌政策和自己写的 Incubator Distribution Guideline 说,政策规定项目正式名称是 Apache ProjectName,所以你的 NPM 包名应该是 apache-projectname,PyPI 包名应该是 apache-projectname。下面一众项目发出问号:

哦对了,其他项目不能被引用论述。这下无敌了。

哦,也不一定。比如 Justin 自己要证明说这个包名用 apache- 前缀是合理的时候,他就可以说

This is no different to any project that comes to the ASF via the incubator. Many of them need to change names, often before joining the incubator, and all need to change their name to be in the form “Apache Foo”.

这又可以了。

双标。

当然,没有 ASF Policy 支持,Justin 也可以创造出一些村规来审查你。

例如,Justin 表示 opendal.databend.rs 被重定向到 opendal.apache.org 上,那么 OpenDAL PMC 就要能控制 opendal.databend.rs 这个域名。

哈?所幸 databend.rs 是捐赠 OpenDAL 的企业 DatafuseLabs 控制的,这件事情可能还没那么离谱。换个思路,任何人今天就可以搞定 opendal.vercel.app 重定向到 opendal.apache.org 上,其他服务只要想找肯定能找到,是不是 OpenDAL PMC 还得买下 Vercel 啊?

不过我依稀记得 Justin 自己 mentor 的项目 Answer 也有过 answer.dev 的旧域名吧?这个怎么说呢?

Answer 域名的问题还是我提出来的,我也是 Answer 的导师之一。在这里,Justin 明确说:

redirection would be best

这又可以了。

双标。

再来看另一个莫名其妙的审查。

上面说到要用 Apache OpenDAL™ 来指称项目的事情,OpenDAL PMC 觉得也不无道理,一些显著的引用改改也行的。于是 Python API 文档的首页就用 Apache OpenDAL™ 来指称了:

opendal-python-apidocs

Justin 说这不行,你第一个 opendal 是包名,没有 Apache 字样。所幸我强忍恶心,耐心问了下商标团队的成员这个问题。商标团队的成员是个正常人,曰:“如果工具限制就是这样的,那也没事”。我补了一刀,说你非要说那 PMC 高低得自己做个 API 文档工具来解决合规问题。

当然,只有这个怎么够呢?这首页行了,没说其他页不行啊。pdoc 生成页面是按 Python 模块生成的,Justin 找来一个模块的文档页,指着说:你看,没有 Apache,不行。

opendal-python-apidocs-layer

真要较真,合着以后大家搞网站全别分页了,塞成一个大单页,就像 Kafka 这样

kafka-single-page

合规只要做一次,岂不美哉?哦,Kafka 这个大单页也不符合 Policy 呢。

这种想要做事的人反而莫名其妙多了很多繁文缛节要搞,可不就是官僚主义么?

小结

开源社群存在的首要目的,包括 ASF 自己写的第一愿景,是支持开源开发者生产开源软件。

所谓的政策、指南、规则,其目的应该是保护社群成员免收意外风险侵扰。本身它们是一种非强制性的指引,有道理不遵循也是可以的,更不要说违反了就等同于违法。

我在 ASF 当中得到过很多人的帮助。OpenDAL 作为一个支持多语言的库,为 ASF 在很多发布方面的共识提供了讨论的基础。例如,在我和 Mark Thomas 以及 Drew Foulks 等人的合作下,OpenDAL 搞定了所有 ASF 流程以支持包括 Maven Central 在内多平台自动发布。

Justin 本人愿意花费大量的时间检查孵化项目的发版和提案,我个人对这一点本身是尊敬的。他实际上也指出过很多项目实际存在的合规或品牌问题,而且确实应该被合理的解决,包括上面一开始点出 OpenDAL 的品牌问题,OpenDAL PMC 是有可以改进的地方,也确实改进了。

但是,Justin 把 Policy 苛刻成一种对内进攻项目的武器,用一种非常令人头疼的语气攻击项目,实际上是对 ASF 品牌和孵化器更大的伤害。

此前,这种苛刻又傲慢的审查已经逼退了只有一个核心开发者的 ZipKin 项目:

OpenZipKin 本是监控领域的明星项目,它愿意进入 ASF 并宣传 The Apache Way 是对 ASF 品牌的巨大帮助。然而,在这封令人伤心的退出提案中,ZipKin 的主创 Adrian Cole 无不失望的写到:

Process and policy ambiguity has been ever present and cost us a lot of time and energy.
The incubator spends more energy on failing us than helping us.

这其实是一个早该被提起更高优先级的反馈,ASF 的孵化项目居然感觉到孵化器在促使它们失败而不是帮助它们成功。有了本文前面的介绍,你应该知道这是怎么一回事。

关于流程和政策的争论,我想引用 Rust 作者 Graydon Hoare 的博文 Batten Down Fix Later

这里所谓的不专业或不健康的处理方式,我至少见过四种具体的形式:

  1. 通过花费时间争论消耗对手的精力。

离开阶段常常显得有点突然,因为其原因不透明,并且也有几种不同的形式,通常对应到上述处理的模式:

  1. 由于精疲力竭或倦怠而退出。

毫无疑问,Justin 苛刻且傲慢的审查,就走在这个模式上。

OpenDAL 跟 ZipKin 相似,有一位明确的主创 Xuanwo。如果没有几位导师支持,就像 ZipKin 的 Adrian 一样独自面对这些东西,很难想象如何能够坚持下来。不止一次 OpenDAL PPMC 成员和项目导师对 Justin 的雷人言语表示“麻了”。Justin 本人近五年没怎么正式写过代码也让他的很多“意见”显得非常业余。

例如,要求更改发布平台上 OpenDAL 的 README 包括 Apache 商标,PMC 改完以后说下次发版就会更新。Justin 来了一句能不能不发版就更新 … 你说呢?

当然,如标题所言,ASF 是一个同侪社群,孵化器和基金会并不会因为有一个特别苛刻而傲慢的人就不工作。但是 Justin 是孵化器主席,还是 ASF 董事会九人组的成员,身在基金会中的我即使知道这是同侪社群都会感觉到不可避免的压力,更不用说对此了解较少的其他开发者了。

真要说起来,Justin 的表达真像他自己那样较真的理解,并没有这么大的压力。

例如,在我挑战 NPM 和 PyPI 的包名到底要不要非得用 apache- 前缀后,他改口说 Guideline 都是 SHOULD 不是 MUST 所以有理由的话不用也行。但是又不死心的加了一条临时村规说这个要改也得在毕业提案前,提前跟 IPMC 商量。争论村规毫无意义,但我确实有心情,就说 IPMC 在每次发布的时候都会检查,这些内容都是公开的。毕业提案前,导师组都觉得没问题,怎么你不在导师组里,就得跟 IPMC 商量了?我看你在导师组的项目都不怎么商量啊。

再有一个例子是蚂蚁集团捐赠 CeresDB 核心代码的时候,出于保留商标的商业动机,用新名字 HoraeDB 捐赠核心代码。另一个 ASF 老玩家 Roman 都说这种 Dual Branding 很正常了,Justin 觉得不行。

“Daul branding” is nothing new, but recently, some entities have taken unfair advantage of this (including one you mentioned), and I feel the Incubator should take care that others do not also do this.

诛心言论,死了也证明不了自己只吃一碗粉。我就觉得你未来要 taken unfair advantage of this 了,你说你不是,我觉得你是。

Why a company would be unwilling to give up that brand or trademark just because it may be convenient in the future is a concern.

为什么呢?商业行为,甚至都找不到 ASF Policy 来说这不行了,但我不喜欢,我觉得是个 concern,你就要给我解释。

HoraeDB 的提案,最后我就说不剩什么正经问题了,你的这些意见我都听到了,该说的都说了,我们投票表决。最终 HoraeDB 以 13 票有效赞成票,1 票其他赞成票“全票通过”。Justin 没有投票。蚂蚁集团的运营也好险能继续沿用“全票通过!”的标题。

最后复述一遍,我写这篇文章是为了阐明 ASF 同侪社群的理念和工作方式,以减少项目在面临不合理的挑战时遭受的挫败,尤其是当它来自于某个看起来权威的成员时。

如何撰写一个 ASF 孵化器提案?

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

随着 Apache 软件基金会(ASF)在国内的深入发展,越来越多的项目希望藉由进入 ASF 孵化器孵化,来建设开源社群。在进入孵化器之前,项目发起人或其核心团队必须撰写一份孵化器提案来介绍项目的基本情况,以供孵化器项目管理委员会(Incubator Project Management Committee, IPMC)评估是否适合孵化。

ASF 孵化器成立于 2002 年,至今已有超过 20 年历史。截至本文写作时,孵化器一共孵化了 347 个项目,其中 241 个项目已毕业,28 个项目正在孵化中。

ASF 孵化器的官网有丰富的文档介绍如何进入孵化器以及按照 The Apache Way 建设开源社群。对于孵化项目而言,最核心的两份文档是:

此外,进入孵化器前需要撰写的提案,孵化器也提供了相应的模板,过往的孵化提案都是公开可查的,这也是撰写孵化提案时的重要参考。ALC Beijing 整理翻译了这份材料,可以阅读其发布文章《ASF 新孵化项目提案指导》

本文从我至今指导孵化五六个项目的经历出发,讨论项目在进入孵化器前撰写提案时经常遇到的问题以及应对方法。

找到一位领路人

提交孵化器提案时,需要一位 IPMC 成员担任该提案的领路人(Champion),其职责主要是作为项目和 ASF 的沟通桥梁,帮助项目进行一些基本的自我评估,并完成孵化提案的评审工作。同样,ALC Beijing 整理翻译了一份材料《Apache 孵化器领路人与导师的职责》做详细介绍。

除了我指导孵化的第一个项目 Kvrocks 是陈亮担任领路人,以及思否捐赠的 Answer 项目是姜宁担任领路人,其他我指导孵化的四个项目都是由我担任这个角色,并帮助项目完成提案撰写和通过孵化器评审。

虽然从流程上说,领路人的职责在项目顺利进入孵化器后就结束,但是许多项目的领路人都会稍后仍然担任项目的导师(Mentor)。毕竟愿意在项目成员还很不了解 ASF 的文化和工作模式的时候,予以指导并帮助项目顺利加入孵化器,需要付出不小的努力。如果不是对项目本身的认同,是很难做这样的投入的。既然如此,那么参与后续孵化,帮助项目进一步建设发展社群,从孵化器中毕业,也就顺理成章。

因此,找到一位领路人不仅是项目进入孵化器时流程上必要的条件,实际上也是找到一位当前在任的 ASF 孵化器导师,愿意支持项目整个孵化过程的发展。

随着 ASF 在国内蓬勃发展,这几年来,进入 ASF 孵化器的源自中国的项目数量可观,我也能看到有许多富有经验的导师和新锐进取的导师在帮助项目孵化。不过,以我现在指导的项目数量来说,我应该很难再支持一个新的项目进入孵化。或许等到现在指导孵化的四五个项目(Kvrocks 已经毕业,OpenDAL 接近毕业)减少到一两个,我还能有多一些时间。国内有作为孵化项目导师意愿的 IPMC 成员我知道一些,但是不合适在这里罗列。如果有想要寻找领路人加入 ASF 孵化的项目,可以跟我联系或者找到 ALC BeijingALC Shenzhen 等组织询问。

第一大原则:真诚

我所指导孵化的六个项目,它们撰写的提案没有第一次就能通过导师自检的。其中最主要的两个问题,一个是英语技能不熟练,另一个就是不够真诚。

原本我想在这写的原则是“诚实”,因为有部分提案编造项目现状尤其是成员多样性和活跃度。这种问题项目导师和后续参与审议的 IPMC 成员稍微查证一下就会暴露,如果在提案正式讨论时才被公开挑战,那么提案通过的概率就非常渺茫了。

不过,“诚实”到底是一个相对客观的标准。很多情况下,项目团队还不至于直接撒谎。然而,如果用“真诚”这样一个相对主观的标准来界定,那么刻意隐瞒、夸大、春秋笔法之类的问题就能囊括其中了。实际评审过程中,不够真诚往往也是很大的挑战。

所谓真诚,意味着明确阐述项目定位,如实披露项目及其社群目前的状况,以及说明加入孵化器的目的和计划。在此之上,知道评议项目提案的人大都是 IPMC 成员,即潜在的孵化器导师,知道 ASF 孵化器的目的是以 The Apache Way 帮助项目发展开源社群,就能够比较好的确定如何写好这个提案。

例如,一个典型的问题是在提案中填充市场材料。这尤其常见于企业团队捐赠的项目,毕竟企业团队在做内部报告和外部宣传时,标配就是一份 PPT 和一系列营销材料。

某项目的孵化提案草案中,几次三番提到捐赠项目的企业建设了中国最大的某品类平台。诚然,首次提及时补充相关信息无可非议,但是原本就不长的的提案里每屏都会出现一到两处提及此事的长难句,属实有点难绷。

某项目的孵化提案草案中,罗列了项目荣获各种开源奖项,希望以此说明项目如何的好。可惜这些开源奖项本身的认可度极低,很多时候是拿着奖项找人领奖。同时其知名度也极低,放在特定环境下或许还有人关注,但是到了 IPMC 成员的审议里,只会认为项目团队是否只是注重虚名。实际上,在提案中需要回答的问题有一条,就是项目需要解释自己的捐赠行为是否只是“对 Apache 品牌的过度迷恋”。

在我的认知中,往往是对社群本身的发展和软件的价值没什么可说的项目,才会期望以填充市场材料的方式来蒙混过关。相反,真正值得展示已有社群规模的项目,通常寥寥数语就能勾勒其形状,IPMC 成员也知道如何求证。

例如,Seata 是阿里巴巴集团和蚂蚁集团共同开发的开源项目,多年以来独立发展得也很好。由于一些原因两家公司希望将 Seata 捐赠到 ASF 当中以在中立的平台和品牌下继续对 Seata 项目做投入。Seata 项目是这样描述其社群的:

Seata is being developed by the development team inside Alibaba who’s responsible for building internal distributed system too. Since Seata was open-sourced on GitHub, it has gained significant traction, receiving up to 24k stars, being forked over 8k times, and having more than 40 versions released. Besides being widely adopted inside Alibaba and Ant Group, Seata is also widely adopted by hundreds of other companies, including … For more information, please click here. We aim to expand the contributor by inviting all those who make valuable contributions and excel in adhering to The Apache Way. The Seata project and its side projects always accept contributions from individuals outside of Alibaba.

因为它确实有人用,所以展示自己的时候才有的放矢。

当然,并非所有项目都像 Seata 一样经过多年的运作。Fury 是蚂蚁集团今年七月份开源的另一个项目,起初项目团队也犯了难,为了让项目看起来勃勃生机万物竞发,把钉钉群的人数也写进去,还把一些未来可能可以做的事情当做已经做完的事情来表述,显得项目似乎得到了广泛的采用。

我首先要求把提及群聊人数的内容给删掉,因为这个大家都知道是怎么回事,背靠大公司或者经过一定的宣传,拉出几个五百人微信群或几千人钉钉群并不是难事,然而其中到底有多少价值,至少不是按群聊人数来衡量的。

此外,我告诉项目团队,Fury 项目的优点是定位清晰,社群用户反馈积极,可以着重突出这一部分。对于未来能做的事情,只要定位说清楚了,再辅以几个确实做过的集成,认真审议的人是能 Get 到的。社群用户反馈积极,则是项目作者杨朝坤在开源的小半年时间里,积极在 Reddit 和 HackerNews 上宣传项目,由于 Fury 作为序列化方案的定位简单易懂,用于替换 Kyro 和 Protobuf 的一些场景下优势明显,所以有不少用户确实公开表示了对项目的兴趣。

最终,我们把 Fury 实际的几个用例做了一点展开来体现项目的价值和其合作的前景:

Currently, Fury has a group of individual users, and organizations such as Alibaba/Vipshop are Fury users, too. Here are some of Fury’s use cases:

  • Ant Ray uses Fury for serialization at 100W+ CPU cores every day at Ant Group.
  • Ant Mars replaced its serialization with Fury, which sped up task scheduling TPS by 2.5X and 4X for data transfer.
  • Ant real-time-graph computing systems replaced Kryo with Fury to speed graph serialization.
  • A few companies replaced Kryo with Fury in Flink jobs for faster data transfer and state persistence.
  • Lindorm at Aliyun uses Fury for serialization between clients and servers.
  • Taobao Android app, which has 880 million monthly active users, is considering using Fury for IPC serialization between Android processes.
  • Vipshop replaced Protobuf with Fury, reducing the end-to-end latency by 30ms.

可以看到,由于开源时间不长,大部分的使用场景仍然在蚂蚁集团内,但这就是实情,而且本身技术场景是多样的。我们没必要编造说因为钉钉群有好几千人,所以或许大概可能这里面对应的几百家企业也在尝试将 Fury 用在生产环境。也许这个逻辑听起来很离谱,但是真的有孵化草案一开始就像这样写的。

这里引出第二个问题,真诚意味着对项目当前的风险如实披露。

例如,Fury 项目开源不久,核心开发者确实只有两个人,而且都是在蚂蚁集团的员工。虽然有过提交的人有几十人,但是显然大家一看 commit 数,时间和内容就清楚怎么回事了。好在前期讨论捐赠的时候,项目引起了 Kvrocks PMC 成员刘明阳(@PragmaTwice)的注意,在提案审议时,项目有三个开发者做出了 non-trivial 的贡献。此外,我为项目找到的另一位导师 PJ Fanning 是 Jackson 的开发者,他也看好 Fury 并改进了 Scala 实现的一些小问题。我也看好 Fury 项目并做了一些微小的改进。最终 Fury 提案是这样表述其开发者团队的:

Currently, Fury has only three core developers, but they are not homogenous: although Chaokun and Weipeng work at the same company, they know each other only due to their common interest in Fury. Mingyang Liu joined the Fury community recently, and he mainly contributed to C++ part of Fury.
We don’t have enough diversity for now. It’s a risk, although we’re optimistic about future developer diversity. Since Fury is open-source, we have attracted more than 20 developers to contribute. We will keep building community diversity following The Apache Way.

在早期商议的时候,其他导师对这一点也提出了担忧。我想到了 OpenDAL 捐赠时也只有 4 个初始成员,且他们都来自 DatafuseLabs 公司,所以我补充了一个评论来自己挑明这个风险和我的判断:

tison’s comment: Although only three initial committers are listed above, PJ (who contributes to Jackson also) and I, as mentors, would participate in the development. Also, another podling that I mentored, named OpenDAL, has four initial committers but so far invited nine (days before its tenth) committers and two PPMC members, done eight (now during its ninth) releases. From my experience with Fury’s initial committers, I saw several shared characteristics with OpenDAL’s members. So, I’d invest efforts to help this project grow within the ASF Incubator.

Hudi 项目到今天也有相当多样的参与者了。然而它起初也只是 Uber 内部的一个方案,在几个合作伙伴间有共享。这就类似蚂蚁集团开发的 Fury 在阿里巴巴集团和唯品会内也有一些采用。Hudi 是这样写关于同质化开发者的风险的:

Currently, the lead developers for Hudi are from Uber. However, we have an active set of early contributors/collaborators from Shopify, DoubleVerify and Vungle, that we hope will increase the diversity going forward. Once again, a primary motivation for incubation is to facilitate this in the Apache way.

这里非常有趣的一句话是,加入 ASF 孵化器的动机之一就是为了发展一个多样的社群。

这体现了撰写孵化器提案的时候另一个常被误解的问题,那就是你并不是来证明说你的项目已经非常好,解决了所有的问题。如果这样,那还要孵化啥呢,直接作为顶级项目运作就好了(这并不是玩笑,实际上已有先例)。或许由于我们总是从一个成功走向另一个成功,我们很少能够坦率的展示项目目前面临的挑战,要么是好的,要么正在往好的方向发展。但是其实 IPMC 并不介意项目有些地方目前还并不跟 ASF 的文化理念和顶级项目的标准对齐,不真诚导致 IPMC 对项目产生信任危机,才是更大的风险。

两个主要段落:现状与风险

我第一次作为孵化项目的领路人,是在 OpenDAL 项目上。当时我误以为孵化提案应该是领路人负责起草的,只是需要项目团队支持补充一些信息。但是,当我实际开始试着写提案的时候,我就发现这个提案的定位就是项目团队的一个自我介绍和自我剖析的。

整个提案大致可以分为两到三个部分:

  1. Abstract 和 Rationale 等前几部分是对项目本身的介绍,项目到底是干嘛的,用在什么场景,相比其他方案有什么差异。这个问题只有核心开发团队才能回答。Initial Goals 是项目捐赠的动机和捐赠后的初步工作,这个也只有核心团队心里才有答案。这部分可以算是简答题。
  2. Current Status 和 Known Risks 也即现状和风险,是几个具体的问题,需要项目披露当前的社群状况和回答一些常见风险的挑战。这部分可以算是问答题。
  3. 此外的内容可以算是填空题,包括源代码的地址,文档的地址,依赖项及其软件协议,捐赠后所需要的资源和初始团队的成员等等。

填空题写起来是比较标准的,简答题除了大规模加塞市场材料突显自己多么 NB 的问题以外通常也不会遇到什么问题,主要出现夸大、掩盖的地方,就是关于现状与风险的问答题。所以我会说它们是一份提案的主要段落。

现状披露分为以下几个方面:

  • Meritocracy
  • Community
  • Core Developers
  • Alignment

Alignment 比较简单,就是说一下跟其他 ASF 项目的联系,没有就不说了,通常没什么问题。是的,提案模板只是一个模板,如果没什么好说的,可以不写。

主要出问题的就是前三项,为了证明社群不只是捐赠方一家在参与,有很多人在用,草案里 Community 的 Core Developers 里凑数夸大的情况非常严重。关于 Meritocracy 就更整蛊了,很多时候项目开源不久或者就是一两家公司的员工在维护,雇佣关系决定提交权限,非要编出多么的开放,甚至为此把一些新来的 Contributor 临时提成 Committer 指着说这就是 Meritocracy 了。

实际上 Meritocracy 并不是多样性的意思,而是任人唯贤,对比的是仅由雇佣关系授予和回收权限的方式。把一个没做什么贡献的人提名成 Committer 反而是违反这一条的。

对 Meritocracy 的回答,其实只要表示出理解它是什么含义就行。当然如果确实已经按照这样在运行了,举出例子是最好的。

风险评估分为以下几个问题:

  • Project Name
  • Orphaned Products
  • Inexperience with Open Source
  • Length of Incubation
  • Homogenous Developers
  • Reliance on Salaried Developers
  • Relationships with Other Apache Products
  • A Excessive Fascination with the Apache Brand

如前所述,下意识的规避项目存在的问题,是不真诚的主要诱因。

Project Name 通常没什么问题,只要做好自检就行。如果要改名或者发现跟其他项目名称冲突,则最好在提案前就完成改名,避免提案对名称产生疑问。Ignite 和 Seatunnel 在捐赠前都改过名字,HoraeDB 也有相似的经历。这里如果没有说清楚改名的缘由,IPMC 评估时发现存在品牌问题,是会有比较大的挑战的。品牌和合规问题是 ASF 当中的红线。

展开说一下,Seatunnel 的原名 Waterdrop 已经被其他组织注册,为了避免品牌问题不得不改名。Ignite 原本是 GridGain 公司的一个项目,其名称 GridGain In-Memory Computing Platform 跟公司名相同,同时作为独立项目也过长。因此实际上 GridGain 可以认为是把 GridGain In-Memory Computing Platform 的核心代码,以 Ignite 的名字捐赠出来。

HoraeDB 情况和 Ignite 类型,是蚂蚁集团的 CeresDB 产品核心代码,用 HoraeDB 的名字捐赠给 ASF 孵化器。然而,由于技术上需要修改代码中对 ceres 文本做替换,我在指导项目撰写提案的时候使用了 rename 一次来表达这个操作。实际评议提案时,技术上的名称替换做了一小部分,rename 的用词导致某些 IPMC 成员对 CeresDB 和 HoraeDB 的关系产生了误会,由此引发了不必要的一系列争论。

Orphaned Products 主要可能的风险有两种,一个是个人开发者捐赠的项目,如果人数很少,一旦开发者转移兴趣,项目可能就死了。另一个是公司捐赠的项目,如果没有 solid 的用例,可能公司一转向,项目也就死了。

如果项目开发者足够多样,有很多用户从中获益,这自然没问题。但是如果人并不多,IPMC 就需要评估项目的定位和未来发展的可能性。例如 Fury 未来长出一个社群的风险并不高,替换逻辑也清晰,不会因为项目没有用而废弃。相反的例子是 Tuweni 作为 Java 作成的区块链工具,虽然也进入了孵化,但是很快就因为没什么人感兴趣,作者自己也觉得没指望而放弃。

如果是公司项目,很多时候公司项目总要打包票说未来可期大大的投入,所以在 IPMC 这关通常也不好挑战。但是对于导师们来说,就要评估这个项目到底有没有足够的高层支持和内外用例。因为公司改主意经常比个人开发者转移兴趣或 burnout 来的快,除非有什么外力牵引,否则项目组换方向或者原地解散,这项目就死定了。对于导师来说,投入帮助项目孵化和成长的努力也就打了水漂。

类似的例子比如 Hotonworks 的 Ambari 项目,在公司被 Cloudera 收购后,项目跟 CDH 冲突,也就停摆了。但是换个角度看,由于 Ambari 仍然有其价值,所以在归档一年后,又有人出来复活这个项目继续运作。可惜的是由于一些其他原因,这次复活后的运作也不顺利。

另一个例子是同样来自 Hotonworks 的 Livy 项目,由于类似的原因停摆。然而在其从孵化器中退休前,有的用户已经上船并在生产环境使用,所以他们自告奋勇来维护这个项目,至少保持能够修复 BUG 和安全漏洞的情况。

Inexperience with Open Source 这个也是经常容易不真诚的地方。

从我的经验来看,大部分想要进入孵化器的项目,往往在社群建设上还有很大的欠缺。如果一个项目已经建立起了完善的社群,例如 ApolloConfig 和 Nacos 这样的,它们很少会想到要捐赠给 ASF 谋求进一步发展。因此,如实披露项目成员在开源方面的经验即可。

反面例子是一些草案在披露是添油加醋故意夸大,说是有某个成员四五年前在某个知名不知名的开源项目里提交过补丁,这就算是不会 Inexperience with Open Source 了。这就像是在建立上放一个 fix typo 的 PR 链接,然后说自己深入参与开源项目开发一样。

Length of Incubation 标准答案是 2 个月进入孵化器,2 年内孵化毕业:

Expect to enter incubation in two months and graduate in about two years.

这个在提案时基本不会被挑战,不过背后是孵化器运作的部分逻辑,即孵化项目是要奔着毕业去的,如果长时间不能毕业,可能会面临被要求退休的风险。历史上退休的孵化项目一共有 78 个,除了项目自己放弃以外,IPMC 会定期评估孵化时间过长的项目,并需要项目对风险做出回应。如果没有回应或确实状况很不健康,可能由 IPMC 出面将其退休。

Homogenous Developers 同质化开发者,即项目核心团队的多样性。这个在前面讨论真诚的时候已经介绍过很多。人的兴趣会转移,精力会消磨,公司的注意力转移得更快。如果项目本身不能凭借自己的价值吸引到足够多样的开发者,那么其失败的风险是极高的。

Reliance on Salaried Developers 这点跟同质化开发者有相似之处,不过其实并不一定是坏事。Flink 的开发如果不是拿钱,一个纯粹的志愿者是很难撑起社群的需要的。所以关于这个问题,我认为要么是不依赖,表达出项目核心团队对技术本身的追求和认同,要么是确实就是一直有钱雇佣开发者做这个项目,都没有问题。同样这里容易产生一些不真诚的地方,明明就是一群拿钱办事的人,编造出自己没钱也会做的谎言就显得很可笑。

HoraeDB 的提案里并没有回避这个问题:

We acknowledge that most developers are supported by their employers to contribute to HoraeDB, which poses a significant risk. However, HoraeDB has already been extensively deployed within Ant Group, with no internal forked versions. The version available on GitHub is the actual production version used in practice. As a result, Ant Group can ensure long-term commitment. We believe that within this timeframe, we can attract more maintainers and developers from diverse backgrounds to address this risk.

作为对比,Fury 的作者独立开发这个项目两年多,他有底气说自己就是会做这项技术,而不只是因为被雇佣做这件事:

Although Fury is created at work time in Ant Group, Chaokun and Weipeng contribute to Fury in their spare time. They love the process of building such a versatile framework and the value it brings to all users and organizations. They will continue to work on Fury even if they leave their current cooperation, and Mingyang Liu also contributes to Fury in his spare time. We plan to attract more committers to address this risk.

Relationships with Other Apache Products 如实回复即可。

A Excessive Fascination with the Apache Brand 这个问题是整个提案模板里最容易被误会的一条,反应了我一开始说的撰写提案时最主要的两个问题,第一个是英语技能不熟练。

这个问题的意思是,项目是否只是“对 Apache 品牌的过度迷恋”而捐赠,而不是孵化器关注的按照 The Apache Way 建设社群。换句话说,是不是只想借 Apache 的品牌做营销。不少草案写作时不知为何理解成要表达对 Apache 品牌的认可,洋洋洒洒写了一堆说 Apache 品牌是如何如何的好,完全是背道而驰。

当然,你说想要捐赠到 ASF 的项目团队不认同 Apache 品牌,这也不可能。可以参考 Kvrocks 提案的写法:

Although we expect that the Apache brand may help attract more contributors, our interest in starting this project is based on the factors mentioned in the fundamentals section. We are interested in joining ASF to increase our connections in the open-source world. Based on extensive collaboration, it is possible to build a community of developers and committers that live longer than the founder.

其他琐碎的问题

第一个,前面已经说过,这里再提出来强调,孵化器提供的模板只是个模板,不一定要完全按照它的格式来写,不知道写啥的可以留空或者询问领路人。

第二个,填空题的标准答案可以看 Fury 提案。一般来说需要三个邮件列表:

  • private 用于讨论不适合公开的内容,例如安全问题的处理,涉及具体人的讨论,例如提名新 Committer 或某人行为不端等。
  • dev 用于项目开发的讨论。以往还会有一个 users 来回答用户问题,但是现在大部分用户习惯开 Issue 或者 Discussion 聊,除非有明确需求,否则复用 dev 已经足够。
  • commits 用于归档项目开发产生的信息。这个主要是 The Apache Way 约定所有开发活动都要在邮件列表上留痕,ASF 会维护邮件服务器和保存所有邮件,这保证 ASF 项目的所有信息资产不会依赖外部系统。

Git 仓库现在基本都是 GitHub 上的迁移,不过 ASF 实际上有自己的 GitBox 做同步来确保不依赖外部系统。

Issue Tracker 大部分新项目都用了 GitHub 的 Issue 功能,老项目很多还使用 ASF 自建的 JIRA 平台。同样这些活动会被同步到邮件列表上,确保信息保存不依赖外部系统。

Initial Committers 的人选要仔细,这个也是经常被挑战的问题之一。在这个问题上,倒是可以多考虑 Meritocracy 的问题,同时不要设置过高的门槛但也不要为了凑人数而把没有什么实质性参与的 Contributor 强行提上来。最后,小心不要把没有参加项目实际工作的公司老板安排进来,这个如果被公开挑战会很难回应,因为跟 ASF 的文化完全格格不入。

Sponsoring Entity 捐赠给孵化器就写孵化器,Sponsors 需要注意领路人(Champion)只负责项目进入孵化器的过程,所以如果他想要继续担任项目导师,在导师一栏里要重复提名。

最后一个,依赖项的协议要认真看,从别处抄来的源代码要保留原先的文件协议头信息。合规和品牌问题是 ASF 的红线,IPMC 里有些人会特别仔细的看这些问题的。关于 ASF 项目如何看待依赖项协议是否合规,可以参考这份材料

注意有不符合 ASF 合规要求的依赖,只要写清楚了而且有后续修复方案,并不需要在进入孵化器前就一定解决。ASF 对孵化项目的 endorsement 其实是有限的,它会要求孵化项目发布时必须带 incuabting 字样和带有一份 DISCLAIMER 文件,来表示孵化项目并不一定完全符合 ASF 承诺的软件标准。

参考案例

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 生成中间表示,以及如何进一步做语义分析。

❌
❌