はじめに
お久しぶりです、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のインストール/アンインストールの機能が発展しメジャーになることを期待しています。
ここまで読んでいただきありがとうございました。
We are hiring!
ミラティブでは新卒およびインターンを募集しています!
興味を持った方は、是非エントリーお待ちしています。