電通総研 テックブログ

電通総研が運営する技術ブログ

JBangを使って複数のJavaをWindows上で管理する。

皆さんこんにちは。システム推進の佐藤太一です。

このエントリでは、Windows上にインストールした複数のJVMを上手く切り替える方法について説明します。

はじめに

Javaでアプリケーション開発をしていると、気が付いたらたくさんのJVMをインストールしていませんか?
私はCorretto やAdoptOpenJDKなど様々なディストリビューションJava 8、Java 17、Java 21、Java 23をそれぞれインストールしています。

eclipseIntelliJといったIDE上でのビルドでは、IDEがランタイムの選択をサポートしてくれます。
GradleMaven にはtoolchainの仕組みがありますので、一旦ビルドが始まってしまえば、適切なランタイムが選択されるでしょう。

しかし、Windowsではそこに至るまでに動作するJVMを上手く管理する方法が少ないです。
このエントリでは、JBang を使って複数のバージョンのJavaをインストールして管理する方法を説明します。
最後はMacLinuxでは日常的に行われているバージョンの自動切り替えについても、WindowsPowerShellを使って実現する方法をご紹介します。

今回の説明で使うPowerShellは7.4系です。事前にPowerShellとscoopをインストールしておいてください。

JBangのインストール

まずは、JBangをインストールしましょう。非常に丁寧に書かれたインストールマニュアルがあるので、それほど迷うことはないはずです。 cf. Installation

今回はscoopを使ってインストールしますので以下のコマンドを実行します。

scoop bucket add jbangdev https://github.com/jbangdev/scoop-bucket
scoop install jbang

JBang用のバケットを追加した上で、そのバケットからインストールしていますね。

JBangを使ったJavaのインストール

JBangをインストールしたら、次はJavaをインストールします。
JBangで簡易的にインストールできるディストリビューションはAdoptOpenJDKのみです。
それ以外のディストリビューションをインストールしたいのであれば、別途インストールした上でJBangの管理下に置く必要があります。今回は簡易的なインストールだけを説明します。

例えば、Java 8 をインストールするなら以下のコマンドを実行します。

jbang jdk install 8

次は、Java 17と21をインストールしてみましょう。

jbang jdk install 17
jbang jdk install 21

ディストリビューションが固定されているので、コマンドが非常に簡潔ですね。

インストール済みのJavaを一覧にしてみましょう。以下のコマンドを実行します。

jbang jdk list

私の環境では以下のように出力されます。

Installed JDKs (<=default):
   8 (1.8.0_412)
   17 (17.0.12+7)
   21 (21.0.1+12-29) <

21 の右端に < が付いているのは、これをデフォルトのJavaに設定しているからです。

デフォルトのJavaを設定する

では、JBangを使ってデフォルトのJavaを設定しましょう。

jbang jdk default 21

これで、デフォルトのJavaが21になりました。ついでに環境変数も追加しておきましょう。

JBangによってインストールしたJavaのフルパスを確認するには、以下のコマンドを実行します。

jbang jdk java-env

これを実行すると、以下のように出力されます。

$env:PATH="C:\Users\taichi\.m2\jdks\jdk-21.0.1\bin;$env:PATH"
$env:JAVA_HOME="C:\Users\taichi\.m2\jdks\jdk-21.0.1"
# Run this command to configure your environment:
# jbang jdk java-env | iex

ユーザのホームディレクトリ以下にファイルが作成されている様子を確認できますね。
JBangは、jdk java-envコマンドの実行結果をiexつまり、Invoke-Expressionすることで実行環境を切り替えていくツールというわけです。

何もしていない時に動作するJavaを指定するために、Windows環境変数を設定するツールでPATH環境変数JAVA_HOME環境変数を設定しておきましょう。

JBangを使ってJavaを切り替える

次は、PowerShell上で利用するJavaを切り替えてみましょう。
まず、PowerShellのターミナルを開いて現在のJavaを確認します。

