まえがき
- ClojureでJavaFXを利用したGUIアプリケーションを実装します
- ユーザ環境にJavaランタイムをインストールしなくて良いように、カスタムJREを使ってアプリケーションを起動できるようにする方法を書きます
- リリース作業をGitHubActionsで自動化します
開発環境
Key | Value |
---|---|
OS | Ubuntu 18.04 |
Clojure | 1.10.1 |
Leiningen | 2.9.4 |
Java | OpenJDK 14 |
JavaFXアプリケーションの配布について
以下の記事でより詳細に書かれているのですが、JavaFXアプリを最新のJavaで起動する形で配布するには準備が必要です。
OpenJFX時代のJDK選び - もしくはOpenJFX時代のアプリケーション配布
Clojureで作成した実行可能JARも同じ問題を抱えています。
ユーザにJavaのランタイム、JavaFX SDKを入れてもらう必要なく、JavaFXアプリケーションを配布するには、カスタムJREを作成して実行可能JARと一緒に配布する必要があります。
各プラットフォーム向けに配布する流れ
カスタムJREを作成してアプリを起動できるようにするのにはいくつか準備が必要で、手順もやや複雑です。なので、段階を追って以下の流れで説明します。
- Clojure+JavaFXのアプリケーションを実装する
- jlinkでカスタムJREを作成し、カスタムJREでアプリケーションを起動できるようにする
- 各プラットフォーム向けに配布するために、CIと連携して自動リリースできるようにする
0. ソースコード
CIでの自動リリースまですべて構築したソースコードは以下です。
今回の記事はこのリポジトリのソースコードをベースに説明します。
1. Clojure+JavaFXのアプリケーションを実装する
まずClojureでJavaFXアプリケーションを起動できるところまで実装します。
この時点ではカスタムJREなどは作らず、lein経由でアプリが起動できればOKです。
プロジェクトの管理にはleiningenを使います。
ClojureでJavaFXを扱うためのライブラリとしてcljfx
を使います。
dependenciesに[cljfx "1.7.10"]
を追加します。
pluginsにフォーマッタや静的解析を入れていますが、
こちらは今回の記事とは関係ないので入れなくても動作すると思います。
コードとしては以下です。
(defproject iconisor "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://github.com/jiro4989/iconisor"
:license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
:url "https://www.eclipse.org/legal/epl-2.0/"}
:dependencies [[org.clojure/clojure "1.10.1"]
[cljfx "1.7.10"]]
:main iconisor.core
;:main ^:skip-aot iconisor.core
:target-path "target/%s"
:plugins [[lein-cloverage "1.2.0"]
[lein-cljfmt "0.7.0"]
[jonase/eastwood "0.3.10"]
[lein-kibit "0.1.8"]]
:profiles {:uberjar {:aot :all
:jvm-opts ["-Dclojure.compiler.direct-linking=true"]
:prep-tasks ["compile"]
:uberjar-name "iconisor.jar"
:injections [(javafx.application.Platform/exit)]}})
最初ここで躓いたのが :injections
の設定です。
アプリケーションの配布用に実行可能JARを作成する必要があるのでlein uberjar
を実行するとuberjarが永遠に終了しないという事象に遭遇しました。
以下のissuesを見つけて解決したのですが:injections [(javafx.application.Platform/exit)]
という記述が必須です。
https://github.com/cljfx/cljfx/issues/17
次にmain関数を実装します。
とりあえずGUIが出れば良いので、cljfxのサンプルコードだけでOKです。
(ns iconisor.core
(:require [cljfx.api :as fx])
(:gen-class))
(defn -main [& args]
(fx/on-fx-thread
(fx/create-component
{:fx/type :stage
:showing true
:always-on-top true
:style :transparent
:scene {:fx/type :scene
:fill :transparent
:stylesheets #{"styles.css"}
:root {:fx/type :v-box
:children [{:fx/type :label
:effect {:fx/type :drop-shadow
:radius 1
:offset-y 2}
:tooltip {:fx/type :tooltip
:text "I am a tooltip!"}
:text "Hi! What's your name?"}
{:fx/type :text-field}]}}})))
これでlein run
を実行すると以下のようなテキストフィールドだけのGUIが起動するようになります。
2. jlinkでカスタムJREを作成し、カスタムJREでアプリケーションを起動できるようにする
カスタムJREを作成するためにjlinkを実行します。
jlinkを実行するには依存ライブラリのモジュールが必要になります。
そのためOpenJFXのサイトからjmodsをダウンロードする必要があります。
僕は以下のシェルを作成し、コマンドラインで取得するようにしています。
#!/bin/bash
set -eux
os_name=linux
version=14.0.1
rm -rf jmods || true
mkdir -p jmods
curl -o jmods/jmods.zip -sSL https://download2.gluonhq.com/openjfx/${version}/openjfx-${version}_${os_name}-x64_bin-jmods.zip
cd jmods
unzip jmods.zip
このシェルを実行するとカレントディレクトリにjmods
ディレクトリが作成されます。
次に、以下のシェルスクリプトを作成します。
#!/bin/bash
set -eux
outdir=$1
jlink --module-path ./jmods/javafx-jmods-14.0.1/ \
--add-modules javafx.base,javafx.controls,javafx.swing,javafx.graphics,javafx.fxml \
--compress=2 \
--output "$outdir"
このシェルを使ってカスタムJREを作成します。
以下のように呼び出します。
./jlink.sh jre
これでjreディレクトリにカスタムJREが作成されます。
このカスタムJREを利用して実行可能JARが起動できることを確認します。
以下のように実行します。
# 実行可能JARの生成
lein uberjar
cd jre
./bin/java -jar ../target/uberjar/iconisor.jar
これでGUIが表示されれば、カスタムJREで起動できるようになっています。
3. CIと連携して自動リリースできるようにする
通常の開発ではjmodsを落としてくる必要はありませんし、カスタムJREの作成も不要です。
リリース作業のためだけにローカル環境を整えるのも手間なのと、リリース作業をコード化して再利用できるようにするために、CI環境(GitHubActions)だけでリリースを完了できるようにします。
具体的には、以下のコマンドを実行して、タグをpushするだけでリリースを終えられるようにするのを目指します。
git tag v0.1.0
git push origin --tags
追加の準備として、起動スクリプトを作成します。
前述のとおり、カスタムJREを使ってアプリケーションを起動するのであれば、コマンドラインからカスタムJREのJavaを指定する必要があります。
一般配布を目指すのでしたら、コマンドライン入力を求めるようにはしたくありません。
なので、前述のコマンドをラップしたスクリプトを作成し、配布時にカスタムJREと一緒に配布する形とします。
以下のスクリプトを作成します。
Windows用。文字コードはSJIS、改行コードはCRLFにしましょう。
@echo off
.\jre\bin\java.exe -jar .\iconisor.jar
Mac, Linux用。文字コードはUTF-8、改行コードはLFにしましょう。
#!/bin/bash
# .github/dist/iconisor
./jre/bin/java -jar iconisor.jar
これらのスクリプトと一緒に、以下のディレクトリ構成となるようにリリース物を収集して圧縮するようなワークフローにしていきます。
iconisor_windows.zip/
iconisor_windows/
jre/ # カスタムJRE
LICENSE
README.txt
iconisor.bat # 起動スクリプト
iconisor # 起動スクリプト
iconisor.jar # 実行可能JAR
ということで、CI環境のみでWindows、Linux、Mac向けにリリース物を生成してアップロードするワークフローを書きました。
以下が全体です。
name: test
on:
push:
paths-ignore:
- 'LICENSE'
- 'README.*'
- 'docs/*'
pull_request:
paths-ignore:
- 'LICENSE'
- 'README.*'
- 'docs/*'
env:
app-name: iconisor # TODO: アプリ名なのでアプリ名に合わせて変更が必要
javafx-version: '14.0.1'
defaults:
run:
shell: bash
jobs:
# リリース自体には不要。テストを書いているなら使う
test:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v1
with:
java-version: '14'
java-package: jdk
architecture: x64
- name: Install xvfb
run: sudo apt install -y xvfb
- run: xvfb-run lein test
- run: xvfb-run lein cloverage --codecov
- uses: codecov/codecov-action@v1
# リリース自体には不要。lintにかけるなら必要。project.cljへのplugin記述も必要
linter:
runs-on: ubuntu-latest
strategy:
matrix:
cmd:
- 'cljfmt check'
- 'kibit'
steps:
- uses: actions/checkout@v2
- run: lein ${{ matrix.cmd }}
# リリースのメイン処理
build:
runs-on: ${{ matrix.runs-on }}
strategy:
matrix:
include:
- runs-on: windows-latest
os: windows
- runs-on: macOS-latest
os: osx
- runs-on: ubuntu-latest
os: linux
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v1
with:
java-version: '14'
java-package: jdk
architecture: x64
- name: Install lein (unix)
uses: DeLaGuardo/setup-clojure@master
with:
cli: '1.10.1.469'
lein: 2.9.4
boot: latest
if: matrix.os != 'windows'
- name: Install lein (windows)
run: |
from urllib import request
import os
lists = [
("https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/%s", "lein.bat"),
]
for elem in lists:
url = elem[0] % elem[1]
file = elem[1]
request.urlretrieve(url, file)
os.system("lein.bat self-install")
shell: python
if: matrix.os == 'windows'
- name: Install openjfx jmods
run: |
mkdir -p jmods
curl -o jmods/jmods.zip -sSL https://download2.gluonhq.com/openjfx/${{ env.javafx-version }}/openjfx-${{ env.javafx-version }}_${{ matrix.os }}-x64_bin-jmods.zip
(
cd jmods
unzip jmods.zip
)
- name: Initialize custom JRE
run: ./jlink.sh jre
- name: Build (unix)
run: lein uberjar
if: matrix.os != 'windows'
- name: Build (windows)
run: lein.bat uberjar
if: matrix.os == 'windows'
shell: cmd
- name: Create artifact
run: |
mkdir -p ${{ env.app-name }}_${{ matrix.os }}/
mv jre .github/dist/* LICENSE target/uberjar/${{ env.app-name }}.jar ${{ env.app-name }}_${{ matrix.os }}/
- name: Compress artifact (unix)
run: |
tar czf ${{ env.app-name }}_${{ matrix.os }}.tar.gz ${{ env.app-name }}_${{ matrix.os }}
if: matrix.os != 'windows'
- name: Compress artifact (windows)
run: |
7z a ${{ env.app-name }}_${{ matrix.os }}.zip ${{ env.app-name }}_${{ matrix.os }}
if: matrix.os == 'windows'
- uses: actions/upload-artifact@v2
with:
name: artifact-${{ matrix.os }}
path: |
${{ env.app-name }}_${{ matrix.os }}.tar.gz
${{ env.app-name }}_${{ matrix.os }}.zip
if: startsWith(github.ref, 'refs/tags/')
create-release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
needs:
- build
steps:
- uses: actions/checkout@v1
- name: Create Release
id: create-release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: ${{ github.ref }}
draft: false
prerelease: false
- name: Write upload_url to file
run: echo '${{ steps.create-release.outputs.upload_url }}' > upload_url.txt
- uses: actions/upload-artifact@v2
with:
name: create-release
path: upload_url.txt
upload-release:
runs-on: ubuntu-latest
needs: create-release
strategy:
matrix:
include:
- os: windows
asset_name_suffix: windows.zip
asset_content_type: application/zip
- os: osx
asset_name_suffix: osx.tar.gz
asset_content_type: application/gzip
- os: linux
asset_name_suffix: linux.tar.gz
asset_content_type: application/gzip
steps:
- uses: actions/download-artifact@v2
with:
name: artifact-${{ matrix.os }}
- uses: actions/download-artifact@v2
with:
name: create-release
- id: vars
run: |
echo "::set-output name=upload_url::$(cat upload_url.txt)"
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.vars.outputs.upload_url }}
asset_path: ${{ env.app-name }}_${{ matrix.asset_name_suffix }}
asset_name: ${{ env.app-name }}_${{ matrix.asset_name_suffix }}
asset_content_type: ${{ matrix.asset_content_type }}
解説すると
- WindowsVM, MacVM, LinuxVMを並列に起動
- それぞれのVMで実行可能JARを生成
- jmodsのダウンロード
- カスタムJREを作成
- カスタムJRE、起動スクリプト類(
.github/dist/*
)、実行可能JARを収集して圧縮 - artifactとしてアップロード
- GitHub Releaseの作成
- artifactをダウンロードし、Releasesにartifactをアップロード
という感じです。
結果として、以下のようにダウンロード可能になります。
まとめ
以下の話をしました。
- Clojure+JavaFXアプリの実装
- カスタムJREの作成方法
- リリースワークフローの実装
ClojureでのJavaFXアプリ開発の一助となれば幸いです。
以上