Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

4つのプラグインを活用して、Scalaソフトウェアを楽々リリースしよう

Table of Contents

  1. 前提: JVM言語を配るのは大変
  2. sbt-release でリリースプロセスの手間暇を省こう
    1. プラグインをインストールする
    2. build.sbtversion.sbt の設定
    3. MavenにはPublishしないようにする
    4. リリースフローを起動する
    5. リリースフローをカスタマイズする
  3. sbt-assembly で UberJAR を簡単に生成しよう
    1. プラグインをインストールする
    2. build.sbt の設定
    3. UberJARを作成する
    4. ライブラリを構成するファイルが衝突した場合の対応策
    5. sbt-release と組み合わせる: リリースフローでUberJARを生成する
  4. sbt-native-packager でDockerイメージを生成しよう
    1. プラグインをインストールする
    2. build.sbt の設定
    3. Dockerイメージをビルドする
    4. Dockerイメージをカスタマイズする
    5. DockerHubにpublishする
    6. sbt-release と組み合わせる: リリースフローでDockerイメージをpublishする
  5. sbt-buildinfo でバージョン情報を活用しよう
    1. プラグインをインストールする
    2. バージョン情報を得る

この記事は、 Scala Advent Calendar 2022 の22日目の記事です。

qiita.com

昨日の記事は、@Kory__3さんによる『Scala と Free モナドで入門するモナド』でした。モナドいいよね・・・いい・・・

qiita.com

それはさておきこの記事では、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.sbtversion.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-assemblysbt-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 / assemblyMergeStrategyMergeStrategy を割り当てます。

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ファイルの生成に対応しています

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?