Swift Package Manager V4 + Utilityで作る!コマンドラインツール

この記事は、eureka Native Engineer Advent Calendar 2017 21日目の記事です。
20日目は山内さんの「ウェブエンジニア、Androidに出会うもView要素をforeachで繰り返せないと知り途方に暮れる」でした。

こんにちは!Pairs JPチームでiOSアプリケーション開発を担当している@satoshin21です。
最近Google Homeを購入し、非ITの嫁を巻き込みながらホームオートメーション実現に向けて動いています。

今日と明日のNative Advent Calendarは私が担当させて頂きます。
今日は Swift Package Manager V4で作るコマンドラインツールについてお話します。
Swift Package Managerとは、Appleの提供するパッケージ管理ツールです。コードの共有や依存関係の管理、バージョニングなどを管理することができます。ただ、現在Swift Package ManagerはiOSをサポートしておらず、活躍の場が限られているためになかなか使う機会がないかもしれません。

しかしそれではもったいない!現在でもサーバサイドSwiftの開発や、今回ご紹介するコマンドラインツールなどを中心に活用の幅は広がってきています。
また、公式のドキュメントでもいずれはシステム上の依存関係を明示的にサポートする仕組みを実装する、と記載されている為、今後はiOSなどでも使えるようになるかと思います。
是非ともこの機会に、Swift Package Manager(以降、SwiftPM)の使い方を習得してみましょう!

今回は、まずSwiftPMを使ったことがない開発者に向けてSwiftPM、主にV4を中心とした使い方、テスト方法を記載します。
そして最後に中級者に向けて、SwiftPMに最近追加された便利なユーティリティについて幾つか紹介します!

各種環境は以下の通りです。

$ sw_vers
ProductName: Mac OS X
ProductVersion: 10.13.2
BuildVersion: 17C88
$ swift package --version
Apple Swift Package Manager - Swift 4.0.0-dev (swiftpm-13752)
$ swift --version
Apple Swift version 4.0.3 (swiftlang-900.0.74.1 clang-900.0.39.2)

Swift Package Manager V4とは?

前述しましたが、Swift Package Managerとは、Appleの提供するパッケージ管理ツールです。コードの共有や依存関係の管理、バージョニングなどを管理することができます。

iOS/macOS開発などで利用されるCocoaPodsCarthageなどと同じようなものですが
2017年12月現在ではiOSやtvOSなどはサポートしておらず、macOSやLinux専用です。主にサーバサイド開発やコマンドラインツールなどのパッケージ管理がメインです。

それではさっそくですが、SwiftPMを使ってHello worldを表示してみましょう!
swift package init --type executableを実行するだけで、プロジェクトファイル一式を生成することができます。
—typeには他にも、librarysystem-moduleを指定できますが、今回はCLIツールを作成するので実行可能形式となるexecutableを指定しましょう。

# swift-pm-sample ディレクトリを作成
$ mkdir swift-pm-sample
$ cd swift-pm-sample/
# 初期化
$ swift package init --type executable
Creating executable package: swift-pm-sample
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/swift-pm-sample/main.swift
Creating Tests/

プロジェクトのビルドも非常に簡単です。
プロジェクトファイル直下でswift buildを実行することでコンパイルが走ります。
後述しますが、ビルド構成はデフォルトでdebugとなっています。

# デバッグビルド
$ swift build
Compile Swift Module 'swift_pm_sample' (1 sources)
# 実行
$ .build/debug/swift-pm-sample
Hello, world!

Package.swift

swift package initを実行すると、直下にPackage.swiftファイルが作成されます。
このファイルがCocoaPodsにおけるPodfileのような、依存関係やパッケージ名などを定義するマニフェストファイルです。
それでは先程、Hello worldで生成されたPackage.swiftを見てみましょう。

// swift-tools-version:4.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "swift-pm-sample",
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, 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 which this package depends on.
.target(
name: "swift-pm-sample",
dependencies: []),
]
)

上から解説していきます。

swift-tools-version

// swift-tools-version:4.0
一行目のこのコメントがSwiftPMをv4で実行する為のマークとなります。
ここの部分を削除すると、SwiftPM v3.1.0でコンパイルされる為、ご注意ください。

dependencies

dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],

依存しているパッケージをPackage.Dependencyの配列で指定します。
たとえば、ネットワークライブラリのAlamofireを追加する場合は以下のようになります。

