Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

CLIツールの管理をSPMからMintに移行した経緯とその課題

はじめに

お久しぶりです、iOSチームにインターンとして参加させて頂いておりますMと申します。 MirrativのiOSアプリでは、これまでSwift製のCLIツールをBuildToolsという名前のSwift Packageを作成してこれにまとめて管理していました。 しかしこの度、ある課題や利便性の観点からyonaskolb/Mintでの管理に移行しました。 今回はMintへの移行に至るまでの背景や経緯、および移行に際して起こったいくつかの課題をどのように解決したかについて書いていこうと思います。

目次

Mintへの移行に至る背景

CLIツールの利用

MirrativのiOSアプリでは、開発に際し様々なCLIツールを導入し、業務の効率化やコード品質の改善を図っています。 例えば、Swiftコードに対するLintツールとしてrealm/SwiftLint、フォーマッタとしてnicklockwood/SwiftFormatを利用しています。また、未使用コード検出のためにperipheryapp/Periphery、ボイラープレートを避けるためにkrzysztofzablocki/Sourceryなども利用しています。

(少し以前の記事になりますが、MirrativのiOSアプリで利用しているライブラリやその導入背景については、以下の関連記事をご覧ください)

関連記事 tech.mirrativ.stream tech.mirrativ.stream

これらのCLIツールは、プロジェクト内にBuildToolsという名前のPackageを作成しSwift Package Manager(以下、SPM)を利用して一元管理していました。

let package = Package(
    name: "BuildTools",
    platforms: [.macOS(.v12)],
    dependencies: [
        .package(url: "https://github.com/nicklockwood/SwiftFormat.git", from: "0.52.0"),
        .package(url: "https://github.com/SwiftGen/SwiftGen.git", from: "6.6.2"),
        .package(url: "https://github.com/peripheryapp/periphery.git", from: "2.18.0"),
        // ... Other CLI Tools
    ]
)

この管理しているCLIツールは, swift run --package-path BuildTools #{CLIツール}というフォーマットにより、swift runコマンド経由で実行しています。

SPMによる管理における課題

SPMで管理するCLIツールの数が増えていく中で、いくつかの課題に遭遇してしまいました。 それぞれ以下に簡単に説明します。

依存関係の不整合

使用しているCLIツールの中には、swift-syntaxに依存しているものが多くありました。このことが原因で、それぞれ使用したいバージョンのツールを1つのSwift Packageにまとめていると、依存するswift-syntaxのバージョンがコンフリクトしてしまう問題が発生しました。今回は起こりませんでしたが、swift-argument-parserなどのCLIツールによく使用されるPackageについても同様の問題が起こることが考えられます。

依存解決の時間

また、依存ライブラリの解決にかかる時間の長さも課題でした。 ある1つのCLIツールを使用したくて、swift run経由で行うと、そのPackageに含まれるそれ以外のすべてのCLIツールの依存もCloneされて解決されてから実行されます。 そのため、必要のないパッケージの解決も行われてしまい、時間がかかっていました。

Mintへの移行

iOSアプリ開発において、CLIツールの管理によく利用されているものとしてyonaskolb/Mintが挙げられます。 Mintでは、SPMで管理する場合と異なり、各CLIツールを単一でビルドし、生成されたバイナリを実行して利用する流れをとっています。そのため、ツール間の依存関係や複数まとめて管理していることによる依存解決時間の増加などの心配がないと考えました。

一般的には現在SPMを採用するケースが増えているながらも、以上の点から、Mirrativでは上記の課題点を両方とも解消することが可能なMintへの移行を行うことを選択しました。 ここではMintへの移行の際に行った作業についてまとめます。

SPMからMintへの移行自体は、特に大変な作業は発生しませんでした。

はじめに、Package.swiftのdependenciesに書いていたCLIツールをMintfileに書き写しました。

nicklockwood/SwiftFormat@0.53.0
SwiftGen/SwiftGen@6.6.2
peripheryapp/periphery@2.18.0

# ... Other CLI Tools

次に、swift run経由で呼び出していた箇所をmint run経由に置き換えました。

swift run --package-path BuiltTools swiftlint

# ↓↓↓↓↓↓↓↓

mint run swiftlint

MINT_PATHという環境変数を利用することで、mintによるビルドの成果物が配置されるディレクトリを指定することが可能です。 しかし、ある値のMINT_PATHの環境でビルド済みであっても、異なる値が設定されている場合、または設定されていない環境でmint runすると再度ビルドが走ってしまうので注意が必要でした。

