9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

YUMEMI Advent CalendarAdvent Calendar 2021

Day 21

Danger-Swift のプラグインを作る手引き、そして実際に作ったプラグインをご紹介!

Last updated at Posted at 2021-12-21

前書き

昨日の記事で Danger-Swift のプラグインを SwiftPM で導入する仕方を紹介しましたが、今日は更に一歩を踏み込んで、実際に自分で Danger-Swift のプラグインの作り方を紹介したいと思います;そしてそのサンプルとして、実際に弊社が作った Danger-Swift のプラグインをご紹介したいと思います。

ちなみに Danger-Swift のプラグイン制作は実は非常に簡単で、間違いなく Dangerfile.swift 書いて動作確認するよりは遥かに簡単です1。最低限の SwiftPM の経験があれば、誰でも簡単に Danger-Swift のプラグインが作れます。

プラグインのご紹介

まずは自分でプラグイン作るときに何ができるのかのイメージが湧きやすいように、簡単に弊社が作ったプラグインをご紹介しましょう。OSS として GitHub で絶賛公開中です :tada:

DangerSwiftShoki

Dangerfile 書いてると、こんなこと考えたことありませんか?いろんなチェックをやりたいのに、それらのチェック結果が全てバラバラで出力されて見づらいし、更に出力されてないものが実際にチェックして無事だから出力されなかったのか、それとも単純にチェックしてなかったのかがわかりにくいので、自分でいちいちチェックの実行も出力しておかないといけないですが面倒ですよねー。

そんなあなたにぜひ使って欲しいのがこの DangerSwiftShoki です!このプラグインを使えば、さまざまなチェック項目を全てまとめてテーブルで出力されて一覧がしやすいし、更にどうしても機械検出が難しいものをレビュアーにチェックし忘れないよう催すチェックリスト記法もあります!例えば下記のようなコードを書いておけば、GitHub でこんな出力が得られます!

Dangerfile.swift
// ...

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
何かのチェック :tada:
... ...
  • コミットメッセージのフォーマットが間違ってないか確認してください
  • ...

Good Job :white_flower:

ちなみに、名前の Shoki は日本語の「書記」からきています。なんとなくやってるのが書記がやってそうなことだからです。

DangerSwiftEda

大規模開発になくてはならないと言っても過言ではない「ブランチ戦略」ありますよね!でもそのブランチ戦略が本当に正しく行われているのか、PR レビューでなかなか見られていないですね2。特に Git-Flow 採用の場合、リリースブランチが main にも develop にも両方入れないといけなかったりしますが、それってなかなか確認し忘れがちですよね。更に会社によってコンフリクト解決に Rebase が推奨の場合3、間違って Merge で解決されてもコミットツリー見ないと分かりにくいですよね!

そんなあなたにぜひ使って欲しいのがこの DangerSwiftEda です!このプラグインを使えば、ブランチ運用が正しいかどうかを、機械的に検出してくれるので、レビュアーにその分の負担を大きく減らせる上、正しいブランチ運用によってデグレのリスクも減らせます!(ただし残念ながら、執筆した現時点では、まだ Git-Flow しか対応していません;GitHub-Flow の対応も視野に入っておりますが、他にも入れて欲しいブランチ戦略があればぜひ Issue や PR をください!)例えば下記のようなコードを書いておけば、 GitHub でこんな出力が得られます:

Dangerfile.swift
// ...

danger.eda.ckeckPR(workflow: .gitFlow(.default))

Feature PR Check

Checking Item Result
Base Branch Check :tada:
Merge Commit Non-Existence Check :tada:
Diff Volume Check :thinking:
ChangeLog Modification Check :thinking:
Warnings
:warning: This PR doesn't contain any modifications in CHANGELOG.md. Please consider to update the ChangeLog.
:warning: 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"), を追加すればいいです:

Package.swift
let package = Package(
    // ...
    dependencies: [
        .package(name: "danger-swift", url: "https://github.com/danger/swift.git", from: "3.0.0"),
    ],
    // ...
)

ここでちょっとだけ面倒なのは、Danger-Swift のパッケージ名とリポジトリ名が違う5ので、パッケージ名を明示的に "danger-swift" で宣言する必要があります。そして依存を導入したら、ターゲットとプロダクトを作ります。パッケージ名、ターゲット名とプロダクト名を全部同じ名前にすると後で色々やりやすいかもです:

Package.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 で宣言すればいいだけです:

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!" を返すメソッドを作ってみます:

HelloWorld.swift
import Danger

extension DangerDSL {

    public func helloWorld() -> String {
        return "Hello, World!"
    }

}

気をつけないといけないのは、プラグインを使うときはモジュールを跨ぐので、ユーザ側が利用するメソッドなどは public で宣言する必要があります。こうすれば、このプラグインを導入した Dangerfile.swift では、我々はこのように "Hello, World!" を取得できます:

Dangerfile.swift
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 と比べるとプラグイン開発が圧倒的に作りやすいと思いませんか。

HelloWorld.swift
import Danger

extension DangerDSL {

    public func sayHelloWorld() {
        message("Hello, World!")
    }

}
Dangerfile.swift
import Danger

let danger = Danger()

// ...

danger.sayHelloWorld()

テストを入れる

