前言

LLGo(https://github.com/goplus/llgo)是一款基于 LLVM 的 Go 编译器,通过 LLVM 为 Go 语言整合了 C 语言生态,拓宽了 Go 语言的边界。但当我们第一次生成并使用 LLGo 调用 C 库时,仿佛置身原始社会——不再被包管理器维护的旧版本库、有时需要手写的 pkg-config 文件……Go 的 go get 如此优雅,为什么 LLGo 的 C 库管理还停留在石器时代?

于是,这成为了我们团队构建 LLPkgStore 的起点。今天,我想带你回顾这场从混乱到秩序的探险——如何让像管理 Go 包一样让 LLGo 方便的管理 C/C++ 依赖。

方案讨论

LLPkg 是一类特殊的 Go Module,其利用 LLGo 的跨语言生态整合能力,让开发者可以在 Golang 中方便地调用 C 语言库。但也正因为其与传统 Go Module 相比之下的特殊性(仅可被 LLGo 编译),直接复用原来的 Go Module 系统进行分发显然不可行。LLPkgStore 便是为了解决这个问题而诞生的。

在最初头脑风暴式地制定方案的过程中,我们围绕着 LLPkg 的生命周期,提出了许多的点子,大致有以下几个:

01 源码分发:

是重新造包管理系统,从头解决版本依赖、文件校验等一系列问题,还是基于已有的 Go Module 系统进行构建?

02 版本映射:

若基于 Go Module 进行开发,Go Module 要求 Module 版本号必须遵循 SemVer(语义化版本),而 C 库往往规范不一,必须进行版本转换才能供 Go Module 使用。 我们是该通过一个固定的转换公式,把C版本号转换为 Go Module 版本号,还是在某处托管版本映射表,自定义这两者之间的关系呢?

03 二进制分发:

LLPkg 实际上代表着从 C 库到 Golang 函数的链接关系——就像头文件一样,并非储存着具体的实现;若想正常使用,还需安装对应的二进制库才可。我们该如何分发二进制?是要自行构建分发,还是依赖系统的 brew, dnf, apt 包管理器,抑或有其他更好的选择呢?

04 生成方式:

LLPkgStore 作为官方性质的 LLPkg 储存源,其 LLPkg 的生成构建必须有详尽的验证过程,以保障安全性与可靠性。该怎样控制生成、更新过程,才能达到这个目的?

接下来,我们将围绕以上四点展开叙述。

从版本转换到版本映射

规范化管理 C/C++ 依赖,其中重要的一环便是良好的版本管理。Go 语言要求包版本号必须遵循 SemVer,然而 C 包对版本号方面并无要求,许多 C 包的版本号无法直接使用。

为了解决这个问题,我们先后提出了两种方案:

01 版本转换:

通过一套固定的版本转换公式,将原来各式各样的版本号转换为形式上符合 SemVer 规范的版本号,版本转换公式固定,转换结果固定,不需要维护额外的服务,用户也可以直观地根据转换后的版本号看出原版本号。

调研:

最初我们决定,若原 C 包版本符合 SemVer,直接复用;若不符合,直接将 C 包原版本号添加到以'v0.1.0'为修订版后的先行版本号(Pre-Release。在修订版之后,以一个连接号开头)的部分。

  • 但是经过调查发现,根据 SemVer 规范,先行版本号的数字部分不得前置补零,而部分 C 包采用形如'2024.01.01'的版本号,这是 SemVer 所不可接受的。

  • 在此过程中,我们又发现了某 C 包版本号形如'0064'、'0065',按上述流程转换后仍然不符合 SemVer 规范,我们提出了另一个补救措施:将前缀从'v0.1.0'改为'v0.1.0-0-'在后方添加转换后的先行版本号部分。

  • 由于可能存在的问题,当我们 C 包版本不变时,我们也有可能需要更新同一版本的LLPkg,为了能够继续沿用这套机制,我们又提出了 patch 方案:当我们需要打 patch 时,将'-patchx'添加到版本号后面(x 为数字)。

被迫放弃:

尽管我们竭尽全力想要去保留原本的设计,但是特殊情况实在太多,除此之外,我们还发现了一系列难以解决的问题:前缀是一成不变的'v0.1.0',倘若某 LLPkg 发生了不兼容的 API 修改,MVS 有可能会选择到不兼容的版本;

此外,上述 patch 机制也会导致 MVS 选择错误的过期版本。

经过反复讨论,我们不得不放弃了最开始的设计,进而提出新的方案,这就是版本映射。

02 版本映射:

维护一个版本映射表,在执行'llgo get pkgname@cversion'命令时先获取映射表,选择最合适的版本。

如果没有某个正式的规范可循,版本号对于依赖的管理并无实质意义...作为一位负责任的开发者,你理当确保每次包升级的运作与版本号的表述一致。  

—— SemVer

显然上述版本转换方案存在一系列复杂且难以解决的问题,且转换的版本号往往很难看且具有迷惑性(e.g., 'v0.1.0-0-cci-20250101')。最重要的是,这只是形式上遵循 SemVer 规范,实际上不符合 SemVer 的初衷。

而映射表可维护,包发布者可手动决定该 LLPkg 应该是什么版本,不仅符合 SemVer 的初衷和最佳实践,还能解决 MVS 的问题。

经过讨论后,我们制定出以下规则:

1)无论原版本号是否为 SemVer,均手工指定版本号:

