コード例
Windows / PowerShell

ENGINEERING NOTEBOOK / 04 GRADLE PRACTICAL

作った業務コードを、
サーバーから動かす。

TrainingAppServerとBusinessAppを使い、ビルド・配置・読み込み・実行をつなげる4章です。TrainingAppServerは実在の業務製品ではありませんが、Java 11で実際に動く小さな研修用フレームワークです。

BusinessApp自身にmainは作りません。別フォルダーにあるサーバーのServerMainを起動し、設定で指定した場所から業務クラスを読み込ませます。HTTP・DB・常駐機能はなく、1回処理して終了します。

Gradle 9.6.0:起動JDK 25 / サーバー・業務コード・JUnit:JDK 11
Windows PowerShell / Eclipse・Buildship / Kotlin・Groovy DSL

第1章:コアJARを用意して依存を分ける

隣接する、2つのディレクトリ。

同じ練習用フォルダーの下に並べます。EclipseでGradle ImportするのはBusinessAppだけ。TrainingAppServerは独立したサーバーで、BusinessAppのbuildやcleanの対象に含めません。図は4章終了時の姿で、build/・lib/・runtime/は手順で生成されます。

EclipseでImportする側 / 選択中のDSL
  • BusinessApp/
    • gradlew / gradlew.bat / gradle/wrapper/
    • settings.gradle.kts
    • build.gradle.kts
    • src/main/java/training/business/
      • BusinessService.java
    • src/test/java/training/business/
      • BusinessServiceTest.java
    • config/
      • app/app.properties
      • common/common.properties
      • release/common.properties
    • build/
      • classes/java/main/
      • libs/BusinessApp.jar
      • staging/dev/・release/
独立した研修用サーバー / Gradleの子Projectではない
  • TrainingAppServer/
    • src/training/server/
      • ServerMain.java
      • api/ServerContext.java
      • internal/BusinessClassLoader.java
    • lib/training-server-core.jar
    • scripts/
      • Build-Core.ps1
      • Deploy-Training.ps1
    • runtime/
      • dev/apps/BusinessApp/
        • classes/training/business/
          • BusinessService.class
        • lib/(外部JAR)
        • config/app.properties
      • release/apps/BusinessApp/
        • application/BusinessApp.jar
        • lib/(外部JAR)
        • config/app.properties
        • common-config/common.properties

Groovy DSLではbuild.gradle・settings.gradleを使います。TrainingAppServer/lib/はサーバー自身のコアJAR、runtime/…/lib/は業務アプリ用の外部JARです。以下は社内製品の仕様ではなく、この研修用フレームワーク専用の配置ルールです。

DELIVERY MAP / 仮配置と読み込み先は別の場所

開発 / 保存・ビルド・配置・起動3列は別々の場所。右端だけをTrainingAppServerが読みます。
① BusinessApp
Eclipseで編集
② Gradleの仮配置
build/staging/dev/
③ TrainingAppServer/
runtime/dev/
apps/BusinessApp/
src/main/java/
training/business/
BusinessService.java
コンパイル
classes/
training/business/
BusinessService.class
配置
classes/
training/business/
BusinessService.class
implementation
Commons Textと推移的依存
収集
app-libs/
外部JAR 2個
配置
lib/
外部JAR 2個
config/app/
app.properties
コピー
app-config/
app.properties
配置
config/
app.properties

中央まではstageDev。右へはDeploy-Training.ps1 -Mode devで別工程としてコピーします。サーバーのコアJARはTrainingAppServer/lib/にあり、この3列の配布物には含めません。

Eclipse / Java Application・Java 11

Main classはtraining.server.ServerMain。引数はBusinessApp/config/common/common.propertiesの絶対パスです。サーバーは設定を読み、右端のclass・外部JAR・アプリ設定を使ってBusinessServiceを呼び出します。共通コンフィグはプロジェクト内のままです。

図の切替は比較用です。ブラウザ内でGradleやサーバーは実行しません。

BEFORE YOU START

教材は1ファイル。演習は2つのフォルダー。

教材本文・図・操作はこのHTMLだけでオフライン閲覧できます。実行にはJDK 11・25とGradle 9.6.0が必要で、Gradleと依存の初回取得には通信が必要です。Kotlin/Groovy切替は表示だけを変えます。

本教材では、TrainingAppServerとBusinessAppを学習者自身が作成します。必要なJavaソース、Gradle設定、PowerShellスクリプトはすべてこのHTMLに掲載しています。完成済みのサーバーやプロジェクトを別途取得する必要はありません。第1章から順番に、コードを指定されたファイルへ保存し、コマンドを実行してください。

Javaソース・設定・スクリプトの全文は「保存する内容」です。JavaソースをPowerShellへ貼り付けて実行するものではありません。エディターで指定された拡張子のファイルへUTF-8で保存し、別に掲載した「PowerShell」の操作コマンドを実行します。

01

PRACTICAL / BUSINESSAPPを育てる

サーバー提供ライブラリと業務アプリの依存を分ける

01今回やりたいこと

TrainingAppServerのコアJARをJDK 11で作り、その公開APIを参照してBusinessAppをコンパイル・テストします。サーバー実装は完成済みの部品として用意し、学習の中心はBusinessAppのGradle設定に置きます。

02なぜ今の構成では困る?

サーバーのコアJARは実行環境が提供します。アプリと一緒に再配布するとAPIや実装が重複します。一方、Commons Text等は業務アプリ側が用意する必要があります。依存する場面と、配布する担当を分けましょう。

03どういう仕組みで解決する?

依存宣言・用途配布
training-server-core.jarcompileOnly:公開APIを使うメインのコンパイル用サーバー側lib/に存在。アプリには含めない
Commons Textimplementation:メインのコンパイルと実行推移的依存Commons Langも別JARで配る
サーバーAPIとJUnittestImplementation等:テスト用テスト用には参照するが配布しない

Configurationは用途別の依存の入れ物です。dependenciesに宣言し、compileClasspathはコンパイル用、runtimeClasspathは実行用としてファイルを解決します。後者にはcompileOnlyやJUnitは入りません。通常のjar Taskは外部JARをアプリJARへ内包しません。

04どのファイルを変更する?

練習用の親フォルダーにTrainingAppServerと空のBusinessAppを並べ、次の順で構築します。サーバー側にはGradle設定を作らず、BusinessAppのsettingsにもincludeしません。

  1. 未使用の練習用親フォルダーを決めます。
  2. 下のフォルダー作成コマンドでBusinessAppとTrainingAppServerを隣接させます。
  3. 同じコマンドでTrainingAppServerのソース用フォルダーとscripts/も作ります。
  4. Javaソース全文を開き、3ファイルを各コード欄の指定先へUTF-8で保存します。
  5. Build-Core.ps1全文を開き、TrainingAppServer/scripts/Build-Core.ps1へUTF-8で保存します。
  6. コアJAR作成コマンドをBusinessAppのルートから実行します。
  7. TrainingAppServer/lib/training-server-core.jarの存在と内容を確認してから、BusinessAppのGradle設定へ進みます。

保存時はファイル名に.txtを付けず、コード欄のファイル名・拡張子をそのまま使ってください。

05実際のコードと設定

PowerShell / JDKとGradleのパスを自分の場所へ置換
$env:JAVA_HOME = 'C:\実際のインストール先\jdk-25'
$lessonJdk11 = 'C:\実際のインストール先\jdk-11'
$lessonGradle = 'C:\実際の展開先\gradle-9.6.0\bin\gradle.bat'
& "$env:JAVA_HOME\bin\java.exe" -version
& "$lessonJdk11\bin\javac.exe" -version
& $lessonGradle --version

Gradle 9.6.0と起動Java 25、javac 11を確認します。以後のコマンドはBusinessAppのルートで実行します。JDK 25を11に置き換えません。

