読者です 読者をやめる 読者になる 読者になる

Swift2で作るコマンドラインツール

会員事業部の三木(@)です。

この記事では、業務改善のために開発者向けのツールをSwiftで開発してみたため、その知見についてお伝えしたいと思います。

なお、この記事はXcode7.1上でSwift2.1を使った開発を前提としています。

作ったもの

クックパッドiOSアプリでは開発の際に、新しい機能を実装したり、インターフェイスを改善したあとにiOSシミュレーターの動画を撮影しPull Requestに貼り付けています。

動画を撮影する際には、汎用的にスクリーンキャストを撮影する社内ツールを使っていたのですが、使いづらい面も多かったため、 簡単にiOSシミュレーターの操作をアニメーションgifとして記録したいという需要がありました。

そのため、空き時間を使って、簡単なユーティリティを実装しました。

f:id:gigi-net:20151109141251g:plain

なぜSwiftで作るのか

今回は、OS Xの開発用SDKであるCocoaを使い、直接ウィンドウの描画結果を取得したいという需要があったため、 一般的なコマンドラインツールによく使われているスクリプト言語ではなくSwiftを採用しました。

わざわざSwiftでコマンドラインツールを実装することには以下のような利点があります。

Swiftで実装する利点

OS XのネイティブAPIを直接扱える

最大の利点は、Cocoaの資産を簡単に利用できることです。

Cocoaをスクリプトから利用する方法として、他に、AppleScriptやJavaScript(JXA)、MacRubyなどによるbindingが存在しますが、 どれも言語として書きづらかったり、全ての機能を利用できないこともあります。

高速に動作する

スクリプトと異なり、ネイティブコードとして実行されるため高速で動作します。

バイナリを配布しやすい

バイナリ形式で配付することで、環境構築をせずともそのまま動作します

Objective-Cに比べて開発しやすい

SwiftにはObjective-Cと比べ、REPL環境やPlaygroundが利用でき、検証が容易です。

また記述量も減り、記述しやすい点も挙げられます。

Swiftで作らない方が良い場合

その一方で、採用しづらい理由として、以下のような点が挙げられます。

  • Mac以外では動作しない
  • SwiftやAPIの仕様が変更される可能性がある
  • 開発環境を整えづらい

これらの特徴を理解し、最適な場面でのみ採用することが肝要です。

Swiftで簡単なスクリプトを書く

早速、Swiftで簡単なスクリプトを記述してみましょう。

Swiftで書いたコードはswiftコマンドを利用して、スクリプト言語のように即座に実行することができます。

例えば、スクリプトからCocoaを呼び出し、ダイアログを表示させてみます。

#!/usr/bin/env swift

import AppKit

let alert: NSAlert = NSAlert()
alert.messageText = "Do you like cooking?"
alert.addButtonWithTitle("Yes")
alert.addButtonWithTitle("No")
alert.alertStyle = NSAlertStyle.WarningAlertStyle
let response: Int = alert.runModal()
switch response {
  case NSAlertFirstButtonReturn:
    print("Yes")
  case NSAlertSecondButtonReturn:
    print("No")
  default:
    break
}

このような短い実装で、簡単にOSのUIを利用したスクリプトを記述することができます。

エントリーポイントの定義なども必要ありません。

他のスクリプト言語のようにshebangを利用することもでき、あたかもスクリプト言語のようにSwiftのコードを実行することができます。

$ chmod +x alert
$ ./alert

f:id:gigi-net:20151109140206p:plain

パッケージ管理を行う

簡単なスクリプトであれば上記のようにすぐに実装することができますが、ある程度複雑なスクリプトを実装しようとすると、外部ライブラリを利用したいケースが出てくるでしょう。

SwiftでもRubyでいうBundlerのような、依存関係を解決してくれるパッケージマネージャとしてCarthageが存在するので、今回はそれを利用します。

CarthageはSwiftで実装されたパッケージマネージャで、iOS/Macアプリ用のライブラリを管理することができます。

似たようなツールとしてCocoaPodsがよく知られていますが、CocoaPodsはアプリケーションへの組み込みを想定したツールであるため、今回のようなケースで利用することが難しいです。

その一方で、Carthageは設定方法がCocoaPodsより複雑である反面、Xcodeのプロジェクトファイルを用いてビルドや依存関係を解決してるので、シンプルでありコマンドラインツールの開発でも利用しやすいことが特徴です。

つい先日、Swift2.xに対応した最新バージョンがHomebrewにリリースされたため簡単に導入することができます。

コマンドラインオプションを実装する

SwiftではProcess.argumentsからコマンドライン引数を受け取ることができます。

let arguments = Process.arguments.suffixFrom(1)
print(arguments)

これは以下のように実行できます。

$ ./arguments I love beer
["I", "love", "beer"]

この状態では、単に文字列として受け取れるだけなので、コマンドラインオプションを自前で実装するには手間がかかります。

Swiftでは他の言語のようにオプションパーサーが標準では用意されていないため、OptionKitCommandantなどの外部のライブラリを利用する必要があります。

今回はCarthageを使い、OptionKitを導入してみます。

