swift 编写第一个CLI程序

用swift 编写第一个CLI(command line tool)程序


起因

因为在研究swiftLint的时候,发现了熟悉的swift code,但却无法理解到底实现了什么?

发现swiftlint 都是基于开源库swift-argument-parser 做的,于是乎去了解apple的开源库swift-argument-parser , 然后才知道了,这里面还大有玄机嘞!

swift-argument-parser 这个是用来解析命令行参数的苹果开源库,应用这个库能很方便的进行命令行参数解析。

后来非常好奇,那么swiftLint工程里的Package.swift文件又是啥呀?

一练的疑问,一头的雾水,于是乎扎入到探究的深坑中…

经过一两天的研究,翻看apple的官方文档、开源库swift-argument-parser 的使用、别人的博客,最后进入了另一个天地。 下面我将逐个解开里边的谜题(当然我是第一次知道,因为之前没接触过…,不要笑话哦😄)

依次来说

首先说一说,我们看到Package.swift文件

其实这是苹果新出的一种代码管理方案,叫做Swift Package Manager, 简称为SPM, 我们之前应该对Cocoapods、Carthage(专门用于swift工程无嵌入式工程管理)都比较了解,但是其实也比较类似,SPM也是用来管理三方库的依赖的。 对应到我们Cocoapods 这个Package.swift文件就相当于.podfile。

那么我们怎么创建呢?

​ 两种方式可以创建:

​ 1. 我们可以通过命令行的方式创建:swift package init [–type executable]

  1. 我们可以通过打开xcode创建,image-20230105181140374

这样我们就可以创建出来这样一个工程,如下图:image-20230105181534945

用xcode 打开Package.swift 即可打开工程。

然后我们需要配置Package.swift :

import PackageDescription

let package = Package(
    name: "Counter",
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0")
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .executableTarget(
            name: "Counter",
            dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")]),
    ]
)

如果需要了解所有的参数解释,我们可以通过看xcode Package这个结构体了解。那其实上我们简单的配置包名、包名、依赖、可执行目标已然足够。 这样我们的一个swift package 就配置完成了,当配置完成后,xcode会自动检测包名、可执行名字 和 类名是否对应,以及检测依赖并且下载安装。

紧接着我们说说命令行能干什么

比如我们用的git命令、pod 命令其实都是命令行程序。只是他们可能不是用swift实现的而已,比如我们熟知的cocapods就是用ruby实现。那么相必这么举例我们就知道命令行能干什么?可干的事情可太多了,当然最早说的swiftLint也是一个命令行程序。

接下来我们根据swift-argument-parser 官方示例,写一个小小的CLI

写一个经典的,读取a文件的内容并统计文件中单词的数量,输出到b文件。

如果不用开源库swift-argument-parser ,我们需要一个main.swift 作为程序的入口:

// main.swift
let counter = Counter()

do {
   try counter.run()
} catch {
    print("An error occurred")
}

因为原生的参数解析比较麻烦,我们就写一个比较简单输出吧,

这个时候我们需要写一个Counter:

// Counter.swift
import Foundation

public struct Counter {
    private var arguments: [String]
    
    init(arguments: [String] = CommandLine.arguments) {
        self.arguments = arguments
    }
    
    public func run() throws {
        print("test")
    }
}

然后我们再当前跟目录下,执行命令: swift build , 然后我们执行 swift run Counter, 就可以看见终端的输出了 image-20230105184936299

这样我们算是一个简单的CLI就开发完了。

ok,我们再使用swift-argument-parser 去实现,我们最初的统计

使用ArgumentParser, 我们就不需要在给main.swift入口,只需要命名类或者结构体,继承ParsableCommand 或者AsyncParsableCommand,然后标识为@main即可。

ArgumentParser 有三种类型标识: @Argument 标识必须的参数,带有顺序的, 不可指定名字 @Option 是无序的,可以指定名字。以及是否简写等, @Flag 通常是一个bool指,一个标识。下面分别举例这三种对应到命令行

// 1 @Argument
@Argument var inputFile: String
@Argument var outputFile: String
// 这种情况对应到命令行我们就需要 Counter test1.txt test2.txt ,以属性的顺序给定参数。并且不需要指定