さて本番のスクリプトならテストを書くのは難しいですが、プラグインライブラリーならテストは簡単に作れます。というわけでせっかくなのでテストも書いてみましょう。最初のステップで swift package init でパッケージを作ったのでしたらそもそもテストターゲットも一つ作られていますが、もしなかったら手動でこのように簡単にテストターゲットを追加できます:

Package.swift
let package = Package(
    // ...
    targets: [
        // ...
        .testTarget(
            name: "MyAwesomeDangerPluginTests",
            dependencies: ["MyAwesomeDangerPlugin"]),
    ]
)

テストターゲット作ったら、今度は Tests フォルダー内にテストターゲットのフォルダーがあるのを確認し、なければ作ります。今後のテストコードはすべてそのフォルダーの中に入れます。

例えばここで先ほど作った helloWorld() -> String の出力が本当に "Hello, World!" であることを保証したいなら、iOS アプリなどのテストと同じ感じでこのようにテストコードを書けます

HelloWorldTests.swift
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 みたいな何かしらの拡張子を経て拡張する、例えば SearchBartext という Observable プロパティーを拡張で作るとき、searchBar.testsearchBar.rxText ではなく、searchBar.rx.text で拡張する手法です。かっこいいだけでなく、名前衝突の心配も大きく減るやり方です。

この二つの手法をうまく使えば、利用する側がスタイリッシュで読み書きしやすいコードを書けるだけでなく、テストもだいぶ書きやすくなります。

まず、我々がテスト書きにくいと感じてる原因は DangerDSLmessage(_:) などの副作用メソッドが置き換えにくいからです;なので逆の発想で、DangerDSL のこれらのメソッド自体を別のテストを考慮した箱に入れるようにすれば、その箱をテストできるので、間接的にこれらのメソッドでもテスト可能になります。例えば DangerSwiftShoki の場合は Shoki がその箱に当たります

DangerDSLBox.swift
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!")
    }

}

このようにすれば、我々は DangerDSLmessage(_:) メソッドを、DangerDSLBox に擬似的に「移動」してきましたので、テスト時は独自で作ったモックのクロージャを代入してあげればいいです。DangerSwiftShoki のテストコードを読んでみると、下のサンプルコードより遥かに難しいことをいっぱいやっていますが、その雰囲気は読み取れるかと思います

DangerDSLBoxTests.swift
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)

    }

}

こうすれば、プロダクトでは DangerDSLmessage(_:) メソッドが呼ばれてメッセージが出力されますし、テストでもちゃんとこのメソッドが呼ばれて "Hello, World!" が出力されていることが保証できます。もちろん残念ながら本番の DangerDSL から生成された DangerDSLBox が必ず danger.message(_:) メソッドを自分の message(_:) メソッドで呼び出してる保証ができませんが、ただ少なくともライブラリー本来の機能が想定通りの動きをしていることが保証できます。

さて、エセ Type Erasure はわかりました、では Targeted Extension はどうすればいいでしょうか。

実は上のサンプルコードを書くとき、DangerDSLBox の生成について敢えてテストコードだけ書いて、プロダクトコードでは書いてなかったですね。ここで勘がいい人ならすでに気づいたかもしれません:そうです。DangerSwiftShoki と同じようにDangerDSL を拡張して DangerDSLBox を作ればいいのです:

DangerDSL+.swift
extension DangerDSL {

    var box: DangerDSLBox {
        return .init(
            messageAction = { message($0) },
            // ...
        )
    }

}

こうすれば利用する側で danger.box を通じて、自分が書いたプラグインのメソッドが利用できますね!

Dangerfile.swift
let danger = Danger()
// ...
danger.box.sayHelloWorld()

後書き

気づけば Danger-Swift についてすでに 3 本もの記事を書きましたが、皆さん Danger-Swift に少しは興味を湧いてきましたでしょうか。本家の Ruby の Danger と比べればまだまだ利用人口が少ないですが、やはり型システムが強固な Swift の方が筆者が好きです。そして SwiftPM による導入でこれまでネックだった CI 側でのキャッシュ問題も解決でき、実行パフォーマンスも本家の手軽な Danger と比べてそんなに遜色しないくらい早くなっていると思います。ぜひ我々が普段の開発で慣れている Swift で、より安全な PR 活動をしてみませんか?

  1. あくまで個人の感想です。

  2. あくまで個人の感想です。

  3. コンフリクト時の解決に Rebase を使うか Merge を使うかの優劣は別として、あくまで Rebase が推奨された場合の話です。

  4. もちろん Danger-Swift を入れなくても、利用する側がそのライブラリーを入れれば使えますが、そしたらそれはただの汎用ライブラリーであって Danger-Swift のプラグインではなくなりますね。

  5. なぜ Danger-Swift のリポジトリ名が swift なのか、未だ謎です。だって Danger-JS はちゃんと danger-js ってリポジトリ名なのに。

  6. 実は DangerDSL からだけでなく、import Danger さえしていれば message(_ message: String) などの出力関数は大域関数としても使えます。大域関数を使うか DangerDSL のプロパティーを使うかは割と好みの問題ですが、筆者は大域関数を嫌う人です。ただし大域関数の場合は、例えば SwiftLint などのような DangerDSL に生やしていないメソッドの出力をコメントに書きたいなどの場合はやはり便利ですね。

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?