Aptos Move 中的 Friend Visibility(好友可见性):从 public/private 到细粒度模块信任
【免费下载链接】aptos-coreAptos is a layer 1 blockchain built to support the widespread use of blockchain through better technology and user experience.项目地址: https://gitcode.com/GitHub_Trending/ap/aptos-core
Friend visibility 是 Move 语言 1.2 版本引入的可见性机制,为模块函数提供介于 private 与 public 之间的"白名单式"访问控制。本文以 friend visibility 变更设计文档 为核心,结合 Aptos 仓库中字节码格式、字节码验证器、编译器 v2 及 Aptos 框架的源码与真实用例,完整讲解public(friend)修饰符、friend 列表声明规则、字节码层面的实现以及设计权衡,帮助你理解并正确使用这一细粒度模块信任机制。
一、背景:传统 public/private 可见性模型的局限
在 Move 1.2 之前,一个函数只有两种可见性:public或 private(不加修饰符)。public函数可以在任何地方被调用,private 函数只能在其定义模块内被调用。这种简单模型在实际开发中暴露出三个问题。
1. 过于宽松的可见性模型
对于"仅限已知的特定模块集合访问"的函数(即白名单式访问),开发者只能将其声明为public,这违背了设计初衷。文档以 Diem 框架中的initialize函数为例:理论上这些初始化函数只应被Genesis模块调用,绝不应暴露给其他模块或脚本。但由于可见性模型的限制,它们不得不被声明为public,同时依靠运行时能力检查和静态验证来保证这些函数在非 genesis 状态下调用时会 abort——这是一种"事后补救"式的安全策略。
2. 模块后续升级不灵活
public函数是面向全世界的契约:它不能被删除、重命名,函数签名也不能被修改。原因在于,要评估修改public函数 API 的影响,必须全局扫描链上已发布的所有代码调用点——在开放发布模型的区块链网络中,这既不可行也不可扩展。
而 friend 函数只对模块的 friend 列表(白名单)构成契约,且模块所有者完全控制 friend 列表的成员。因此更新 friend 函数要容易得多:只需协调 friend 列表中的模块即可,尤其当 friend 函数与所有 friend 模块都由同一所有者维护时。
3. 规范编写与验证的简化机会
Friend visibility 能简化 Move Prover 的规范编写与验证:给定一个 friend 函数及其宿主模块的 friend 列表,可以轻松且穷尽地找到该 friend 函数的所有调用点。基于此,甚至可以完全跳过 friend 函数的规范,将其实现内联到调用方,从而简化验证技术并证明更强的性质。相比之下,public函数必须编写完整且准确的规范。
二、核心概念:四种可见性级别与 friend 列表
Friend visibility 将可见性级别从两种扩展为四种:
| 可见性 | 源码写法 | 字节码文件格式中的表示 |
|---|---|---|
| private | (无修饰符) | Private |
| friend | public(friend) | Friend |
| script | public(script) | Script |
| public | public | Public |
其中 Script 可见性解决的是 Diem 框架中一个正交的问题,详见 Script Visibility 变更文档。
从字节码层面看,Visibility枚举定义在 move-binary-format 的 file_format.rs 中:
#[repr(u8)] pub enum Visibility { /// Accessible within its defining module only. #[default] Private = 0x0, /// Accessible by any module or script outside of its declaring module. Public = 0x1, // DEPRECATED for separate entry modifier // Script = 0x2, /// Accessible by this module as well as modules declared in the friend list. Friend = 0x3, }注意两点:
Friend = 0x3是一个独立的字节码值,0x2(Script)已标记为 DEPRECATED,改为独立的 entry 修饰符;- 工具方法
is_public()对Friend返回false,而is_public_or_friend()对Friend返回true,说明在需要"对外可见"的判断语境中,friend 函数被归类为受限可见。
除新的public(friend)修饰符外,每个模块允许拥有一个 friend 列表,通过零个或多个friend <address::name>语句声明。friend 列表中的模块可以调用宿主模块中定义的public(friend)函数,而非 friend 模块则被禁止访问public(friend)函数。在字节码文件格式中,friend 列表是一个新的 section,由friend_decls: Vec<ModuleHandle>字段承载(见 file_format.rs)。
三、public(friend)新可见性修饰符
public(friend)可以应用于模块中的任何函数定义。一个public(friend)函数可以被以下两种函数调用:
- 同一模块(模块
M)中的任何其他函数; - 模块
M的 friend 列表中任一模块所定义的任何函数。
除此之外,public(friend)函数与其他模块内函数遵循相同规则:它们可以调用同一模块中的其他函数(public(script)函数除外)、创建新的结构体实例、访问(该模块声明的类型的)全局存储等。
Aptos 框架中大量使用该特性。例如 aptos_account.move 中的register_apt、burn_from_fungible_store_for_gas、mint_to_fungible_store_for_gas等均为public(friend)函数;再如 aptos_coin.move 中的initialize函数:
public(friend) fun initialize(aptos_framework: &signer): (BurnCapability<AptosCoin>, MintCapability<AptosCoin>) {这与变更文档中"initialize这类函数应只被特定模块使用"的动机完全对应——在 Aptos 框架中,aptos_coin::initialize正是通过 friend 机制仅向genesis、transaction_fee等受信模块开放。
四、friend 列表声明语法
一个模块可以通过 friend 声明语句声明其他模块为好友,支持两种形式:
friend <address::name>— 使用完全限定模块名的 friend 声明;friend <module-name-alias>— 使用模块别名(通过use语句引入)的 friend 声明。
一个模块可以有多个 friend 声明,所有 friend 模块的并集被记录在 friend 列表中。为了可读性,friend 声明通常应放在模块定义的开头附近。需要注意:Move 脚本不能声明 friend 模块,因为 friend 函数的概念在脚本中根本不存在。
以 coin.move 的实际声明为例,其模块定义开头即是三个 friend 声明:
module aptos_framework::coin { use std::error; use std::option::{Self, Option}; ... friend aptos_framework::aptos_coin; friend aptos_framework::genesis; friend aptos_framework::transaction_fee;同样的模式还出现在 aptos_account.move,它声明了genesis、resource_account、transaction_fee、transaction_validation四个 friend。在 aptos-framework 的全部源码中,friend声明的使用数量达上百处,可见该机制已是框架内部模块协作的标准手段。
五、friend 声明的规则约束
Friend 声明受以下规则约束,且这些规则并非仅停留在文档层面,而是由字节码验证器强制实施:
- 模块不能将自己声明为 friend,例如
0x2::M不能声明0x2::M为 friend; - friend 模块必须在同一账户地址下,例如
0x2::M不能声明0x3::N为 friend; - friend 关系不能形成循环模块依赖,例如
0x2::Afriend0x2::Bfriend0x2::Cfriend0x2::A是不允许的;更一般地,声明 friend 会给 friend 模块增加对当前模块的依赖,若该 friend 模块已被直接或间接使用,就会产生依赖环; - friend 在模块发布时必须存在,例如
0x2::M不能声明一个加载器无法解析的0x2::X为 friend; - friend 列表不能包含重复项。
其中规则 1、2、5 的强制性可以在源码中找到直接证据:
- move-bytecode-verifier/src/friends.rs 是专门的验证 pass:若
friend_decls包含模块自身,返回INVALID_FRIEND_DECL_WITH_SELF;若存在地址与自身不同的 friend 模块,返回INVALID_FRIEND_DECL_WITH_MODULES_OUTSIDE_ACCOUNT_ADDRESS。源码注释明确说明:同账户地址限制是策略决定而非技术要求——VM 和字节码验证器的其他 pass 并不依赖"friend 模块必须同地址"这一假设,只是因为缺乏跨账户 friend 的明确用例、且为最小化发布流程改动,才暂时强制此约束,未来可能放开; - 重复项检查由 check_duplication.rs 中的
check_module_handles(module.friend_decls())完成; - 对应的 VM 状态码定义在 move-core 的 vm_status.rs:
INVALID_FRIEND_DECL_WITH_SELF = 1104、INVALID_FRIEND_DECL_WITH_MODULES_OUTSIDE_ACCOUNT_ADDRESS = 1105、INVALID_FRIEND_DECL_WITH_MODULES_IN_DEPENDENCIES = 1106。
从编译器侧看,module_generator.rs 在生成字节码时遍历module_env.get_friend_modules(),为每个 friend 模块生成ModuleHandle并push进module.friend_decls,从而把源码中的friend声明物化为字节码中的 friend 列表 section。
六、完整示例:模块 A 与其 friend 模块
变更文档给出了一个典型示例,展示public(friend)函数与 friend 模块的完整协作方式:
address 0x2 { module A { // friend declaration via fully qualified module name friend 0x2::B; // friend declaration via module alias use 0x2::C; friend C; public(friend) fun foo() { // a friend function can call other non-script functions in the same module i_am_private(); i_am_public(); bar(); } public(friend) fun bar() {} fun i_am_private() { // other functions in the same module can also call friend functions bar(); } public fun i_am_public() { // other functions in the same module can also call friend functions bar(); } } module B { use 0x2::A; public fun foo() { // as a friend of 0x2::A, functions in B can call friend functions in A A::foo(); } public fun bar() { 0x2::A::bar(); } } }该示例完整展示了 friend 可见性的三条访问路径:
- 模块
A通过完全限定名friend 0x2::B;和模块别名use 0x2::C; friend C;两种方式声明 friend; public(friend)函数foo可以调用同模块的 private、public 以及其他 friend 函数;- 同模块的 private 函数
i_am_private和 public 函数i_am_public也可以调用 friend 函数bar; - friend 模块
B中的函数既可以通过use引入的别名(A::foo())调用,也可以通过完全限定路径(0x2::A::bar())调用 friend 函数。
七、设计权衡:为何选择"模块到模块"粒度
变更文档详细记录了设计过程中对 friend 粒度、声明位置和发布顺序的权衡,理解这些有助于正确运用该特性。
1. 粒度选择:Module-to-Module(已采纳)
四种候选方案中,最终选择了模块到模块的粒度——模块B是模块A的 friend,模块B中任何函数都能访问模块A中的任何 friend 函数。理由如下:
- 模块一直是 Move 语言的信任边界:现有可见性模型以宿主模块定义 public/private;Struct/Resource 类型只有定义模块才能访问其内部结构。因此以模块作为 friend 访问的信任边界最自然;
- 与其他语言(如 C++)的 friend 粒度呼应。
未被采纳的方案及其原因:
- Module-to-Function(模块 B 是函数 foo 的 friend):粒度过细,破坏了"模块是边界"的心智模型,且可能导致一个模块对每个 friend 函数都要重复写
friend A; - Function-to-Module(函数 foo 是模块 A 的 friend):表达上很奇怪——信任
0x3::B::foo()却不信任同一模块中的0x3::B::bar(),难以设想有效用例; - Function-to-Function(函数 foo 是函数 bar 的 friend):除了"信任同一模块的一个函数而非另一个"的怪异感外,还会导致开发不灵活:若
B::foo是 private 函数,重命名它居然需要同步更新模块 A 中的内容,private 函数将不再真正"私有"。
2. 声明位置:Callee-side(已采纳)
Friend 列表由被调用方(模块所有者)在源码中声明。优点是 friend 列表与模块源码同文件,开发者查看同一文件即可知道谁能调用 friend 函数、friend 函数应如何加固;后期增删 friend 只需更新列表并重新发布模块(受可升级性与兼容性检查约束)。
备选的 Caller-side(调用方申请友谊)方案则存在明显缺陷:代码所有者无法直观掌握 friend 关系全貌,来源真相需要额外存储(VM 可更新的字节码 section 或账户中的FriendList实体),且查看模块源码时无法获知谁可访问 friend 函数。
3. 发布顺序与跨模块引用
Friend 机制引入了跨模块引用,使发布流程复杂化。考虑如下模块:
address 0x2 { module M { friend 0x2::N; public(friend) fun foo() {} } module N { use 0x2::M; fun bar() { M::foo(); } } }模块N因use 0x2::M依赖M,而M因friend 0x2::N反向引用N。在逐个模块发布的模型下:
- 显然必须先发布
M(先发布N会使N::bar()调用失败;先发布M无副作用,因为无人能调用M::foo()); - 发布
M时,字节码验证器看到指向尚不存在的N::bar()的可见性约束(前向声明),不会尝试解析该函数句柄,必须容忍这种前向引用; - 风险在于竞态条件:若 Alice 和 Eve 都能在
0x2发布,当M发布后,Eve 可能抢在 Alice 之前发布一个恶意bar()的N,滥用M开发者对 Alice 的信任。
文档给出的缓解方案有两种:
- 仍用单模块发布模型的安全三步流程:先发布一个空的占位模块
N,再发布模块M,最后发布使用 friend 函数的更新版N; - 未来的多签名 + 多模块原子发布模型:允许一批模块(即使位于不同账户)在单笔交易中原子发布/更新,从根本上消除竞态风险,也无需三步流程。
4. 其他"共享可见性"方案的对比
- Address visibility(地址可见性):类似 Java 的 package 概念,用地址充当命名空间,实现
public(address)(即internal)。问题在于无法控制后续向该地址的发布,可能违反 Move"发布时所有绑定已知且不可更改"的原则——若同地址发布即获得他人模块内部状态访问权,就可能读写原本仅限一组模块私有的状态; - Package visibility(包可见性):即 .NET CLR 的
internal模型,一起编译(在 Move 中即一起发布)的模块可互访内部状态。问题在于:发布后(如加载时)难以验证可见性/可访问性;版本升级可能遗留"保留权限但非本意"的模块;且 Move 尚无多模块包概念。相比而言,针对选定模块的 friend visibility 提供比包可见性更细粒度的访问控制,因此是更优的选择。
八、总结
Friend visibility 为 Move 提供了面向模块白名单的细粒度访问控制,从语言层面解决了"受限访问函数被迫 public"的历史问题:
- 语言层面:新增
public(friend)修饰符与friend <address::name>声明语句,四种可见性级别完整覆盖私有实现、好友协作、脚本入口与全局公开四种场景; - 字节码层面:
Visibility::Friend = 0x3与模块的friend_decls列表成为字节码格式的一部分(见 file_format.rs); - 验证层面:字节码验证器以独立 pass 强制实施"不能自我 friend、必须同账户地址、不能重复"等规则(见 friends.rs 与 check_duplication.rs);
- 实践层面:Aptos 框架(coin.move、aptos_account.move、aptos_coin.move 等)已将 friend 机制作为模块间受信协作的标准工具,尤其用于
initialize、gas 结算、代币铸造/销毁等敏感操作。
对开发者而言,在设计模块 API 时:面向全局的稳定契约用public;仅供内部实现用 private;只允许同地址下特定模块访问的敏感能力,则应优先考虑public(friend)+ friend 列表,它既提供了比public更严格的安全边界,又保留了比 private 更灵活的跨模块协作能力,同时显著降低了未来升级维护的成本。
【免费下载链接】aptos-coreAptos is a layer 1 blockchain built to support the widespread use of blockchain through better technology and user experience.项目地址: https://gitcode.com/GitHub_Trending/ap/aptos-core
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考