以下のパスは例です。自分のPCのまだ存在しない練習用フォルダーへ置き換えて、PowerShellで実行します。以後は同じPowerShellを使い、BusinessAppのルートで操作します。

PowerShell / 親・プロジェクト・サーバーソースのフォルダーを作成
$lessonRoot = 'C:\training\gradle-practical'
if (Test-Path -LiteralPath $lessonRoot) {
    throw 'Choose a new practice folder before continuing.'
}
@(
    'BusinessApp',
    'TrainingAppServer\src\training\server\api',
    'TrainingAppServer\src\training\server\internal',
    'TrainingAppServer\scripts'
) | ForEach-Object {
    New-Item -ItemType Directory -Path (Join-Path $lessonRoot $_) -Force | Out-Null
}
Set-Location (Join-Path $lessonRoot 'BusinessApp')

先にサーバーコアJARを用意します。次の2つの折りたたみを開き、Javaソース3ファイルとBuild-Core.ps1を保存してください。../TrainingAppServer/は、現在いるBusinessAppの隣のフォルダーです。

研修用サーバーのJavaソース全文 / 3ファイルを保存

下記の場所を作り、UTF-8で保存します。BusinessServiceはここに含めません。ServerContextは業務コードと共有するAPI、ServerMainは設定を読んでmessage(ServerContext, String)を呼ぶ入口です。内部実装は第3章で動作と対応づけます。

../TrainingAppServer/src/training/server/api/ServerContext.java
package training.server.api;

public interface ServerContext {
    String applicationName();
}
../TrainingAppServer/src/training/server/internal/BusinessClassLoader.java
package training.server.internal;

import java.net.URL;
import java.net.URLClassLoader;
import training.server.api.ServerContext;

/** Training only: platform classes + shared server API + configured app URLs. */
public final class BusinessClassLoader extends URLClassLoader {
    public BusinessClassLoader(URL[] urls) {
        super("training-business", urls, ClassLoader.getPlatformClassLoader());
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {
        if (name.startsWith("training.server.api.")) {
            return ServerContext.class.getClassLoader().loadClass(name);
        }
        return super.loadClass(name, resolve);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class<?> type = super.findClass(name);
        System.out.println("Loaded " + name + " from: "
                + type.getProtectionDomain().getCodeSource().getLocation());
        return type;
    }
}
../TrainingAppServer/src/training/server/ServerMain.java
package training.server;

import java.io.Reader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import training.server.api.ServerContext;
import training.server.internal.BusinessClassLoader;

public final class ServerMain {
    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            throw new IllegalArgumentException("Usage: ServerMain <common.properties>");
        }
        System.out.println("TrainingAppServer started (Java "
                + System.getProperty("java.version") + ")");
        Path config = Path.of(args[0]).toAbsolutePath().normalize().toRealPath();
        Properties settings = read(config);
        System.out.println("Configuration loaded: " + config);
        Path base = config.getParent();
        Path code = resolve(base, settings, "business.code");
        Path libs = resolve(base, settings, "business.libs");
        Path appConfig = resolve(base, settings, "business.properties");
        if (!Files.isDirectory(code)
                && !(Files.isRegularFile(code) && code.toString().endsWith(".jar"))) {
            throw new IllegalArgumentException("business.code must be a directory or JAR: " + code);
        }
        if (!Files.isDirectory(libs)) {
            throw new IllegalArgumentException("business.libs must be a directory: " + libs);
        }
        List<URL> urls = new ArrayList<>();
        urls.add(code.toUri().toURL());
        try (Stream<Path> files = Files.list(libs)) {
            for (Path jar : files.filter(Files::isRegularFile)
                    .filter(p -> p.toString().endsWith(".jar"))
                    .sorted().collect(Collectors.toList())) {
                urls.add(jar.toUri().toURL());
            }
        }
        System.out.println("Business code: " + code);
        System.out.println("Business libraries: " + libs);
        Properties app = read(appConfig);
        System.out.println("Application properties loaded: " + appConfig);
        String prefix = required(app, "display.prefix");
        ServerContext context = () -> prefix;
        try (BusinessClassLoader loader = new BusinessClassLoader(urls.toArray(new URL[0]))) {
            Class<?> type = loader.loadClass(required(settings, "business.class"));
            Object service = type.getDeclaredConstructor().newInstance();
            Object result = type.getMethod("message", ServerContext.class, String.class)
                    .invoke(service, context, "gRADLE");
            System.out.println("RESULT: " + result);
        }
    }

    private static Properties read(Path file) throws Exception {
        Properties result = new Properties();
        try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
            result.load(reader);
        }
        return result;
    }

    private static String required(Properties values, String key) {
        String value = values.getProperty(key);
        if (value == null || value.trim().isEmpty()) {
            throw new IllegalArgumentException("Missing property: " + key);
        }
        return value.trim();
    }

    private static Path resolve(Path base, Properties values, String key) throws Exception {
        return base.resolve(required(values, key)).normalize().toRealPath();
    }
}
コアJAR作成スクリプト全文 / javacとjarの呼び出し
../TrainingAppServer/scripts/Build-Core.ps1
param([Parameter(Mandatory = $true)][string]$Jdk11)
$ErrorActionPreference = 'Stop'
$serverRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$javac = Join-Path $Jdk11 'bin/javac.exe'
$jar = Join-Path $Jdk11 'bin/jar.exe'
$compilerVersion = (& $javac -version 2>&1 | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $compilerVersion -notmatch '^javac 11[.]') {
    throw 'Use a JDK 11 compiler.'
}
# A fresh output directory prevents old classes from entering the core JAR.
$classes = Join-Path $serverRoot ('build/core-' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $classes -Force | Out-Null
$sources = @(
    (Join-Path $serverRoot 'src/training/server/api/ServerContext.java'),
    (Join-Path $serverRoot 'src/training/server/internal/BusinessClassLoader.java'),
    (Join-Path $serverRoot 'src/training/server/ServerMain.java')
)
& $javac -encoding UTF-8 --release 11 -d $classes @sources
if ($LASTEXITCODE -ne 0) { throw 'Core compilation failed.' }
$lib = Join-Path $serverRoot 'lib'
New-Item -ItemType Directory -Path $lib -Force | Out-Null
$coreJar = Join-Path $lib 'training-server-core.jar'
& $jar --create --file $coreJar -C $classes .
if ($LASTEXITCODE -ne 0) { throw 'Core JAR creation failed.' }
Write-Output "Core JAR created: $coreJar"
PowerShell / BusinessAppのルートで実行
& ..\TrainingAppServer\scripts\Build-Core.ps1 -Jdk11 $lessonJdk11
if (-not $?) { throw 'Core build failed.' }
Test-Path ..\TrainingAppServer\lib\training-server-core.jar
& "$lessonJdk11\bin\jar.exe" tf ..\TrainingAppServer\lib\training-server-core.jar

Trueと、ServerMain・ServerContext・BusinessClassLoaderの.classを確認します。BusinessService.classはコアに含みません。スクリプトはJDK 11のjavac --release 11とjarを使い、TrainingAppServer/lib/training-server-core.jarを生成します。

コアJARができたらGradleの最小構成を生成します。PowerShellのスクリプト実行が組織の設定で禁止されている場合は規程に従い、設定を無効化せず管理担当へ確認してください。

PowerShell / BusinessAppのルートで実行
& $lessonGradle init --type basic --dsl kotlin --project-name BusinessApp --no-incubating --use-defaults

Wrapperとsettings・buildを生成。コアJARは別フォルダーなので、この生成やBusinessAppのcleanでは消えません。

PowerShell / BusinessAppのルートで実行
.\gradlew.bat --version

Wrapperも9.6.0、起動Java 25であることを確認します。

Java PluginとToolchainを追加。生成されたbuildファイルのコメント以外を次へ置き換えます。

build.gradle.kts生成されたbuildファイルを置き換える
plugins {
    java
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(11)
    }
}
PowerShell / BusinessAppのルートで実行
.\gradlew.bat -q javaToolchains

