Skip to content

Commit 23a9853

Browse files
mbhavewilkinsona
andcommitted
Add support for layered jars in gradle plugin
Closes spring-projectsgh-19792 Co-authored-by: Andy Wilkinson <[email protected]>
1 parent e513fe4 commit 23a9853

File tree

8 files changed

+213
-2
lines changed

8 files changed

+213
-2
lines changed

spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging.adoc

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,29 @@ include::../gradle/packaging/boot-war-properties-launcher.gradle[tags=properties
272272
include::../gradle/packaging/boot-war-properties-launcher.gradle.kts[tags=properties-launcher]
273273
----
274274

275+
276+
[[packaging-layered-jars]]
277+
==== Packaging layered jars
278+
279+
By default, the `bootJar` tasks builds an archive that contains the application's classes and dependencies in `BOOT-INF/classes` and `BOOT-INF/lib` respectively.
280+
For cases where a docker image needs to be built from the contents of the jar, the jar format can be enhanced to support layer folders.
281+
To use this feature, the layering feature must be enabled:
282+
283+
[source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
284+
.Groovy
285+
----
286+
include::../gradle/packaging/boot-jar-layered.gradle[tags=layered]
287+
----
288+
289+
[source,kotlin,indent=0,subs="verbatim,attributes",role="secondary"]
290+
.Kotlin
291+
----
292+
include::../gradle/packaging/boot-jar-layered.gradle.kts[tags=layered]
293+
----
294+
295+
The jar will then be split into layer folders which may include:
296+
297+
* `application`
298+
* `resources`
299+
* `snapshots-dependencies`
300+
* `dependencies`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
plugins {
2+
id 'java'
3+
id 'org.springframework.boot' version '{version}'
4+
}
5+
6+
bootJar {
7+
mainClassName 'com.example.ExampleApplication'
8+
}
9+
10+
// tag::layered[]
11+
bootJar {
12+
layered()
13+
}
14+
// end::layered[]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import org.springframework.boot.gradle.tasks.bundling.BootJar
2+
3+
plugins {
4+
java
5+
id("org.springframework.boot") version "{version}"
6+
}
7+
8+
tasks.getByName<BootJar>("bootJar") {
9+
mainClassName = "com.example.ExampleApplication"
10+
}
11+
12+
// tag::layered[]
13+
tasks.getByName<BootJar>("bootJar") {
14+
layered()
15+
}
16+
// end::layered[]

spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2019 the original author or authors.
2+
* Copyright 2012-2020 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,7 +16,10 @@
1616

1717
package org.springframework.boot.gradle.tasks.bundling;
1818

19+
import java.io.BufferedWriter;
1920
import java.io.File;
21+
import java.io.IOException;
22+
import java.io.StringWriter;
2023
import java.util.Collections;
2124
import java.util.concurrent.Callable;
2225

@@ -26,14 +29,22 @@
2629
import org.gradle.api.file.FileCopyDetails;
2730
import org.gradle.api.file.FileTreeElement;
2831
import org.gradle.api.internal.file.copy.CopyAction;
32+
import org.gradle.api.java.archives.Attributes;
33+
import org.gradle.api.resources.TextResource;
2934
import org.gradle.api.specs.Spec;
35+
import org.gradle.api.tasks.Input;
3036
import org.gradle.api.tasks.Internal;
3137
import org.gradle.api.tasks.bundling.Jar;
3238

39+
import org.springframework.boot.loader.tools.Layer;
40+
import org.springframework.boot.loader.tools.Layers;
41+
import org.springframework.boot.loader.tools.Library;
42+
3343
/**
3444
* A custom {@link Jar} task that produces a Spring Boot executable jar.
3545
*
3646
* @author Andy Wilkinson
47+
* @author Madhura Bhave
3748
* @since 2.0.0
3849
*/
3950
public class BootJar extends Jar implements BootArchive {
@@ -47,6 +58,10 @@ public class BootJar extends Jar implements BootArchive {
4758

4859
private FileCollection classpath;
4960

61+
private Layers layers;
62+
63+
private static final String BOOT_INF_LAYERS = "BOOT-INF/layers/";
64+
5065
/**
5166
* Creates a new {@code BootJar} task.
5267
*/
@@ -73,6 +88,12 @@ private Action<CopySpec> classpathFiles(Spec<File> filter) {
7388
@Override
7489
public void copy() {
7590
this.support.configureManifest(this, getMainClassName(), "BOOT-INF/classes/", "BOOT-INF/lib/");
91+
Attributes attributes = this.getManifest().getAttributes();
92+
if (this.layers != null) {
93+
attributes.remove("Spring-Boot-Classes");
94+
attributes.remove("Spring-Boot-Lib");
95+
attributes.putIfAbsent("Spring-Boot-Layers-Index", "BOOT-INF/layers.idx");
96+
}
7697
super.copy();
7798
}
7899

@@ -122,6 +143,54 @@ public void launchScript(Action<LaunchScriptConfiguration> action) {
122143
action.execute(enableLaunchScriptIfNecessary());
123144
}
124145

146+
/**
147+
* Configures the archive to have layers.
148+
*/
149+
public void layered() {
150+
this.layers = Layers.IMPLICIT;
151+
this.bootInf.eachFile((details) -> {
152+
Layer layer = layerForFileDetails(details);
153+
if (layer != null) {
154+
details.setPath(
155+
BOOT_INF_LAYERS + "/" + layer + "/" + details.getPath().substring("BOOT-INF/".length()));
156+
}
157+
}).setIncludeEmptyDirs(false);
158+
this.bootInf.into("", (spec) -> spec.from(createLayersIndex()))
159+
.eachFile((details) -> details.setPath("BOOT-INF/layers.idx"));
160+
}
161+
162+
private Layer layerForFileDetails(FileCopyDetails details) {
163+
String path = details.getPath();
164+
if (path.startsWith("BOOT-INF/lib/")) {
165+
return this.layers.getLayer(new Library(details.getFile(), null));
166+
}
167+
if (path.startsWith("BOOT-INF/classes/")) {
168+
return this.layers.getLayer(details.getSourcePath());
169+
}
170+
return null;
171+
}
172+
173+
private TextResource createLayersIndex() {
174+
try {
175+
StringWriter content = new StringWriter();
176+
BufferedWriter writer = new BufferedWriter(content);
177+
for (Layer layer : this.layers) {
178+
writer.write(layer.toString());
179+
writer.write("\n");
180+
}
181+
writer.flush();
182+
return getProject().getResources().getText().fromString(content.toString());
183+
}
184+
catch (IOException ex) {
185+
throw new RuntimeException("Failed to create layers.idx", ex);
186+
}
187+
}
188+
189+
@Input
190+
boolean isLayered() {
191+
return this.layers != null;
192+
}
193+
125194
@Override
126195
public FileCollection getClasspath() {
127196
return this.classpath;

spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ void bootJarAndJar() {
178178
assertThat(bootJar).isFile();
179179
}
180180

181+
@TestTemplate
182+
void bootJarLayered() throws IOException {
183+
this.gradleBuild.script("src/docs/gradle/packaging/boot-jar-layered").build("bootJar");
184+
File file = new File(this.gradleBuild.getProjectDir(),
185+
"build/libs/" + this.gradleBuild.getProjectDir().getName() + ".jar");
186+
assertThat(file).isFile();
187+
try (JarFile jar = new JarFile(file)) {
188+
JarEntry entry = jar.getJarEntry("BOOT-INF/layers.idx");
189+
assertThat(entry).isNotNull();
190+
}
191+
}
192+
181193
protected void jarFile(File file) throws IOException {
182194
try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) {
183195
jar.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));

spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2019 the original author or authors.
2+
* Copyright 2012-2020 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,6 +16,15 @@
1616

1717
package org.springframework.boot.gradle.tasks.bundling;
1818

19+
import java.io.IOException;
20+
21+
import org.gradle.testkit.runner.InvalidRunnerConfigurationException;
22+
import org.gradle.testkit.runner.TaskOutcome;
23+
import org.gradle.testkit.runner.UnexpectedBuildFailure;
24+
import org.junit.jupiter.api.TestTemplate;
25+
26+
import static org.assertj.core.api.Assertions.assertThat;
27+
1928
/**
2029
* Integration tests for {@link BootJar}.
2130
*
@@ -27,4 +36,21 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
2736
super("bootJar");
2837
}
2938

39+
@TestTemplate
40+
void upToDateWhenBuiltTwiceWithLayers()
41+
throws InvalidRunnerConfigurationException, UnexpectedBuildFailure, IOException {
42+
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
43+
.isEqualTo(TaskOutcome.SUCCESS);
44+
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
45+
.isEqualTo(TaskOutcome.UP_TO_DATE);
46+
}
47+
48+
@TestTemplate
49+
void notUpToDateWhenBuiltWithoutLayersAndThenWithLayers()
50+
throws InvalidRunnerConfigurationException, UnexpectedBuildFailure, IOException {
51+
assertThat(this.gradleBuild.build("bootJar").task(":bootJar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
52+
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
53+
.isEqualTo(TaskOutcome.SUCCESS);
54+
}
55+
3056
}

spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
import java.io.File;
2020
import java.io.IOException;
21+
import java.io.InputStream;
22+
import java.util.List;
2123
import java.util.jar.JarFile;
2224

2325
import org.junit.jupiter.api.Test;
@@ -28,6 +30,7 @@
2830
* Tests for {@link BootJar}.
2931
*
3032
* @author Andy Wilkinson
33+
* @author Madhura Bhave
3134
*/
3235
class BootJarTests extends AbstractBootArchiveTests<BootJar> {
3336

@@ -57,6 +60,48 @@ void contentCanBeAddedToBootInfUsingCopySpecAction() throws IOException {
5760
}
5861
}
5962

63+
@Test
64+
void layers() throws IOException {
65+
BootJar bootJar = getTask();
66+
bootJar.setMainClassName("com.example.Main");
67+
bootJar.layered();
68+
File classesJavaMain = new File(this.temp, "classes/java/main");
69+
File applicationClass = new File(classesJavaMain, "com/example/Application.class");
70+
applicationClass.getParentFile().mkdirs();
71+
applicationClass.createNewFile();
72+
File resourcesMain = new File(this.temp, "resources/main");
73+
File applicationProperties = new File(resourcesMain, "application.properties");
74+
applicationProperties.getParentFile().mkdirs();
75+
applicationProperties.createNewFile();
76+
File staticResources = new File(resourcesMain, "static");
77+
staticResources.mkdir();
78+
File css = new File(staticResources, "test.css");
79+
css.createNewFile();
80+
bootJar.classpath(classesJavaMain, resourcesMain, jarFile("first-library.jar"), jarFile("second-library.jar"),
81+
jarFile("third-library-SNAPSHOT.jar"));
82+
bootJar.requiresUnpack("second-library.jar");
83+
executeTask();
84+
List<String> entryNames = getEntryNames(bootJar.getArchiveFile().get().getAsFile());
85+
assertThat(entryNames).containsSubsequence("org/springframework/boot/loader/",
86+
"BOOT-INF/layers/application/classes/com/example/Application.class",
87+
"BOOT-INF/layers/resources/classes/static/test.css",
88+
"BOOT-INF/layers/application/classes/application.properties",
89+
"BOOT-INF/layers/dependencies/lib/first-library.jar",
90+
"BOOT-INF/layers/dependencies/lib/second-library.jar",
91+
"BOOT-INF/layers/snapshot-dependencies/lib/third-library-SNAPSHOT.jar");
92+
assertThat(entryNames).doesNotContain("BOOT-INF/classes").doesNotContain("BOOT-INF/lib")
93+
.doesNotContain("BOOT-INF/com/");
94+
try (JarFile jarFile = new JarFile(bootJar.getArchiveFile().get().getAsFile())) {
95+
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Classes")).isEqualTo(null);
96+
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Lib")).isEqualTo(null);
97+
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Layers-Index"))
98+
.isEqualTo("BOOT-INF/layers.idx");
99+
try (InputStream input = jarFile.getInputStream(jarFile.getEntry("BOOT-INF/layers.idx"))) {
100+
assertThat(input).hasContent("dependencies\nsnapshot-dependencies\nresources\napplication\n");
101+
}
102+
}
103+
}
104+
60105
@Override
61106
protected void executeTask() {
62107
getTask().copy();

spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@ bootJar {
1010
properties 'prop' : project.hasProperty('launchScriptProperty') ? launchScriptProperty : 'default'
1111
}
1212
}
13+
if (project.hasProperty('layered') ? layered: false) {
14+
layered()
15+
}
1316
}

0 commit comments

Comments
 (0)