6.1.4 编写构建工具插件

原文链接: https://docs.swift.org/latest/documentation/packagemanagerdocs/writingbuildtoolplugin/

6.1.4 编写构建工具插件

创建一个构建工具来处理或生成文件。

概述

编写包插件的第一步是确定你需要哪种插件。如果你要生成应当参与构建的源文件,或者在每次构建开始时执行其他操作,就实现构建工具插件。构建工具插件在包被构建之前调用,用于构造出作为构建一部分来运行的命令调用。

构建工具插件可以提供两类命令:

  • 预构建命令:包管理器在构建开始之前运行的命令。预构建命令可以生成任意数量的输出文件,其名称在运行命令之前无法预测。
  • 构建命令:包管理器把它们纳入构建系统的依赖图,并在构建期间的适当时机,根据其预定义输入与输出的存在情况和时间戳来运行。

注意:如果你的目标是提供一个可以随时执行、且与构建无关的动作, 那么应当实现命令插件。 关于创建命令插件的细节,参见编写命令插件。

对于预构建命令和构建命令都需要注意:真正干活的不是构建工具插件,而是它构造出的、之后由构建来运行的命令,做实际工作的是那些命令。插件可以相当小,通常只关心为真正干活的构建命令拼出命令行。

构建工具插件对定义它的包可用;如果存在对应的插件产品,那么对任何直接依赖该定义包的包也可用。

构建命令

当所有输入和输出的路径在命令运行之前都已知时,优先创建构建命令,而不是预构建命令。构建工具命令更高效,因为它们向构建系统提供了所需的信息,让构建系统能高效地判断何时应当调用它们。

一个例子是源码转换工具,它为每个输入文件生成一个(名称可预测的)输出文件。其他例子还有:构建命令无需先运行工具就能控制输出的名称。在这些情况下,只有部分预期输出缺失,或者输入在上次运行命令之后发生了变化时,构建系统才会运行这些命令。构建命令不要求输入与输出一一对应;它可以通过检查输入目标,自由决定创建多少个(如果有的话)输出文件。

预构建命令

只有当输出的名称要等到工具运行之后才已知时,才创建预构建命令。如果输入文件的内容(而不是输入文件名)决定了输出文件的数量和名称——例如根据配置文件的内容生成代码——就属于这种情况。构建系统会在每次构建之前运行预构建命令。因此,它们应当自己做好缓存,把工作量降到最低,以免拖慢增量构建。

在包清单中声明构建工具插件

在包清单中声明构建工具插件。这通过在包的 targets 部分添加 pluginTarget 条目来完成。再在产品部分添加对应的 plugin 条目,让其他包也能使用该插件。

下面的例子定义了一个名为 “MyBuildToolPlugin” 的构建工具,它依赖产品 SomeTool,并且可以被其他包使用:

 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
import PackageDescription

let package = Package(
    name: "MyPluginPackage",
    products: [
        .plugin(
            name: "MyBuildToolPlugin",
            targets: [
                "MyBuildToolPlugin"
            ]
        )
    ],
    dependencies: [
        .package(
            url: "https://github.com/example/sometool",
            from: "0.1.0"
        )
    ],
    targets: [
        .plugin(
            name: "MyBuildToolPlugin",
            capability: .buildTool(),
            dependencies: [
                .product(name: "SomeTool", package: "sometool"),
            ]
        )
    ]
)

插件目标声明插件的名称、能力以及它的依赖。.buildTool() 这一能力表明它定义的是一个构建工具插件。该能力同时表明插件应当实现哪个入口点。

当你声明 plugin 产品时,就使该插件对依赖本包的其他包可见。插件名不必与产品名一致,但为了避免混淆,它们通常相同。只列出该目标所提供的插件名即可。如果你只在包内使用该构建工具插件,就不需要声明 plugin 产品。

构建工具目标的依赖

这些依赖指定插件构造命令时可用的命令行工具。每个依赖可以是同一个包中的 executableTarget 或 binaryTarget 目标,也可以是另一个包中的 executable 产品。在上面的例子中,插件依赖假想的 SomeTool 产品,它位于定义插件的包所依赖的 sometool 包中。注意,这并不一定意味着调用插件时 SomeTool 已经被构建好;它的含义是插件可以查到该工具在插件所构造的任何命令运行时会存在的位置。