dependencies: [
.package(url: "https://github.com/Alamofire/Alamofire.git", from: "4.0.0")
]

SwiftPM V3では.Package(url:majorVersion:)などのメソッドで指定していましたが、V4になって指定方法が変更されているのでご注意下さい。from:でバージョンを直接指定する他、ブランチやリビジョンを指定する事も可能です。

// `from`でVersion指定(Versionはtagで指定する)
.package(url: "git@github.com...", from: "1.0.0")
.package(url: "git@github.com...", from: .init(1, 0, 0))

// Range指定
.package(url: "git@github.com...", "1.0.0"..<"2.0.0")

// ブランチ、リビジョン等指定可能
.package(url: "git@github.com...", .branch("develop"))
.package(url: "git@github.com...", .revision("8b4975a"))

targets

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: "swift-pm-sample",
dependencies: []),
]

Targetは生成されるモジュールや、テストスイートを指定することが可能です。
Targetインスタンスのイニシャライザでも生成可能ですが、基本的には通常のモジュールであれば.target、テストスイートであれば.testSuiteを指定することになるでしょう。
それぞれのターゲットのコンパイル対象ファイルは、プロジェクトディレクトリに生成されている/Sourcesディレクトリ直下のサブディレクトリに含まれているフォルダが指定されます。
公開されているソースを見ると、その他コンパイル対象外のファイルを指定するexclude:や、上記ターゲットのパスを独自のものに変更するpath引数などもあるため、ぜひとも参考にしてください。

Swift Package ManagerでCLIツールを作る

それではSwiftPMを使って簡単なCLIツールを作りましょう!
GitHub APIを利用して、引数として受け取ったクエリを元にリポジトリ検索できるようにします。
今回はURLSessionでリクエスト、SwiftyJSONでJSONをパースします。

実装前に、まずは我々iOSエンジニアが普段使い慣れているXcodeで編集する為、xcodeprojファイルを生成しましょう!

$ swift package generate-xcodeproj

こちらをプロジェクトディレクトリ直下で実行する事で、Package.swiftを元にxcodeprojファイルを生成します。
依存対象のパッケージも生成時にクローンします。

まずは、Package.swiftから編集していきます。

// swift-tools-version:4.0
import PackageDescription
let package = Package(
name: "RepositorySearch",
dependencies: [
.package(url: "https://github.com/SwiftyJSON/SwiftyJSON.git", from: "4.0.0")
],
targets: [
.target(
name: "RepositorySearch",
dependencies: ["Core"]),
.target(
name: "Core",
dependencies: ["SwiftyJSON"]),
.testTarget(
name: "CoreTests",
dependencies: ["Core"],
path: "Tests/CoreTests")
]
)

DependencyとしてSwiftyJSONを追加し、Coreターゲットから依存させています。
またコマンドラインとして実行するターゲットと主な処理を担当する部分をそれぞれRepositorySearchCoreとして分離させています。

もちろんHello Worldのときのように実行処理部分にすべての処理を書いてもビルドは可能ですが、基本的に実行部分のモジュールはテスト対象にすることができません。
これはXCTestの仕様上、テストをmain.swift上で実行しなければならず、main.swiftをもつexecutableなターゲットはテストできない為と思われます。

最後にテストスイートを.testTargetで指定し、Coreをテスト対象のモジュールとします。

続いて、Coreモジュールに移り、リクエスト周りの実装部分です。

import Foundation
import SwiftyJSON
public final class GitHub {
    public static func requestRepositories(q: String) -> Result<[String], GitHub.Error> {
        var result: Result<[String], GitHub.Error>?
        let semaphore = DispatchSemaphore(value: 0)
let url = URL(string: "https://api.github.com/search/repositories?q=\(q)&sort=stars&order=desc")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data else {
                result = .failure(.request)
return
}

do {
                let json = try JSON(data: data)
let repositories = json["items"].arrayValue.map({ "\($0["name"].stringValue)" })
result = .success(repositories)
} catch {
                result = .failure(.jsonParse)
return
}

semaphore.signal()
}
        task.resume()
semaphore.wait()
        guard let successResponse = result else {
return .failure(.unexpected)
}
        return successResponse
}
}

ココらへんの処理については、iOSの開発らとほぼ代わりありません。
同期的にリクエストを処理する為、DispatchSemaphoreを使って待ち合わせ処理をしています。
別のモジュールから呼び出し可能とする為、可視属性はpublicを指定しています。

