导入路径解析

为了能够在所有平台上支持可重现的构建,Solidity 编译器必须抽象出存储源文件的底层文件系统的细节。在导入中使用的路径必须在所有地方以相同的方式工作,而命令行界面必须能够使用特定于平台的路径来提供良好的用户体验。本节旨在详细解释 Solidity 如何协调这些要求。

虚拟文件系统

编译器维护一个内部数据库(简称虚拟文件系统VFS),每个源单元都分配了一个唯一的源单元名称,它是一个不透明且无结构的标识符。当你使用import 语句时,你指定一个导入路径,该路径引用源单元名称。

导入回调

VFS 最初只包含编译器接收到的输入文件。在编译过程中可以使用导入回调加载其他文件,该回调根据使用的编译器类型而有所不同(见下文)。如果编译器在 VFS 中找不到与导入路径匹配的任何源单元名称,它将调用回调,回调负责获取要放置在该名称下的源代码。导入回调可以自由地以任意方式解释源单元名称,而不仅仅是作为路径。如果没有回调可用或回调无法找到源代码,则编译失败。

默认情况下,命令行编译器提供主机文件系统加载程序——一个将源单元名称解释为本地文件系统中的路径的基本回调。可以使用--no-import-callback命令行选项禁用此回调。JavaScript 接口默认情况下不提供任何回调,但用户可以提供一个。此机制可用于从本地文件系统以外的位置(甚至可能无法访问,例如当编译器在浏览器中运行时)获取源代码。例如,Remix IDE提供了一个通用的回调,允许你从 HTTP、IPFS 和 Swarm URL 导入文件,或直接引用 NPM 存储库中的包

注意

主机文件系统加载程序的文件查找依赖于平台。例如,源单元名称中的反斜杠可以被解释为目录分隔符,也可以不被解释,查找可以区分大小写,也可以不区分大小写,这取决于底层平台。

为了可移植性,建议避免使用只在特定导入回调或特定平台上才能正确工作的导入路径。例如,你应该始终使用正斜杠,因为它们在支持反斜杠的平台上也用作路径分隔符。

虚拟文件系统的初始内容

VFS 的初始内容取决于你如何调用编译器

  1. solc/命令行界面

    当使用编译器的命令行界面编译文件时,你需要提供一个或多个包含 Solidity 代码的文件的路径

    solc contract.sol /usr/local/dapp-bin/token.sol
    

    以这种方式加载的文件的源单元名称是通过将其路径转换为规范形式构建的,并且如果可能,将其设为相对于基础路径或其中一个包含路径的路径。有关此过程的详细说明,请参阅CLI 路径规范化和剥离

  2. 标准 JSON

    当使用标准 JSON API(通过JavaScript 接口--standard-json命令行选项)时,你需要提供 JSON 格式的输入,其中包含所有源文件的内容等

    {
        "language": "Solidity",
        "sources": {
            "contract.sol": {
                "content": "import \"./util.sol\";\ncontract C {}"
            },
            "util.sol": {
                "content": "library Util {}"
            },
            "/usr/local/dapp-bin/token.sol": {
                "content": "contract Token {}"
            }
        },
        "settings": {"outputSelection": {"*": { "*": ["metadata", "evm.bytecode"]}}}
    }
    

    sources 字典成为虚拟文件系统的初始内容,其键用作源单元名称。

  3. 标准 JSON(通过导入回调)

    使用标准 JSON,还可以告诉编译器使用导入回调获取源代码

    {
        "language": "Solidity",
        "sources": {
            "/usr/local/dapp-bin/token.sol": {
                "urls": [
                    "/projects/mytoken.sol",
                    "https://example.com/projects/mytoken.sol"
                ]
            }
        },
        "settings": {"outputSelection": {"*": { "*": ["metadata", "evm.bytecode"]}}}
    }
    

    如果提供导入回调,编译器将依次向其提供urls中指定的字符串,直到成功加载一个或达到列表末尾。

    源单元名称的确定方式与使用content时相同——它们是sources 字典的键,并且urls的内容不会以任何方式影响它们。

  4. 标准输入

    在命令行中,也可以通过将源代码发送到编译器的标准输入来提供源代码

    echo 'import "./util.sol"; contract C {}' | solc -
    

    -用作参数之一,指示编译器将标准输入的内容放置在虚拟文件系统中,位于一个特殊的源单元名称下:<stdin>

VFS 初始化后,只能通过导入回调向其添加其他文件。