可执行依赖会作为构建的一部分针对宿主机平台构建,而二进制依赖则是对包含预编译二进制的 artifactbundle 归档的引用(参见 SE-305)。当工具是用包管理器之外的构建系统构建的,或者按需构建它的代价高得离谱、又或者它需要特殊的构建环境时,常常会使用二进制目标。

根据所声明的依赖类型,把可执行目标的名称、可执行产品的名称或可执行产物的名称传给 PluginContext.tool(named:)。二进制产物包必须包含支持宿主机平台的变体,因为即使包正在被交叉编译,构建工具仍然运行在宿主机上。

声明依赖是让构建工具插件能够使用某个工具的可移植做法。Swift Package Manager 也会搜索包含所选 Swift 编译器的目录,这让当前工具链中的工具可用;但它不会为构建工具插件搜索用户的 PATH。IDE 和其他宿主可能提供不同的搜索目录。不要让用户必须在 PATH 上安装某个构建工具;应该通过可执行依赖或二进制依赖来提供它。

实现构建工具插件脚本

默认情况下,Swift Package Manager 会在 Plugins 目录下与插件目标同名的子目录中查找所声明插件的实现。这可以用目标声明中的 path 参数覆盖。

一个插件由一个或多个 Swift 源文件组成。让构建工具插件脚本的主入口点遵循 BuildToolPlugin 协议。

就像包清单会导入包管理器提供的 PackageDescription 模块一样,包插件会导入 PackagePlugin 模块。PackagePlugin 模块包含插件从包管理器接收信息、并向其回传结果的 API。插件脚本可以导入 Foundation 和其他标准库,但不能导入其他库。

下面的例子返回一个 buildCommand 实例,因此包管理器会把它纳入构建系统的命令图。如果有任何输出文件缺失,或者任何输入文件的内容在上次运行该命令之后发生了变化,构建系统就会运行它。

 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
30
31
32
33
34
35
36
37
38
39
import PackagePlugin

@main
struct MyPlugin: BuildToolPlugin {
    
  func createBuildCommands(context: PluginContext, 
                           target: Target) throws -> [Command] {
    // 这个插件只对可以有源文件的包目标运行。
    guard let sourceFiles = target.sourceModule?.sourceFiles else { return [] }

    // 找到要运行的代码生成器工具(请替换成实际的那个)。
    let generatorTool = try context.tool(named: "my-code-generator")

    // 为每个具有特定后缀的源文件构造一条构建命令。
    return sourceFiles.map(\.url).compactMap {
        createBuildCommand(for: $0, in: context.pluginWorkDirectoryURL, with: generatorTool.url)
    }
  }

  func createBuildCommand(for inputPath: URL,
                          in outputDirectoryPath: URL,
                          with generatorToolPath: URL) -> Command? {
    // 跳过任何扩展名不是我们要找的文件
    // (请替换成实际的扩展名)。
    guard inputPath.pathExtension == "my-input-suffix" else { return .none }

    // 返回一条将在构建期间运行、用于生成输出文件的命令。
    let inputName = inputPath.lastPathComponent
    let outputName = inputPath.deletingPathExtension().lastPathComponent + ".swift"
    let outputPath = outputDirectoryPath.appendingPathComponent(outputName)
    return .buildCommand(
        displayName: "Generating \(outputName) from \(inputName)",
        executable: generatorToolPath,
        arguments: ["\(inputPath)", "-o", "\(outputPath)"],
        inputFiles: [inputPath],
        outputFiles: [outputPath]
    )
  }
}

构建工具插件总是作用于某个目标,该目标作为参数提供。只有源模块目标才有源文件,因此遍历源文件的插件通常会检查传给它的目标是否遵循 SourceModuleTarget。

构建工具插件也可以返回 prebuildCommand 类型的命令。这些命令在构建开始之前运行,可以向某个目录写入一批输出文件,而这些文件的名称要等命令运行之后才已知:

 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
import PackagePlugin
import Foundation

@main
struct MyBuildToolPlugin: BuildToolPlugin {
    
