Edited at
SlackDay 6

Slack×Swift×Fastlane:iOSアプリ配信ボット

More than 1 year has passed since last update.

この記事は Slack Advent Calendar 2016 - Qiita の6日目の記事です。

昨日は S_Shimotori さんの HubotとTypeScriptで翻訳Slack botでした。

Hi, I'm John! 今日はエウレカのiOS開発チームが使っている便利なボットを紹介したいです!Swiftで書かれて、Swiftをビルドするボット、 porygon と呼んでる。

このボットには、Slackでこういうメッセージ(コマンド)を送ると:

@porygon deploy jp-fabric-staging

porygon_deploy.gif

XcodeのコンパイルからiTunesConnectやCrashlyticsまでデプロイしてくれるボットです!リアルタイムの報告もできる:

porygon_done.png

ビルドはエラーがあった場合、ちゃんとログも送ってくれる:

porygon_failed.png

そしてporygonの特徴は、ローカルのMacでも実行できる!

porygonみたいなボットが作りたくなってきたかな?


Technologies Used

Porygon is powered by といえば、


1. Xcode 8:iOS+Swift 3アプリのビルド

Xcode.png


2. Vapor:サーバーサイドSwiftフレームワーク

Vapor.png

GitHub - vapor/vapor: A web framework and server for Swift that works on macOS and Ubuntu.


3. Fabric(特にBeta):ベータテスト用アプリの配信に便利

Fabric.png

Fabric - Twitter's Mobile Development Platform


4. Fastlane:デプロイの作業など、全てスクリプト化されてる

Fastlane.png

fastlane - iOS and Android Automation for Continuous Delivery


5. そして、もちろん、Slack

Slack.png

Slack: Be less busy


Architecture

実はVaporの会社はSlackbotのテンプレートフレームワークまで作ってくれた:

GitHub - vapor/slack-bot

このボットは基本的にSlackのRTM API(Websocket)で動いています。ボットトークンの設定などはテンプレートプロジェクトを参考すればすぐ分かると思います。

(slack botの初期化方法はこちらご参考まで!

さて、これで全てのツールが揃いましたので、どう連携しているのか紹介します!

Architecture.png


① SlackのメッセージをWebsocketで受信する

参考:https://api.slack.com/rtm

返ってきているJSONからは、text項目をパースする。まず、ボットのメンションがないと無視するべきなので、そのバリデーションを行う。例えば:

@porygon deploy appstore-staging

メッセージを送ると、 @porygon の部分のメンションは実際のJSONにはこういう形になる:

{

// ...
"channel": "C12345678",
"text": "<@U12345678> deploy appstore-staging",
// ...
}

C12345678は返事を送るチャンネルのIDです。これを保持してレスポンスを送るときに使う。

U12345678はボットのIDです。そのIDが見つかるとコマンドをパースして、自由にハンドリングする。私たちの場合、 deploy <Xcode scheme名> が見つかったら Fastlane のシェルスクリプトをコールしている。


② FastlaneコマンドをSwiftコードから実行する

参考:https://fastlane.tools/

Swiftからシェルコマンドを実行するには、Process (NSProcess)を使用する。

シェルのoutput streamはPipe (NSPipe)で取得できる。

先の例を続けると、deploy appstore-stagingの文字列が見つかったら、

let task = Process()

// ...
task.launchPath = "/bin/bash"
task.arguments = ["-l", "-c", "fastlane deploy_appstore_staging"]

// ...

task.launch()
task.waitUntilExit()

を処理している。fastlane コマンドで deploy_appstore_staging というlaneを実行している。Fastlaneのlaneというのは、デプロイするときの全てのタスクを実施できるrubyスクリプトです。Fastfileの作り方にはこちらをご参考ください。

Fastfileが出来たら、


  • Xcodeプロジェクトのビルド

  • Code Sign

  • Xcode Archive

  • iTunesConnectへのアップロード又はFabricへのアップロード

を一発で fastlane <lane名> コマンドでデプロイできる。

重要

fastlaneの実行はバックグラウンドスレッドで行わないと、ボットが動くなかったりwebsocketがタイムアウトしたりしてしまうので、Processをコールするメソッドは必ずバックグラウンドスレッドで実行してください!Grand Central DispatchのSerial Queueはおすすめです。


③ & ④ FastlaneがXcodeプロジェクトをビルドして、出来上がったバイナリをiTunesConnectやFabricへアップロードする

これはfastlaneが全てやってくれる!

が、もちろんビルドの進捗・結果をslackbotに教えて欲しいですよね。そのために先ほどの Processの初期化には、Pipe (NSPipe)のオブサーバーを実装して、シェルのoutput文字列を全てパースする:

let task = Process()

// ...
task.arguments = ["-l", "-c", "fastlane deploy_appstore_staging"]

// ...
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe

let readHandle = pipe.fileHandleForReading
readHandle.waitForDataInBackgroundAndNotify()

var logObserver: NSObjectProtocol?
logObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name.NSFileHandleDataAvailable,
object: readHandle,
queue: nil,
using: { (notification) -> Void in

let data = readHandle.availableData
if data.count > 0 {

if let string = String(data: data, encoding: .utf8) {

// stringはシェルの1行出力の文字列になります
print("[log] \(string)", terminator: "")
didReceiveLogString(string, channel: channel) // 後ほど説明する
}
readHandle.waitForDataInBackgroundAndNotify()
}
else {

print("[log] EOF on stdout from process")
NotificationCenter.default.removeObserver(logObserver)
}
}
)