导入

import 语句指定一个导入路径。根据导入路径的指定方式,我们可以将导入分为两类

  • 直接导入,你直接指定完整的源单元名称。

  • 相对导入,你指定以./../开头的路径,该路径将与导入文件的源单元名称组合。

contracts/contract.sol
import "./math/math.sol";
import "contracts/tokens/token.sol";

在上面的例子中,./math/math.solcontracts/tokens/token.sol是导入路径,而它们转换成的源单元名称分别是contracts/math/math.solcontracts/tokens/token.sol

直接导入

不以./../开头的导入是直接导入

import "/project/lib/util.sol";         // source unit name: /project/lib/util.sol
import "lib/util.sol";                  // source unit name: lib/util.sol
import "@openzeppelin/address.sol";     // source unit name: @openzeppelin/address.sol
import "https://example.com/token.sol"; // source unit name: https://example.com/token.sol

应用任何导入重映射后,导入路径将简单地变成源单元名称。

注意

源单元名称只是一个标识符,即使它的值恰好看起来像一个路径,它也不受你在 shell 中通常期望的规范化规则的约束。任何/.//../段或多个斜杠的序列都将保留在其中。当源代码通过标准 JSON 接口提供时,完全有可能将不同的内容与指向磁盘上相同文件的源单元名称相关联。

当源代码在虚拟文件系统中不可用时,编译器会将源单元名称传递给导入回调。主机文件系统加载程序将尝试将其用作路径,并在磁盘上查找文件。此时,特定于平台的规范化规则生效,在 VFS 中被认为不同的名称实际上可能导致加载相同的文件。例如,/project/lib/math.sol/project/lib/../lib///math.sol在 VFS 中被认为是完全不同的,即使它们指向磁盘上的同一个文件。

注意

即使导入回调最终从磁盘上的同一个文件加载了两个不同源单元名称的源代码,编译器仍然会将它们视为单独的源单元。重要的是源单元名称,而不是代码的物理位置。

相对导入

./../开头的导入是相对导入。此类导入指定相对于导入源单元的源单元名称的路径

/project/lib/math.sol
import "./util.sol" as util;    // source unit name: /project/lib/util.sol
import "../token.sol" as token; // source unit name: /project/token.sol
lib/math.sol
import "./util.sol" as util;    // source unit name: lib/util.sol
import "../token.sol" as token; // source unit name: token.sol

注意

相对导入始终./../开头,因此import "util.sol"import "./util.sol"不同,是直接导入。虽然这两个路径在主机文件系统中都被认为是相对的,但util.sol在 VFS 中实际上是绝对的。

让我们将路径段定义为路径的任何非空部分,该部分不包含分隔符,并且以两个路径分隔符为界。分隔符是正斜杠或字符串的开头/结尾。例如,在./abc/..//中,有三个路径段:.abc..

编译器根据导入路径将导入解析为源单元名称,方式如下

  1. 我们从导入源单元的源单元名称开始。

  2. 从已解析的名称中删除最后一个包含前导斜杠的路径段。

  3. 然后,对于导入路径中的每个段,从最左边的段开始

    • 如果该段是.,则跳过它。

    • 如果段是 ..,则从解析的名称中删除最后一个路径段及其前面的斜杠。

    • 否则,该段(如果解析的名称不为空,则在前面加上一个斜杠)将附加到解析的名称。

