2576 文字
6 分
日本語

一つの設計を、複数の言語で使う

慣れていない言語でプロジェクトを始めるとき、最初に知りたいことはだいたい決まっている。ランタイムは何で入れるのか、パッケージは何で管理するのか、どのロックファイルをgitに入れ、どの生成物を除外するのか。この記事では、Node、Java、Swift、Rust、Go、Rubyについて、その答えを一か所にまとめた。必要な章だけ読み返すためのリファレンスとして使う。

前提となるのは、シリーズ最初の記事で導入した4層構造だ。

The four-layer stack: System, Runtime version, Package manager, Project dependencies Layer 3 Project dependencies lockfiles in git Layer 2 Package manager uv, pnpm, cargo Layer 1 Runtime version mise, or a sovereign tool Layer 0 System Homebrew + Xcode CLT

Layer 1 — Runtime version の担当は、ツールの主権についての記事で決めた基準に従う。言語に強力な公式ツールがあればそれに任せ、なければ mise を使う。以下では結論の理由を繰り返さず、実際の設定と、間違えやすい点に絞る。

Node.js / TypeScript#

Nodeには主権を持つバージョン管理ツールがないため、Nodeのバージョンは mise に任せる。パッケージマネージャーには pnpm を使うが、別途 brew install はせず、corepack から有効にする。こうすると、package.jsonpackageManager フィールドでプロジェクトごとに pnpm のバージョンを固定できる。

mise.toml
[tools]
node = "22"
[settings]
# let corepack manage the pnpm version from package.json's packageManager field
Terminal window
corepack enable # ships with Node; activates pnpm/yarn shims

brew install nodebrew install pnpm も使わない。前者はLayer 1の所有権をHomebrewへ渡してしまい、後者はプロジェクト側のパッケージマネージャー固定を迂回してしまう。Nodeには別の仮想環境もいらない。node_modules がもともとプロジェクト単位なので、Pythonが .venv で得ている隔離は最初から存在する。

Node製のCLIを一度だけ実行するなら、通常は pnpm dlx を使う。継続的にインストールする必要がある場合は、PATH に追加した PNPM_HOME を明示的に管理し、npm install -g を無秩序に増やさない。

pnpm-lock.yaml はコミットし、次を除外する。

node_modules/
*.tsbuildinfo
.turbo/
dist/

Java#

Javaには統一された主権バージョンツールがなく、JDKにも複数のディストリビューションがある。私は、中立的でよく保守されているTemurinを mise から入れている。

mise.toml
[tools]
java = "temurin-21"

javaPATH に入っていることだけでなく、miseJAVA_HOME を正しく設定しているかも確認する。Javaのツールには JAVA_HOME を直接読むものが多く、古いインストールの値が残っていると、意図したJDKよりそちらが静かに優先される。mise where javaecho $JAVA_HOME は同じ場所を指すべきだ。

Terminal window
mise where java
echo $JAVA_HOME

ビルドには、グローバルなGradleやMavenではなく、リポジトリに含まれる ./gradlew または ./mvnw を使う。wrapperはビルドツールのバージョンをリポジトリ内で固定する。通常の依存関係だけでなく、ビルドツールにもLayer 3と同じ考え方を適用できる。

Androidは別扱いでよい。Android Studioは自身のJDKとSDKを同梱しているため、SwiftをXcodeに任せるのと同様に、その環境はAndroid Studioに任せる。

GradleとMavenはビルドファイルで依存関係を宣言する。ビルドファイルとwrapperはコミットし、出力は除外する。

.gradle/
build/
target/

Swift / iOS#

macOSにおけるSwiftの主権ツールはXcodeだ。Xcodeは Layer 0 — SystemLayer 1 — Runtime version の両方をまたぎ、ツールチェーン、SDK、ビルドシステムを一つのアプリとして提供する。私はMac App StoreからXcodeを入れているため、アプリ管理の記事では mas の担当にしている。依存関係にはSwift Package Managerを優先する。

macOSで brew install swift はしない。ツールチェーンの所有者が二つに増える一方で、ビルドの別の部分は引き続きXcodeが握るため、分かりにくい競合が起きる。CocoaPodsも、多くの新しいプロジェクトでは不要になったRuby依存を追加する。必要なパッケージがSPMに対応していれば、依存関係の管理をAppleのツールチェーン内で完結できる。

自分のプロジェクトには、うまく機能している境界の例がある。KotobaLabはSwift、付属するDictionaryBuilderはPythonで書かれている。二つはランタイムもパッケージマネージャーもビルドシステムも共有せず、一つのSQLiteファイルだけで接続する。DictionaryBuilderがデータベースを書き、KotobaLabが読む。中立なデータインターフェースを挟むことで、どちらのツールチェーンも相手の内部を知る必要がない。

SPMのロックファイルである Package.resolved はコミットする。ユーザー固有のファイルとビルド生成物は除外する。

xcuserdata/
DerivedData/
.build/
*.xcuserstate

Rust#