// 2. @Option
@Option var inputFile: String
@Option var outputFile: String
// 这种情况对应到命令行我们就需要 Counter --input-file test1.txt --output-file test2.txt ,当然顺序可以随意,只要指定即可。当然我们也可以@Option(name: help:)等初始化方法指定提示语、简写等等

// 2. @Flag
@Option var inputFile: String
@Option var outputFile: String
@Flag var verbose: Bool = false
// 这种情况对应到命令行我们就需要 Counter --verbose --input-file test1.txt --output-file test2.txt。

OK,大概swift-argument-parser就介绍这么多,具体的可以查看github, 或者官网参考用法。

接下来是我们统计的命令行实现:

import ArgumentParser
import Foundation

@main
struct Counter: ParsableCommand {
    @Option(name: [.short, .customLong("input")], help: "A file to read")
    var inputFile: String
    
    @Option(name: [.short, .customLong("output")], help: "A file to save word counts to")
    var outputFile:String
    
    
    @Flag(name: .shortAndLong, help: "Print status updates while counting") var verbose: Bool = false
    
    mutating func run() throws {
        if verbose {
            print("""
                Counting words in '\(inputFile)' \
                and write result into '\(outputFile)'
                """)
        }
        
        guard let input = try? String(contentsOfFile: inputFile) else {
            throw RuntimeError("Can't read \(inputFile) contents")
        }
        
        let words = input.components(separatedBy: .whitespacesAndNewlines)
            .map { word in
                word.trimmingCharacters(in: CharacterSet.alphanumerics.inverted).lowercased()
            }
            .compactMap { word in
                word.isEmpty ? nil : word
            }
        
        let counts = Dictionary(grouping: words, by: { $0 })
            .mapValues { $0.count }
            .sorted(by: { $0.value > $1.value })
        
        if verbose {
            print("found \(counts.count) words")
        }
        
        let output: String = counts.map { word, count in
            "\(word): \(count)"
        }.joined(separator: "\n")
        
        guard let _ = try? output.write(toFile: outputFile, atomically: true, encoding: .utf8) else {
            throw RuntimeError("Can't write to \(outputFile)")
        }
    }
}

struct RuntimeError: Error, CustomStringConvertible {
    var description: String
    
    init(_ description: String) {
        self.description = description
    }
    
}

在我们理解了ArgumentParser之后和了解了命令行开发之后,在看这些swift的代码就比较简单了。写起来也很简单了。

我们测试一下:

新建test1.txt、test2.txt, 在test1.txt里加入image-20230105190717915

然后,执行 swift run Counter -i test1.txt -o test2.txt -v, 查看输出

image-20230105190933826

我们查看test2.txt文件

image-20230105191015159

确实也写进去了。

我们的命令现在只能在当前工程目录下,并且通过swift run命令执行,如何使得命令在任何目录终端都可以执行?

我们需要打一个release的可执行包,手动移到/urs/local/bin下。

# build 打包默认是根据你的mac系统打包的
swift build --configuration release --product Counter
# 我们还可以增加参数--build-system [native | xcode] 进行打包,xcode 打出来的包适合x86和arm系统
swift build --configuration release --build-system xcode --product Counter
cd .build/release/Counter
cp -f Counter /usr/local/bin/counter

这样,不管在哪,我们都可以直接执行counter命令啦!

当然如果我们需要将我们CLI进行推广使用,肯定不能像以上那样进行,这样我们可以进行以下的方式进行安装:

1、写一个自动安装脚本,下载已经打好的可执行文件,然后将可执行文件移动到/usr/local/bin/目录下

2、我们可以制作home brew 通过home brew 自动完成安装。感兴趣的可以参考我的另一篇文章:创建三方home brew

结束语(鸡汤来一碗~)

往往有些事情超出了我们了解的范围之后,我们内心是有些陌生、一头雾水、抗拒的,但是只要你沉下心来,去以一颗学习的心,去了解,去学习,那么就打开一个新世界。

弄清楚以上这些之后,回头在看swiftLint代码,也变得容易多了。好了,接着学习swiftlint实现去了~

参考

https://blog.csdn.net/Desgard_Duan/article/details/111878243

https://www.avanderlee.com/swift/command-line-tool-package-manager/

https://developer.apple.com/documentation/packagedescription

https://github.com/apple/swift-argument-parser

https://apple.github.io/swift-argument-parser/documentation/argumentparser/gettingstarted/