C:\Users\taichi> java -version
openjdk version "21.0.1" 2023-10-17
OpenJDK Runtime Environment (build 21.0.1+12-29)
OpenJDK 64-Bit Server VM (build 21.0.1+12-29, mixed mode, sharing)

PATH環境変数Java 21が設定されているので、このように表示されます。
これを、Java 17に切り替えてみましょう。以下のコマンドを実行します。

jbang jdk java-env 17 | iex

これで切り替わりましたので、確認してみましょう。

C:\Users\taichi> java -version
openjdk version "17.0.12" 2024-07-16
OpenJDK Runtime Environment Temurin-17.0.12+7 (build 17.0.12+7)
OpenJDK 64-Bit Server VM Temurin-17.0.12+7 (build 17.0.12+7, mixed mode, sharing)

切り替わっていますね。MavenやGradleが参照するJAVA_HOME環境変数も確認してみましょう。

C:\Users\taichi> $env:JAVA_HOME
C:\Users\taichi\.jbang\cache\jdks\17

正しく切り替わっています。

PowerShellの補助関数を使って切り替える

JBangを使った切り替えはコマンドが少し長いので、覚えていられないという問題があります。
そこで、PowerShellのProfileという機能を使って切り替え用のコマンドを実装してみましょう。
プロファイルの詳細については、以下のドキュメントを参照してください。

要は$PROFILE でパスを確認できるPowerShellスクリプトにコードを書いておくとPowerShellが起動されるたびに、それが自動的に動く仕組みです。

プロファイルに以下のような関数を定義します。

function global:switchJava([string]$version) {
  jbang jdk java-env $version | iex
  java -version
}

以下のコマンドを実行してプロファイルをリロードします。

. $PROFILE

補助関数を実行して利用するJavaのバージョンを変更してみましょう。新しいターミナルを起動して、以下のコマンドを実行します。

switchJava 17

以下のように出力されてJavaのバージョンが切り替わります。

C:\Users\taichi> switchJava 17
openjdk version "17.0.12" 2024-07-16
OpenJDK Runtime Environment Temurin-17.0.12+7 (build 17.0.12+7)
OpenJDK 64-Bit Server VM Temurin-17.0.12+7 (build 17.0.12+7, mixed mode, sharing)

PowerShellのプロファイルを作りこんで自動で切り替える

補助関数でのバージョン切り替えは便利ですが、ターミナルを起動するたびに利用するべきJavaのバージョンを確認して切り替えていくのは面倒です。
現在のディレクトリ内にある設定ファイルを見て利用するJavaを自動的に切り替えてほしいですよね。
Windows以外の環境で動作するbashzshといったシェルでは、例えば.java-version のようなファイルの存在確認をしてその中身によってバージョンを切り替えるという事が一般的に行われています。
Windowsではあまり広く行われていませんが、PowerShellを使えば実装可能であることを紹介します。

まずは、プロファイルに以下のコードをそのまま追加します。
ただし、既にプロファイルをカスタマイズしている人は global:prompt の編集だけ注意してください。
要は、prompt関数の好きな場所に autoJava を追加すれば動作します。

<#
Copyright 2024 DENTSU SOKEN INC.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
#>

function global:prompt {
  $origLastExitCode = $LASTEXITCODE

  autoJava

  $LASTEXITCODE = $origLastExitCode
}

function switchJava([string]$version) {
  $SB = {
    param([Parameter(Mandatory = $true)] $version)
    jbang jdk java-env $version
    if ($LASTEXITCODE -ne 0) {
      throw "jbang jdk java-env command failed with exit code $LASTEXITCODE"
    }
  }
  $output = Start-ThreadJob -ScriptBlock $SB -ArgumentList $version | Receive-Job -Wait
  $output -join "`n" | iex
  java -version
}

$global:lastKnownLocation = ""