  func createBuildCommands(context: PluginContext, 
                           target: Target) throws -> [Command] {

    // 这个例子让 `sometool` 写入插件工作目录中的
    // "GeneratedFiles" 目录
    // (该目录对每个插件和每个目标都是唯一的)。
    let outputDir = context.pluginWorkDirectoryURL
        .appendingPathComponent("GeneratedFiles")
    try FileManager.default.createDirectory(
        at: outputDir,
        withIntermediateDirectories: true)

    // 返回一条把 `sometool` 作为预构建命令运行的命令。
    // 它在每次构建之前运行,并把源文件生成到
    // 构建上下文提供的输出目录中。
    return [.prebuildCommand(
            displayName: "Running SomeTool",
            executable: try context.tool(named: "SomeTool").path,
            arguments: [ "--verbose", "--outdir", outputDir ],
            outputFilesDirectory: outputDir)
    ]
  }
}

对于预构建命令,任何依赖都必须是二进制目标,因为这些命令在构建开始之前运行。

构建工具插件可以同时返回构建工具命令和预构建命令。插件运行之后,构建系统会把它提供的构建命令纳入构建图。这可能导致一些变化,需要在后续构建期间运行相应命令。

构建系统会在插件运行之后、构建开始之前运行预构建命令。预构建命令所声明的 outputFilesDirectory 中的任何文件,都会被当作该目标中的源文件来评估。预构建命令应当在这个目录中添加或删除文件,以反映命令运行的结果。

包管理器支持把生成的 Swift 源文件和资源作为输出,但不支持非 Swift 源文件。任何生成的资源都会像在清单中用 .process() 规则声明过那样被处理。目标(最终)是支持任何可以作为源文件包含进目标的文件类型,并让插件对生成文件的下游处理拥有更大的控制权。

诊断信息

插件入口点被标记为 throws,从入口点抛出的任何错误都会导致构建系统把该插件调用标记为失败。包管理器会把抛出的错误呈现给用户,因此它应当清晰地描述出了什么问题。

此外,插件可以使用 PackagePlugin 中的 Diagnostics API 发出警告和错误。这些信息可以选择性地引用文件路径和这些文件中的行号。

调试与测试

包管理器目前还没有针对插件调试和测试的专门支持。许多插件充当适配器,负责构造命令行来调用真正干活的工具。在插件中包含非平凡代码的情况下,一个好办法是把这些代码抽到单独的源文件中,再通过带相对路径的符号链接把它们纳入单元测试。

Xcode 对 PackagePlugin API 的扩展

当你在 Apple 的 Xcode IDE 中调用插件时,插件可以访问 Xcode 提供的一个名为 XcodeProjectPlugin 的库模块。这个模块扩展了 PackagePlugin 的 API,让插件除了包之外还能处理 Xcode 目标。

为了写出既能在各种环境中处理包、又能在 Xcode 中运行时条件性地处理 Xcode 项目的插件,插件应当在 XcodeProjectPlugin 模块可用时条件性地导入它。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin

extension MyCommandPlugin: XcodeCommandPlugin {

  // 为 Xcode 项目中的目标创建构建命令的入口点。
  func createBuildCommands(context: XcodePluginContext,
                           target: XcodeTarget) throws -> [Command] {
    // 找到要运行的代码生成器工具(请替换成实际的那个)。
    let generatorTool = try context.tool(named: "my-code-generator")

    // 为每个具有特定后缀的源文件构造一条构建命令。
    return target.inputFiles.map(\.url).compactMap {
        createBuildCommand(for: $0,
                           in: context.pluginWorkDirectoryURL,
                           with: generatorTool.url)
    }
  }
}
#endif

XcodePluginContext 输入结构与 PluginContext 结构类似,只是它提供对 Xcode 项目的访问。Xcode 项目在项目模型上使用 Xcode 的命名和语义,与包管理器有些不同。有一些底层类型,例如 FileList 或 Path,在 PackagePlugin 和 XcodeProjectPlugin 中是相同的。

如果在 Xcode 用户界面中选中了某些目标,Xcode 会把它们的名称作为 --target 参数传给插件。

其他 IDE 或使用包管理器的自定义环境,同样可以提供模块来定义新的入口点,并扩展核心 PackagePlugin API 的功能。