この中では、Rustの所有関係が最も単純だ。rustup がLayer 1を持ち、同梱される cargo がLayer 2に加えて、ビルド、テスト、依存関係の管理、公開まで担当する。

Terminal window
# rustup installs the toolchain; cargo comes with it
rustup default stable

多言語リポジトリにすでに mise.toml がある場合は、そこへ rust = "1.78" と書き、mise から rustup へ委譲してもよい。ツールの主権についての記事で説明した mise as proxy の形だ。どちらでも動くが、同じプロジェクトのRustを両方から管理してはいけない。

Cargoは依存関係をプロジェクト単位で扱うため、Rustに仮想環境は必要ない。ripgrepfdbat のようにRustで書かれたグローバルCLIには、brew のformulaを選ぶ。Homebrewならビルド済みのバイナリが入るが、cargo install はソースからコンパイルする。まだパッケージ化されていないツールにだけ cargo install を使えばよい。

アプリケーションやバイナリでは Cargo.lock をコミットする。ライブラリでは、利用側が自分でバージョンを解決できるよう、コミットしないのが長年の慣例だ。次を除外する。

/target/

Go#

Go公式の dl インストーラーは強力なバージョン管理ツールとはいえないので、Goのバージョンは mise に任せている。

mise.toml
[tools]
go = "1.23"

現在のGoはmodulesを使うため、昔の GOPATH ワークスペース配置は不要で、プロジェクトをディスク上の好きな場所に置ける。一方、go install の出力先は管理したいので GOBIN を設定する。私は、すでに PATH に追加してある ~/.local/bin を使っている。Pythonの記事で個人用スクリプトを置いた場所と同じだ。

00-env.zsh
export GOBIN="$HOME/.local/bin"

依存関係を宣言する go.mod と、チェックサムを固定する go.sum は両方コミットする。Goのプロジェクトには細かな生成物があまり残らない。ビルドしたバイナリを名前で除外するか、除外済みの出力ディレクトリへまとめればよい。

Ruby#

Rubyには主権バージョンツールがないので、Layer 1は mise に任せられる。ただし、実際にRubyを必要とするプロジェクトができたときだけインストールする。

macOSに付属するシステムRubyをプロジェクトの依存関係に使ってはいけない。古いうえ、Appleも変更を推奨していない。そこへ sudo gem install すると、システム環境を壊す典型的な原因になる。

# mise.toml — only when a project genuinely needs it
[tools]
ruby = "3.3"

macOSの開発者がRubyを入れる理由としてCocoaPodsは今もよくあるが、多くのSwiftプロジェクトではSPMによって不要になった。SPMで足りるなら、Rubyは入れなくてよい。これはメンテナンスの記事で扱った、必要になってから導入する方針にも合う。システムRubyが古いからといって、RubyプロジェクトのないMacへ急いで mise 管理のRubyを追加する理由にはならない。

Rubyを使う場合は Gemfile.lock をコミットする。Bundlerのローカルパスを vendor/bundle/ などに設定したなら、そのディレクトリは除外する。

言語が変わっても同じこと#

ランタイムのバージョンは、各開発者のグローバルな初期値ではなく、リポジトリ側で宣言する。mise.toml または .tool-versions をコミットしておけば、新しくcloneした環境でも mise install で必要なバージョンを用意できる。グローバルな初期値は一時的な作業用として残せる。

ロックファイルはgitに入れ、派生物は入れない。pnpm-lock.yamlCargo.lockgo.sumPackage.resolveduv.lockGemfile.lock をコミットし、node_modulestarget/DerivedData/.venv などは .gitignore に入れる。

一つの mise.tomlnodepythongo をまとめて書ける。多言語プロジェクトだからといって、言語ごとにランタイム設定を分ける必要はない。一つのコマンドで全体を用意でき、実際の管理を一部の主権ツールへ委譲する場合にも、統一された入口には意味がある。

同じ事実を複数の層で重複宣言してはいけない。ただし、似て見える設定が別の意味を持つことはある。Pythonでは、pyproject.tomlrequires-python はコードが対応するバージョン範囲を示し、mise.toml はそのマシンで使う一つのバージョンを選ぶ。両方を残すのは重複ではなく、まとめると情報が失われる。本当の重複は一つの事実に二つの情報源を作ってしまう。これはシリーズ最初の記事で避けようとした問題そのものだ。

このモデルが適用できない領域もある。CとC++には「Cのバージョン」を所有する同種のツールがなく、システムコンパイラ、SDK、さまざまなビルドシステムが層を曖昧にする。私は無理に同じ形へ押し込めない。ネイティブ拡張やビルド依存として現れた場合は、それを必要とした上位のツールに処理を任せている。

シリーズ最後の記事では、この設定を終えた後、メンテナンスそのものを新しいプロジェクトにせず、環境を使える状態に保つ方法を扱う。

一つの設計を、複数の言語で使う
https://www.shiinayane.com/ja/posts/polyglot/
著者
YANKAI WANG
公開日
2026-05-30
ライセンス
CC BY-NC-SA 4.0