はじめに
私はこれまでSwift製CLIツール(SwiftLint、XcodeGenなど)の管理をMintで行っていました。
SwiftPMで管理できることを知り、実際にMintからSwiftPMに乗り換えたことによる自分なりのベストプラクティスを紹介します。
SwiftPMで管理する方法についてはSwiftFormatのREADMEに記載されているので、先に読んでいただけると私の説明がわかりやすいです。
環境
- OS:macOS Big Sur 11.4
- Xcode:12.4 (12D4e)
- Swift:5.3.2
- swift-tools:5.3
ベストプラクティス
ベストプラクティスをCLIツールの導入時と実行時に分け、順に紹介します。
導入
CLIツール導入時のベストプラクティスです。
CLIツールをまとめるフォルダを作る
プロジェクトのルートフォルダにたくさんフォルダを作ると煩雑になるため、CLIツールをまとめるフォルダを作ります。
私は Tools
という名前で作りました。
$ tree -L 1
.
├── Tools
1つのパッケージで足りる場合は不要です。
できる限り1つのパッケージにまとめる
すべてのCLIツールを1つのパッケージにまとめたいのですが、以下の理由により難しいです。
- 依存関係の競合
- ターゲット名の衝突
まとめられなかったCLIツール同士でパッケージをまとめるのもいいですが、パッケージ名を考えたりするのが手間なので、私は独立させています。
私は以下の3パッケージとなりました。
XcodeGen
SwiftLint
SpellChecker
LicensePlist
R.swift
xcbeautify
IBLinter
Mockolo
私は CLIツールはそれぞれ独立して動くべき だと考えているのですが、CLIツールごとにパッケージを作るとファイル数が増えてしまうため、依存ライブラリを共有することにしました。
パッケージ名にプリフィックスを付ける
パッケージ名がCLIツールと被るとビルドエラーになるため、プリフィックスを付けます。
私はアプリ名をプリフィックスとして付けました。
メインのパッケージを {App name}Tools
、独立したパッケージを {App name}{CLI tools name}
としました。
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "UhooiPicBookTools", // !!!: パッケージ名にプリフィックスを付ける
// ...
)
3つのパッケージを先ほど作成した Tools
フォルダに格納します。
$ tree -L 2
.
├── Tools
│ ├── UhooiPicBookIBLinter
│ ├── UhooiPicBookMockolo
│ └── UhooiPicBookTools
ターゲット名にもプリフィックスを付ける
ターゲット名もCLIツールと被るとビルドエラーになるため、プリフィックスを付けます。
私はパッケージ名と同名にしています。
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "UhooiPicBookTools",
// ...
targets: [.target(name: "UhooiPicBookTools", path: "")] // !!!: ターゲット名とパッケージ名を同じにする
)
ターゲットの配列を空にしても動作するようなので、そうすればターゲット名の被りを気にする必要がなくなります。
- targets: [.target(name: "UhooiPicBookTools", path: "")]
+ targets: []
ただ Package.swift
をXcodeで開いたときに「My Mac」が選択肢になくてビルドできなかったため、私はターゲットを明記しています。
CLIツールのバージョンを固定する
CLIツールのバージョンを指定して使いたい、かつ他の開発メンバーと同じバージョンを使いたいため、 from:
でなく .exact()
で指定してバージョンを固定します。
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "UhooiPicBookTools",
// ...
// CLIツールのバージョンを `.exact()` を使って固定する
dependencies: [
.package(url: "https://github.com/yonaskolb/XcodeGen", .exact("2.23.0")),
.package(url: "https://github.com/realm/SwiftLint", .exact("0.43.1")),
.package(url: "https://github.com/fromkk/SpellChecker", .exact("0.1.0")),
.package(url: "https://github.com/mono0926/LicensePlist", .exact("3.0.7")),
.package(url: "https://github.com/mac-cain13/R.swift", .exact("5.4.0")),
.package(url: "https://github.com/thii/xcbeautify", .exact("0.9.1"))
],
// ...
)
ちなみに通常のCLIツールやライブラリの開発で .exact()
を使うのは推奨されていません。
複数のパッケージが特定のパッケージに依存している場合、依存関係が競合する可能性が高いためです。
参考:https://github.com/apple/swift-package-manager/blob/ce50cb0de101c2d9a5742aaf70efc7c21e8f249b/Documentation/PackageDescription.md#methods-4
今回はCLIツールなので Swift Argument Parser に依存しているものが多く、競合する可能性が高いです。
パッケージを分けたりバージョンを下げて使ったりして回避しましょう。
実行
CLIツール実行時のベストプラクティスです。
リリースビルドしたバイナリを使う
swift run -c release swiftlint
のように swift run
コマンドでもCLIツールを実行できますが、以下のデメリットがあります。
-
swift run
とCLIツールでオプションが衝突すると予期せぬ動作になることがある - 2回目以降の実行でもオーバーヘッドがある
そのため swift build -c release swiftlint
のようにリリースビルドし、バイナリを呼び出して実行するのがオススメです。
# ビルド
swift build -c release --package-path ./Tools/UhooiPicBookTools --product swiftlint
# 実行
./Tools/UhooiPicBookTools/.build/release/swiftlint --fix --format
私は make build-cli-tools
ですべてのCLIツールをリリースビルドできるようにしています。
PRODUCT_NAME := UhooiPicBook
CLI_TOOLS_PACKAGE_PATH := Tools/${PRODUCT_NAME}Tools
CLI_TOOLS_PATH := ${CLI_TOOLS_PACKAGE_PATH}/.build/release
.PHONY: build-cli-tools
build-cli-tools: # Build CLI tools managed by SwiftPM
$(MAKE) build-cli-tool CLI_TOOL_NAME=swiftlint
swift build -c release --package-path Tools/UhooiPicBookIBLinter --product iblinter
$(MAKE) build-cli-tool CLI_TOOL_NAME=SpellChecker
swift build -c release --package-path Tools/UhooiPicBookMockolo --product mockolo
$(MAKE) build-cli-tool CLI_TOOL_NAME=license-plist
$(MAKE) build-cli-tool CLI_TOOL_NAME=xcbeautify
.PHONY: build-cli-tool
build-cli-tool:
swift build -c release --package-path ${CLI_TOOLS_PACKAGE_PATH} --product ${CLI_TOOL_NAME}
CIでキャッシュを取得する
パッケージごとに .swiftpm
と .build
フォルダが生成されますが、 .build
フォルダをキャッシュすることでCIの実行時間を短縮できます。
GitHub Actionsを使っている場合は公式ドキュメントの例がほぼそのまま使えます。
jobs:
build:
runs-on: macOS-latest
steps:
# ...
# SwiftPMで管理しているCLIツールのキャッシュ
- name: Cache CLI tools managed by SwiftPM
uses: actions/cache@v2
with:
path: Tools/UhooiPicBookTools/.build
key: ${{ runner.os }}-spm-${{ hashFiles('**/Tools/UhooiPicBookTools/Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-
- name: Cache IBLinter managed by SwiftPM
uses: actions/cache@v2
with:
path: Tools/UhooiPicBookIBLinter/.build
key: ${{ runner.os }}-spm-${{ hashFiles('**/Tools/UhooiPicBookIBLinter/Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-
- name: Cache Mockolo managed by SwiftPM
uses: actions/cache@v2
with:
path: Tools/UhooiPicBookMockolo/.build
key: ${{ runner.os }}-spm-${{ hashFiles('**/Tools/UhooiPicBookMockolo/Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-
# ...
全体図
最後にフォルダ構成と各パッケージの Package.swift
を載せます。
$ tree -L 3
.
├── Tools
│ ├── UhooiPicBookIBLinter
│ │ ├── Empty.swift
│ │ ├── Package.resolved
│ │ └── Package.swift
│ ├── UhooiPicBookMockolo
│ │ ├── Empty.swift
│ │ ├── Package.resolved
│ │ └── Package.swift
│ └── UhooiPicBookTools
│ ├── Empty.swift
│ ├── Package.resolved
│ └── Package.swift
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "UhooiPicBookTools",
platforms: [
.macOS(.v11)
],
dependencies: [
.package(url: "https://github.com/yonaskolb/XcodeGen", .exact("2.23.0")),
.package(url: "https://github.com/realm/SwiftLint", .exact("0.43.1")),
// .package(url: "https://github.com/IBDecodable/IBLinter", .exact("0.4.25")),
.package(url: "https://github.com/fromkk/SpellChecker", .exact("0.1.0")),
// .package(url: "https://github.com/uber/mockolo", .exact("1.4.0")),
.package(url: "https://github.com/mono0926/LicensePlist", .exact("3.0.7")),
.package(url: "https://github.com/mac-cain13/R.swift", .exact("5.4.0")),
.package(url: "https://github.com/thii/xcbeautify", .exact("0.9.1"))
],
targets: [.target(name: "UhooiPicBookTools", path: "")]
)
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "UhooiPicBookIBLinter",
platforms: [
.macOS(.v11)
],
dependencies: [
// TODO: The target name will no longer conflict with XcodeGen in the next release,
// and then we'll put the package together.
.package(url: "https://github.com/IBDecodable/IBLinter", .exact("0.4.25")),
],
targets: [.target(name: "UhooiPicBookIBLinter", path: "")]
)
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "UhooiPicBookMockolo",
platforms: [
.macOS(.v11)
],
dependencies: [
.package(url: "https://github.com/uhooi/mockolo", .exact("1.3.4")) // TODO: Replace uhooi with uber
],
targets: [.target(name: "UhooiPicBookMockolo", path: "")]
)
おわりに
これでSwiftPMを使ってSwift製CLIツールを管理できます!
私がOSSで開発している個人アプリのPRが参考になると思うので、リンクを貼ります。
ここに行き着くまでにたくさんつまづき、複数のCLIツールにPRを出したりしたので、別記事で紹介したいと思っています。
おまけ: SwiftPM vs Mint
本記事ではSwift製CLIツールの管理をMintからSwiftPMに置き換えましたが、果たしてどちらがいいのでしょうか。
私が置き換えて感じたことは、 Mintを使うのがかんたんで無難 ということです。
理由は大きく2つです。
- CLIツール同士の依存関係を気にしなくていい
- SwiftPMは依存関係の解決が大変だった
- Mintで管理したほうが実行時に速い
- おそらくCLIツールのインストール時にビルドしてバイナリを保持しているため
SwiftPMでも保持しているはずだが、2回目以降の実行でもMintより時間がかかる
- おそらくCLIツールのインストール時にビルドしてバイナリを保持しているため
ただサードパーティ製のツールに依存しなくなるのはメリットなので、個人開発では導入に踏み切りました。
業務ではMintを使い続けています。
参考リンク
- mintやめてSwiftPMにしました
- CA.swift#14 - Speaker Deck
- swift-package-manager/PackageDescription.md at main · apple/swift-package-manager
- swift-evolution/0162-package-manager-custom-target-layouts.md at master · apple/swift-evolution
- Add Cleaning SwiftPM by uhooi · Pull Request #205 · uhooi/UhooiPicBook
- https://twitter.com/kateinoigakukun/status/1397927091174219779?s=20
- https://twitter.com/treastrain/status/1397971421473607680?s=20