如何使用 Swift Package Manager 开发命令行工具?

最近准备魔改一下 R.swift 以支持 Pod 库生成对应的 R.generated.swift 文件。经研究后发现,R.swift 的本质是使用 Swift Package Manager(简称 SPM) 开发了一个命令行工具 rswift。很显然,要想魔改 R.swift,必须要学习如何使用 Swift Package Manager 来开发命令行工具。本文,则通过一个简单的例子来对此进行介绍。

初始化

假设,我们要开发一个命令行工具:filecreator,能够根据输入的文件名创建对应的文件。

首先,我们可以通过以下命令来创建并初始化项目。

1
2
3
$ mkdir FileCreator
$ cd FileCreator
$ swift package init --type executable

其中,--type executable 表示我们要创建的是一个可执行文件(本文创建一个命令行工具),而不是一个 framework。

当初始化完毕之后,项目目录下会生成一系列文件:

  • Package.swift:一个描述文件,定义了 Package 的基本信息以及依赖。
  • Sources/:源码目录。初始化时会自动生成一个 main.swift 文件,即 命令行工具的入口
  • Tests/:测试目录,存放测试代码。
  • .gitignore:默认忽略了 SPM 的编译目录 .build 以及 Xcode 项目相关文件。

代码拆分

对于开发 SPM 可执行文件,一般建议将源代码拆分为两部分,分别用于创建 framework 和可执行文件。这样做不但可以让测试变得更加简单,而且可以让包含核心功能的 framework 作为其他工具的依赖库。

对此,我们可以在 Sources 目录下创建一个用于 framework 的目录,如下所示:

1
2
$ cd Sources
$ mkdir FileCreatorCore

由于 SPM 使用文件系统作为编译参照,我们可以通过创建一个新的目录来定义一个新的模块。

接下来,更新 Package.swift 来定义两个 target:一个用于 FileCreator,一个用于 FileCreatorCore,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
import PackageDescription

let package = Package(
name: "FileCreator",
targets: [
.target(
name: "FileCreator",
dependencies: ["FileCreatorCore"]
),
.target(name: "FileCreatorCore")
]
)

其中,描述文件中定义了 FileCreator 依赖 FileCreatorCore

基于 Xcode 开发

由于 Xcode 支持代码补全、调试、运行等功能,开发者会倾向于使用 Xcode 进行开发。对此,SPM 提供了一条命令可以让我们快速生成一个 xcode 项目。与之对应,.gitignore 文件也默认忽略了相关的 xcode 文件。

在项目的根目录下,我们可以通过如下命令生成 Xcode 项目。

1
$ swift package generate-xcodeproj

程序入口

我们知道在 c 语言开发中所有程序都有一个 main 入口函数。这里,虽然没有定义 main 函数,但是定义了一个 main.swift 文件(因此我们不能修改 main.swift 的文件名)。一般而言,我们建议将核心实现逻辑放在单独一个文件中,而核心调用逻辑则放在 main.swift 中。

对此,我们在 Sources/FileCreatorCore 目录下创建一个核心实现文件 FileCreatorCore.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Sources/FileCreatorCore/FileCreatorCore.swift
import Foundation

public final class FileCreator {
private let arguments: [String]

public init(arguments: [String] = CommandLine.arguments) {
self.arguments = arguments
}

public func run() throws {
print("Hello world")
}
}

然后,在 main.swift 中调用 run() 方法。

1
2
3
4
5
6
7
8
9
import FileCreatorCore

let tool = FileCreator()

do {
try tool.run()
} catch {
print("Whoops! An error occurred: \(error)")
}

编译运行

接下来,我们来对项目进行编译并运行,命令分别如下:

1
2
3
$ swift build
$ swift run
> Hello world

添加依赖

和 iOS 开发一样,我们开发 swift package 时也会依赖第三方的 swift package 或 framework。依赖关系则定义在 Package.swift 中,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import PackageDescription

let package = Package(
name: "FileCreator",
dependencies: [
.package(
name: "Files",
url: "https://github.com/johnsundell/files.git",
from: "4.0.0"
)
],
targets: [
.target(
name: "FileCreator",
dependencies: ["FileCreatorCore"]
),
.target(
name: "FileCreatorCore",
dependencies: ["Files"]
)
]
)

其中,Files 是一个基于 swift 编写的第三方框架,支持简单的处理文件和目录。我们在 fileCreator 中使用 Files 实现在当前目录下创建文件。

安装依赖

当声明的依赖发生变化后,我们可以使用 SPM 来解析并安装依赖,然后重新生成 Xcode 项目。

1
2
$ swift package update
$ swift package generate-xcodeproj

安装完依赖后,会生成一个 Package.resolved 文件,这是 SPM 生成的锁存文件,记录了依赖更新后的全版本列表。

读取参数

接下来,我们来修改 Sources/FileCreatorCore/FileCreator.swift,替换掉 print "Hello world",实现通过传入参数创建文件的功能。

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

public final class FileCreator {
private let arguments: [String]

public init(arguments: [String] = CommandLine.arguments) {
self.arguments = arguments
}

public func run() throws {
guard arguments.count > 1 else {
throw Error.missingFileName
}

let fileName = arguments[1]

do {
try Folder.current.createFile(at: fileName)
} catch {
throw Error.failedToCreateFile
}
}
}

public extension FileCreator {
enum Error: Swift.Error {
case missingFileName
case failedToCreateFile
}
}

测试

由于之前我们将命令行工具的实现才分为了两部分:framework 和可执行文件,因此,测试也变得非常简单。我们要做的仅仅是运行并断言创建了相应的文件即可。

首先,我们在 Package.swift 中声明一个测试模块,如下所示:

1
2
3
4
.testTarget(
name: "FileCreatorTests",
dependencies: ["FileCreatorCore", "Files"]
)

然后,重新生成 Xcode 项目。

1
$ swift package generate-xcodeproj

重新使用 Xcode 打开项目,修改 FileCreatorTests.swift

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 Foundation
import XCTest
import Files
import FileCreatorCore

class FileCreatorTests: XCTestCase {
func testCreatingFile() throws {
// Setup a temp test folder that can be used as a sandbox
let tempFolder = Folder.temporary
let testFolder = try tempFolder.createSubfolderIfNeeded(
withName: "FileCreatorTests"
)

// Empty the test folder to ensure a clean state
try testFolder.empty()

// Make the temp folder the current working folder
let fileManager = FileManager.default
fileManager.changeCurrentDirectoryPath(testFolder.path)

// Create an instance of the command line tool
let arguments = [testFolder.path, "Hello.swift"]
let tool = FileCreator(arguments: arguments)

// Run the tool and assert that the file was created
try tool.run()
XCTAssertNotNil(try? testFolder.file(named: "Hello.swift"))
}
}

最后,执行 swift test 即可进行测试。

安装命令行工具

截止现在,我们已经构建并测试了我们的命令行工具,接下来我们来安装它,使得能够在命令行进行全局调用。对此,我们需要使用 release 配置进行构建,然后将编译产出的二进制放到 /usr/local/bin 目录下。

1
2
3
$ swift build -c release
$ cd .build/release
$ cp FileCreator /usr/local/bin/filecreator

参考

  1. R.swift
  2. swiftc: 强大的命令行工具
  3. Building a command line tool using the Swift Package Manager
  4. Scripting and Compiling Swift on the Command Line