前書き
昨日の記事で Danger-Swift のプラグインを SwiftPM で導入する仕方を紹介しましたが、今日は更に一歩を踏み込んで、実際に自分で Danger-Swift のプラグインの作り方を紹介したいと思います;そしてそのサンプルとして、実際に弊社が作った Danger-Swift のプラグインをご紹介したいと思います。
ちなみに Danger-Swift のプラグイン制作は実は非常に簡単で、間違いなく Dangerfile.swift
書いて動作確認するよりは遥かに簡単です1。最低限の SwiftPM の経験があれば、誰でも簡単に Danger-Swift のプラグインが作れます。
プラグインのご紹介
まずは自分でプラグイン作るときに何ができるのかのイメージが湧きやすいように、簡単に弊社が作ったプラグインをご紹介しましょう。OSS として GitHub で絶賛公開中です
DangerSwiftShoki
Dangerfile 書いてると、こんなこと考えたことありませんか?いろんなチェックをやりたいのに、それらのチェック結果が全てバラバラで出力されて見づらいし、更に出力されてないものが実際にチェックして無事だから出力されなかったのか、それとも単純にチェックしてなかったのかがわかりにくいので、自分でいちいちチェックの実行も出力しておかないといけないですが面倒ですよねー。
そんなあなたにぜひ使って欲しいのがこの DangerSwiftShoki です!このプラグインを使えば、さまざまなチェック項目を全てまとめてテーブルで出力されて一覧がしやすいし、更にどうしても機械検出が難しいものをレビュアーにチェックし忘れないよう催すチェックリスト記法もあります!例えば下記のようなコードを書いておけば、GitHub でこんな出力が得られます!
// ...
var report = danger.shoki.makeInitialReport(title: "My Report")
danger.shoki.check("何かのチェック", into: &report) {
if checkPassed {
return .good
} else {
if isAcceptable {
return .acceptable(warningMessage: "可能であれば直して欲しいですが、とりあえず現状のままでもマージ OK です")
} else {
return .rejected(failureMessage: "チェックが通らないので、修正してください")
}
}
}
// ...
danger.shoki.askReviewer(to: "コミットメッセージのフォーマットが間違ってないか確認してください", into: $report)
// ...
danger.shoki.report(report)
My Report
Checking Item Result 何かのチェック ... ...
- コミットメッセージのフォーマットが間違ってないか確認してください
- ...
Good Job
ちなみに、名前の Shoki
は日本語の「書記」からきています。なんとなくやってるのが書記がやってそうなことだからです。
DangerSwiftEda
大規模開発になくてはならないと言っても過言ではない「ブランチ戦略」ありますよね!でもそのブランチ戦略が本当に正しく行われているのか、PR レビューでなかなか見られていないですね2。特に Git-Flow 採用の場合、リリースブランチが main にも develop にも両方入れないといけなかったりしますが、それってなかなか確認し忘れがちですよね。更に会社によってコンフリクト解決に Rebase が推奨の場合3、間違って Merge で解決されてもコミットツリー見ないと分かりにくいですよね!
そんなあなたにぜひ使って欲しいのがこの DangerSwiftEda です!このプラグインを使えば、ブランチ運用が正しいかどうかを、機械的に検出してくれるので、レビュアーにその分の負担を大きく減らせる上、正しいブランチ運用によってデグレのリスクも減らせます!(ただし残念ながら、執筆した現時点では、まだ Git-Flow しか対応していません;GitHub-Flow の対応も視野に入っておりますが、他にも入れて欲しいブランチ戦略があればぜひ Issue や PR をください!)例えば下記のようなコードを書いておけば、 GitHub でこんな出力が得られます:
// ...
danger.eda.ckeckPR(workflow: .gitFlow(.default))
Feature PR Check
Checking Item Result Base Branch Check Merge Commit Non-Existence Check Diff Volume Check ChangeLog Modification Check
Warnings This PR doesn't contain any modifications in CHANGELOG.md. Please consider to update the ChangeLog. There's too much diff. Please make PRs smaller.
お気づきでしょうか?はい、DangerSwiftEda はまさに DangerSwiftShoki も利用しています、つまりプラグインが更に別のプラグインを使っています。このようなことも、Danger-Swift では可能です!
ちなみに、名前の Eda
はお察しの通り、ブランチこと「枝」です。
プラグインの作成
さて宣伝が一通り終わったので、次はいよいよこれらのプラグインをサンプルに、実際にプラグインの作り方をご紹介したいと思います。
Package.swift を作る
Danger-Swift は SwiftPM の仕組みでプラグインを利用していますので、まずは SwiftPM のプロジェクトを作ります。ここでターミナルを開いて swift package init
を叩けば、今いるディレクトリーで初期の Package.swift
だけでなく、.gitignore
なども適宜に生成してくれるのでおすすめです。
初期の Package.swift
ファイルが作られたら、次に依存として DangerSwift
を入れます4。参考に DangerSwiftShoki の Package.swift ファイルを見てもらえば分かりやすいかと思いますが、単純に dependencies
に .package(name: "danger-swift", url: "https://github.com/danger/swift.git", from: "3.0.0"),
を追加すればいいです:
let package = Package(
// ...
dependencies: [
.package(name: "danger-swift", url: "https://github.com/danger/swift.git", from: "3.0.0"),
],
// ...
)
ここでちょっとだけ面倒なのは、Danger-Swift のパッケージ名とリポジトリ名が違う5ので、パッケージ名を明示的に "danger-swift"
で宣言する必要があります。そして依存を導入したら、ターゲットとプロダクトを作ります。パッケージ名、ターゲット名とプロダクト名を全部同じ名前にすると後で色々やりやすいかもです:
let package = Package(
name: "MyAwesomeDangerPlugin",
products: [
.library(
name: "MyAwesomeDangerPlugin",
targets: ["MyAwesomeDangerPlugin"]),
],
// ...
targets: [
.target(
name: "MyAwesomeDangerPlugin",
dependencies: [.product(name: "Danger", package: "danger-swift")]),
// ...
]
)
Danger-Swift 自体の導入と同じように、Danger-Swift をターゲットに依存として追加する際も気をつけないといけないのは、プロダクト名とパッケージ名がまた違うので、.product(name: "Danger", package: "danger-swift")
で宣言する必要があります。
ちなみに、ここの例は Danger-Swift のみに依存しているプラグインの場合ですが、それだけでなく、他の Danger-Swift プラグインに更に依存しているプラグインの作成も可能です、その場合は DangerSwiftEda のようにそれらの依存を全部 Package.swift
で宣言すればいいだけです:
let package = Package(
// ...
dependencies: [
.package(name: "danger-swift", url: "https://github.com/danger/swift.git", from: "3.0.0"),
.package(name: "AnotherDangerPlugin", url: "https://github.com/some/plugin", from: "1.2.3"),
],
targets: [
.target(
name: "MyAwesomeDangerPlugin",
dependencies: [
.product(name: "Danger", package: "danger-swift"),
"AnotherDangerPlugin",
]),
// ...
]
)
機能を実装する
Package.swift
をそのまま Xcode で開くと、SwiftPM の開発経験がある方ならお馴染みの Swift Package プロジェクトの画面が表示されます。経験がなくても iOS アプリとかの Xcode プロジェクトの画面とそんなに大きく違わないので、すぐ慣れると思います。何はともあれ、初回開いた場合、Xcode は各種依存パッケージを落としてきてくれるので、それが完了するまで少し待ちます。
ダウンロードが終わったら、次は Sources フォルダー内に各ターゲット名のフォルダーが入っているのが確認できると思います(現状ではターゲットが 1 つしかないですが)。今後のこのプラグインのすべてのソースコードはそのフォルダーに入ります。そしてプラグインは実行バイナリではなくライブラリーなので、main.swift
みたいな入口も特に必要ないです。
例えば Danger で "Hello, World!"
を返すメソッドを実装してみます。まず新しい .swift
ファイルを作ります。次に import Danger
をすると、DangerDSL
を拡張できます。この DangerDSL
が我々が Dangerfile.swift
で作ってる let danger = Danger()
の実態です。というわけで、DangerDSL
を拡張して、"Hello, World!"
を返すメソッドを作ってみます:
import Danger
extension DangerDSL {
public func helloWorld() -> String {
return "Hello, World!"
}
}
気をつけないといけないのは、プラグインを使うときはモジュールを跨ぐので、ユーザ側が利用するメソッドなどは public
で宣言する必要があります。こうすれば、このプラグインを導入した Dangerfile.swift
では、我々はこのように "Hello, World!"
を取得できます:
import Danger
let danger = Danger()
// ...
let string = danger.helloWorld()
さて、もちろん Danger-Swift のことだから、大事なのは GitHub でコメントをしてくれたり、ワーニングやエラーを残すなりすることですね。これらはすべて DangerDSL
から message(_ message: String)
や fail(_ message: String)
などのような API6 があります。ここがまさに Swift ならではのメリットではないでしょうか:とりあえず extension DangerDSL
書いておけば、中にどんなメソッドがあるのかとかはすべて Xcode が補完で教えてくれるし、なんなら DangerDSL
を opt クリックで簡単に定義にジャンプできるし、何か間違っていればコンパイラーがすぐビルドエラーで教えてくれるので、仕様をある程度熟知していないと簡単に手を出せない Ruby の Danger と比べるとプラグイン開発が圧倒的に作りやすいと思いませんか。
import Danger
extension DangerDSL {
public func sayHelloWorld() {
message("Hello, World!")
}
}
import Danger
let danger = Danger()
// ...
danger.sayHelloWorld()
テストを入れる
さて本番のスクリプトならテストを書くのは難しいですが、プラグインライブラリーならテストは簡単に作れます。というわけでせっかくなのでテストも書いてみましょう。最初のステップで swift package init
でパッケージを作ったのでしたらそもそもテストターゲットも一つ作られていますが、もしなかったら手動でこのように簡単にテストターゲットを追加できます:
let package = Package(
// ...
targets: [
// ...
.testTarget(
name: "MyAwesomeDangerPluginTests",
dependencies: ["MyAwesomeDangerPlugin"]),
]
)
テストターゲット作ったら、今度は Tests フォルダー内にテストターゲットのフォルダーがあるのを確認し、なければ作ります。今後のテストコードはすべてそのフォルダーの中に入れます。
例えばここで先ほど作った helloWorld() -> String
の出力が本当に "Hello, World!"
であることを保証したいなら、iOS アプリなどのテストと同じ感じでこのようにテストコードを書けます
import XCTest
import Danger
@testable import MyAwesomeDangerPlugin
final class HelloWorldTests: XCTestCase {
func test_helloWorld() {
let danger = Danger()
XCTAssertEqual(danger.helloWorld(), "Hello, World!")
}
}
ただお気づきかと思いますが、純粋関数は非常にテストが書きやすいです、これも我々が実際の開発でなるべく純粋関数を多用したい理由の一つです。しかし sayHelloWorld()
のような直接 message(_:)
を呼び出すメソッドは非常にテストがしにくいです。しかも DangerDSL
は継承できない struct
である上テストケース用のイニシャライザーも用意されていないです。なので sayHelloWorld()
のような DangerDSL
の副作用に依存するメソッドを厳密にテストすることはほぼ不可能です。でも諦めないでください、ちょっと回りくどいですが、間接的な方法はあります。
Type Erasure と Targeted Extension
いきなり難しそうな単語が出てきましたが、大丈夫です、そんな難しいことではありません。
まずここで Type Erasure(型消去)というキーワードが出ていますが、すみませんちょっと大袈裟です。厳密にはここで型消去をしていません;どっちかというと利用している手法が型消去でよく使うクロージャ方式を利用しただけの型消去っぽい何かです。
そして Targeted Extension も、RxSwift などにお馴染みがある方でしたら .rx
拡張子はきっと記憶にあると思います、Targeted Extension はつまり外部ライブラリーが自分自身が作った拡張を該当の型にそのままではなく、.rx
みたいな何かしらの拡張子を経て拡張する、例えば SearchBar
の text
という Observable
プロパティーを拡張で作るとき、searchBar.test
や searchBar.rxText
ではなく、searchBar.rx.text
で拡張する手法です。かっこいいだけでなく、名前衝突の心配も大きく減るやり方です。
この二つの手法をうまく使えば、利用する側がスタイリッシュで読み書きしやすいコードを書けるだけでなく、テストもだいぶ書きやすくなります。
まず、我々がテスト書きにくいと感じてる原因は DangerDSL
の message(_:)
などの副作用メソッドが置き換えにくいからです;なので逆の発想で、DangerDSL
のこれらのメソッド自体を別のテストを考慮した箱に入れるようにすれば、その箱をテストできるので、間接的にこれらのメソッドでもテスト可能になります。例えば DangerSwiftShoki の場合は Shoki
がその箱に当たります。
public struct DangerDSLBox {
private let messageAction(message: String)
// ...
init(
messageAction: @escaping (String) -> Void,
// ...
) {
self.messageAction = messageAction
// ...
}
func message(_ message: String) {
messageAction(message)
}
}
extension DangerDSLBox {
public func sayHelloWorld() {
message("Hello, World!")
}
}
このようにすれば、我々は DangerDSL
の message(_:)
メソッドを、DangerDSLBox
に擬似的に「移動」してきましたので、テスト時は独自で作ったモックのクロージャを代入してあげればいいです。DangerSwiftShoki のテストコードを読んでみると、下のサンプルコードより遥かに難しいことをいっぱいやっていますが、その雰囲気は読み取れるかと思います
final class DangerDSLBoxTests: XCTestCase {
func test_sayHelloWorld() {
let messageExpectation = expectation(description: "Message")
let box = DangerDSLBox(
messageAction: { XCTAssertEqual($0, "Hello, World!"); messageExpectation.fulfill() }
// ...
)
box.sayHelloWorld()
wait(for: [messageExpectation], timeout: 0)
}
}
こうすれば、プロダクトでは DangerDSL
の message(_:)
メソッドが呼ばれてメッセージが出力されますし、テストでもちゃんとこのメソッドが呼ばれて "Hello, World!"
が出力されていることが保証できます。もちろん残念ながら本番の DangerDSL
から生成された DangerDSLBox
が必ず danger.message(_:)
メソッドを自分の message(_:)
メソッドで呼び出してる保証ができませんが、ただ少なくともライブラリー本来の機能が想定通りの動きをしていることが保証できます。
さて、エセ Type Erasure はわかりました、では Targeted Extension はどうすればいいでしょうか。
実は上のサンプルコードを書くとき、DangerDSLBox
の生成について敢えてテストコードだけ書いて、プロダクトコードでは書いてなかったですね。ここで勘がいい人ならすでに気づいたかもしれません:そうです。DangerSwiftShoki と同じように、DangerDSL
を拡張して DangerDSLBox
を作ればいいのです:
extension DangerDSL {
var box: DangerDSLBox {
return .init(
messageAction = { message($0) },
// ...
)
}
}
こうすれば利用する側で danger.box
を通じて、自分が書いたプラグインのメソッドが利用できますね!
let danger = Danger()
// ...
danger.box.sayHelloWorld()
後書き
気づけば Danger-Swift についてすでに 3 本もの記事を書きましたが、皆さん Danger-Swift に少しは興味を湧いてきましたでしょうか。本家の Ruby の Danger と比べればまだまだ利用人口が少ないですが、やはり型システムが強固な Swift の方が筆者が好きです。そして SwiftPM による導入でこれまでネックだった CI 側でのキャッシュ問題も解決でき、実行パフォーマンスも本家の手軽な Danger と比べてそんなに遜色しないくらい早くなっていると思います。ぜひ我々が普段の開発で慣れている Swift で、より安全な PR 活動をしてみませんか?
-
あくまで個人の感想です。 ↩
-
あくまで個人の感想です。 ↩
-
コンフリクト時の解決に Rebase を使うか Merge を使うかの優劣は別として、あくまで Rebase が推奨された場合の話です。 ↩
-
もちろん Danger-Swift を入れなくても、利用する側がそのライブラリーを入れれば使えますが、そしたらそれはただの汎用ライブラリーであって Danger-Swift のプラグインではなくなりますね。 ↩
-
なぜ Danger-Swift のリポジトリ名が
swift
なのか、未だ謎です。だって Danger-JS はちゃんとdanger-js
ってリポジトリ名なのに。 ↩ -
実は
DangerDSL
からだけでなく、import Danger
さえしていればmessage(_ message: String)
などの出力関数は大域関数としても使えます。大域関数を使うかDangerDSL
のプロパティーを使うかは割と好みの問題ですが、筆者は大域関数を嫌う人です。ただし大域関数の場合は、例えばSwiftLint
などのようなDangerDSL
に生やしていないメソッドの出力をコメントに書きたいなどの場合はやはり便利ですね。 ↩