CLIツールはmake経由で利用することが多いと思いますが、Makefileの冒頭などでMINT_PATHを設定しておくと良いと思います。

export MINT_PATH := ./.mint/lib

Mintへの移行時に発生した課題とその解決

以上の手順でSwift PackageからMintでの管理に移行を行いましたが、それに伴って幾つかの課題も生じました。 ここでは、発生した課題とそれをどのように解決したかについて説明します。

バージョンの自動更新

SPMで管理している際は、dependabotによって使用するCLIツールのバージョンを自動で定期更新を行うことができていました。 しかし、Mintはdependabotに対応してないので、各CLIツールのリリース情報を見ながら手動でMintfileで指定してあるバージョンの更新を行う必要が発生してしまいました。

バージョン更新のためのコマンドを実装

Mint自体もSwiftで実装されており、Swift Packageのexecutable package productとして定義されています。 このmintコマンドの実装のコアとなる部分はMintKitというモジュールにまとめられています。よって、このMintKitを用いて、Mintfileの自動バージョン更新のためのコマンドを新たに実装することにしました。

以下のリポジトリに実装したコマンドのソースを公開しています。
github.com

実装の簡単な概要を紹介します。

1. Mintfileのパスを取得

バージョン更新の対象となるMintfileのパスを引数として受け取ります。 本家であるmintコマンドの実装にならい、デフォルトではカレントディレクトリのMintfileを参照するようにしています。

2. 現在のパッケージ定義一覧の取得

MintKitの実装を利用して、現在のMintfileに定義されている、CLIツールのリポジトリ名とバージョンのペアの一覧を取得します。

Mintfileモデルのpackagesプロパティがpublicでなかったため、ややトリッキーな実装となってしまっています。

var mintfile = try Mintfile(path: mintFilePath)
let packages = withUnsafePointer(to: &mintfile) { ptr in
    let raw = UnsafeRawPointer(ptr)
    return raw.load(as: [PackageReference].self)
}

3. 各パッケージ(CLIツール)の最新バージョンの取得

各パッケージについてそれぞれ最新バージョンを取得します。 git ls-remoteサブコマンドを利用して、タグ一覧を取得しています。 取得したタグ一覧をセマンティックバージョニングにしたがってソートして、最も新しいバージョンとなるタグ名を取得しています。

func findLatestVersion(
    for package: PackageReference
) throws -> String? {
    let tagOutput = try Task.capture(
        bash: "git ls-remote --tags --refs \(package.gitPath)"
    )
    let tagReferences = tagOutput.stdout
    var tags = tagReferences
        .split(separator: "\n")
        .map {
            String(
                $0.split(separator: "\t")
                .last!
                .split(separator: "/")
                .last!
            )
        }
    tags.sort { $0.compare($1, options: .numeric) == .orderedAscending }

    return tags.last
}

4. Mintfileの更新

上記で取得した最新バージョンのタグ名を利用して、Mintfile内で指定してあるパッケージのバージョン部分を書き換えていきます。

はじめに、replacement(for Package, in Mitfile)関数で、Mintfile内の置き換え対象のパッケージ定義一覧を取得しています。 バージョン指定のないものや、ブランチ名で指定してあるものは除外しています。

typealias MintfileReplacement = (from: Package, to: Package)
private func replacement(
    for package: PackageReference,
        in mintfile: Mintfile
) -> MintfileReplacement? {
    guard !["master", "develop", "main"].contains(package.version),
          !package.version.isEmpty else {
        return nil
    }
    guard let latest = try? findLatestVersion(for: package),
          package.version != latest else {
        return nil
    }
    let from = Package(repo: package.repo, version: package.version)
    let to = Package(repo: package.repo, version: latest)
    return (from, to)
}

そして、実際にMintfileを更新していきます。 念の為、ログも出しています。

private func updateMintfile(_ replacements: [MintfileReplacement]) throws {
    var string: String = try mintFilePath.read()
    for replacement in replacements {
        print("🌱 bump \(replacement.from.repo) from \(replacement.from.version) to \(replacement.to.version)")
        string = string.replacingOccurrences(
            of: replacement.from.line,
            with: replacement.to.line
        )
    }

    try mintFilePath.write(string, encoding: .utf8)
}

バージョン更新のためのコマンドを定期実行

BitriseやGitHub Actionsを用いて、上記で作成したバージョン自動更新のためのコマンドを定期実行するようにしました。

今回は、fastlane内に新たにMintfile自動更新用のlaneを定義して、これを、Bitrise/GitHub Actionsから実行する形をとりました。