function autoJava {
  $p = $PWD.Path
  if ($p -ne $global:lastKnownLocation) {
    guessJava
    $global:lastKnownLocation = $p
  }
}

function guessJava {
  $versionFile = Join-Path -Path $PWD -ChildPath ".java-version"
  if (Test-Path $versionFile) {
    $content = Get-Content $versionFile
    $version = $content.Trim()
    if (findJava $version -eq $true) {
      $res = switchJava $version
    } else {
      Write-Host ".java-versionファイルでは $version が指定されていますが、そのバージョンのJavaはインストールされていません。"
      Write-Host "jbang jdk install $version を実行するとインストールできるかもしれません。"
    }
  } else {
    useDefaultJava
  }
}

function useDefaultJava {
  foreach ($tup in listJava) {
    if ($tup.IsDefault -eq $true) {
      switchJava $tup.Label
      return
    }
  }
}

function findJava([string]$version) {
  foreach ($tup in listJava) {
    if ($tup.Label -eq $version) {
      return $true
    }
    if ($tup.FullVersion -eq $version) {
      return $true
    }
    if ($tup.FullVersion.StartsWith($version)) {
      return $true
    }
  }
  return $false
}

function listJava {
  $SB = {
    jbang jdk list
    if ($LASTEXITCODE -ne 0) {
      throw "jbang jdk list command failed with exit code $LASTEXITCODE"
    }
  }
  $output = Start-ThreadJob -ScriptBlock $SB | Receive-Job -Wait
  return $output | Select-Object -Skip 1 | ForEach-Object {
      if ($_ -match '^\s*(\d+)\s+\(([\d\._\+\-]+)\)\s*(<)?\s*') {
          [PSCustomObject]@{
              Label = $Matches[1]
              FullVersion = $Matches[2]
              IsDefault = $Matches[3] -eq "<"
          }
      }
  }
}

コードの細かい処理内容については説明しませんが、いくつかある奇妙な部分について説明します。

Start-ThreadJob を使う理由

jbangコマンドがPowerShellスクリプトだからです。

prompt関数からPowerShellスクリプトが推移的に呼び出されると、呼び出されたスクリプトの終了時点で解釈が終了するのです。
私の場合は、posh-git などを使ってプロンプトを装飾しています。
prompt関数の戻り値がないと、デフォルトのプロンプトになってしまうので困ります。

というわけで、jbangコマンドをジョブとして実行してその結果の標準出力だけを使っているのです。

バージョンの評価ロジックについて

findJava関数に実装してあるバージョンの評価ロジックは非常に単純なものです。

  • JBangでインストールされているバイナリのメジャーバージョン番号との完全一致
  • JBangでインストールされているバイナリの詳細なバージョン番号との完全一致
  • JBangでインストールされているバイナリの詳細なバージョン番号との前方一致

.java-versionに記載されているバージョン番号は多くの場合に、メジャーバージョン番号のみです。
Javaは非常に高度な互換性を保ちながら開発されているので、日常的な開発において細かいリビジョン番号やパッチ番号を気にする必要はないと考えています。
もし細かい処理が必要な方はこのコードを是非改善して、その内容をブログに書いて貰えるとありがたいですね。

グローバル変数を使った処理回数の低減

prompt関数は、PowerShellのプロンプトが表示されるたびに実行される関数なので非常に実行回数が多いものです。
なので、カレントディレクトリが変更されるまではJavaのバージョンを確認する処理をしないようにしてあります。
そのために使っている変数が lastKnownLocation です。

類似するツール

まとめ

このエントリでは、JBangとそれを使ったPowerShellスクリプトを紹介しました。
このスクリプトは少し応用すれば、nodeやPythonといった他の言語でも利用できるものです。

このエントリによって、Windows環境で快適に開発できる技術者が少しでも増えることを願っています。

執筆:@sato.taichi、レビュー:@yamashita.tsuyoshi
Shodoで執筆されました