23
Help us understand the problem. What are the problem?

More than 3 years have passed since last update.

posted at

updated at

Swiftでコマンドラインツール作成の誘い

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 )を追加します。
2.png

// 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”にしてあげないとコンパイルエラーとなってしまいます。
3.png

まとめ

今回のコードはGitHubに上げておきました。
https://github.com/gibachan/swift-cli-sample

正直、ちょっとした仕事であればRubyとかの方がパッとかけて便利な気はします :sweat_smile:
でもSwiftもとても楽しい言語なので、もし興味がありましたらお試しください!

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
23
Help us understand the problem. What are the problem?