JDK 11が検出されることを確認します。未検出なら個人用Gradle User Homeのgradle.propertiesにorg.gradle.java.installations.paths=C:/実際のJDK11の場所を追記し、再確認します。

続けて依存とテスト設定を追加。../はBusinessAppの1つ上の親フォルダーです。そこから隣のTrainingAppServer/libを参照します。これはbuild.gradle(.kts)のあるプロジェクトディレクトリが基準で、端末の偶然の起動位置を基準にしません。

build.gradle.kts既存の設定を残し、該当箇所へ追加
repositories { mavenCentral() }

dependencies {
    compileOnly(files("../TrainingAppServer/lib/training-server-core.jar"))
    implementation("org.apache.commons:commons-text:1.15.0")
    testImplementation(files("../TrainingAppServer/lib/training-server-core.jar"))
    testImplementation(platform("org.junit:junit-bom:5.13.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test { useJUnitPlatform() }

files(...)はローカルのコアJAR、mavenCentral()はCommons TextやJUnitの取得先です。テストもServerContextの型を使うため、コアJARをtestImplementationに追加します。compileOnlyだけではテスト実行用には揃いません。testImplementationへ書いてもメインのruntimeClasspathには入りません。

platformはJUnitのBOMで関連部品の版を揃え、useJUnitPlatform()はtestにJUnit Platformを使わせます。Kotlinのtasks.test、Groovyのtasks.named('test')はいずれも既存Taskの設定です。

サーバーの場所がPCごとに異なる場合だけ

基本の相対配置を使えない案件では、GradleプロパティserverCoreJarや環境変数SERVER_CORE_JARをProviderから読み、filesへ渡す方法があります。未設定時の扱いを決め、compileOnlyとtestImplementationで同じパスを使います。このハンズオンでは相対指定をそのまま使います。

PowerShell / BusinessAppのルートでソース保存先を作成
@('src\main\java\training\business', 'src\test\java\training\business') | ForEach-Object {
    New-Item -ItemType Directory -Path $_ -Force | Out-Null
}

続く2つのJavaソースはPowerShellで実行せず、コード欄の保存先へUTF-8で保存します。

業務コードとJUnitを保存。BusinessServiceはmainを持たず、ServerContextから表示名を受け取り、Commons Textで文字を整えます。テストではラムダでContextを代用します。

src/main/java/training/business/BusinessService.java
package training.business;

import org.apache.commons.text.WordUtils;
import training.server.api.ServerContext;

public class BusinessService {
    public String message(ServerContext context, String name) {
        return context.applicationName() + ": " + WordUtils.capitalizeFully(name);
    }
}
src/test/java/training/business/BusinessServiceTest.java
package training.business;

import org.junit.jupiter.api.Test;
import training.server.api.ServerContext;
import static org.junit.jupiter.api.Assertions.assertEquals;

class BusinessServiceTest {
    @Test
    void formatsMessage() {
        ServerContext context = () -> "BusinessApp";
        assertEquals("BusinessApp: Gradle",
                new BusinessService().message(context, "gRADLE"));
    }
}

06操作して確かめる

PowerShell / BusinessAppのルートで実行
.\gradlew.bat compileJava test --info
if ($LASTEXITCODE -ne 0) { throw 'Build or test failed.' }
& "$lessonJdk11\bin\javap.exe" -verbose .\build\classes\java\main\training\business\BusinessService.class | Select-String 'major version'

コンパイラーとGradle Test ExecutorのJavaが11、major version 55、build/reports/tests/test/index.htmlが1件・失敗0件か確認します。

PowerShell / BusinessAppのルートで実行
.\gradlew.bat dependencies --configuration compileClasspath
.\gradlew.bat dependencies --configuration runtimeClasspath
.\gradlew.bat dependencyInsight --dependency commons-lang3 --configuration runtimeClasspath

Commons Text 1.15.0と推移的依存Commons Lang 3.20.0を確認します。filesで指定したローカルコアJARはツリーに通常のMaven依存のようには出ません。APIを使うコンパイル・テストの成功と、第3章の配置物にコアが入らないことも合わせて確認します。

07何が変わった?

サーバー側lib/にコアJAR、BusinessApp側に業務classができました。どちらもJava 11で動きます。サーバーはBusinessServiceを直接コンパイル参照せず、実行時に設定のクラス名から呼び出します。

08 / 次へ進む条件

コアJARにMainとAPIがあり、業務コードのコンパイル・JUnitがJava 11で成功する。サーバー提供とアプリ提供のJARを区別できる。

第1章終了時のbuildファイル全文(答え合わせ)
build.gradle.ktsこの章終了時の完成コード
plugins {
    java
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(11)
    }
}

repositories { mavenCentral() }

dependencies {
    compileOnly(files("../TrainingAppServer/lib/training-server-core.jar"))
    implementation("org.apache.commons:commons-text:1.15.0")
    testImplementation(files("../TrainingAppServer/lib/training-server-core.jar"))
    testImplementation(platform("org.junit:junit-bom:5.13.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test { useJUnitPlatform() }
02

PRACTICAL / BUSINESSAPPを育てる

JARに含めないプロパティを管理する

01今回やりたいこと

サーバーが読む起動用コンフィグと、業務の表示を変えるアプリ設定を作ります。どちらもJARの外へ置き、第3章で実際に読み込ませます。

02なぜ今の構成では困る?

src/main/resourcesへ置くとprocessResourcesを経て通常JARへ入ります。今回は外部設定だけを変えて、業務JARを再作成せず表示を変えたいのでconfig/で管理します。

03どういう仕組みで解決する?

ファイル読み方・用途
config/app/app.propertiesServerMainがdisplay.prefixを読み、ServerContext.applicationName()として業務へ渡す
config/common/common.properties開発用。プロジェクト内のまま起動引数で渡す
config/release/common.properties本番相当の配布用。第4章でcommon-config/へコピーする

TrainingAppServerはUTF-8のpropertiesを読みます。business.codeはclassディレクトリまたはJAR、business.libsは外部JARのフォルダー、business.propertiesはアプリ設定、business.classは呼び出すクラス名です。propertiesをJavaクラスとしてロードするわけではありません。

04どのファイルを変更する?

BusinessAppのconfig/app、config/common、config/releaseを作ります。buildファイルとJavaソースは変更しません。

05実際のコードと設定

PowerShell / BusinessAppのルートで設定保存先を作成
@('config\app', 'config\common', 'config\release') | ForEach-Object {
    New-Item -ItemType Directory -Path $_ -Force | Out-Null
}

次の3つの設定を、それぞれコード欄に示すファイルへUTF-8で保存します。

config/app/app.properties / UTF-8
display.prefix=Practice
config/common/common.properties / 開発用・UTF-8
# Paths are relative to this common.properties file (config/common/).
business.code=../../../TrainingAppServer/runtime/dev/apps/BusinessApp/classes
business.libs=../../../TrainingAppServer/runtime/dev/apps/BusinessApp/lib
business.properties=../../../TrainingAppServer/runtime/dev/apps/BusinessApp/config/app.properties
business.class=training.business.BusinessService

設定内の相対パスは、そのpropertiesファイルの親フォルダーが基準です。config/commonから../../../で2つのプロジェクトの親へ戻り、TrainingAppServer/runtime/dev/を参照します。BusinessAppとTrainingAppServerを隣接したまま移動すれば同じ値を使えます。Windowsでもproperties内は / を使い、バックスラッシュのエスケープを避けます。

config/release/common.properties / 配布先で読む本番相当用・UTF-8
# Paths are relative to the DEPLOYED runtime/release/apps/BusinessApp/common-config/.
business.code=../application/BusinessApp.jar
business.libs=../lib
business.properties=../config/app.properties
business.class=training.business.BusinessService

本番相当の設定は、配置後のruntime/release/apps/BusinessApp/common-config/を基準に、隣のapplication/・lib/・config/を指します。ソースのconfig/releaseや仮配置中のcommon-configからは起動しません。第4章で所定のサーバー側へ配置した後に使います。開発用設定をコピーするとdevを誤参照するため、2つの設定を分けました。

06操作して確かめる

PowerShell / BusinessAppのルートで実行
.\gradlew.bat jar
& "$lessonJdk11\bin\jar.exe" tf .\build\libs\BusinessApp.jar

BusinessService.classと通常メタデータだけを確認。properties・外部JAR・サーバーAPIのclassは入っていません。

app.propertiesだけdisplay.prefix=Changedへ変更し、もう一度jarを実行します。他の入力を変えていなければUP-TO-DATEです。確認後はPracticeへ戻して保存してください。

PowerShell / BusinessAppのルートで実行
.\gradlew.bat jar

config/はJARの入力に含めていないため、外部設定変更だけではJARを再生成しません。表示への反映は第3章で実際に確認します。

07何が変わった?

開発用と本番相当用の参照先を分離しました。今はruntime側のファイルが未配置なので、まだサーバーを起動しません。パスの文字だけ正しくても読み込むファイルが必要です。

08 / 次へ進む条件

3つのpropertiesを作り、相対パスの基準と開発/本番相当の違いを説明できる。通常JARに設定が入っていない。

第2章終了時のbuildファイル全文(答え合わせ)
build.gradle.ktsこの章終了時の完成コード
plugins {
    java
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(11)
    }
}

repositories { mavenCentral() }

dependencies {
    compileOnly(files("../TrainingAppServer/lib/training-server-core.jar"))
    implementation("org.apache.commons:commons-text:1.15.0")
    testImplementation(files("../TrainingAppServer/lib/training-server-core.jar"))
    testImplementation(platform("org.junit:junit-bom:5.13.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test { useJUnitPlatform() }
03

PRACTICAL / BUSINESSAPPを育てる

開発用に配置し、Eclipseの起動設定を整える

01今回やりたいこと

ソースを修正したら、Gradleでコンパイルしたclass・外部JAR・アプリ設定を一度に準備します。その後、TrainingAppServerのServerMainをJava 11で実際に起動します。

02なぜ今の構成では困る?

ファイルを手で選んでコピーすると、古いclassや依存JARが残ったり、共通propertiesまでコピーしたりしがちです。また、Eclipseで赤い印が消えても、Gradleのコンパイル結果が更新済みとは限りません。

03どういう仕組みで解決する?

compileJavaGradleの出力へコンパイル
classesクラス・リソースを準備
stageDev必要なファイルをSync
矢印は「先に必要なTask → 依存するTask」。processResourcesなどの枝を省略しています。

classes はcompileJavaやprocessResourcesに依存する準備用Taskです。stageDevだけを指定すれば必要なTaskが実行対象になり、毎回手でcompileJavaを先に実行する必要はありません。変更がなければ処理が省略されることがあります。

仕事入力・出力
EclipseのJava BuilderIDE独自のコンパイル。GradleのcompileJavaと同じTaskではない
今回のstageDevGradleのbuild/classes/java/mainのclassを使う。Eclipseの出力先はコピーしない
Copyコピー先にだけある余分なファイルは削除しない
Syncコピーに加え、コピー先を入力に合わせる。余分なファイルを削除し得る

04どのファイルを変更する?

BusinessAppの build.gradle.kts の末尾へ、次の2つのTaskを追加します。設定入力は第2章のconfig/を使います。このTaskは仮配置までです。操作手順で別途TrainingAppServer/runtimeへ配置します。

05実際のコードと設定

① 小さなコピー処理を、自分のTaskとして登録する。TaskはGradleへ頼む一つの仕事です。Java Pluginが用意するcompileJavaなどに加え、tasks.registerで名前・型・設定を登録できます。ここで付けるcopyAppConfig、stageDev、stageReleaseは教材で作る名前で、Gradle標準Taskではありません。登録しただけではコピーしません。端末から指定するか、他Taskの依存として実行対象になったときに処理します。

Kotlinのtasks.register<Copy>("copyAppConfig")とGroovyのtasks.register('copyAppConfig', Copy)は同じ登録です。Copyという型がコピーの仕事とfrom/into等の設定項目を提供します。Syncもコピーしますが、さらに入力にない配置先のファイルを取り除きます。まずCopyを専用の小さな場所で試します。

from("config/app/app.properties")はBusinessAppのプロジェクトディレクトリを基準とした入力ファイルです。layoutはプロジェクトと生成物の場所を扱う窓口、buildDirectoryは既定ではBusinessApp/buildです。dir("staging/copy-demo/app-config")はその下の場所をProviderとして表します。ルート直下のstaging/ではありません。

Providerをintoへ渡すことで、場所を必要な時点で評価でき、buildDirectoryの設定変更にも追従できます。この記述だけでフォルダーは作られません。Copyが処理する際に、app.propertiesを出力先へコピーします。入力パスが違い対象がなければNO-SOURCEになる場合があるため、成功表示だけでなく出力ファイルを確認します。

build.gradle.kts既存の設定を残し、該当箇所へ追加
tasks.register<Copy>("copyAppConfig") {
    from("config/app/app.properties")
    into(layout.buildDirectory.dir("staging/copy-demo/app-config"))
}

② 開発用のSyncを組み立てる。次の抜粋は、最後に掲載するstageDev全文の読み方です。抜粋を一つずつ追記するのではなく、意味を確認してから全文を1回追加します。

配置先と、先に必要な仕事を指定する

設定の読み方 / 下のTask全文の抜粋。重ねて追加しない
tasks.register<Sync>("stageDev") {
    dependsOn(tasks.named("classes"))
    into(layout.buildDirectory.dir("staging/dev"))
    // コピー対象はこの後に指定
}

tasks.named("classes")は既に登録されたclassesを名前で参照します。registerのように新設しません。dependsOnは「このTaskの前に、そのTaskが必要」という依存関係です。stageDevを指定するとclassesと、その依存であるcompileJava・processResourcesも実行対象になります。必要な処理が失敗すればstageDevへ進まず、UP-TO-DATEなら再処理せず準備済みの出力を使います。独立したTask同士の順序まで固定する指定ではありません。

外側のintoは配置全体の根です。ここではBusinessApp/build/staging/dev/になります。単なるパス指定には「誰が作るか」の情報はありません。後述の出力Provider等から依存を推論できる場合もありますが、このコードはclassesへの依存を明記して、必要な準備を表しています。

配置するclassを指定する

設定の読み方 / 下のTask全文の抜粋。重ねて追加しない
from(sourceSets.main.get().output.classesDirs) {
    into("classes")
}

sourceSetsはソースと出力を用途別にまとめた設定で、mainは本番コード、outputはその生成物です。classesDirsはクラス出力ディレクトリの集合を表し、Javaだけの今回の既定値はbuild/classes/java/main/です。Kotlinのmain.get()とGroovyのmainは、ここでは同じmainの設定を参照します。

compileJavaの出力
build/classes/java/main/
training/business/BusinessService.class
Sync
stageDevの出力
build/staging/dev/classes/
training/business/BusinessService.class

fromは入力、内側のinto("classes")は外側の配置先に対するサブフォルダーです。入力ディレクトリの中身を、パッケージの階層を保ってコピーします。内側のintoを省くとdev/直下へ入り、想定した配置と変わります。今回リソース出力は対象外です。

Eclipseの出力は例えばbin/main/ですが、実際の場所はJava Build Pathで確認します。これはGradleのbuild/classes/java/main/とは別です。stageDevはcompileJavaと結び付いた出力を使い、端末からも同じ準備を再現します。Eclipseで保存しただけではGradle側のclassは更新されません。

アプリに必要な外部JARを集める

設定の読み方 / 下のTask全文の抜粋。重ねて追加しない
from(configurations.runtimeClasspath) {
    into("app-libs")
}

configurationsからruntimeClasspathを取り出し、実行用依存をファイルへ解決してfromの入力にします。implementationとruntimeOnlyの依存が対象です。Commons Text自身が必要とするCommons Langも依存情報をたどって解決されるため、この演習では2つのJARがdev/app-libs/へ入ります。省くと業務classだけがあり、呼び出しに必要な外部クラスが不足します。

compileClasspathと違い、runtimeClasspathにはcompileOnlyのサーバーAPI JARが入りません。サーバーから提供される前提なので配布から除けます。JUnitもテスト用の集合であり対象外です。ただし同じJARをimplementationにも宣言すれば対象になります。このConfigurationは外部依存の集合で、BusinessService.classやBusinessApp.jar自体は含みません。

アプリ固有propertiesだけを分ける

設定の読み方 / 下のTask全文の抜粋。重ねて追加しない
from("config/app/app.properties") {
    into("app-config")
}

入力はBusinessApp/config/app/app.properties、出力はbuild/staging/dev/app-config/app.propertiesです。ファイル名まで指定するので、同じフォルダーのバックアップ等は入りません。省けば設定ファイルは準備されません。共通のconfig/common/common.propertiesはコピーせず、後のEclipse起動引数で元の場所を渡します。

同じ配置先への重複を見逃さない

設定の読み方 / 下のTask全文の抜粋。重ねて追加しない
duplicatesStrategy = DuplicatesStrategy.FAIL

例えば2つのコピー元にある同名のconfig.propertiesを、ともにdev/app-config/config.propertiesへ入れようとすると重複です。FAILはこの時点でTaskを失敗させ、どちらを使うか曖昧な配置を防ぎます。EXCLUDEは後続の重複を除外、INCLUDEは重複を許可(通常のコピーでは上書きになり得る)、WARNは警告して許可します。既定のINHERITは親の設定に従うため、ここでは意図をFAILで明示します。

検出するのはコピー対象同士の配置先パスです。異なる名前のJAR内部に同じclassがあることや、実サーバーに既存のJARとの衝突は検出しません。今回のSyncでは入力にない古い配置物の除去も行うので、専用の仮配置先だけを指定します。

理解した設定をまとめて追加する

build.gradle.kts既存の設定を残し、該当箇所へ追加
tasks.register<Sync>("stageDev") {
    dependsOn(tasks.named("classes"))
    into(layout.buildDirectory.dir("staging/dev"))
    from(sourceSets.main.get().output.classesDirs) {
        into("classes")
    }
    from(configurations.runtimeClasspath) {
        into("app-libs")
    }
    from("config/app/app.properties") {
        into("app-config")
    }
    duplicatesStrategy = DuplicatesStrategy.FAIL
}

保存したら、次のコマンドで登録したTaskを実行し、class・2つの依存JAR・アプリ設定の対応を確認します。

06操作して確かめる

PowerShell / BusinessAppのルート
.\gradlew.bat copyAppConfig stageDev
Get-ChildItem .\build\staging\dev -Recurse -File

copyAppConfigは比較用にapp.propertiesだけをコピーします。stageDevはclass・アプリ依存・アプリ設定を準備します。次の4ファイルがあり、training-server-core.jar・common.properties・JUnitがないことを確認します。

開発用 / build/staging/dev/
  • dev/
    • classes/
      • training/business/BusinessService.class
    • app-libs/
      • commons-text-1.15.0.jar
      • commons-lang3-3.20.0.jar
    • app-config/
      • app.properties

CopyとSyncの違いを確かめる。自分で作ったold.txtだけを追加し、もう一度Taskの仕事を実行させます。

PowerShell / BusinessAppのルート
Set-Content .\build\staging\copy-demo\app-config\old.txt 'obsolete'
Set-Content .\build\staging\dev\old.txt 'obsolete'
.\gradlew.bat copyAppConfig stageDev --rerun-tasks
Test-Path .\build\staging\copy-demo\app-config\old.txt
Test-Path .\build\staging\dev\old.txt

--rerun-tasksは最新判定による省略をせず、対象Taskの処理をやり直す指定です。Copy側はTrue(残る)、Sync側はFalse(消える)を確認。Copy側の教材用old.txtは、後のcleanで他の生成物と一緒に消えます。

① 仮配置物を、研修用サーバーへ配置する

操作・入口役割
Build-Core.ps1(第1章)JDK 11のjavac・jarでサーバーのコアJARを作成する
stageDev / stageReleaseGradleがBusinessAppのbuild/staging/へ配置物を準備する
Deploy-Training.ps1(この章で保存)準備した配置物をTrainingAppServerの研修用runtime/へコピーする
ServerMain設定の場所から業務クラス・外部JARを読み込み、業務処理を実行する

stageDevの出力はBusinessApp/build/staging/dev/です。サーバーはそこを読みません。別工程のDeploy-Training.ps1で、TrainingAppServer/runtime/dev/apps/BusinessApp/へ対応づけてコピーします。

仮配置(BusinessApp/build/staging/dev/)読み込み先(TrainingAppServer/runtime/dev/apps/BusinessApp/)
classes/classes/(パッケージ階層を維持)
app-libs/lib/
app-config/config/

第1章で作成したTrainingAppServer/scripts/に、下の全文をDeploy-Training.ps1という名前でUTF-8で保存します。保存後、その下のPowerShellコマンドで呼び出します。入力を検査し、リンクを拒否し、研修用runtime内の指定モードのBusinessAppだけを置き換えます。コアJARや他モードは削除しません。

配置スクリプト全文 / 研修専用の固定出力先
../TrainingAppServer/scripts/Deploy-Training.ps1
param(
    [Parameter(Mandatory = $true)][ValidateSet('dev', 'release')][string]$Mode,
    [Parameter(Mandatory = $true)][string]$BusinessApp
)
$ErrorActionPreference = 'Stop'
$serverRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$appRoot = (Resolve-Path -LiteralPath $BusinessApp).Path
$sourceRoot = Join-Path $appRoot "build/staging/$Mode"
$runtimeRoot = [IO.Path]::GetFullPath((Join-Path $serverRoot 'runtime'))
$destination = [IO.Path]::GetFullPath((Join-Path $runtimeRoot "$Mode/apps/BusinessApp"))
if (-not $destination.StartsWith($runtimeRoot + [IO.Path]::DirectorySeparatorChar,
        [StringComparison]::OrdinalIgnoreCase)) { throw 'Destination outside training runtime.' }
if ($appRoot -eq $serverRoot -or $sourceRoot.StartsWith($runtimeRoot,
        [StringComparison]::OrdinalIgnoreCase)) { throw 'Invalid BusinessApp source.' }
$mapping = if ($Mode -eq 'dev') {
    @(@('classes', 'classes'), @('app-libs', 'lib'), @('app-config', 'config'))
} else {
    @(@('application', 'application'), @('dependencies', 'lib'),
      @('app-config', 'config'), @('common-config', 'common-config'))
}
# Validate all inputs before changing the destination.
foreach ($entry in $mapping) {
    $inputDir = Join-Path $sourceRoot $entry[0]
    if (-not (Test-Path -LiteralPath $inputDir -PathType Container)) {
        throw "Missing staging input: $inputDir"
    }
}
$required = if ($Mode -eq 'dev') { 'classes/training/business/BusinessService.class' }
            else { 'application/BusinessApp.jar' }
foreach ($file in @($required, 'app-config/app.properties')) {
    if (-not (Test-Path -LiteralPath (Join-Path $sourceRoot $file) -PathType Leaf)) {
        throw "Missing staging file: $file"
    }
}
if ($Mode -eq 'release' -and -not (Test-Path -LiteralPath (Join-Path $sourceRoot 'common-config/common.properties') -PathType Leaf)) {
    throw 'Missing release configuration.'
}
# Refuse junctions/symlinks in the target ancestors and both trees.
function Assert-NoLink([string]$Path) {
    if (Test-Path -LiteralPath $Path) {
        $item = Get-Item -LiteralPath $Path -Force
        if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
            throw "Link is not allowed: $Path"
        }
    }
}
$ancestor = $destination
while ($ancestor) {
    Assert-NoLink $ancestor
    $ancestor = Split-Path -Parent $ancestor
}
foreach ($tree in @($sourceRoot, $destination)) {
    Assert-NoLink $tree
    if (Test-Path -LiteralPath $tree) {
        Get-ChildItem -LiteralPath $tree -Recurse -Force | ForEach-Object {
            Assert-NoLink $_.FullName
        }
    }
}
Write-Output "Training-only destination: $destination"
# Only this fixed per-mode BusinessApp directory is replaced, never lib/ or the other mode.
if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Recurse -Force }
foreach ($entry in $mapping) {
    $outputDir = Join-Path $destination $entry[1]
    New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
    Get-ChildItem -LiteralPath (Join-Path $sourceRoot $entry[0]) -Force | ForEach-Object {
        Copy-Item -LiteralPath $_.FullName -Destination $outputDir -Recurse -Force
    }
}
Write-Output "Training deployment completed: $Mode"
PowerShell / BusinessAppのルートで実行
& ..\TrainingAppServer\scripts\Deploy-Training.ps1 -Mode dev -BusinessApp (Get-Location).Path
if (-not $?) { throw 'Training deployment failed.' }

表示された出力先が自分のTrainingAppServer/runtime/dev/apps/BusinessAppであることを確認します。共有先や業務環境で実行しません。

② まずPowerShellで起動と読み込み元を確認する

ServerMainは引数を1個受け取ります。ここではconfig/common/common.propertiesの絶対パスです。-cpはコアJARだけにし、BusinessAppの出力やCommons Textを起動クラスパスへ足しません。

PowerShell / BusinessAppのルートで実行
& "$lessonJdk11\bin\java.exe" -cp ..\TrainingAppServer\lib\training-server-core.jar training.server.ServerMain (Resolve-Path .\config\common\common.properties).Path
if ($LASTEXITCODE -ne 0) { throw 'Server execution failed.' }

RESULT: Practice: Gradleと、BusinessServiceがruntime/dev/…/classes/、WordUtilsとStringUtilsがruntime/dev/…/lib/のJARから読み込まれたログを確認します。ServerMainが開始しただけでは完了ではありません。

成功ログ例 / パス・Javaのパッチ版は環境ごとに変わる
TrainingAppServer started (Java 11.…)
Configuration loaded: …/BusinessApp/config/common/common.properties
Business code: …/TrainingAppServer/runtime/dev/apps/BusinessApp/classes
Business libraries: …/TrainingAppServer/runtime/dev/apps/BusinessApp/lib
Application properties loaded: …/TrainingAppServer/runtime/dev/apps/BusinessApp/config/app.properties
Loaded training.business.BusinessService from: file:/…/runtime/dev/apps/BusinessApp/classes/
Loaded org.apache.commons.text.WordUtils from: file:/…/runtime/dev/apps/BusinessApp/lib/commons-text-1.15.0.jar
Loaded org.apache.commons.lang3.StringUtils from: file:/…/runtime/dev/apps/BusinessApp/lib/commons-lang3-3.20.0.jar
RESULT: Practice: Gradle
TrainingAppServerを起動し、業務コードを呼び出すこの教材で実装する、実際に動く流れです。
① Eclipse / Java Application・Java 11
Main:training.server.ServerMain(コアJAR内)
Program arguments:BusinessApp/config/common/common.propertiesの絶対パス
Mainを起動し、パスを渡す
② ServerMainがUTF-8のコンフィグを読む
business.code・business.libs・business.propertiesを
コンフィグの親フォルダー基準で解決する
指定されたruntime/dev/のファイルへ
③ クラスと設定を読み込む
BusinessClassLoader:業務classと外部JAR内のクラス
ServerMainの設定処理:app.propertiesのdisplay.prefix
クラス名と公開APIで呼び出す
④ BusinessService.message(context, "gRADLE")
Contextの表示名とCommons Textを使い、RESULT: Practice: Gradleを表示。
BusinessApp自身にはmainがない。

必要なクラスが使われる際に読み込まれ、Loadedログに読み込み元が出ます。一括ロードやサーバー常駐は行いません。

③ Eclipseでも、同じServerMainを起動する

  1. File → Import → Gradle → Existing Gradle ProjectでBusinessAppをImport。Gradle distributionはWrapper、Java homeはJDK 25。TrainingAppServerはImport対象にしません。
  2. Gradle Refresh後、JRE System LibraryとJava Compilerが11であることを確認。未登録ならPreferences → Java → Installed JREsへJDK 11を追加します。
  3. Run → Run Configurations → Java Applicationで新規作成し、Project=BusinessApp、Main class=training.server.ServerMainを入力します。Mainが検索候補にない場合も完全修飾名を入力できます。
  4. JREタブでJava 11を明示。Arguments → Program argumentsに、common.propertiesの絶対パスを引用符で囲んで1個渡します。下の表を自分の練習フォルダーへ置き換えます。
  5. Classpath(版によりDependencies)で起動時のユーザークラスパスを明示します。必要ならUse default classpathをオフにし、Advanced / Add External JARs等からTrainingAppServer/lib/training-server-core.jarを追加します。この実行構成のユーザー項目はコアJARだけにし、BusinessAppの出力とGradle依存を除外します。JRE System Libraryは残します。ProjectのJava Build Pathそのものは削除しません。
  6. Apply → Run。PowerShellと同様に、runtime/dev/からのLoadedログとRESULTを確認します。compileOnlyの同期だけで、この実行構成も正しくなるとは考えません。
Eclipseの項目今回の設定
ProjectBusinessApp
Main classtraining.server.ServerMain
Program arguments"C:\training\BusinessApp\config\common\common.properties"
自分の絶対パスへ置換。VM argumentsではない。
JREJava 11
Classpath / DependenciesJRE+training-server-core.jar。業務class・外部JARはここへ足さない。

④ 設定だけの変更と、Javaの変更を比べる

  1. BusinessApp/config/app/app.propertiesをdisplay.prefix=Changedへ変更して保存。stageDev → Deploy-Training.ps1 -Mode dev → 同じServerMainの再起動の順で実行します。RESULT: Changed: Gradleになり、compileJavaは変更なしならUP-TO-DATEです。設定の読み直しだけで表示が変わります。
  2. Practiceへ戻して同じ手順を繰り返します。次にBusinessServiceの戻り値の末尾へ + "!" を追加して保存します。stageDevで再コンパイル、配置、再起動し、RESULT: Practice: Gradle!を確認します。保存だけや再起動だけではruntime側のclassは更新されません。
  3. JUnitの期待値は元のままなので、このJava変更中のtestは失敗します。確認後はJavaも元へ戻し、buildで1件・失敗0件を確かめてからstageDev・再配置・再起動します。第4章へ失敗状態を持ち込みません。
PowerShell / BusinessAppのルートで実行
.\gradlew.bat stageDev --console=verbose
if ($LASTEXITCODE -ne 0) { throw 'stageDev failed.' }
& ..\TrainingAppServer\scripts\Deploy-Training.ps1 -Mode dev -BusinessApp (Get-Location).Path
if (-not $?) { throw 'Training deployment failed.' }
& "$lessonJdk11\bin\java.exe" -cp ..\TrainingAppServer\lib\training-server-core.jar training.server.ServerMain (Resolve-Path .\config\common\common.properties).Path
if ($LASTEXITCODE -ne 0) { throw 'Server execution failed.' }

変更を保存してから、この3段階を毎回実行します。stageDevはコンフィグを自動生成・書き換えせず、実サーバー配置も担当しません。

補足:Eclipseの同名classを偶然使わない仕組み

通常のURLClassLoaderは親にもクラスを探します。今回は親をJDKのPlatform ClassLoaderにして、Eclipseのアプリケーションクラスパスを探索対象から外します。ただしtraining.server.api.*だけはコア側のローダーへ委譲し、ServerContextを同じ型として共有します。その他は指定されたclass/JARとJDKから読みます。

この研修用の簡単な境界であり、業務サーバー一般の優先順位ではありません。読み込み元はLoadedログで確認します。配置からCommons TextをなくしてもEclipse側に同じJARがあるから動く、という状態を避けています。サーバー実装は未信頼コードを隔離するセキュリティ機構ではありません。

07何が変わった?

Gradleで準備したファイルがruntime/dev/へ配置され、設定からその場所を読んだTrainingAppServerがBusinessServiceを実行しました。ビルド・配置・起動は別の操作で、ログからそれぞれの結果を確認できます。

08 / 次へ進む条件

runtime/dev/から業務classと外部JARを読み込み、Practice: Gradleを表示できる。設定変更とJava変更の違いを試し、元に戻してJUnitが成功する。

第3章終了時のbuildファイル全文(答え合わせ)
build.gradle.ktsこの章終了時の完成コード
plugins {
    java
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(11)
    }
}

repositories { mavenCentral() }

dependencies {
    compileOnly(files("../TrainingAppServer/lib/training-server-core.jar"))
    implementation("org.apache.commons:commons-text:1.15.0")
    testImplementation(files("../TrainingAppServer/lib/training-server-core.jar"))
    testImplementation(platform("org.junit:junit-bom:5.13.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test { useJUnitPlatform() }

tasks.register<Copy>("copyAppConfig") {
    from("config/app/app.properties")
    into(layout.buildDirectory.dir("staging/copy-demo/app-config"))
}

tasks.register<Sync>("stageDev") {
    dependsOn(tasks.named("classes"))
    into(layout.buildDirectory.dir("staging/dev"))
    from(sourceSets.main.get().output.classesDirs) {
        into("classes")
    }
    from(configurations.runtimeClasspath) {
        into("app-libs")
    }
    from("config/app/app.properties") {
        into("app-config")
    }
    duplicatesStrategy = DuplicatesStrategy.FAIL
}
04

PRACTICAL / BUSINESSAPPを育てる

本番用の配布物を作る

01今回やりたいこと

業務classを通常のBusinessApp.jarにまとめ、外部依存JAR・アプリ設定・共通設定と分けて準備します。その後、研修用runtime/releaseへ配置してJARから起動します。実際の本番サーバーへはコピーしません。

02なぜ今の構成では困る?

開発用のclass配置をそのまま配っても、本番側のJAR配置ルールに合いません。また、jarだけ成功していてもテストが失敗していれば、正常なリリースとして扱えません。

03どういう仕組みで解決する?

buildassembleとcheckを通す
stageRelease成功後にSync
release/4種類を別フォルダーへ
矢印は依存による準備の流れ。jarとtestの相互の順番を固定する図ではありません。

build はJARなどの組み立てと検証をまとめます。Java Pluginではtestも実行対象です。stageReleaseがbuildに依存するので、テスト失敗時はstageReleaseを実行しません。buildが自動でcleanやrunを実行するわけではありません。

04どのファイルを変更する?

BusinessAppの build.gradle.kts の末尾へstageReleaseを追加します。Java・テスト・configはそのまま使います。出力は build/staging/release/ だけです。

05実際のコードと設定

本体JARを作るTaskと、コピーするTaskをつなぐ

jar Taskが作るBusinessApp.jarを、後続のstageReleaseへ渡したいところです。ファイル名を固定して探す代わりに、生成するTaskの出力を参照します。以下は読み方の説明であり、新しいTaskを追加する指示ではありません。

設定の読み方 / 下のTask全文の抜粋。重ねて追加しない
from(tasks.named<Jar>("jar").flatMap { it.archiveFile }) {
    into("application")
}

1. tasks.named:Java Pluginが既に登録したjarを参照します。Kotlinの<Jar>とGroovyの第2引数JarはTaskの型です。JARを作る型だと分かるため、その出力プロパティを扱えます。ここではTaskProviderという、必要なときにTaskを得る参照が返ります。下の完成コードにあるKotlinのtasks.jarは、この型付き参照の短い書き方です。

2. archiveFile:jarが生成するアーカイブファイルを表す出力プロパティです。今回の場所はbuild/libs/BusinessApp.jar。JARの中身を指すものではなく、JARファイル一つの場所を表します。ここを参照しても、その場でJARは生成されません。

3. flatMap:Taskの参照から、そのTaskの出力ファイルのProviderへつなぎ替えます。itはここで参照するJar Taskです。archiveFile自身もProviderなので、flatMapでProviderを二重にせず「出力ファイルを得る参照」にします。Kotlin/Groovyとも、このGradle共通の仕組みを使います。

4. from / into:出力Providerをコピー元として渡し、Task全体の配置先release/の下のapplication/へコピーします。JARの名前が設定で変わっても、jarが実際に生成する出力を追えます。パスの文字列を決め打ちすると、名前の変更後に入力が見つからない場合があります。

ファイルの流れ / Providerが生成元Taskの情報もつなぐ
jar Task(Jar型)
build/libs/BusinessApp.jar
archiveFileが出力を表す
出力を渡す
stageRelease(Sync型)
fromで受け取り
release/application/へコピー

この出力Providerには生成元の情報があるため、GradleはstageReleaseにjarが必要だと推論できます。dependsOn(tasks.named("build"))は別の意図で明記しています。JARを得る依存だけではテストを保証しないため、buildの組み立てとcheck/testも先に必要とする指定です。これを省くとJARの準備だけでSyncへ進めてしまいます。

4種類を、別々のフォルダーへ振り分ける

外側のintoはbuild/staging/release/。その下のapplication/に本体JAR、dependencies/にruntimeClasspathの2つの外部JAR、app-config/にconfig/app/app.propertiesを置きます。開発用config/commonではなく、本番相当用のconfig/release/common.propertiesをcommon-config/へコピーします。中の相対パスは配置後のcommon-configを基準にしています。このfromを省くと、本番用の共通設定が揃いません。

コピー先名は仮配置用の分類です。stageReleaseのintoはそのままにし、別工程の配置スクリプトでruntime/releaseへ対応づけます。Syncの余分なファイル除去とFAILによる重複検出は、第3章と同じ意味です。

stageRelease全文を追加する

build.gradle.kts既存の設定を残し、該当箇所へ追加
tasks.register<Sync>("stageRelease") {
    dependsOn(tasks.named("build"))
    into(layout.buildDirectory.dir("staging/release"))
    from(tasks.jar.flatMap { it.archiveFile }) {
        into("application")
    }
    from(configurations.runtimeClasspath) {
        into("dependencies")
    }
    from("config/app/app.properties") {
        into("app-config")
    }
    from("config/release/common.properties") {
        into("common-config")
    }
    duplicatesStrategy = DuplicatesStrategy.FAIL
}

コードを保存したら、次の手順でテストの成功と4種類の配置物を確認します。入力のpropertiesはファイル名まで指定しているので、バックアップ等は丸ごと入りません。

06操作して確かめる

PowerShell / BusinessAppのルートで実行
.\gradlew.bat clean stageRelease --info
if ($LASTEXITCODE -ne 0) { throw 'Release failed; do not deploy.' }
Get-ChildItem .\build\staging\release -Recurse -File
& "$lessonJdk11\bin\jar.exe" tf .\build\staging\release\application\BusinessApp.jar

Java 11のテスト1件成功、次の5ファイル、JARに業務classだけが含まれることを確認します。コアJAR・JUnit・外部propertiesをアプリJARへ入れません。

Gradleの配布準備 / BusinessApp/build/staging/release/
  • release/
    • application/BusinessApp.jar
    • dependencies/
      • commons-text-1.15.0.jar
      • commons-lang3-3.20.0.jar
    • app-config/app.properties
    • common-config/common.properties

第3章で保存したDeploy-Training.ps1をreleaseモードで使います。場所はこのPC内の研修用TrainingAppServer/runtime/release/apps/BusinessApp/です。devの配置やコンフィグを参照しません。

仮配置release/サーバー側runtime/release/apps/BusinessApp/
application/application/
dependencies/lib/
app-config/config/
common-config/common-config/
PowerShell / BusinessAppのルートで実行
& ..\TrainingAppServer\scripts\Deploy-Training.ps1 -Mode release -BusinessApp (Get-Location).Path
if (-not $?) { throw 'Training deployment failed.' }
& "$lessonJdk11\bin\java.exe" -cp ..\TrainingAppServer\lib\training-server-core.jar training.server.ServerMain (Resolve-Path ..\TrainingAppServer\runtime\release\apps\BusinessApp\common-config\common.properties).Path
if ($LASTEXITCODE -ne 0) { throw 'Release execution failed.' }

Loaded training.business.BusinessServiceの末尾がruntime/release/…/application/BusinessApp.jarであり、RESULT: Practice: Gradleが出ることを確認します。外部JARは同じrelease側のlib/から読みます。

本番相当の実行ログ例 / 環境固有パスは省略
Business code: …/runtime/release/apps/BusinessApp/application/BusinessApp.jar
Loaded training.business.BusinessService from: file:/…/runtime/release/apps/BusinessApp/application/BusinessApp.jar
Loaded org.apache.commons.text.WordUtils from: file:/…/runtime/release/apps/BusinessApp/lib/commons-text-1.15.0.jar
RESULT: Practice: Gradle

失敗したビルドを配布しない

テストの期待値だけWRONGへ変えてclean stageReleaseを実行すると、testが失敗してstageReleaseは実行されません。この時は配置スクリプトを実行しません。Gradleのcleanはサーバーruntimeを消さないので、そこには前回の配置が残ります。古い配置が動いても今回のビルド成功とは扱いません。

PowerShell / BusinessAppのルートで実行
.\gradlew.bat clean stageRelease
$LASTEXITCODE
Test-Path .\build\staging\release

意図的な失敗では非0終了、staging/releaseはFalseを確認。必ず期待値を元へ戻し、上の成功時コマンドを最初から実行します。

Syncの不要ファイル除去と、配布物の再現性を確認
PowerShell / BusinessAppのルートで実行
Set-Content .\build\staging\release\old.txt 'obsolete'
.\gradlew.bat stageRelease --rerun-tasks
Test-Path .\build\staging\release\old.txt

FalseならSyncが入力にないファイルを除去。実際の配布候補はclean stageReleaseへ戻します。

PowerShell / BusinessAppのルートで実行
$beforeRelease = Get-ChildItem .\build\staging\release -Recurse -File | Get-FileHash | Select-Object Path,Hash
.\gradlew.bat clean stageRelease
if ($LASTEXITCODE -ne 0) { throw 'Release failed.' }
$afterRelease = Get-ChildItem .\build\staging\release -Recurse -File | Get-FileHash | Select-Object Path,Hash
Compare-Object $beforeRelease $afterRelease -Property Path,Hash

同じ入力・環境・依存なら何も表示されず、配布物のファイル集合と内容が一致することを確認します。

DuplicatesStrategy.FAILは同じコピー先パスの重複を検出します。別名JAR内の同一クラスや、別の実サーバーが持つライブラリとの互換性まで検出するものではありません。推移的依存を確認せず削除しないでください。

0 / 8 確認(ページを閉じるとリセット)

07何が変わった?

開発時はclassを直接準備し、本番向けには通常のJARへまとめる形になりました。依存JARと2種類の設定は分離したままです。今回はローカルのruntime/releaseに分けて配置し、配布したコンフィグを渡すことで、同じServerMainがBusinessApp.jarから業務処理を実行します。実際の社内サーバーへ適用するときは、その仕様と対応づけ直します。

項目開発用stageDev本番用stageRelease
業務コードclasses/の.classapplication/の通常JAR
アプリ依存app-libs/の外部JARdependencies/の外部JAR
アプリ設定app-config/app-config/
共通設定コピーせずプロジェクトを参照common-config/に準備
サーバー提供JAR含めない含めない
検証と起動コンパイル後、EclipseからサーバーMainテスト成功後に配布物準備。ServerMainでJARを読み込む
08 / 次へ進む条件

Java 11のテスト成功と配布物の5ファイルを確認できる。開発/本番の違い、配らないもの、runtime/devのclassとruntime/releaseのJARの読み込み元をログで区別できる。

第4章終了時のbuildファイル全文(答え合わせ)
build.gradle.ktsこの章終了時の完成コード
plugins {
    java
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(11)
    }
}

repositories { mavenCentral() }

dependencies {
    compileOnly(files("../TrainingAppServer/lib/training-server-core.jar"))
    implementation("org.apache.commons:commons-text:1.15.0")
    testImplementation(files("../TrainingAppServer/lib/training-server-core.jar"))
    testImplementation(platform("org.junit:junit-bom:5.13.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test { useJUnitPlatform() }

tasks.register<Copy>("copyAppConfig") {
    from("config/app/app.properties")
    into(layout.buildDirectory.dir("staging/copy-demo/app-config"))
}

tasks.register<Sync>("stageDev") {
    dependsOn(tasks.named("classes"))
    into(layout.buildDirectory.dir("staging/dev"))
    from(sourceSets.main.get().output.classesDirs) {
        into("classes")
    }
    from(configurations.runtimeClasspath) {
        into("app-libs")
    }
    from("config/app/app.properties") {
        into("app-config")
    }
    duplicatesStrategy = DuplicatesStrategy.FAIL
}

tasks.register<Sync>("stageRelease") {
    dependsOn(tasks.named("build"))
    into(layout.buildDirectory.dir("staging/release"))
    from(tasks.jar.flatMap { it.archiveFile }) {
        into("application")
    }
    from(configurations.runtimeClasspath) {
        into("dependencies")
    }
    from("config/app/app.properties") {
        into("app-config")
    }
    from("config/release/common.properties") {
        into("common-config")
    }
    duplicatesStrategy = DuplicatesStrategy.FAIL
}

公式資料と検証条件

対象:Gradle 9.6.0(起動JDK 25)、サーバー・業務コード・テストはJava 11。Commons Text 1.15.0とJUnit 5.13.4を使います。TrainingAppServerはこの教材専用の実装であり、社内製品の仕様を表しません。

完成サンプルが存在しない状態で、掲載したソースとコマンドだけから空の環境を構築し、Kotlin / Groovy両DSLで開発用・本番相当の起動まで再現できることを確認しました。Kotlin / Groovy両DSLでビルド・JUnit 1件成功、classのmajor 55、開発用classと本番相当JARからの起動、外部JARの読み込み元、設定・コード変更の反映を確認しました。Copy / Sync・Providerの依存推論・重複時の失敗・テスト失敗時の配置停止も検証済みです。Eclipseの実機Import・Run操作は未検証です。

HTMLは単体で閲覧できます。第1章でサーバーソースとBuild-Core.ps1、業務コードを保存し、第2章で設定を作成、第3章でDeploy-Training.ps1を保存します。Gradle設定は各章の説明と「buildファイル全文」に掲載しています。演習実行用のファイルは、このHTMLから自分のPCに作成します。

印刷前に補足と開発/本番相当の図を展開します。コードは選択中のDSLです。