Vitalik:以太坊是否应该在协议中封装更多功能?
原文:https://vitalik.eth.limo/general/2023/09/30/enshrinement.html 作者:Vitalik Buterin 译者:ChatGPT、登链社区翻译小组 共同完成 译文链接:https://learnblockchain.cn/article/edit/6710
译者注:这个文章,有不少译文,但发现很多译文缺少内容,且很多忽略了文章类的参考链接,因此登链社区翻译小组重新翻译下。
简短总结版: Vitalik 对最初的最小封装理念有了新思考:最小封装可能带来协议外复杂度,同时带来外部某些区域的中心化风险,而封装某些功能,一些封装可以大大提高Gas效率,将广泛采用的外部协议封装到以太坊协议,可以用社区共识修复bug。 本文讨论了将ERC4337、ZK-EVM、提案者-构建者分离、私有内存池及某些预编译封装进协议的可能性和权衡点,同时在更多抽像和更多封装之间可能存在某种最小可行封装,这是复杂的权衡。
特别感谢 Justin Drake、Tina Zhen 和 Yoav Weiss 的反馈和校对。
从以太坊项目开始,就有一个强烈的理念,即尽量使核心以太坊尽可能简单,并通过在其上构建协议来尽可能多地完成任务。在区块链领域,“在 L1 上完成”与“专注于 L2”之间的辩论通常被认为主要是关于扩展性的问题,但实际上,类似的问题也存在于满足以太坊许多类型用户需求:数字资产交换、隐私、用户名、高级密码学、账户安全、抗审查、抢跑交易保护等等。然而,最近一些人开始谨慎地对将更多这些功能封装到以太坊核心协议中表示兴趣。
本文将探讨一些关于最初的最小封装理念的哲学推理,以及一些关于这些想法的最新思考方式。目标是开始建立一个更好地识别可能的目标的框架,以考虑在协议中封装某些功能是否值得考虑。
在当时被称为“以太坊 2.0”的历史早期,人们强烈希望创建一个干净、简单和美观的协议,尽量少地自行完成任务,几乎所有任务都留给用户来完成。理想情况下,协议只是一个虚拟机,验证一个区块只需要进行一次虚拟机调用。
这是我和 Gavin Wood 在 2015 年初制作的一个白板草图的非常粗略的重建,讨论了以太坊 2.0 的样子。
“状态转换函数”(处理区块的函数)只需进行一次虚拟机调用,所有其他逻辑都通过合约来实现:一些系统级合约,但主要是由用户提供的合约。这种模型的一个非常好的特点是,即使整个硬分叉也可以被描述为对区块处理合约的单个交易,该交易将通过链下或链上治理获得批准,然后以提升的权限( escalated permissions.)运行。
这些讨论在 2015 年特别适用于我们关注的两个领域:账户抽象和扩展性。在扩展性方面,我们的想法是尝试创建一种最大程度抽象的扩展形式,使其感觉像上面的图表的自然延伸。合约可以调用大多数以太坊节点未存储的数据,并且协议会检测到这一点,并通过某种非常通用的扩展计算功能解析调用。从虚拟机的角度来看,调用将进入某个单独的子系统,然后在一段时间后以正确的答案神奇地返回。
这种思路曾经被简要探讨过,但很快被放弃,因为我们过于忙于验证是否有可能实现任何形式的区块链扩展性。尽管后来我们会看到,数据可用性抽样和ZK-EVMs的结合意味着以太坊扩展性的一个可能未来实际上可能看起来非常接近那个愿景!另一方面,对于账户抽象,我们从一开始就知道某种实现是可能的,因此立即开始研究如何将“交易只是一个调用(a transaction is just a call)”的纯粹起点尽可能地变为现实。
在处理交易并进行实际的底层 EVM 调用之间,存在大量样板代码,之后还有更多样板代码。我们如何将这样的代码减少到尽可能接近零的程度?
这里的一个重要代码片段是validate_transaction(state, tx)
,它执行诸如检查交易的 nonce 和签名是否正确之类的操作。账户抽象的实际目标从一开始就是允许用户用自己的验证逻辑替换基本的 nonce 递增和 ECDSA 验证,以便用户更容易使用社交恢复和多签钱包等功能。因此,将apply_transaction
重新架构为仅仅是一个简单的 EVM 调用,并不仅仅是为了使代码整洁而进行的任务;相反,它是将逻辑移入用户的账户代码,以给予需要灵活性的用户。
然而,坚持让 apply_transaction
尽可能不包含固定逻辑的做法最终引入了许多挑战。为了了解原因,让我们详细研究最早的账户抽象提案之一,EIP 86:
EIP 86 规范
如果
block.number >= METROPOLIS_FORK_BLKNUM
,则:1. 如果交易的签名为(0, 0, 0)
(即v = r = s = 0
),则将其视为有效,并将发送者地址设置为2**160 - 1
。2. 将通过创建交易创建的任何合约的地址设置为sha3(0 + init code) % 2**160
,其中+
表示连接符,替换了先前的地址公式sha3(rlp.encode([sender, nonce]))
。3. 在0xfb
处创建一个新的操作码CREATE_P2SH
,将创建地址设置为sha3(sender + init code) % 2**160
。如果该地址已经存在一个合约,则失败并返回 0,就好像初始代码已经用完了一样 gas。
基本上,如果将签名设置为 (0, 0, 0),那么交易确实变成了“只是一个调用”。账户本身将负责具有解析交易、提取和验证签名和 nonce,并支付费用的代码;请参见这里以获取该代码的早期示例版本,以及这里以获取与此账户代码相替代的非常相似的 validate_transaction
代码。
作为在协议层面上的这种简化,矿工(或者现在的区块提议者)获得了额外的责任,即运行额外的逻辑,仅接受和转发那些“有实际支付费用的交易账户”的交易。那么这个逻辑是什么呢?嗯,老实说,EIP-86 对此并没有考虑得太多:
请注意,矿工需要制定一种接受这些交易的策略。这种策略需要非常具有区分性,因为否则他们有可能接受不支付任何费用的交易,甚至可能接受没有任何效果的交易(例如,因为该交易已经被包含,所以 nonce 不再是当前的)。一种简单的方法是为他们接受交易发送到的账户的代码哈希值建立一个白名单;经过批准的代码将包括支付矿工交易费用的逻辑。然而,这可能过于限制;一种更宽松但仍然有效的策略是接受与上述相同的一般格式相符的任何代码,仅消耗有限数量的 gas 来执行 nonce 和签名检查,并保证交易费用将支付给矿工。另一种策略是在其他方法之外,尝试处理任何请求少于 250,000 gas 的交易,并仅在执行交易后矿工的余额适当增加时才包含该交易。
如果 EIP-86 照原样被包含进来,它会减少 EVM 的复杂性,但会大大增加以太坊堆栈的其他部分的复杂性。这就要求在其他地方基本上编写完全相同的代码,同时引入了全新的奇怪情况,比如相同哈希的相同交易可能会在链上出现多次,更不用说多次无效化的问题了。
账户抽象中的多次无效化问题。一次被包含在链上的交易可能会使内存池中的数千个其他交易无效,从而使内存池易于被廉价地洪水般攻击。
账户抽象从那时起逐步发展。EIP-86 变成了 EIP-208,后来成为了这篇 ethresear.ch 上关于“账户抽象提案中的权衡”,然后半年后成为了 这篇 ethresear.ch 文章。最终,从所有这些中,诞生了实际上相对可行的 EIP-2938。
然而,EIP-2938 一点也不简约。该 EIP 包括:
PAYGAS
操作码,它处理 gas 价格和 gas 限制检查,作为 EVM 执行断点,并同时临时存储用于支付费用的 ETH。为了在不牵涉到以太坊核心开发人员的情况下推动账户抽象的发展,因为他们正忙于优化以太坊客户端并实施合并,EIP-2938 最终被重新架构为完全的 协议外 ERC-4337。
ERC-4337, 完全依赖于 EVM 调用来完成所有操作
因为它是一个 ERC,所以不需要硬分叉,并且在技术上“存在于以太坊协议之外”。那么...问题解决了吗?嗯,事实证明并没有完全解决。当前的 ERC-4337 中期路线图实际上涉及最终将 ERC-4337 的大部分内容转化为一系列协议特性,这是一个有用的示例,可以看到为什么考虑采用这种路径。
讨论了一些将 ERC-4337 最终纳入协议的关键原因:
tx.origin
。当前 ERC-4337 本身使tx.origin
返回将一组用户操作打包到一个交易中的“捆绑者”的地址。原生账户抽象可以解决这个问题,通过使tx.origin
指向实际发送交易的账户,使其与 EOA 的工作方式相同。值得进一步关注效率问题。在当前形式下,ERC-4337 的成本比“基本”以太坊交易要高得多:交易费用为 21,000 gas,而 ERC-4337 的费用约为 42,000 gas。这篇文档列出了一些原因:
- 需要支付大量的单独存储读写成本,对于 EOA 而言,这些成本被打包在一个 21000 gas的支付中:
- 编辑包含公钥+nonce 的存储槽(约 5000)
- UserOperation calldata 成本(约 4500,通过压缩可减少到约 2500)
- ECRECOVER(约 3000)
- 唤醒钱包本身(约 2600)
- 唤醒接收方账户(约 2600)
- 将 ETH 转移到接收方账户(约 9000)
- 编辑存储以支付费用(约 5000)
- 访问包含代理的存储槽(约 2100),然后访问代理本身(约 2600)
- 除了上述存储读写成本之外,合约还需要执行“业务逻辑”(解包 UserOperation,对其进行哈希处理,重排变量等),而 EOA 交易则由以太坊协议“免费”处理
- 需要花费 gas 来支付日志费用(EOA 不发出日志)
- 一次性合约创建成本(基础成本为 32000,加上代理中的每个代码字节的 200 gas,再加上 20000 用于设置代理地址)
从理论上讲,应该可以调整 EVM 的 gas 成本系统,使协议内的成本和访问存储的额外协议成本相匹配;当其他类型的存储编辑操作更便宜时,转移 ETH 为什么需要花费 9000 gas 是没有道理的。实际上,与即将到来的Verkle 树过渡相关的两个 EIP([1] [2])实际上试图做到这一点。但即使我们这样做了,仍然有一个巨大的原因,封装协议功能无论 EVM 变得多么高效,都将不可避免地比 EVM 代码便宜得多:封装的代码不需要支付 gas 用于预加载。
完全功能的 ERC-4337 钱包是庞大的,这个实现,编译并放在链上,占用了约 12,800 字节。当然,你可以部署这个庞大的代码一次,并使用DELEGATECALL
允许每个个体钱包调用它,但是每个使用它的区块仍然需要访问该代码。根据Verkle 树 gas 的成本 EIP,12,800 字节将占据 413 个块,访问这些块将需要支付 2 倍的WITNESS_BRANCH_COST
(总共 3,800 gas)和 413 倍的WITNESS_CHUNK_COST
(总共 82,600 gas)。这还没有提到 ERC-4337 入口本身,版本 0.6.0 中占用了 23,689 字节(根据 Verkle 树 EIP 规则,加载所需的费用约为 158,700 gas)。
这导致了一个问题:实际访问这段代码的gas成本必须以某种方式在交易之间进行分摊。ERC-4337 目前使用的方法并不理想:捆绑中的第一笔交易会消耗一次性的存储/代码读取成本,使其比其他交易更加昂贵。在协议中封装将允许这些常用共享库成为协议的一部分,对所有人都可访问且无需费用。
在这个例子中,我们看到了几个将账户抽象的方面封装到协议中的理由。
但是重要的是要记住,与现状相比,即使在协议中封装了账户抽象,仍然是一种巨大的“去封装化”。如今,顶级以太坊交易只能由外部拥有账户(EOA)发起,它们使用单个 secp256k1 椭圆曲线签名进行验证。账户抽象去除了这一限制,使用户可以定义验证条件。因此,在关于账户抽象的故事中,我们也看到了反对封装的最大理由:灵活地满足不同用户的需求。
让我们进一步充实这个故事,看看最近考虑封装其他一些功能的例子。我们将特别关注:ZK-EVM、提案者-构建者分离、私有内存池、流动性质押和新的预编译。
让我们将注意力转向以太坊协议中的另一个潜在封装目标:ZK-EVM。目前,我们有大量的ZK-rollups,它们都必须编写相当相似的代码来验证在 ZK-SNARK 内类似以太坊的区块执行。这是一个相当多样化的独立实现生态系统:PSE ZK-EVM、Kakarot、Polygon ZK-EVM、Linea、Zeth等等。
EVM ZK-rollup 领域最近的一个争议与如何处理 ZK 代码中可能存在的错误有关。目前,所有正在运行的系统都有一种“安全委员会”机制,可以在出现错误时覆盖证明系统。在去年的这篇文章中,我试图创建一个标准化框架,鼓励项目明确表明他们对证明系统和安全委员会的信任程度,并逐渐减少安全委员会的权力。
在中期,Rollups 可能会依赖于多个证明系统,只有在两个不同的证明系统发生分歧的极端情况下,安全委员会才会有任何权力。
然而,从某种意义上说,其中一些工作显得多余。我们已经有了以太坊基础层,其中包含了一个 EVM,并且我们已经有了一个处理实现中的错误的工作机制:如果有错误,具有错误的客户端会更新以修复错误,然后链继续进行。从有错误的客户端的角度来看,看起来已经确定的区块将不再是最终确定的,但至少我们不会看到用户损失资金。同样,如果一个 Rollup 只想保持与 EVM 等效,那么他们需要实现自己的治理来不断调整其内部的 ZK-EVM 规则以匹配以太坊基础层的升级,这种做法似乎是错误的,当他们最终在以太坊基础层之上构建时,他需要知道自己何时进行升级以及升级到什么新规则。
由于这些 L2 ZK-EVM 基本上使用的是与以太坊完全相同的 EVM,我们是否可以以某种方式将“在 ZK 中验证 EVM 执行”作为协议特性,并通过应用以太坊的社会共识来处理错误和升级等特殊情况,就像我们已经为基础层的 EVM 执行所做的那样?
这是一个重要且具有挑战性的话题。有一些细微之处:
在原生 ZK-EVM 中,数据可用性的一个可能争议的主题是状态性(statefulness)。如果 ZK-EVM 不必携带“见证”数据,那么它们在数据效率上要高得多。也就是说,如果某个特定的数据在之前的某个块中已经被读取或写入过,我们可以简单地假设证明者可以访问它,而不必再次提供它。这不仅仅是不重新加载存储和代码;事实证明,如果一个 Rollup 正确地压缩数据,相比于无状态的压缩,有状态压缩可以节省高达 3 倍的数据。
这意味着对于一个 ZK-EVM 预编译,我们有两个选择:
我们可以从中得出什么教训?有一个相当好的理由将 ZK-EVM 验证封装进协议:Rollup 们已经在构建自己的定制版本,并且以太坊愿意将其多个实现和链下社会共识加入到 L1 上的 EVM 执行背后,但是做完全相同工作的 L2 却必须实现涉及安全委员会的复杂装置,这感觉不对。但另一方面,细节中有一个巨大的问题:有不同版本的封装的 ZK-EVM,具有不同的成本和收益。有状态与无状态的分界只是表面;试图支持由其他系统证明的具有自定义代码的“类-EVM”可能会揭示出更大的设计空间。因此,封装 ZK-EVM 既带来了希望,也带来了挑战。
MEV的兴起使得区块生产成为一个规模经济密集型的活动,精明的参与者能够生成比默认算法(指仅仅监视内存池中的交易并包含它们)产生更多收入的区块。以太坊社区迄今为止尝试通过使用协议外的提案者-构建者分离方案(如MEV-Boost)来应对这个问题,该方案允许常规验证者("提案者")将区块构建外包给专门的参与者("构建者")。
然而,MEV-Boost 对一个新类别的参与者——中继——存在信任假设。在过去的两年里,已经有许多提案来创建"封装 PBS"。这样做的好处是什么?在这种情况下,答案非常简单:直接利用协议的能力构建的 PBS 比在没有这些能力的情况下构建的 PBS 更强大(在信任假设较弱的意义上)。这类似于封装协议内价格预言机的情况类似——尽管在那种情况下,也存在强烈的反对意见。
当用户发送一笔交易时,该交易立即变为公开的,并对所有人可见,即使在被包含在区块链上之前。这使得许多应用程序的用户容易受到经济攻击,如抢跑交易:如果用户在例如 Uniswap 上进行了一笔大额交易,攻击者可以在他们之前提交一笔交易,提高他们购买的价格,并获得套利利润。
最近,出现了一些专门创建"私有内存池"(或"加密内存池")的项目,这些项目将用户的交易加密,直到它们被不可逆地接受到一个区块中。
然而,问题在于这样的方案需要一种特定类型的加密:为了防止用户洪水对涌入系统并抢先解密,加密必须在交易真正被不可逆地接受后自动解密。
要实现这种形式的加密,有各种不同的技术可供选择,它们具有不同的权衡,Jon Charbonneau 的这篇文章(以及这个视频和幻灯片)对此进行了很好的描述:
不幸的是,每种方法都有不同的弱点。由于明显的原因,中心化运营商不适合在协议中使用。传统的时间锁加密在公共内存池中运行成本太高,无法应用于数千个交易。一种更强大的称为延迟加密的原语允许高效解密无限数量的消息,但在实践中很难构建,并且攻击现有构建有时仍然会被发现。就像哈希函数一样,我们可能需要更多年的研究和分析才能使延迟加密变得足够成熟。门限加密要求信任多数方不会勾结,而且在他们可以不可检测地勾结的情况下(与 51%攻击不同,参与者立即就会被发现)。SGX 会对单个可信制造商产生依赖。
对于每个解决方案,都有部分用户对其感到满意,但没有一个解决方案足够受信任,以至于可以实际上被接受到Layer1。 因此,在第一层封装反抢跑交易似乎是一个困难的提议,至少在延迟加密完善或出现其他技术突破之前,即使它是一个非常有价值的功能,已经出现了许多应用解决方案。
以太坊 DeFi 用户普遍要求能够同时将他们的 ETH 用于质押和作为其他应用的抵押品。另一个常见需求是仅为了方便:用户希望能够进行质押,而无需运行节点并始终保持在线状态(以及保护他们在线上的质押密钥)。
迄今为止,最简单的满足这两个需求的“接口”是只需一个 ERC20 代币:将你的 ETH 转换为“质押 ETH”,持有它,然后稍后再转换回来。实际上,已经出现了诸如 Lido 和 Rocketpool 等流动性质押提供商来实现这一点。然而,流动性质押存在一些自然的中心化机制:人们自然而然地选择最大版本(服务商)来质押 ETH,因为它最为熟悉、最具流动性(并且最受应用程序的支持,而这些应用程序又支持它,因为它是大多数用户听说过的)。
每个版本的质押 ETH 都需要一些机制来确定谁可以成为底层节点运营者。它不能是无限制的,因为否则攻击者将加入并利用用户的资金放大攻击。目前,排名前两位的是 Lido以及 Rocket Pool,Lido有一个 DAO 白名单节点运营者, Rocket Pool 允许任何人运行节点,只需抵押 8 ETH(即资本的四分之一)。这两种方法都存在不同的风险:Rocket Pool 的方法允许攻击者对网络进行 51% 攻击,并迫使用户承担大部分成本。对于 DAO 方法,如果某个质押 代币占主导地位,那将导致一个单一的、潜在 可攻击的治理机构控制着所有以太坊验证者的很大一部分。值得赞扬的是像 Lido 这样的协议已经实施了防护措施,但一层防御可能不足够。
在短期内,一种选择是鼓励生态系统参与者使用多样化的流动性质押提供者,以降低任何一个提供者变得过大而成为系统性风险的可能性。然而,在长期内,这是一个不稳定的均衡,过于依赖道德压力来解决问题是有风险的。一个自然的问题出现了:是否有必要在协议中封装某种功能来降低流动性质押的中心化?
在这里,关键问题是:什么样的协议功能?简单地创建一个协议内的“质押 ETH” 代币存在一个问题,即它要么需要有一个封装的以太坊治理来选择谁运行节点,要么是开放入口,从而成为攻击者的工具。
一个有趣的想法是 Dankrad Feist 关于流动性质押极大主义的论述。首先,我们接受这样一个事实,即如果以太坊遭受 51%攻击,只有大约 5%的攻击 ETH 会被削减。这是一个合理的权衡;目前已经有超过 2600 万 ETH 被质押,而攻击成本为其中的 1/3(约 800 万 ETH)已经远远超出需求,尤其考虑到还有许多种“模型之外”的攻击可以以更低的成本实施。事实上,在实现单个slot最终性的"超级委员会"提案中已经探索了类似的权衡。
如果我们接受只有 5%的攻击 ETH 会被削减,那么超过 90%的质押 ETH 将不会受到削减的影响,因此可以将 90%的质押 ETH 放入协议内的流动性质押代币中,然后由其他应用程序使用。
这条路径很有意思。但仍然存在一个问题:具体应该封装什么?RocketPool已经以非常类似的方式运作:每个节点运营者提供一部分资金,而流动性质押提供者提供其余部分。我们可以简单地调整一些常数,将最大削减惩罚限制在例如 2 ETH,并且 Rocket Pool 的现有 rETH 将变得无风险。
有其他聪明的方法可以通过简单的协议调整来实现。例如,想象一下,我们希望有一个系统,其中有两个“层次”的质押:节点运营商(高抵押要求)和存款人(没有最低要求,可以随时加入和离开),但我们仍然希望通过给随机抽样的存款人委员会赋予一些权力来防止节点运营商的中心化,比如建议包含的交易列表(出于反审查原因),在链不活跃时控制分叉选择,或者需要对区块进行签名。这可以通过在协议中进行微调的方式来实现,要求每个验证者提供(i)一个常规的质押密钥,和(ii)一个 ETH 地址, 该地址在每个slot中可以调用以输出第二个质押密钥。协议将赋予这两个密钥权力,但在每个slot中选择第二个密钥的机制可以留给质押池协议来处理。直接封装某些功能可能仍然更好,但值得注意的是,“封装某些事物,将其他事物留给用户”的设计空间是存在的。
预编译(或“预编译合约”)是以太坊合约的一种形式,实现了复杂的加密操作,其逻辑是在客户端代码中本地实现的,而不是在 EVM 智能合约代码中。预编译是以太坊开发初期采用的一种妥协方案:由于虚拟机的开销对于某些非常复杂和高度专业化的代码来说太大,我们可以在本地代码中实现一些对于重要类型应用程序有价值的关键操作,以提高其速度。目前,这基本上包括一些特定的哈希函数和椭圆曲线操作。
目前有一种力量推动增加secp256r1 的预编译,这是一种与用于基本以太坊账户的 secp256k1 略有不同的椭圆曲线,因为它得到了可信硬件模块的良好支持,因此广泛使用它可以提高钱包的安全性。近年来,还有力量推动增加BLS-12-377、BW6-761、广义配对和其他功能的预编译。
对于这些对更多预编译的要求的反驳是,之前添加的许多预编译(例如 RIPEMD 和 BLAKE)最终被使用的情况比预期要少得多,并且我们应该从中吸取教训。我们不应该为特定操作添加更多的预编译,而是应该采取更温和的方法,如基于像 EVM-MAX 和 “休眠但始终可恢复”的 SIMD 提案 这样的想法,使 EVM 实现能够更便宜地执行广泛的代码类别。甚至可以删除现有但很少使用的预编译,并用EVM 代码实现相同功能(不可避免地效率较低)。尽管如此,仍然有可能存在某些具有足够价值的密码操作,加速这些操作加入到预编译可能是合理的。
希望尽可能少地封装某些功能是可以理解和可取的;它源自于创建极简主义软件并可以轻松适应用户不同需求的 Unix 哲学传统,避免了软件膨胀的诅咒。然而,区块链不是个人计算操作系统;它们是社会系统。这意味着在协议中封装某些功能的理由超出了纯个人计算环境中存在的理由。
在许多情况下,这些其他示例重新概括了与账户抽象相似的教训。但也有一些新的教训被学到:
此外,流动性质押、ZK-EVM 和预编译案例展示了一种中间道路的可能性:最小可行封装(minimal viable enshrinement)。与其封装整个功能,协议可以封装解决使该功能易于实现的关键挑战的特定部分,而不过于主观或狭隘。其中的例子包括:
我们可以根据之前在帖子中的图表进行扩展,如下所示:
有时,甚至可能有必要去封装一些功能。移除很少使用的预编译就是一个例子。正如前面提到的,账户抽象作为一个整体也是一种重要的去封装形式。如果我们希望为现有用户提供向后兼容性,那么机制实际上可能与去封装预编译的机制非常相似:其中一个提案是EIP-5003,它将允许 EOA 将其账户原地转换为具有相同(或更好)功能的合约。
应该将哪些功能引入协议,哪些功能应留给生态系统的其他层面,这是一个复杂的权衡,我们应该预计这种权衡会随着时间的推移而不断发展,因为我们对用户需求的理解以及可用的想法和技术套件也在不断改进。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!