a)C 包处于稳定版,则 LLPkg 版本号从'v1.0.0'开始;

b)C 包不是稳定版,则 LLPkg 版本号从'v0.1.0'开始。

2)Bump 规则如下:

a)当 C 包发生了不兼容的 API 修改,则更新 LLPkg 版本号的主版本号(MAJOR);

b)当 C 包发生了向下兼容的功能性新增或向下兼容的问题修正,则更新 LLPkg 的次版本号(MINOR);

c)当我们需要对已发布的 LLPkg 进行修复(与原来的 C 包无关),则更新 LLPkg 的修订号(PATCH);

d)上述定义详见:语义化版本 2.0.0 | Semantic Versioning(https://semver.org/lang/zh-CN/);

e)在 C 包发布了新的向下兼容的功能性新增后,不再发布 C 包上一版本的向下兼容的问题修正。

发布与安全

我们在为LLGo引入包管理机制时,被一个很简单的问题所困扰,那就是——用户该如何发布自己 LLPkg?

为了设计出一个合理的发布机制,我们参考了常见的包管理器:

  • NPM

  • Go Module

  • Homebrew

根据上述包管理器,我们总结出现阶段包管理器三种发布模式,即依靠用户主动发布(NPM),用户自托管型(Go Module)和发布托管二者结合(Homebrew)。

01 用户主动发布:

以用户主动发布为主的 NPM,需要用户先完成包编写,然后通过 npm publish 将包发布到 npm registry 中。

这样做的好处就是,方便管理所有的包和保证可复现性构建,而缺点也是不言而喻的:容易被滥用和过度依赖中心化服务器。

02 用户自托管型:

以用户自托管型为主的 Go Module,主打就是去中心化,用户不需要发布到registry类似的中心化服务器,只需要自行托管到自己的仓库上,为 Go Module 提供 VCS 信息即可。

为了保证可复现性构建,Go Module 设计了 SumDB 和 Goproxy 机制,具体原理就是将包的哈希信息记录到 Google 的 SumDB 服务器上,每次则从 Goproxy 拉取对应的包,并查询 SumDB 哈希信息与拉取的包进行哈希比对,保证可复现性。

Go Module 设计解决了用户主动发布模式过渡依赖中心化服务器等问题,但由于 LLPkg 特殊的与 C 库绑定的性质,我们无法直接沿用 Go Module。

03 二者结合:

Homebrew 则是充分发挥了二者的优势,允许用户提供自托管的仓库,通过 GitHub Repo 来对包进行整合。
04 讨论:

在完成调研后,我们进行了深度的讨论,最后决定采取类似二者结合的方案,即标准 / 官方 LLPkg 库由一个 GitHub 仓库进行统一管理,第三方用户库则采用了Go Module 的机制,由用户自行管理。

这种设计好处就在于:不仅完美兼容普通 Go Module ,而且实现了我们想要的 "LLPkg" 。

如果用户通过命令拉取的是一个普通的 Go Module ,那么可以直接沿用 Go Module 机制;如果用户拉取是 LLPkg,那么我们也只需要在 Go Module 基础上做一些改动即可。

我们团队认为,向前兼容 Go ,是非常有必要的,因此我们没有采取再造一个 “LLGO Module” 的方案,而是采用了目前这套。

换句话说,我们深入了解 LLGo 用户的痛点,与 Go 对齐颗粒度,并打出向前兼容的组合拳,进而实现了 Go 与 LLGo 生态的闭环。

05 提交流程:

当我们谈到包管理平台时,可复现性构建不是个可选项——它是整个系统的生命线。

同样,我们在 LLPkgtore 的设计中,可复现性构建是一个核心关注点。

我们希望不同用户在不同环境下构建相同版本的 LLPkg 时能够得到完全一致的结果,这对于保证安全性和规范的 debug 流程至关重要。

验证机制:

为了实现可复现性构建,我们引入了多层验证机制:

1)提交验证:GitHub Action 会在 PR 验证阶段对 llpkg.cfg 进行检查,确保其格式正确、目录名与包名匹配。

2)构建验证:自动化 LLPkg 生成过程,通过 Action 在多个平台上执行相同的构建步骤:从上游获取二进制/头文件并索引到 .pc 文件中。

