Swift Advent Calendar 2018 の 8 日目です。
はじめに
Swiftの話題としてはやはりiOSアプリ開発に関わるものが多いかと思います。
ですがもっといろん場面で活用されればいいなと思い、ここでは手軽にCLIツールを作る方法を紹介します。
と思って、Qiitaで検索したら既にSwift Package Manager (SwiftPM) で作るコマンドラインツール - Qiita でかなり丁寧まとめられてました。とはいえ、時の流れとともに内容に若干変更されている部分もありますので挫けずに進めたいと思います。(つまりこの記事にも賞味期限があるということですね)
前提としている環境
- macOS Mojave
- Swift 4.2.0
コマンドラインでswiftを実行するには
例えば以下のように、あらかじめソースファイルを作っておけばswiftコマンドに渡すことで実行できます。
$ echo "print(\"Hello world\")" > hello.swift
$ swift hello.swift
Hello world
ほんのちょっとしたものであればこれで済むかもしれませんが、
ある程度規模が大きかったり、外部ライブラリを使用したいとかになるとSwift Package Managerを利用するのが便利です。
Swift Package Manager (SPM)
Swift用のパッケージ管理ツールです。
パッケージの構成やライブラリの依存関係などを管理してくれます。
ここではQiita APIを使って記事を検索するCLIツールを作成するイメージでSPMの使い方を紹介します。
パッケージを初期化
新しいディレクトリを作成し、パッケージとして初期化します。
この時ディレクトリの名前がそのままパッケージ名として設定されます。
$ mkdir Qiita
$ cd Qiita/
$ swift package init --type executable
Creating executable package: Qiita
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/Qiita/main.swift
Creating Tests/
Creating Tests/LinuxMain.swift
Creating Tests/QiitaTests/
Creating Tests/QiitaTests/QiitaTests.swift
Creating Tests/QiitaTests/XCTestManifests.swift
ここで -- type executable
としているのは実行可能形式とすることを示します。
ライブラリ形式など他にも指定できますがここでは触れません。
いくつかのディレクトリ・ファイルが作成されます。
-
Package.swift
- マニフェストファイルと呼ばれます。
- パッケージの構成やライブラリの依存関係などをここで定義します。
-
Sources/
- ソースコードを入れる場所です。Target毎にサブディレクトリを切ります。
- ここでは
Sources/Qiita/main.swift
がエントリポイントになっています。
-
Tests/
- テストコードを入れる場所です。
この時点でビルド&実行できる状態になっています。
$ swift build # ビルド(デバッグビルド)
$ swift run # 実行
Hello, world!
ビルドした成果物は .build/
ディレクトリ下に配置されています。
外部ライブラリを参照
CLIツールにはオプション指定がつきものですので、その辺を楽に解決するためにライブラリを導入します。探すといろいろ見つかりますが、ここではSPMが提供する Utility
を使ってみることにします。
外部ライブラリの参照を追加するために Package.swift
を編集します。
let package = Package(
name: "Qiita",
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-package-manager.git", from: "0.3.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 which this package depends on.
.target(
name: "Qiita",
dependencies: ["Utility"]), // ここに追加
.testTarget(
name: "QiitaTests",
dependencies: ["Qiita"]),
]
)
Packageのdependenciesに依存ライブラリへのパスとバージョンを指定し、Targetのdependenciesに依存するパッケージ名を追加します。
そして以下のコマンドでパッケージを更新し、ライブラリをインストールします。
$ swift package update
ではコードを編集しましょう。
Sources/Qiita/main.swift
を以下のように編集します。
// main.swift
import Utility // ArgumentParserのために必要
// CommandLine.argumentsでコマンドラインから引数を受け取れます
// arguments[0]にはコマンド名が入ってくるので除いておきます。
let arguments = Array(CommandLine.arguments.dropFirst())
// コマンドオプションの定義
let parser = ArgumentParser(usage: "-k [keyword]", overview: "Qiitaで記事を検索します")
let keyword = parser.add(option: "--keyword", shortName: "-k", kind: String.self)
do {
let result = try parser.parse(arguments)
if let keyword = result.get(keyword) {
print("'\(keyword)'でQiitaの記事を検索します")
} else {
print("エラー")
}
} catch {
print(error)
}
ここではコードの内容については深く触れません。編集したらビルド&実行します。
$ swift run
エラー
オプションを指定していないので「エラー」となりますね。
オプションを与えて実行する場合には次のようにします。
$ swift run Qiita --keyword swift #swift run コマンド名 オプション
'swift'でQiitaの記事を検索します
Targetを追加
これからQiita APIを叩く処理を追加するのですが、その部分をTargetを分けて実装したいと思います。
その方が後でテストを書きやすくなりますので。
Package.swift
を編集し、新しいTargetとして QiitaCore
を追加します。Qiita
からQiitaCore
への依存も追加しています。
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 which this package depends on.
.target(
name: "Qiita",
dependencies: ["Utility", "QiitaCore"]), // "QiitaCore"を追加
.target( // これを追加
name: "QiitaCore",
dependencies: []),
.testTarget(
name: "QiitaTests",
dependencies: ["Qiita"]),
]
また、Targetと同名のサブディレクトリ Sources/QiitaCore/
として作成し、そこにソースファイル( Qiita.swift
)を追加します。
// Qiita.swift
import Foundation
public struct Qiita {
private(set) var keyword: String
private let session: URLSession
public init(keyword: String, session: URLSession = .shared) {
self.keyword = keyword
self.session = session
}
// keywordをもとに記事を検索する
public func search(completion: @escaping (String?) -> Void) {
let url = URL(string: "https://qiita.com/api/v2/items?page=1&per_page=20&query=\(keyword)")!
var req = URLRequest(url: url)
req.httpMethod = "GET"
session.dataTask(with: req) { (data, _, _) in
if let data = data, let result = String(data: data, encoding: .utf8) {
// 実際にはここでごにょごにょ整形する
completion(result)
} else {
completion(nil)
}
}.resume()
}
}
そしてこれを Sources/Qiita/main.swift
から利用します。
// main.swift
import Utility // ArgumentParserのために必要
import QiitaCore // Qiitaのために必要
import Dispatch // dispatchMainのために必要
// CommandLine.argumentsでコマンドラインから引数を受け取れます
// arguments[0]にはコマンド名が入ってくるので除いておきます。
let arguments = Array(CommandLine.arguments.dropFirst())
// コマンドオプションの定義
let parser = ArgumentParser(usage: "-k [keyword]", overview: "Qiitaで記事を検索します")
let keyword = parser.add(option: "--keyword", shortName: "-k", kind: String.self)
do {
let result = try parser.parse(arguments)
if let keyword = result.get(keyword) {
print("'\(keyword)'でQiitaの記事を検索します")
let q = Qiita(keyword: "swift")
q.search { result in
print("result: \(result ?? "")")
exit(0)
}
dispatchMain() // 非同期処理の終了を待つ
} else {
print("エラー")
}
} catch {
print(error)
}
コードの内容は本題から逸れるので軽く流して頂きたいですが、
非同期処理がある場合は、プログラムが即時終了しないようにする必要があります。
あとはビルド&実行するだけです。
$ swift build
$ swift run Qiita -k swift
....いっぱい出てくる....
テスト
Package.swift
を編集します。
テスト用のTargetとして QiitaTests
があるので、dependenciesを QiitaCore
に変更しておきます。
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 which this package depends on.
.target(
name: "Qiita",
dependencies: ["Utility", "QiitaCore"]),
.target(
name: "QiitaCore",
dependencies: []),
.testTarget(
name: "QiitaTests",
dependencies: ["QiitaCore"]), // ここを変更
]
テストコードは Tests/
ディレクトリの下にあります。
ここでは Tests/QiitaTests/QiitaTests.swift
にテストコードを書いていきます。
// QiitaTests.swift
import XCTest
import QiitaCore
final class QiitaTests: XCTestCase {
func testSearch() throws {
let session = URLSessionMock()
let qiita = Qiita(keyword: "swift", session: session)
qiita.search { _ in }
XCTAssertEqual(session.url, "https://qiita.com/api/v2/items?page=1&per_page=20&query=swift")
}
static var allTests = [
("testSearch", testSearch),
]
}
final class URLSessionDataTaskMock: URLSessionDataTask {
override func resume() {
// Do nothing
}
}
final class URLSessionMock: URLSession {
typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void
var url: String = ""
override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
self.url = request.url?.absoluteString ?? ""
return URLSessionDataTaskMock()
}
}
本題とは逸れるのでコードの内容は(略)
QiitaTests.allTests
は何?と思うかもしれませんが、Linuxでのテスト対象となるテストケースを指定しています。(Tests/LinuxMain.swift
はLinux上でのテスト実行のためのもの、これを読むとわかると思います。)
次のコマンドでテストを実行します。
$ swift test
...
Test Suite 'All tests' passed at 2018-12-07 17:46:22.131.
Executed 1 test, with 0 failures (0 unexpected) in 0.083 (0.083) seconds
インストール
最終的に作成したCLIツールをリリースビルドしてPATHを通せばおしまいです。
$ swift build -c release -Xswiftc -static-stdlib
$ cd .build/release
$ cp -f Qiita /usr/local/bin/
ここで swift build
のオプションに -c release
を渡すことでリリースビルドとなります。また -Xswiftc -static-stdlib
としているのはSwift標準ライブラリを静的リンクし、実行環境のSwiftのバージョンに依存しなくて済むためです。
[補足]Xcodeプロジェクトの作成
次のコマンドでXcodeプロジェクトを作成できます。
$ swift package generate-xcodeproj
※ Xcodeで開くと最初は実行環境がiPhoneになっているかもしれません。”My Mac”にしてあげないとコンパイルエラーとなってしまいます。
まとめ
今回のコードはGitHubに上げておきました。
https://github.com/gibachan/swift-cli-sample
正直、ちょっとした仕事であればRubyとかの方がパッとかけて便利な気はします
でもSwiftもとても楽しい言語なので、もし興味がありましたらお試しください!