最後に実行部分です。
RepositorySearchモジュールのmain.swiftに以下のように記載します。

import Foundation
import Core
private func main(args: [String]) {
    let args = args.dropFirst()
guard let query = args.first, !query.isEmpty else {
        print("ERROR: please input query")
exit(1)
}
    let res = GitHub.requestRepositories(q: query)

switch res {
case .success(let repositories):
            print(repositories.reduce("") { "\($0)\($1)\n"})
exit(0)
case .failure(let e):
            print("ERROR: \(e.localizedDescription)")
exit(1)
}
}
main(args: CommandLine.arguments)

Coreモジュールをインポートし、mainメソッド上でリクエストしています。
CommandLine.argumentsによりCLI上で渡された引数を取得可能ですが、index:0は実行コマンドのフルパスが格納されているため、dropFirst()しています。exit(Int32)メソッドにより、終了ステータスを指定してプロセスを終了させる事ができます。

Coreのテスト実行ファイルを./Tests/CoreTests直下に置きます。
XCTestを用いてテストを書くことが可能です。

import XCTest
@testable import Core
class CoreTests: XCTestCase {
    func testGitHubError() {
...
}
}

テストを実行する場合は、コマンドライン上でswift testを実行してください。
テストに成功した場合は、以下のように出力されるはずです。

$ swift test
Test Suite 'RepositorySearchPackageTests.xctest' started at 2017-12-15 19:54:06.902
Test Suite 'CoreTests' started at 2017-12-15 19:54:06.902
Test Case '-[CoreTests.CoreTests testGitHubError]' started.
Test Case '-[CoreTests.CoreTests testGitHubError]' passed (0.087 seconds).
Test Suite 'CoreTests' passed at 2017-12-15 19:54:06.989.
Executed 1 test, with 0 failures (0 unexpected) in 0.087 (0.087) seconds
Test Suite 'RepositorySearchPackageTests.xctest' passed at 2017-12-15 19:54:06.989.
Executed 1 test, with 0 failures (0 unexpected) in 0.087 (0.087) seconds
Test Suite 'All tests' passed at 2017-12-15 19:54:06.990.
Executed 1 test, with 0 failures (0 unexpected) in 0.087 (0.087) seconds

それではビルド&実行してみましょう!

$ swift build -c release
$ .build/x86_64-apple-macosx10.10/release/RepositorySearch swift
swift
Alamofire
free-programming-books-zh_CN
awesome-ios
ReactiveCocoa
...

無事GitHub Repositoryの一覧が出力されました!
-cオプションでビルド構成を指定する事が可能です。今回はreleaseビルドを行いました。かりにオプションに何も指定しない場合、debug構成でビルドされます。
指定可能なオプションはdebugreleaseの2種類となります。大まかな違いとしては、最適化オプション(-O)が付与されるか、デバッグ情報が生成されるかの違いとなります。どのような最適化オプションが付いた状態でビルドされるのかはドキュメントを参照ください。

今回作ったサンプルは公開しておりますので、ぜひとも照らし合わせながら遊んでみてください!

satoshin21/SwiftPM-Sample

Swift Package Manager Utilities

SwiftPMには、CLIツール作成に便利なユーティリティがいくつか付属しています。まだまだ未実装な部分や安定していないものも多く、心もとない部分も多いですが、現段階でも活用できそうなものについていくつかピックアップして紹介させて頂きます!
以下紹介するユーティリティも、上記サンプルにUtilitySampleモジュールとして用意しているため、そちらもご参照ください。

Utilityを使う準備

ユーティリティを使う場合は、Package.swiftのdependenciesにswift-package-managerを追加します。
swift-package-managerUtilityモジュールが含まれているため、これをtargetの依存先に指定します。

...
dependencies: [
.package(url: "https://github.com/apple/swift-package-manager.git", from: "0.1.0")
],
targets: [
.target(
name: "Core",
dependencies: ["Utility"]),
]
...

Progress Bar

大きめのファイルをダウンロードするときなど、処理の進捗をプログレスバーとして表示させたい場合があるかと思います。
SwiftPMには、プログレスを表示するUtilityが含まれています。ユーティリティを使用すると、以下のようにプログレスを表示することが可能です。

サンプルコードはこちらになります。