删除最后一个路径段及其前面的斜杠的操作理解如下

  1. 删除最后一个斜杠后的所有内容(即 a/b//c.sol 变为 a/b//)。

  2. 删除所有尾部斜杠(即 a/b// 变为 a/b)。

请注意,该过程根据 UNIX 路径的常用规则对来自导入路径的解析源单元名称的一部分进行规范化,即删除所有 ...,并将多个斜杠压缩为一个。另一方面,来自导入模块的源单元名称的一部分保持未规范化。这确保了如果导入文件使用 URL 标识,则 protocol:// 部分不会变成 protocol:/

如果您的导入路径已经规范化,那么您可以预期上述算法会产生非常直观的結果。以下是如果您没有规范化,您可以预期的结果的一些示例

lib/src/../contract.sol
import "./util/./util.sol";         // source unit name: lib/src/../util/util.sol
import "./util//util.sol";          // source unit name: lib/src/../util/util.sol
import "../util/../array/util.sol"; // source unit name: lib/src/array/util.sol
import "../.././../util.sol";       // source unit name: util.sol
import "../../.././../util.sol";    // source unit name: util.sol

注意

不建议使用包含前导 .. 段的相对导入。可以使用更可靠的方法通过使用带有 基本路径和包含路径 的直接导入来实现相同的效果。

基本路径和包含路径

基本路径和包含路径表示主机文件系统加载器将从其中加载文件的目录。当源单元名称传递给加载器时,它会在其前面添加基本路径并执行文件系统查找。如果查找不成功,则对包含路径列表中的所有目录执行相同的操作。

建议将基本路径设置为项目的根目录,并使用包含路径指定可能包含项目依赖的库的附加位置。这使您可以以统一的方式从这些库中导入,无论它们相对于项目的存储位置在文件系统中的什么位置。例如,如果您使用 npm 安装包,并且您的合约导入 @openzeppelin/contracts/utils/Strings.sol,您可以使用这些选项告诉编译器该库可以在 npm 包目录之一中找到

solc contract.sol \
    --base-path . \
    --include-path node_modules/ \
    --include-path /usr/local/lib/node_modules/

您的合约将编译(具有完全相同的元数据),无论您是在本地或全局包目录中安装库,还是直接在项目根目录下安装库。

默认情况下,基本路径为空,这会使源单元名称保持不变。当源单元名称是相对路径时,这会导致在调用编译器的目录中查找该文件。这也是唯一一个导致源单元名称中的绝对路径实际上在磁盘上解释为绝对路径的值。如果基本路径本身是相对路径,则它被解释为相对于编译器的当前工作目录。

注意

包含路径不能有空值,并且必须与非空基本路径一起使用。

注意

包含路径和基本路径可以重叠,只要它不会使导入解析产生歧义。例如,您可以将基本路径内的目录指定为包含目录,或者让包含目录是另一个包含目录的子目录。如果传递给主机文件系统加载器的源单元名称表示与多个包含路径或包含路径和基本路径组合在一起的现有路径,则编译器只会发出错误。

CLI 路径规范化和剥离

在命令行上,编译器的工作方式与您对任何其他程序的预期相同:它接受平台本地的格式的路径,相对路径相对于当前工作目录。但是,分配给在命令行上指定其路径的文件的源单元名称不应仅因为项目正在不同的平台上编译,或者编译器恰好从不同的目录调用而更改。为了实现这一点,必须将来自命令行的源文件路径转换为规范形式,并且如果可能,使其相对于基本路径或包含路径之一。

规范化规则如下

  • 如果路径是相对路径,则通过在其前面添加当前工作目录将其变为绝对路径。

  • 内部 ... 段被折叠。

  • 特定于平台的路径分隔符被替换为正斜杠。

  • 多个连续路径分隔符的序列被压缩为单个分隔符(除非它们是 UNC 路径 的前导斜杠)。

  • 如果路径包含根名称(例如 Windows 上的驱动器号),并且根名称与当前工作目录的根名称相同,则根名称将被替换为 /

  • 路径中的符号链接不会被解析。

    • 唯一的例外是用于将相对路径变为绝对路径的过程中添加的当前工作目录的路径。在某些平台上,始终报告工作目录,符号链接已解析,因此为了保持一致性,编译器在所有地方都解析它们。

  • 即使文件系统不区分大小写但 区分大小写 并且磁盘上的实际大小写不同,也会保留路径的原始大小写。

注意

在某些情况下,路径无法独立于平台。例如,在 Windows 上,编译器可以通过将当前驱动器的根目录称为 / 来避免使用驱动器号,但对于指向其他驱动器的路径,驱动器号仍然是必要的。您可以通过确保所有文件都可以在同一驱动器上的单个目录树中使用来避免这种情况。

规范化后,编译器尝试使源文件路径变为相对路径。它首先尝试基本路径,然后按给定的顺序尝试包含路径。如果基本路径为空或未指定,则它被视为等于当前工作目录的路径(已解析所有符号链接)。只有当规范化的目录路径是规范化的文件路径的确切前缀时,才会接受结果。否则,文件路径将保持为绝对路径。这使得转换明确无误,并确保相对路径不会以 ../ 开头。生成的文件夹路径将成为源单元名称。

注意

通过剥离产生的相对路径必须在基本路径和包含路径中保持唯一。例如,如果 /project/contract.sol/lib/contract.sol 都存在,则编译器将对以下命令发出错误

solc /project/contract.sol --base-path /project --include-path /lib

注意

在 0.8.8 版之前,不会执行 CLI 路径剥离,并且应用的唯一规范化是路径分隔符的转换。在使用旧版本的编译器时,建议从基本路径调用编译器,并且仅在命令行上使用相对路径。

允许的路径

作为安全措施,主机文件系统加载器将拒绝加载来自默认情况下被认为安全的几个位置之外的文件

  • 标准 JSON 模式之外

    • 包含命令行上列出的输入文件的目录。

    • 用作 重映射 目标的目录。如果目标不是目录(即没有以 //./.. 结尾),则使用包含目标的目录。

    • 基本路径和包含路径。

  • 在标准 JSON 模式中

    • 基本路径和包含路径。

可以使用 --allow-paths 选项将其他目录列入白名单。该选项接受以逗号分隔的路径列表

cd /home/user/project/
solc token/contract.sol \
    lib/util.sol=libs/util.sol \
    --base-path=token/ \
    --include-path=/lib/ \
    --allow-paths=../utils/,/tmp/libraries

当编译器使用上面显示的命令调用时,主机文件系统加载器将允许从以下目录导入文件

  • /home/user/project/token/(因为 token/ 包含输入文件,并且也是因为它是基本路径),

  • /lib/(因为 /lib/ 是包含路径之一),

  • /home/user/project/libs/(因为 libs/ 是包含重映射目标的目录),

  • /home/user/utils/(因为 ../utils/ 传递给 --allow-paths),

  • /tmp/libraries/(因为 /tmp/libraries 传递给 --allow-paths),

注意

只有当编译器的当前工作目录恰好是基本路径(或基本路径未指定或其值为 null)时,它才是默认允许的路径之一。

注意

编译器不检查允许的路径是否实际存在以及它们是否为目录。不存在或为空的路径将被简单地忽略。如果允许的路径匹配文件而不是目录,则该文件也被认为在白名单中。

注意

即使文件系统不区分大小写,允许的路径也区分大小写。大小写必须与您在导入中使用的大小写完全匹配。例如,--allow-paths tokens 将不匹配 import "Tokens/IERC20.sol"

警告

只能通过允许目录中的符号链接访问的文件和目录不会自动列入白名单。例如,如果上面的示例中的 token/contract.sol 实际上是一个指向 /etc/passwd 的符号链接,则编译器将拒绝加载它,除非 /etc/ 也是允许的路径之一。

导入重映射

导入重映射允许您将导入重定向到虚拟文件系统中的不同位置。该机制通过更改导入路径和源单元名称之间的转换来实现。例如,您可以设置一个重映射,以便来自虚拟目录 github.com/ethereum/dapp-bin/library/ 的任何导入都被视为来自 dapp-bin/library/ 的导入。

您可以通过指定一个上下文来限制重映射的范围。这允许创建仅对位于特定库或特定文件中的导入起作用的重映射。如果没有上下文,重映射将应用于虚拟文件系统中所有文件中的每个匹配导入。

导入重映射的格式为 context:prefix=target

  • context 必须与包含导入的文件的源单元名称的开头匹配。

  • prefix 必须与导入产生的源单元名称的开头匹配。

  • target 是用它替换前缀的值。

例如,如果您在本地将 https://github.com/ethereum/dapp-bin/ 克隆到 /project/dapp-bin 并使用以下命令运行编译器:

solc github.com/ethereum/dapp-bin/=dapp-bin/ --base-path /project source.sol

您可以在您的源文件中使用以下内容:

import "github.com/ethereum/dapp-bin/library/math.sol"; // source unit name: dapp-bin/library/math.sol

编译器将在 VFS 中的 dapp-bin/library/math.sol 下查找该文件。如果该文件不可用,源单元名称将传递给主机文件系统加载器,它将接着在 /project/dapp-bin/library/math.sol 中查找。

警告

有关重映射的信息存储在合约元数据中。由于编译器生成的二进制文件在其嵌入的元数据哈希中,对重映射的任何修改都将导致不同的字节码。

因此,您应该注意不要在重映射目标中包含任何本地信息。例如,如果您的库位于 /home/user/packages/mymath/math.sol 中,类似 @math/=/home/user/packages/mymath/ 的重映射将导致您的主目录被包含在元数据中。为了能够在不同的机器上使用这种重映射重现相同的字节码,您将需要在 VFS 中(如果您依赖于主机文件系统加载器)以及主机文件系统中重新创建本地目录结构的一部分。

为了避免将您的本地目录结构嵌入元数据中,建议将包含库的目录指定为包含路径。例如,在上面的示例中,--include-path /home/user/packages/ 将允许您使用以 mymath/ 开头的导入。与重映射不同的是,此选项本身不会使 mymath 显示为 @math,但这可以通过创建符号链接或重命名包子目录来实现。

作为一个更复杂的示例,假设您依赖于一个模块,该模块使用了一个您签出到 /project/dapp-bin_old 的旧版本的 dapp-bin,那么您可以运行以下命令:

solc module1:github.com/ethereum/dapp-bin/=dapp-bin/ \
     module2:github.com/ethereum/dapp-bin/=dapp-bin_old/ \
     --base-path /project \
     source.sol

这意味着 module2 中的所有导入都指向旧版本,但 module1 中的导入指向新版本。

以下是管理重映射行为的详细规则:

  1. 重映射只影响导入路径和源单元名称之间的转换。

    以任何其他方式添加到 VFS 的源单元名称不能重映射。例如,您在命令行上指定的路径以及标准 JSON 中的 sources.urls 中的路径不受影响。

    solc /project/=/contracts/ /project/contract.sol # source unit name: /project/contract.sol
    

    在上面的示例中,编译器将从 /project/contract.sol 加载源代码,并将其放在 VFS 中的该确切的源单元名称下,而不是放在 /contract/contract.sol 下。

  2. 上下文和前缀必须与源单元名称匹配,而不是导入路径。

    • 这意味着您不能直接重映射 ./../,因为它们在转换为源单元名称时会被替换,但您可以重映射它们被替换的部分名称。

      solc ./=a/ /project/=b/ /project/contract.sol # source unit name: /project/contract.sol
      
      /project/contract.sol
      import "./util.sol" as util; // source unit name: b/util.sol
      
    • 您不能重映射基本路径或任何其他仅由导入回调在内部添加的路径部分。

      solc /project/=/contracts/ /project/contract.sol --base-path /project # source unit name: contract.sol
      
      /project/contract.sol
      import "util.sol" as util; // source unit name: util.sol
      
  3. 目标直接插入源单元名称中,不一定必须是有效的路径。

    • 只要导入回调可以处理它,它可以是任何东西。在主机文件系统加载器的情况下,这也包括相对路径。当使用 JavaScript 接口时,如果您的回调可以处理它们,您甚至可以使用 URL 和抽象标识符。

    • 重映射在相对导入已解析为源单元名称后发生。这意味着以 ./../ 开头的目标没有特殊含义,它们相对于基本路径,而不是相对于源文件的位置。

    • 重映射目标不会被规范化,因此 @root/=./a/b// 将重映射 @root/contract.sol./a/b//contract.sol 而不是 a/b/contract.sol

    • 如果目标没有以斜杠结尾,编译器不会自动添加斜杠。

      solc /project/=/contracts /project/contract.sol # source unit name: /project/contract.sol
      
      /project/contract.sol
      import "/project/util.sol" as util; // source unit name: /contractsutil.sol
      
  4. 上下文和前缀是模式,匹配必须是精确的。

    • a//b=c 不会匹配 a/b

    • 源单元名称不会被规范化,因此 a/b=c 也不会匹配 a//b

    • 文件和目录名称的部分也可以匹配。 /newProject/con:/new=old 将匹配 /newProject/contract.sol 并将其重映射到 oldProject/contract.sol

  5. 对单个导入最多应用一次重映射。

    • 如果多个重映射匹配相同的源单元名称,则选择具有最长匹配前缀的重映射。

    • 如果前缀相同,则最后指定的重映射获胜。

    • 重映射不适用于其他重映射。例如,a=b b=c c=d 不会导致 a 被重映射到 d

  6. 前缀不能为空,但上下文和目标是可选的。

    • 如果 target 是空字符串,则 prefix 将从导入路径中简单地删除。

    • context 意味着重映射应用于所有源单元中的所有导入。

在导入中使用 URL

大多数 URL 前缀,如 https://data:// 在导入路径中没有特殊含义。唯一的例外是 file://,它由主机文件系统加载器从源单元名称中剥离。

在本地编译时,您可以使用导入重映射将协议和域名部分替换为本地路径。

solc :https://github.com/ethereum/dapp-bin=/usr/local/dapp-bin contract.sol

请注意前面的 :,当重映射上下文为空时,这是必需的。否则,https: 部分将被编译器解释为上下文。