CLOVER🍀

That was when it all began.

sbtとMavenで、実行可能JARファイルの先頭に起動スクリプトを差し込む

sbt-assemblyやMaven Shade Pluginなどで実行可能なJARファイル(java -jar xxxxx.jar)を作成することが
できますが、このJARファイルの最初の方にシェルスクリプトやWindowsのバッチファイルの内容を差し込むことで、
単体のファイルとして実行することができるようになります。

Embulkが、この手法を使っています。
https://github.com/embulk/embulk/blob/v0.8.18/embulk-cli/src/main/sh/selfrun.sh

参考)
実行可能 jar をコマンドっぽく実行するために(java -jar 使いたくない)

実行可能JARファイルをバッチファイルまたはシェルスクリプトに結合して実行する - torutkの日記

確かにJARファイルだけだと、別に起動用のスクリプトもつけてあげないと苦しい時はある気もするというか、
単体で実行できるとそれはそれで便利かもしれません。

というわけで、自分でも試してみようと思います。

まずは素で作る

とりあえず、最初はなにも使わずに同様のものを作成したいと思います。そのあとで、sbtとMavenを使いましょう。

例えば、こういうJavaソースコードを用意。
app/Hello.java

package app;

public class Hello {
    public static void main(String... args) {
        String word;

        if (args.length > 0) {
            word = args[0];
        } else {
            word = "World";
        }

        System.out.printf("Hello %s!!%n", word);
    }
}

コンパイルして、実行可能JARファイルを作成します。

$ javac app/Hello.java
$ jar -cvfm hello-app.jar META-INF/MANIFEST.MF app/Hello.class 
マニフェストが追加されました
app/Hello.classを追加中です(入=558)(出=370)(33%収縮されました)

ここで、MANIFEST.MFは以下のとおり。

Manifest-Version: 1.0
Created-By: 1.8.0_121 (Oracle Corporation)
Main-Class: app.Hello

これで、「java -jar」で実行できます、と。

$ java -jar hello-app.jar 
Hello World!!
$ java -jar hello-app.jar Java
Hello Java!!

で、例えばこんな感じの起動スクリプトを用意して
run.sh

java -jar "$0" "$@"
exit $?

JARファイルとくっつけて、ファイルに実行権限を与えます。

$ cat run.sh hello-app.jar > hello-app
$ chmod a+x hello-app

JARファイルの先頭に、スクリプトが追加された状態になります。

これで、JARファイルを直接実行できるようになります。

$ ./hello-app 
Hello World!!
$ ./hello-app Java
Hello Java!!

Windows向けの場合は、シェルスクリプトではなくてbatファイルの内容(改行コードはCRLF)にすればOKです。

これを、sbtとMavenでやってみます。

sbt+sbt-assembly

sbtで、実行可能JARファイルを作るといえば、sbt-assemblyです。
※依存関係もまとめて作成するので、単一のJARファイルにするという意味でも

GitHub - sbt/sbt-assembly: Deploy fat JARs. Restart processes. (port of codahale/assembly-sbt)

まずは、実行可能JARファイルを作れるように設定してみます。

build.sbt

name := "executable-jar"

version := "1.0"

scalaVersion := "2.12.1"

organization := "org.littlewings"

scalacOptions ++= Seq("-Xlint", "-unchecked", "-deprecation", "-feature")

updateOptions := updateOptions.value.withCachedResolution(true)

mainClass in assembly := Some("app.Hello")

assemblyJarName in assembly := "hello-app.jar"

プラグインの追加。
project/plugins.sbt

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.4")

対象のソースコード。
src/main/scala/app/Hello.scala

package app

object Hello {
  def main(args: Array[String]): Unit = {
    val word = args.toList.headOption.getOrElse("World")

    println(s"Hello ${word}!!")
  }
}

実行可能JARファイルを作成。

> assembly

できあがり。

$ java -jar target/scala-2.12/hello-app.jar
Hello World!!
$ java -jar target/scala-2.12/hello-app.jar Scala
Hello Scala!!

ここで、ドキュメントに習いシェルスクリプトを追加してみます。

Prepending Shebang

sbt-assemblyの部分だけ抜粋すると、こんな感じです。

mainClass in assembly := Some("app.Hello")

assemblyJarName in assembly := "hello-app.jar"

import sbtassembly.AssemblyPlugin.defaultShellScript
assemblyOption in assembly :=
  (assemblyOption in assembly)
    .value
    .copy(prependShellScript = Some(defaultShellScript))

再度asemblyで実行可能JARファイルを作成して、「java -jar」ではなく直接実行してみます。

$ ./target/scala-2.12/hello-app.jar
./target/scala-2.12/hello-app.jar: 2: ./target/scala-2.12/hello-app.jar: : not found./target/scala-2.12/hello-app.jar: 2: ./target/scala-2.12/hello-app.jar: cannot create �

〜略〜

…思い切りコケました。

ここでちょっとファイルの先頭を見ると、どうも改行が足りていない様子。

$ head -n 2 ./target/scala-2.12/hello-app.jar
#!/usr/bin/env sh
exec java -jar "$0" "$@"PK躎JMETA-INF/MANIFEST.MF����=�0
                                                          ��R��
                                                               $�5+
          
〜省略〜

そこで、とりあえずと改行を足してみます。

import sbtassembly.AssemblyPlugin.defaultShellScript
assemblyOption in assembly :=
  (assemblyOption in assembly)
    .value
    .copy(prependShellScript = Some(defaultShellScript :+ System.lineSeparator))