// 標準出力のstreamを取得
guard let stdout = stdoutStream as? LocalFileOutputByteStream else {
    exit(1)
}
let progressBar = createProgressBar(forStream: stdoutStream, header: "Loaging")
for i in 1...100 {
    let text: String = {
switch i {
case ..<30:
return "Hoge"
case ..<60:
return "Foo"
default:
return "Bar"
}
}()
    progressBar.update(percent: i, text: text)
Thread.sleep(forTimeInterval: 0.03)
}
progressBar.complete()
print("Completed!")
exit(0)

まずはSwiftPMに含まれているBasicモジュールからstdoutStreamを取得し、
続いて、Utilityモジュールに含まれているcreateProgressBar(fromStream:header:)に渡してProgressBarインスタンスを生成します。
内部的にTerminalControllerを生成していますが、こちらがTerminalやiTermなどのコンソールを操作する為のクラスです。
ProgressBarを生成したタイミングで既にコンソール上にProgressBarが表示されている為、後はよしなのタイミングでupdate(percent:text:)を呼び出すことでプログレスが動作します。

Text Color

TerminalControllerを用いて、出力に色を指定することが可能です。
基本的にTerminalController.Colorの列挙型で指定できる7色のみ指定可能です。

guard let tc = TerminalController(stream: stdout) else {
    exit(1)
}
tc.write("Hoge\n", inColor: .red, bold: true)
tc.write("Foo\n", inColor: .yellow, bold: true)
tc.write("Bar\n", inColor: .green, bold: true)

ArgumentParser

以前はCLIツールのインターフェースを作るときのライブラリとしてkylef/Commanderなどがありましたが、swift-package-managerのユーティリティにも同様のツールであるArgumentParserが用意されています。
ArgumentParserを用いて、さきほど作成したサンプルRepositorySearchのインターフェースをちょっと改良してみましょう。
RepositorySearchの引数を—query(-q)で指定可能にします。

do {
// CLIツールについての説明
let parser = ArgumentParser(commandName: "RepositorySearch", usage: "query [--query swift]", overview: "It's a just sample for swift package manager.")
    // --query オプションを追加
let queryArg = parser.add(option: "--query", shortName: "-q", kind: String.self, usage: "A word what you want to search repository in GitHub.com", completion: .none)
    // 引数をパースしてクエリを取得
let args = Array(CommandLine.arguments.dropFirst())
let result = try parser.parse(args)
guard let query = result.get(queryArg) else {
        throw ArgumentParserError.expectedArguments(parser, ["query"])
}
    // 実行
main(query: query)
} catch ArgumentParserError.expectedValue(let value) {
    print("Missing value for argument \(value).")
} catch ArgumentParserError.expectedArguments(let parser, let stringArray) {
    parser.printUsage(on: stdoutStream)
} catch {
print(error.localizedDescription)
}

ArgumentParserにコマンド名や使い方を記述します。ここに記述した内容が--helpオプション指定時に表示されます。
続いて、必要となるオプションを追加します。今回は—-query、もしくは-qを指定します。addメソッドの返り値でOptionArgument<T>が取得できます。
parse(_:)で実際の引数をパースするので、その後さきほど取得したOptionArgument<T>get(_:)で実際のパラメータを取得可能です。

これにより-qオプションでクエリを指定可能になりました。—helpを指定して上記の説明分がフォーマットされた状態で取得できます。

$ .build/debug/RepositorySearch --help
OVERVIEW: It's a just sample for swift package manager.
USAGE: RepositorySearch query [--query swift]
OPTIONS:
--query, -q
A word what you want to search repository in GitHub.com
--help Display available options

まとめ

Swift Package Manager V4を使って、簡単なCLIツールを作成する所までの紹介でした。
iOS,macOSのネイティブアプリケーションとまではいかないものの、軽めのツールを作りたい!という時は、Swiftでコマンドラインツールをサクッと作ってみてもおもしろいんじゃないかと思います。僕は時々スクリプト実行ができるBitBarAlfredのワークフローをSwiftで書いて遊んでいます。
これからはサーバサイドもクライアントもコマンドラインも全部Swiftで書いて、Swiftという言語を盛り上げていきましょう!

明日の記事も僕が担当で、Interface Builderに頼らないiOS開発のススメについてご紹介します。よろしくおねがいします!

サンプル

GitHub - satoshin21/SwiftPM-Sample

参考

GitHub - apple/swift-package-manager: The Package Manager for the Swift Programming Language
Apple’s new Utility library will power up command-line apps | Swift Developer News – Hacking with Swift