a)检测配置文件中的生成器(例如 llcppg);

b)使用生成器为不同平台自动生成 LLPkg;

c)将生成的结果组合到一个 Go 模块中。

3)构建测试:自动运行 _demo 目录中的测试,验证生成的 LLPkg 是否可以正确导入、编译和运行。这确保了生成的库符合预期功能。

4)版本审核:要求 PR 提交消息中包含 {MappedVersion},格式为 Release-as: {CLibraryName}/{MappedVersion},确保版本信息的唯一性和可追踪性。

哈希验证与安全:

为确保从远程拉取的包未被篡改,我们采用以下机制:

1)自动化构建流程:PR 合并后,自动触发后处理 GitHub Action,根据版本标记规则为提交添加标签,避免人为干预引入不一致。

2)贡献者验证:通过 GitHub Action 验证提交者身份,确保只有经过授权的贡献者才能向仓库贡献代码。

3)PR 自动校验:提交 PR 后,自动对生成产物进行一致性检查,防止恶意代码注入或构建异常。

这套机制确保了 LLPkg 的构建过程透明、可验证且可复现,为 LLGo 生态系统提供了坚实的基础。

构建环境标准化:

我们通过以下方式标准化构建环境:

1)统一的工具链:使用 llcppg 作为标准化的 C/C++ 绑定生成器,消除了不同工具导致的构建差异。

2)环境变量规范:设计了统一的环境变量结构,确保构建和运行时能够正确找到依赖:

二进制分发

分发 LLPkg 源码的同时,还需分发其对应的二进制动态链接库,才可确保用户正常使用。在二进制管理分发方案上,我们经历了多次技术选型。

01 系统包管理器的局限性:

我们自然而然地考虑使用 apt、dnf、brew 等系统包管理器,毕竟,在之前手动构建 LLPkg 的过程中,获取二进制通常还是经由系统包管理器安装。但这些包管理器往往只会保留某个库的最新版本,因为大多数普通用户并没有安装历史版本的需要。我们很快发觉这是灾难性的:若我们的项目依赖了某个二进制库的历史版本,当该历史版本在包管理中不再可用之后,我们的项目就无法再编译了。

意识到这点后,我们重新梳理了项目需求,将历史版本的可用性排在了首位,能否直接获取到二进制反而是次要的了。也正是这样的想法,将我们导向了 Conan 包管理器。

02 专用的 C/C++ 二进制管理器:

Conan 是一款开源的、跨平台的 C/C++ 包管理器。

Conan 有着远程仓库的概念,其官方仓库为 ConanCenter。 对于某个包的近几个版本,远端仓库会生成并保留其二进制文件,方便直接下载使用;对于历史版本,ConanCenter 利用 GitHub 托管其构建配方,保证了历史版本的可用性。Conan 还支持丰富的编译选项,可以指定静态或动态编译,同时具有快速生成 pkg-config 文件的能力。最终经过调研,我们选择了 Conan 作为二进制分发的核心工具。

03 GitHub Releases:

最初,我们曾考虑放弃使用 GitHub Releases 进行二进制分发。尽管它提供了方便的发布与下载机制,但其最大的缺陷在于:GitHub Releases 中的 Assets 是可变的。发布后的二进制文件可以被覆盖,这违背了我们对可复现构建的基本要求。

然而,出于实用性考虑,我们目前对官方维护的 LLPkg 仍计划使用 GitHub Releases 进行二进制分发,这主要是考虑到 llgo get 工具的轻量化,使用官方 LLPkg 的用户无需安装和配置 Conan 等依赖管理工具,就可以直接获取预构建的二进制。

这种方式显著简化了用户的使用流程,且由于是官方 LLPkg,其二进制由 Conan 生成,所以依然可以满足可复现构建的要求。然而对于对可复现构建有着较高要求的场景,如第三方 LLPkg,我们自然是无法沿用 GitHub Releases 进行二进制分发的方案。

在未来,我们还会转而使用其他方法去解决这个问题。

历史版本维护

即使当我们 C 包版本不变时,我们也有可能需要更新同一版本的 LLPkg。为了保证可复现性构建,我们也会选择保留原来的问题版本,通过版本映射发布一个新的仅修改了 patch 的版本。

总结与展望

LLGo 依赖管理系统的构建,解决了 C/C++ 生态与 Go 语言在版本控制、分发一致性上的割裂。未来,随着工具链的自动化升级和社区共建的推进,我们期待 LLGo 能进一步打破语言边界,成为跨平台、跨语言开发的一站式解决方案。这一探索不仅是技术的胜利,更是对开源协作模式的一次成功实践——通过标准化、可验证的流程,让复杂系统的依赖管理变得高效、透明且可信赖。