task.launch()
task.waitUntilExit()

didReceiveLogString() は出力からの文字列をパースするメソッドですが、進捗をどうすれば判定する?と思っているでしょう。そのためには、Fastfileの設定に戻りましょう。私たちの場合は、このようなコードをFastfileに入れている:

lane :deploy_appstore_staging do

# ...
bump_build

puts "--- Hook: Xcodeでコンパイル中..."
ipa_path = gym(scheme: scheme,
output_name: scheme,
export_team_id: team_id,
use_legacy_build_api: true,
include_bitcode: true,
include_symbols: true)

puts "--- Hook: Betaへアップロード中..."
crashlytics(ipa_path: ipa_path)
upload_symbols_to_crashlytics

puts "--- Hook: appstore-stagingの配布が完了しました!"
end

そう、コンソールへわざとputs(プリント)している!その文字列には --- Hook: みたいなプレフィックスをつけて、そのあとの文字列をslackbotがパースするべき、という仕組みです。

先ほどのdidReceiveLogString()メソッドに戻ると、

func didReceiveLogString(_ string: String, channel: String) {

guard let range = string.range(of: "--- Hook:") else{
return // 定義したプレフィックスがないと無視する
}
let response = string.substring(from: range.lowerBound)
// responseをchannelへSlackのWeb APIで送る
}

responseをSlackのRTM APIのwebsocketではなく、Web APIでPOSTする。理由は、websocketのAPIでは送信済みのメッセージを変更できないからです。それと違くてWeb APIでは、



  • Deploying...chat.postMessageで送って、タイムスタンプを保存する

  • 同じタイムスタンプをchat.updateのパラメータで使って、Xcodeでコンパイル中...Betaへアップロード中...appstore-stagingの配布が完了しました! に上書きできる。

私たちの場合は、Slackのカスタム絵文字を使って、この感じで表示しています:

porygon_done.png


⑤ & ⑥ ビルドとアップロードの成功・失敗を判定してボットで報告する

エラーが発生する場合は --- Hook: みたいなアウトプットはもちろん出せないです。失敗した時ログが知りたいので、それもファイルに保存してみましょう:

// ...

let pipe = Pipe()
// ...

// ログファイルを作る
FileManager.default.createFile(atPath: outputFilePath, contents: nil, attributes: nil)
let writeHandle = FileHandle(forWritingAtPath: outputFilePath)

var logObserver: NSObjectProtocol?
logObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name.NSFileHandleDataAvailable,
object: readHandle,
queue: nil,
using: { (notification) -> Void in

let data = readHandle.availableData
if data.count > 0 {

writeHandle?.write(data) // アウトプットをログファイルに保存
// ...
}
else {

// ...
}
}
)
var exitObserver: NSObjectProtocol?
exitObserver = NotificationCenter.default.addObserver(
forName: Process.didTerminateNotification,
object: task,
queue: nil,
using: { (notification) -> Void in

writeHandle?.closeFile()
NotificationCenter.default.removeObserver(exitObserver)
}
)
task.launch()
task.waitUntilExit()

まず、シェル出力をパースしているところには writeHandle?.write(data) でファイルに書き込む。それで Process.didTerminateNotification の通知をオブサーブして、 writeHandle?.closeFile() でファイルを閉じる。

fastlaneのスクリプトが成功したか失敗したかは task.waitUntilExit() が終わった後判別できる:

// ...

task.launch()
task.waitUntilExit()

if task.terminationStatus == 0 {
// ... 成功
}
else {
// ... 失敗
sendLogFile(outputFilePath, channel: channel)
}

成功した場合はchat.updateで報告する。

porygon_done.png

失敗した場合は、ログファイルをfiles.upload APIでアップロードする:

porygon_failed.png


Bonus Features

Fastlaneで様々な便利な機能があるので、Slackbotに連携して是非実施してみてください!


  • AppStoreの審査状況をSlackbotに教えてもらう

  • gitブランチを選択できるようにする

  • debugサーバーかproductionサーバーかパラメータ化する

  • アプリのデプロイ先はFastlaneかiTunesConnectかパラメータ化する