まずプロジェクトにCartfileを置き、依存関係を定義します。

github "nomothetis/OptionKit" ~> 1.0.0

その後、updateを実行することで、Carthage/Build/Mac以下にビルド済みのフレームワークが生成されます。

$ carthage update

OptionKitを用いると、以下のように簡単にコマンドラインオプションを実装することができます。

#!/usr/bin/env swift -FCarthage/Build/Mac

import OptionKit
import CoreFoundation

let arguments = Array((Process.arguments[1..<Process.arguments.count]))

// Define options
let frameRateOption = Option(trigger: OptionTrigger.Mixed("f", "fps"), numberOfParameters: 1, helpDescription: "Recording frames per second")
let outputPathOption = Option(trigger: OptionTrigger.Mixed("o", "outputPath"), numberOfParameters: 1, helpDescription: "Animation output path")
let helpOption = Option(trigger:.Mixed("h", "help"))

// Create Parser
let parser = OptionParser(definitions: [frameRateOption, outputPathOption]) 

// Parse options
do {
    let (options, _) = try parser.parse(arguments)

    if options[helpOption] != nil {
        print(parser.helpStringForCommandName("option-parser"))
        exit(EXIT_FAILURE)
    }
    
    if let frameRate: UInt = options[frameRateOption]?.flatMap({ UInt($0) }).first {
        print(frameRate)
    }
    
    if let outputPath = options[outputPathOption]?.first {
        print(outputPath)
    }
} catch let OptionKitError.InvalidOption(description: description) {
    print(description)
    exit(EXIT_FAILURE)
}

ポイントとして、1行目のshebangを変更して、今フレームワークをインストールしたディレクトリをサーチパスとして追加しています。 これによって、実行時に自動的に依存しているフレームワークを読み込んで実行することができます。

OptionKitではSwift2から実装された例外が利用されており、オプションの取得に失敗すると例外が送出されます。

このスクリプトを実行し、-hコマンドを呼び出すと、以下のようにオプションの一覧が表示されます。

$ ./option-parser -h
$ usage: option-parser [-f|--fps] [-o|--outputPath] [-h|--help]

signalをフックする

最後にシェルからINTERRUPTTERMINATEのシグナルを取得する方法を見てみましょう。

今回はSwiftからCのライブラリであるsignalを直接呼び出しています。

signalには引数として関数ポインタを渡す必要がありますが、通常の関数の場合は、以下のようにCの関数ポインタのように扱うことができます。

import CoreFoundation
func callback(_: Int32) {
    print("process killed")
    exit(EXIT_SUCCESS)
}

signal(SIGINT, callback)
while true {
}

クロージャを渡したい場合は少し複雑です。 ここではSwift2から導入された@conventionシンタックスを利用しています。

SwiftではCの関数ポインタの型は@conventionを用いて表されます。 signalの引数はvoid (*)(int)型であり、これをSwift2上では@convention(c) (Int32) -> Voidという型で表せます。

その後、Swiftのクロージャを上記で定義した型にキャストし、関数ポインタとして渡します。

import CoreFoundation

typealias SignalCallback = @convention(c) (Int32) -> Void
let callback: @convention(block) (Int32) -> Void = { (Int32) -> Void in
    print("process killed")
    exit(EXIT_SUCCESS)
}

// Convert Objective-C block to C function pointer
let imp = imp_implementationWithBlock(unsafeBitCast(callback, AnyObject.self))
signal(SIGINT, unsafeBitCast(imp, SignalCallback.self))
while true {
}

これにより、シェルからのCtrl + Cをフックして、独自の処理を実行することが可能になりました。

今回は動画の撮影終了を検知するのに利用しています。

バイナリとして配付する

あとはCocoaの知見があればコマンドラインツールを簡単に作成することができます。

このままスクリプトとして配布してしまうと、Xcodeを導入したり、開発環境を整えなければスクリプトを実行することができません。 そのため、今回はビルドしてバイナリとして配付してみましょう。

xcodebuildを利用してプロジェクトファイルからビルドする方法もありますが、今回は構造がシンプルなのでxcrunを使ってビルドしてみます。

$ xcrun -sdk macosx swiftc main.swift \
        -FCarthage/Build/Mac \
        -Xlinker -rpath -Xlinker "@executable_path/../Frameworks/" \
        -o simrec

外部のフレームワークは実行バイナリに含めることができないため、実行バイナリのパスから相対パスでフレームワークを読むような設定にします。

今回は、実装したフレームワークとバイナリの配布にはHomebrewを用いることを想定して、以下のようなディレクトリ構成でインストールされると仮定します。

├── Frameworks
│   └── OptionKit.framework
└── bin
    └── simrec

そのため、実行バイナリであるsimrecから見て../Frameworks/に配置された外部フレームワーク(今回はOptionKit)を読めるようにビルドしています。

まとめ

この記事ではSwiftを使ったコマンドラインツールの実装例をご紹介しました。

なかなか知見が少なく取っつきづらい部分も多いですが、お役に立ちましたら幸いです。

また、今回実装したものを公開していますので、ご興味のある方は参照してみてください。

giginet/SimRecorder

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/