Table of Contents
- 前提: JVM言語を配るのは大変
sbt-release
でリリースプロセスの手間暇を省こうsbt-assembly
で UberJAR を簡単に生成しようsbt-native-packager
でDockerイメージを生成しようsbt-buildinfo
でバージョン情報を活用しよう
この記事は、 Scala Advent Calendar 2022 の22日目の記事です。
昨日の記事は、@Kory__3さんによる『Scala と Free モナドで入門するモナド』でした。モナドいいよね・・・いい・・・
それはさておきこの記事では、Scalaでソフトウェアをリリースする工程で使うと便利な sbt
プラグインを紹介します。
具体的には、以下のプラグインを紹介します:
- Git管理下にあるScalaソフトウェアの包括的なリリースフローを提供するプラグイン
sbt-release
- ScalaソフトウェアのUberJARファイルを作成するためのプラグイン
sbt-assembly
- ScalaソフトウェアをDockerなどの形式で配布できるようにする
sbt-native-packager
- プロジェクトのバージョン情報等を実行時に得られるようにする
sbt-buildinfo
具体的な読者として、自分で書いたプロダクトをインターネット越しに配布してみたいエンジニアを想定しています。 この記事を読むことで、Scalaソフトウェアを誰かに使ってもらうための一助になれば幸いです。
前提: JVM言語を配るのは大変
JVM言語に限らず、ソフトウェアを配布するというのは開発するよりも骨の折れる作業です。依存ライブラリの整理や、適切なバージョニングを行ってファイル名や内部の設定に反映させる必要があるからです。
特に依存ライブラリの整理が大変です。現代的なソフトウェアは無数のライブラリに依存しており、それらの全てが揃わなければソフトウェアは動作することができません。最近はGo言語がシングルバイナリ戦略を採用し、必要なライブラリを全て静的リンクして1つのバイナリにまとめる流儀が話題になりました。これは、最小限の制約でソフトウェアを起動させることを可能にします。
さて、JVM言語でも似たようなアプローチが昔から行われてきました。それは UberJAR と呼ばれるもので、直訳すると「JAR以上のもの」「スゴJAR」程度の意味1です。 UberJARはJARファイルの一種です が、依存するライブラリを単一のJARファイルにまとめてあるため、単体で起動できます。ただし、JARファイルの宿命として、起動のためにはAmazon CorrettoといったJavaランタイムが必要であり、 java -jar uberjar.jar
のような形式で呼び出す必要があります。
つまり、ユーザにJVM言語のソフトウェアを配布するためには、何らかの形でUberJARを作成し、さらにJavaランタイムをインストールしておいてもらう必要があります。 Go言語などの直接機械語にコンパイルする言語と比べると、やや煩雑な感じは否めません。
これから紹介する sbt
プラグインは、こうしたバージョニングや依存関係処理の煩雑な手間を軽減してくれます。各節を順に読み進めて、Scalaプロダクトを配布するための技法を学びましょう。
sbt-release
でリリースプロセスの手間暇を省こう
これから紹介する sbt-release
は、sbtプロジェクトのリリースプロセスを包括的に支援するプラグインです。つまり、これを入れておけばリリースフローを全部カバーしてくれます。
sbt release
を実行してインタラクティブな質問に答えるだけで、バージョン修正やGitのタグ打ち等のリリースフローを完了させることができます(具体的なフローについては後述します)。 凄い!!
プラグインをインストールする
sbt-release
を利用するためには、 project/plugins.sbt
に以下のような依存関係を設定します(バージョンは執筆時のものを利用しています):
addSbtPlugin("com.github.sbt" % "sbt-release" % "1.1.0")
build.sbt
と version.sbt
の設定
人間の代わりに sbt-release
がバージョンを管理するため、バージョン設定ファイルを隔離する必要があります。 version.sbt
を新規作成し、 build.sbt
に記載されたバージョン情報を移動させましょう:
ThisBuild / version := "0.2.0.4-SNAPSHOT"
これに加えて、以下のような形で build.sbt
に正しい組織情報を設定しておきましょう。GitHubにソフトウェアをホストしている場合は、 com.github.${ユーザ名}
とするのがおすすめです。
ThisBuild / organization := "com.github.windymelt"
ThisBuild / organizationName := "windymelt"
MavenにはPublishしないようにする
いったんここではMavenにはpublishしない前提で話を進めます。というのも、Maven Central Repositoryへの登録の手間が大きいのと、この記事ではリリースフローの概観を掴んでほしいからです。 Mavenへのpublishを行いたい場合は、 SBTのマニュアル を参照し、publishのための設定を行ってください。
リリースフローでpublishしないようにするには、 build.sbt
で以下のように設定します:
lazy val root = (project in file("."))
.settings(
/* ... */
publish / skip := true, // Mavenにpublishする準備が整ったら削除する
/* ... */
)
リリースフローを起動する
たったこれだけでリリースフローが整いました。 sbt
を起動し、 release
を実行すると、以下のフローが開始します(公式マニュアルの情報を参考)。
- 今いるGitリポジトリがcleanな状態かチェックする(未コミットなファイルがあるなど、dirtyな状態のときは中断する)
- リリースに付与するバージョンと、リリース後に使う開発バージョンを尋ねる。
sbt-release
は自動的に「最後の部分を1つインクリメントしたバージョン」を提示してくれるので、多くの場合はデフォルトに従えばよい- 例えば
0.1.2
の場合は、リリースバージョンとして0.1.3
はどうですか、と聞いてくれる
- 例えば
- 安全のため、タスク
clean
が実行される。 - タスク
test:test
が実行される。テストが失敗した場合、リリースフローは中断される。 version.sbt
上のバージョンをリリースバージョンに書き換える。version.sbt
上の変更をコミットする。- ↑コミットに
v$version
というタグを付ける。 publish
タスクが実行される。(ここではpublish / skip
を設定しているので、何も起こらない)version.sbt
上のバージョンを開発バージョンに書き換える。version.sbt
上の変更をコミットする。
リリースフローをカスタマイズする
ここまでの手順でできることはタグを打ってプッシュするだけですが(それだけでも十分便利です)、 sbt-release
にはリリースフローをカスタマイズする機能があるため、後述する sbt-assembly
や sbt-native-packager
と組み合わせられるようになっています。
リリースフローは Seq[ReleaseStep]
で表現されており、 build.sbt
で書き換えることができます:
import ReleaseTransformations._
lazy val root = (project in file("."))
.settings(
/* ... */
releaseProcess := Seq[ReleaseStep](
checkSnapshotDependencies,
inquireVersions,
runClean,
runTest,
setReleaseVersion,
commitReleaseVersion,
tagRelease,
releaseStepTask(ここに任意のsbtタスクを挟むことができる),
setNextVersion,
commitNextVersion,
pushChanges,
)
/* ... */
)
上掲の例では、タグを打った後に任意の sbt
タスクを実行させています。後述する sbt-assembly
などでも、この機能を活用します。
sbt-assembly
で UberJAR を簡単に生成しよう
これから紹介する sbt-assembly
は、sbtプロジェクトを1つの実行可能なJARファイル(UberJAR)としてビルドするプラグインです。開発中のプロジェクトの場合、sbtプロジェクトを実行するには sbt run
などを実行してScalaコンパイラを呼び出し、クラスファイルを生成・実行する必要がありますが、 sbt-assembly
はクラスファイルを配布可能な形でまとめたJARファイルに変換し、さらに依存ライブラリをまとめて単体で実行可能なUberJARを生成します。
プラグインをインストールする
sbt-assembly
を利用するためには、 project/plugins.sbt
に以下のような依存関係を設定します(バージョンは執筆時のものを利用しています):
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.0")
build.sbt
の設定
sbt-assembly
でUberJARを生成する際に必須の設定は特にありません。
必要に応じて、起動時に呼び出されるmain classを指定してください:
lazy val root = (project in file("."))
.settings(
/* ... */
assembly / mainClass := Some("com.github.windymelt.zmm.Main"),
/* ... */
)
UberJARを作成する
UberJARを作成するに必要な手順は、 sbt assembly
を実行するだけです。
$ sbt assembly
[info] welcome to sbt 1.7.2 (GraalVM Community Java 17.0.5)
...
[info] 6 file(s) merged using strategy 'Rename' (Run the task at debug level to see the details)
[info] 53 file(s) merged using strategy 'Discard' (Run the task at debug level to see the details)
[info] 2 file(s) merged using strategy 'First' (Run the task at debug level to see the details)
[info] Built: /PATH/TO/PROJECT/target/scala-2.13/PROJECT-assembly-x.y.z-SNAPSHOT.jar
[info] Jar hash: 3537310d4978977a58be67b6a582cd47646f457d
[success] Total time: 5 s, completed 2022/12/21 13:05:03
作成したUberJARは java -jar
コマンドで実行できます。
$ java -jar PROJECT-assembly-x.y.z.SNAPSHOT.jar
Scala / Javaで完結しているソフトウェアであれば、このままこれを配布することができます。
ライブラリを構成するファイルが衝突した場合の対応策
slf4j
といった特定のライブラリを使っている場合は、ライブラリのJARファイルに含まれているファイル同士が衝突してしまうことがあります。
というのも、UberJARは各ライブラリのJARファイルの中身を1つのJARファイルにまとめるという方式で作成されるため、同名のファイルが存在すると衝突してしまうのです。
これによりUberJARを作成できなくなることを防ぐために、 sbt-assembly
はJARに含まれる各ファイルをどうマージするかの戦略をファイルごとに設定できるようになっています。
この設定を行うには、 ThisBuild / assemblyMergeStrategy
に MergeStrategy
を割り当てます。
ThisBuild / assemblyMergeStrategy := {
// 特定のファイルが衝突する場合はどちらかを優先させる設定ができる
case PathList("META-INF", "versions", "9", "module-info.class") => MergeStrategy.first
// それ以外の場合はデフォルトのマージ戦略を使う
case x =>
val oldStrategy = (ThisBuild / assemblyMergeStrategy).value
oldStrategy(x)
}
sbt-release
と組み合わせる: リリースフローでUberJARを生成する
前項で紹介した sbt-release
のリリースプロセスをカスタマイズする機能と sbt-assembly
のUberJARを生成する機能を組み合わせることで、リリースフローにともなってUberJARも生成するという便利なフローを構築できます。
lazy val root = (project in file("."))
.settings(
/* ... */
releaseProcess := Seq[ReleaseStep](
checkSnapshotDependencies,
inquireVersions,
runClean,
runTest,
setReleaseVersion,
commitReleaseVersion,
tagRelease,
releaseStepTask(assembly), // assemblyを起動する
setNextVersion,
commitNextVersion,
pushChanges,
)
/* ... */
)
これにより、 sbt release
を実行すると自動的にUberJARも生成されます。これをそのままGitHubのリリースページにアップロードすればJARファイルの配布ができますね。
sbt-native-packager
でDockerイメージを生成しよう
これから紹介する sbt-native-packager
は、sbtプロジェクトをDockerイメージなどのJavaランタイムを介しない "Nativeな" フォーマットとしてビルドするためのプラグインです。Dockerイメージに限らず、このプラグインは様々なフォーマットへのビルドに対応しています2が、ここではDockerイメージへのビルドに注目して解説します。
プラグインをインストールする
sbt-native-packager
を利用するためには、 project/plugins.sbt
に以下のような依存関係を設定します(バージョンは執筆時のものを利用しています):
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.4")
build.sbt
の設定
ここでは、Dockerイメージを生成する前提で sbt-native-packager
の設定方法を紹介します。Dockerイメージをビルドできるようにするには、 build.sbt
に以下のような設定を記載します:
import com.typesafe.sbt.packager.docker._
lazy val root = (project in file("."))
.enablePlugins(JavaAppPackaging) // for DockerPlugin
.enablePlugins(DockerPlugin)
Dockerイメージをビルドする
この状態で sbt Docker/publishLocal
を実行すると、自動的に Dockerfile
が生成され、プロジェクト名と同名のイメージが生成されます。
$ docker run --rm -it projectName
Dockerイメージをカスタマイズする
Scalaプロダクトに同梱しなければならないファイルがある場合、DockerイメージをカスタマイズしてDockerイメージに任意のファイルを配置させることができます。
筆者が開発しているZMMの build.sbt
では、以下のようにして必要なファイルを同梱しています:
import com.typesafe.sbt.packager.docker._
lazy val root = (project in file("."))
/* ... */
.settings(
dockerBaseImage := "amazoncorretto:17",
Docker / daemonUser := "root",
Docker / maintainer := "Windymelt",
dockerRepository := Some("docker.io"),
dockerUsername := Some("windymelt"),
dockerUpdateLatest := true,
dockerCommands ++= Seq(
Cmd("USER", "root"),
ExecCmd("RUN", "mkdir", "-p", "/app/artifacts/html"),
ExecCmd("RUN", "mkdir", "/app/assets"),
ExecCmd("ADD", "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js", "/app/highlight.min.js"),
ExecCmd("RUN", "mkdir", "-p", "/app/highlight/styles"),
ExecCmd("ADD", "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css", "/app/highlight/styles/default.min.css"),
Cmd("WORKDIR", "/root"),
ExecCmd("RUN", "yum", "-y", "install", "wget", "tar", "xz"),
ExecCmd("RUN", "wget", "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"),
ExecCmd("RUN", "tar", "xvf", "ffmpeg-release-amd64-static.tar.xz"),
ExecCmd("RUN", "mv", "ffmpeg-5.1.1-amd64-static/ffmpeg", "/usr/bin/ffmpeg"),
ExecCmd("RUN", "mv", "ffmpeg-5.1.1-amd64-static/ffprobe", "/usr/bin/ffprobe"),
ExecCmd("RUN", "amazon-linux-extras", "install", "-y", "epel"),
ExecCmd("RUN", "yum", "update", "-y"),
ExecCmd("RUN", "yum", "install", "-y", "chromium"),
Cmd("WORKDIR", "/app"),
),
その他のDocker関連のカスタマイズオプションについては、公式サイトで確認できます。
DockerHubにpublishする
sbt Docker / publish
を実行することで、DockerHubやECRといったDockerイメージレジストリにイメージをpublishできます。
ただし、 docker login
によってDockerHub等にログインしておく必要があります( sbt-native-packager
自体には、自動的にログインする機能はありません)。
前述したZMMではDockerHubにイメージをpublishしています。https://hub.docker.com/r/windymelt/zmm
これにより、誰でもDockerイメージをダウンロードして実行できるようになりました。
sbt-release
と組み合わせる: リリースフローでDockerイメージをpublishする
前項で紹介した sbt-release
のリリースプロセスをカスタマイズする機能と sbt-native-packager
のDockerイメージを生成する機能を組み合わせることで、リリースフローにともなってDockerイメージも生成しpublishするという便利なフローを構築できます。
lazy val root = (project in file("."))
.settings(
/* ... */
releaseProcess := Seq[ReleaseStep](
checkSnapshotDependencies,
inquireVersions,
runClean,
runTest,
setReleaseVersion,
commitReleaseVersion,
tagRelease,
releaseStepTask(Docker / publish), // イメージをpublishする
setNextVersion,
commitNextVersion,
pushChanges,
)
/* ... */
)
sbt-buildinfo
でバージョン情報を活用しよう
これは番外編です。これから紹介する sbt-buildinfo
は、sbtプロジェクトのバージョンなどのビルド情報をScalaコードから読み取るためのプラグインです。バージョン情報をプログラマティックに表示したいときに便利です。
プラグインをインストールする
sbt-buildinfo
を利用するためには、 project/plugins.sbt
に以下のような依存関係を設定します(バージョンは執筆時のものを利用しています):
addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0")
バージョン情報を得る
sbt-buildinfo
を利用するためには、 build.sbt
に以下の一文を設定します。
lazy val root = (project in file("."))
/* ... */
.enablePlugins(BuildInfoPlugin)
.settings(
buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion),
buildInfoPackage := "ビルド情報を.知っている.オブジェクトを.配置する.パッケージ"
)
これにより、コンパイル時に自動的に buildInfoPackage
以下に BuildInfo
というオブジェクトが生成されるようになり、 BuildInfo.version
を参照することでバージョンを取得できるようになります。
import ビルド情報を.知っている.オブジェクトを.配置する.パッケージ.BuildInfo
println(BuildInfo.version) // => 1.2.3
Footnotes
1 ドイツ語でüberは「より上の」といった意味で、おおむね英語のoverとかsuperに該当する単語です。
2 例えばWindows向けのMSIパッケージや、RHEL系OS向けのRPMファイルの生成に対応しています