Add support for layered jars in gradle plugin

Closes gh-19792

Co-authored-by: Andy Wilkinson <awilkinson@pivotal.io>
pull/19863/head
Madhura Bhave 5 years ago
parent 9ff50f903f
commit df5b0f1163

@ -272,3 +272,29 @@ include::../gradle/packaging/boot-war-properties-launcher.gradle[tags=properties
include::../gradle/packaging/boot-war-properties-launcher.gradle.kts[tags=properties-launcher]
----
[[packaging-layered-jars]]
==== Packaging layered jars
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.
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.
To use this feature, the layering feature must be enabled:
[source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
.Groovy
----
include::../gradle/packaging/boot-jar-layered.gradle[tags=layered]
----
[source,kotlin,indent=0,subs="verbatim,attributes",role="secondary"]
.Kotlin
----
include::../gradle/packaging/boot-jar-layered.gradle.kts[tags=layered]
----
The jar will then be split into layer folders which may include:
* `application`
* `resources`
* `snapshots-dependencies`
* `dependencies`

@ -0,0 +1,14 @@
plugins {
id 'java'
id 'org.springframework.boot' version '{version}'
}
bootJar {
mainClassName 'com.example.ExampleApplication'
}
// tag::layered[]
bootJar {
layered()
}
// end::layered[]

@ -0,0 +1,16 @@
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
java
id("org.springframework.boot") version "{version}"
}
tasks.getByName<BootJar>("bootJar") {
mainClassName = "com.example.ExampleApplication"
}
// tag::layered[]
tasks.getByName<BootJar>("bootJar") {
layered()
}
// end::layered[]

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,7 +16,10 @@
package org.springframework.boot.gradle.tasks.bundling;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.util.Collections;
import java.util.concurrent.Callable;
@ -26,14 +29,21 @@ import org.gradle.api.file.FileCollection;
import org.gradle.api.file.FileCopyDetails;
import org.gradle.api.file.FileTreeElement;
import org.gradle.api.internal.file.copy.CopyAction;
import org.gradle.api.java.archives.Attributes;
import org.gradle.api.specs.Spec;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.bundling.Jar;
import org.springframework.boot.loader.tools.Layer;
import org.springframework.boot.loader.tools.Layers;
import org.springframework.boot.loader.tools.Library;
/**
* A custom {@link Jar} task that produces a Spring Boot executable jar.
*
* @author Andy Wilkinson
* @author Madhura Bhave
* @since 2.0.0
*/
public class BootJar extends Jar implements BootArchive {
@ -47,6 +57,10 @@ public class BootJar extends Jar implements BootArchive {
private FileCollection classpath;
private Layers layers;
private static final String BOOT_INF_LAYERS = "BOOT-INF/layers/";
/**
* Creates a new {@code BootJar} task.
*/
@ -73,6 +87,12 @@ public class BootJar extends Jar implements BootArchive {
@Override
public void copy() {
this.support.configureManifest(this, getMainClassName(), "BOOT-INF/classes/", "BOOT-INF/lib/");
Attributes attributes = this.getManifest().getAttributes();
if (this.layers != null) {
attributes.remove("Spring-Boot-Classes");
attributes.remove("Spring-Boot-Lib");
attributes.putIfAbsent("Spring-Boot-Layers-Index", "BOOT-INF/layers.idx");
}
super.copy();
}
@ -122,6 +142,56 @@ public class BootJar extends Jar implements BootArchive {
action.execute(enableLaunchScriptIfNecessary());
}
/**
* Configures the archive to have layers.
*/
public void layered() {
this.layers = Layers.IMPLICIT;
this.bootInf.eachFile((details) -> {
Layer layer = layerForFileDetails(details);
if (layer != null) {
details.setPath(
BOOT_INF_LAYERS + "/" + layer + "/" + details.getPath().substring("BOOT-INF/".length()));
}
}).setIncludeEmptyDirs(false);
this.bootInf.into("", (spec) -> spec.from(createLayersIndex()));
}
private Layer layerForFileDetails(FileCopyDetails details) {
String path = details.getPath();
if (path.startsWith("BOOT-INF/lib/")) {
return this.layers.getLayer(new Library(details.getFile(), null));
}
if (path.startsWith("BOOT-INF/classes/")) {
return this.layers.getLayer(details.getSourcePath());
}
return null;
}
private File createLayersIndex() {
try {
StringWriter content = new StringWriter();
BufferedWriter writer = new BufferedWriter(content);
for (Layer layer : this.layers) {
writer.write(layer.toString());
writer.write("\n");
}
writer.flush();
File source = getProject().getResources().getText().fromString(content.toString()).asFile();
File indexFile = new File(source.getParentFile(), "layers.idx");
source.renameTo(indexFile);
return indexFile;
}
catch (IOException ex) {
throw new RuntimeException("Failed to create layers.idx", ex);
}
}
@Input
boolean isLayered() {
return this.layers != null;
}
@Override
public FileCollection getClasspath() {
return this.classpath;

@ -178,6 +178,18 @@ class PackagingDocumentationTests {
assertThat(bootJar).isFile();
}
@TestTemplate
void bootJarLayered() throws IOException {
this.gradleBuild.script("src/docs/gradle/packaging/boot-jar-layered").build("bootJar");
File file = new File(this.gradleBuild.getProjectDir(),
"build/libs/" + this.gradleBuild.getProjectDir().getName() + ".jar");
assertThat(file).isFile();
try (JarFile jar = new JarFile(file)) {
JarEntry entry = jar.getJarEntry("BOOT-INF/layers.idx");
assertThat(entry).isNotNull();
}
}
protected void jarFile(File file) throws IOException {
try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) {
jar.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,6 +16,15 @@
package org.springframework.boot.gradle.tasks.bundling;
import java.io.IOException;
import org.gradle.testkit.runner.InvalidRunnerConfigurationException;
import org.gradle.testkit.runner.TaskOutcome;
import org.gradle.testkit.runner.UnexpectedBuildFailure;
import org.junit.jupiter.api.TestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link BootJar}.
*
@ -27,4 +36,21 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
super("bootJar");
}
@TestTemplate
void upToDateWhenBuiltTwiceWithLayers()
throws InvalidRunnerConfigurationException, UnexpectedBuildFailure, IOException {
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
.isEqualTo(TaskOutcome.SUCCESS);
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
.isEqualTo(TaskOutcome.UP_TO_DATE);
}
@TestTemplate
void notUpToDateWhenBuiltWithoutLayersAndThenWithLayers()
throws InvalidRunnerConfigurationException, UnexpectedBuildFailure, IOException {
assertThat(this.gradleBuild.build("bootJar").task(":bootJar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
.isEqualTo(TaskOutcome.SUCCESS);
}
}

@ -18,6 +18,8 @@ package org.springframework.boot.gradle.tasks.bundling;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
@ -28,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link BootJar}.
*
* @author Andy Wilkinson
* @author Madhura Bhave
*/
class BootJarTests extends AbstractBootArchiveTests<BootJar> {
@ -57,6 +60,48 @@ class BootJarTests extends AbstractBootArchiveTests<BootJar> {
}
}
@Test
void layers() throws IOException {
BootJar bootJar = getTask();
bootJar.setMainClassName("com.example.Main");
bootJar.layered();
File classesJavaMain = new File(this.temp, "classes/java/main");
File applicationClass = new File(classesJavaMain, "com/example/Application.class");
applicationClass.getParentFile().mkdirs();
applicationClass.createNewFile();
File resourcesMain = new File(this.temp, "resources/main");
File applicationProperties = new File(resourcesMain, "application.properties");
applicationProperties.getParentFile().mkdirs();
applicationProperties.createNewFile();
File staticResources = new File(resourcesMain, "static");
staticResources.mkdir();
File css = new File(staticResources, "test.css");
css.createNewFile();
bootJar.classpath(classesJavaMain, resourcesMain, jarFile("first-library.jar"), jarFile("second-library.jar"),
jarFile("third-library-SNAPSHOT.jar"));
bootJar.requiresUnpack("second-library.jar");
executeTask();
List<String> entryNames = getEntryNames(bootJar.getArchiveFile().get().getAsFile());
assertThat(entryNames).containsSubsequence("org/springframework/boot/loader/",
"BOOT-INF/layers/application/classes/com/example/Application.class",
"BOOT-INF/layers/resources/classes/static/test.css",
"BOOT-INF/layers/application/classes/application.properties",
"BOOT-INF/layers/dependencies/lib/first-library.jar",
"BOOT-INF/layers/dependencies/lib/second-library.jar",
"BOOT-INF/layers/snapshot-dependencies/lib/third-library-SNAPSHOT.jar");
assertThat(entryNames).doesNotContain("BOOT-INF/classes").doesNotContain("BOOT-INF/lib")
.doesNotContain("BOOT-INF/com/");
try (JarFile jarFile = new JarFile(bootJar.getArchiveFile().get().getAsFile())) {
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Classes")).isEqualTo(null);
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Lib")).isEqualTo(null);
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Layers-Index"))
.isEqualTo("BOOT-INF/layers.idx");
try (InputStream input = jarFile.getInputStream(jarFile.getEntry("BOOT-INF/layers.idx"))) {
assertThat(input).hasContent("dependencies\nsnapshot-dependencies\nresources\napplication\n");
}
}
}
@Override
protected void executeTask() {
getTask().copy();

@ -10,4 +10,7 @@ bootJar {
properties 'prop' : project.hasProperty('launchScriptProperty') ? launchScriptProperty : 'default'
}
}
if (project.hasProperty('layered') && project.getProperty('layered')) {
layered()
}
}

Loading…
Cancel
Save