今度はOKでした!

$ ./target/scala-2.12/hello-app.jar
Hello World!!
$ ./target/scala-2.12/hello-app.jar Scala
Hello Scala!!

なお、この追加されるスクリプトの定義はこちらです。
https://github.com/sbt/sbt-assembly/blob/v0.14.4/src/main/scala/sbtassembly/AssemblyPlugin.scala#L20

prependShellScriptに設定する値を変えれば、カスタマイズも可能です。今回は自作のスクリプトを追加してみましょう。
ちょっと頑張って、LinuxとWindowsの両対応にしてみます。

こんな2つのスクリプトを用意。

Windows用。
※改行コードCRLFで作成
scripts/run.bat

: <<BAT
@echo off
java -jar %~f0 %*

exit /b
BAT

Linux用。
scripts/run.sh

exec java -jar "$0" "$@"
exit 127

Embulkのスクリプトを、ものすごく簡易化したものですが。「:」とヒアドキュメントで、Windows用の部分がうまく無視されるように
なっていてすごいですね…。

で、これをくっつけます。

assemblyOption in assembly :=
  (assemblyOption in assembly)
    .value
    .copy(prependShellScript = Some(
      Seq(scala.io.Source.fromFile("scripts/run.bat", "UTF-8").mkString +
        scala.io.Source.fromFile("scripts/run.sh", "UTF-8").mkString))
    )

これでassemblyすると、WindowsとLinux両対応の実行可能ファイルができあがります。

Windowsの場合は、拡張子を.batにしてから実行。

>move hello-app.jar hello-app.bat
>hello-app.bat
Hello World!!
>hello-app.bat Scala
Hello Scala!!

Linux。

$ ./target/scala-2.12/hello-app.jar
Hello World!!
$ ./target/scala-2.12/hello-app.jar Scala
Hello Scala!!

Windowsの知識がなくて、ムダにハマりました…。

まあ、やりたいことはできたのでOKです。

Maven

Mavenの場合は、Spring BootのMaven Pluginを使うのが簡単かなと思います。

59. Installing Spring Boot applications

プラグインと設定。Spring Boot本体は、別になくてもOKです。

    <build>
        <finalName>hello-app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>1.5.2.RELEASE</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <executable>true</executable>
                </configuration>
            </plugin>
        </plugins>
    </build>

設定で変わっている点は、executableをtrueにしているところです。

Javaのソースコードとしては、こちらを用意。
src/main/java/app/Hello.java

package app;

public class Hello {
    public static void main(String... args) {
        String word;

        if (args.length > 0) {
            word = args[0];
        } else {
            word = "World";
        }

        System.out.printf("Hello %s!!%n", word);
    }
}

これでパッケージングすると、直接実行可能なJARファイルができあがります。

$ mvn package

実行。

$ ./target/hello-app.jar
Hello World!!
$ ./target/hello-app.jar Java
Hello Java!!

デフォルトのスクリプトはCentOS、Ubuntuでテストされたものみたいです。

実体はこれですね。
https://github.com/spring-projects/spring-boot/blob/v1.5.2.RELEASE/spring-boot-tools/spring-boot-loader-tools/src/main/resources/org/springframework/boot/loader/tools/launch.script

というわけで、JARファイルの先頭にえらいごっついスクリプトが入っています。

$ head -n 30 ./target/hello-app.jar
#!/bin/bash
#
#    .   ____          _            __ _ _
#   /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
#  ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
#   \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
#    '  |____| .__|_| |_|_| |_\__, | / / / /
#   =========|_|==============|___/=/_/_/_/
#   :: Spring Boot Startup Script ::
#

### BEGIN INIT INFO
# Provides:          executable-jar
# Required-Start:    $remote_fs $syslog $network
# Required-Stop:     $remote_fs $syslog $network
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: executable-jar
# Description:       executable-jar
# chkconfig:         2345 99 01
### END INIT INFO

[[ -n "$DEBUG" ]] && set -x

# Initialize variables that cannot be provided by a .conf file
WORKING_DIR="$(pwd)"
# shellcheck disable=SC2153
[[ -n "$JARFILE" ]] && jarfile="$JARFILE"
[[ -n "$APP_NAME" ]] && identity="$APP_NAME"

差し込むスクリプトを変更する場合は、embeddedLaunchScriptに対象のスクリプトファイルを指定します。

            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>1.5.2.RELEASE</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <executable>true</executable>
                    <embeddedLaunchScript>scripts/run.sh</embeddedLaunchScript> 
                </configuration>
            </plugin>

ここは、もうシェルスクリプトだけにしました…。
scripts/run.sh

exec java -jar "$0" "$@"
exit 127

あとはパッケージングすればOKです。

まとめ

実行可能JARファイルの先頭にシェルスクリプト、もしくはbatファイルの内容を差し込んで、単体のファイルで実行可能なように
してみました。

別に起動用スクリプトがいらなくなるので便利は便利なんですけど、スクリプト内に「java -jar」が固定で書かれちゃうので、
JavaVMへのオプション(例えば-Xmx)とかシステムプロパティを設定したい時ってちょっと難しかったりします。

Embulkも「-J」をつけたものを見分けていましたし。

他にも、「JAVA_OPTS」とかの環境変数越しに渡すという手もあるでしょう。

まあ、方法としては覚えておいて、使えそうなところではトライしてみましょうというところでしょうか。