desc "MintでインストールしているCLIツールを更新します。"
lane :update_mint_dependencies do |options|
  date = Date.today.to_s # 現在日付を取得
  branch_name = "feature/update-mint-dependencies-" + date # branch名

  sh("cd ../ && make update-cli-tools") # 上記で実装した定期更新用コマンドの実行
  sh("git checkout -b #{branch_name}")

  git_add
  git_commit(path: "./", message: "定期のMint管理CLIツールの更新(#{date})")

  push_to_git_remote(
    remote: "origin",
    local_branch: branch_name,
    remote_branch: branch_name,
    tags: false
  )

  create_pull_request(
    repo: "XXX/yyyyyy", # リポジトリ名
    title: "[定期] Mint管理のCLIツール更新(#{date})", # 作成されるプルリクのタイトル
    head: branch_name,
    base: "develop",
    body: "定期のMint管理CLIツールの更新です"
  )
end

以上により、dependabotによる定期バージョン更新の代替となるものを実装することができました。

CIでのキャッシュ

SPMで管理している際は、そのパッケージ配下にある「.build」ディレクトリをキャッシュしておくことで、各CLIツールの再ビルドが発生せずにより早く実行することが可能でした。

Mintへの移行後は、MINT_PATH環境変数に設定してあるディレクトリをキャッシュしておくことで解決しました。

移行による依存解決にかかる時間の改善

Mintへの移行を行ったことで、SPMの場合と比較しどの程度必要な実行時間に変化が見られたのかついて検証を行いました。 MintとSPMそれぞれで同様のCLIツールを管理しつつ、その中のある特定のツールのみ実行した際にかかる実行時間について計測してみました。

計測には、Unix系OSに標準搭載されているtimeコマンドを利用します。 以下のようなフォーマットでコマンドを実行することで、そのコマンドの実行時間の計測することが可能です。

time コマンド

また、初回実行時と2回目以降での実行では、キャッシュであったりビルドの必要の有無から、必要な時間が大きく異なるため、それぞれのケースで計測を行いました。 初回実行時の計測については、SPMのキャッシュである、~/.swiftpmディレクトリ、Swift Package直下にある.buildディレクトリ削除しておきました。また、Mintの成果物が保存されるMINT_PATHに指定してあるディレクトリも削除しておきました。

それぞれ実行時間は以下の結果となりました。

SPM Mint
初回 114.78[s] 57.449[s] 
2回目移行 1.285[s] 0.093[s]

初回実行時において、SPMでは不必要な依存ライブラリの解決も行われていることにより、 Mintと比較して2倍近くの時間がかかっています。今回は3つのCLIツールを依存に追加していましたが、この数がもっと増えたり、巨大なCLIツールを利用している場合は、この開きはより大きくなるでしょう。

また、ビルドの必要がない2回目移行の実行でもMintの方が高速でした。
(この点については、SPMの場合においてもswift runコマンドを経由せず、.buildディレクトリ配下に生成されるバイナリを直接指定してあげることで、差異なく実行することが可能でした。)

まとめ

  • iOSアプリ開発におけるCLIツールの管理
    • これまではSwift Packageを利用して管理していた
    • しかし各CLIツールの依存関係の不整合が頻発してしまっていた。
    • Mintへの移行を検討
  • Mintへの移行
    • 基本的にPackage.swiftに書いてあった依存をMintfileに書き写すだけで完了
    • CLIツールの呼び出しは、swift run --package-path {PACKAGE NAME}からmint runに変更するだけ
  • Mintへの移行後の課題とその解決
    • Swift Packageはdependabotに対応しておりバージョンの自動更新が可能
    • Mintの自動更新はdependabotに対応していない(Renovateは対応しているよう)
    • Mintfileの自動バージョン更新用のコマンドを実装
    • MINT_PATHをCIでキャッシュすることで高速化

さいごに

今回は、iOSアプリ開発におけるCLIツールの管理をMintに移行した経緯、およびそれに伴って発生した課題とその解決方法について書きました。 Mintへ移行することで、依存関係の調整を行う必要がなくなりました。Mintを利用する上での懸念となっていたバージョンの自動更新やキャッシュに関する懸念点も解消した上で導入することができ、良かったと考えています。

個人的には、SPMに現在実験的に搭載されているexcutable productのインストール/アンインストールの機能が発展しメジャーになることを期待しています。

github.com

ここまで読んでいただきありがとうございました。

We are hiring!

ミラティブでは新卒およびインターンを募集しています!

興味を持った方は、是非エントリーお待ちしています。

www.mirrativ.co.jp

mirrativ.notion.site