From df5b0f1163104c89426317e89e3c3e668e072fe8 Mon Sep 17 00:00:00 2001 From: Madhura Bhave Date: Sat, 18 Jan 2020 10:57:12 -0800 Subject: [PATCH] Add support for layered jars in gradle plugin Closes gh-19792 Co-authored-by: Andy Wilkinson --- .../src/docs/asciidoc/packaging.adoc | 26 +++++++ .../gradle/packaging/boot-jar-layered.gradle | 14 ++++ .../packaging/boot-jar-layered.gradle.kts | 16 +++++ .../boot/gradle/tasks/bundling/BootJar.java | 72 ++++++++++++++++++- .../docs/PackagingDocumentationTests.java | 12 ++++ .../bundling/BootJarIntegrationTests.java | 28 +++++++- .../gradle/tasks/bundling/BootJarTests.java | 45 ++++++++++++ .../bundling/BootJarIntegrationTests.gradle | 3 + 8 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-jar-layered.gradle create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-jar-layered.gradle.kts diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging.adoc index 8a76e0a512..1d518c4d56 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging.adoc @@ -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` diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-jar-layered.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-jar-layered.gradle new file mode 100644 index 0000000000..502f87f4c6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-jar-layered.gradle @@ -0,0 +1,14 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +bootJar { + mainClassName 'com.example.ExampleApplication' +} + +// tag::layered[] +bootJar { + layered() +} +// end::layered[] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-jar-layered.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-jar-layered.gradle.kts new file mode 100644 index 0000000000..2d6735740a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-jar-layered.gradle.kts @@ -0,0 +1,16 @@ +import org.springframework.boot.gradle.tasks.bundling.BootJar + +plugins { + java + id("org.springframework.boot") version "{version}" +} + +tasks.getByName("bootJar") { + mainClassName = "com.example.ExampleApplication" +} + +// tag::layered[] +tasks.getByName("bootJar") { + layered() +} +// end::layered[] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java index 8f488ec50f..93a08521f9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java @@ -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; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java index f70506cc86..95d3db5247 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java @@ -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")); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java index 99342bd4da..86c1266989 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java @@ -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); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java index 76db1abb0b..e44a74aa59 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java @@ -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 { @@ -57,6 +60,48 @@ class BootJarTests extends AbstractBootArchiveTests { } } + @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 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(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.gradle index 1befd0db06..8d1ae66420 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.gradle @@ -10,4 +10,7 @@ bootJar { properties 'prop' : project.hasProperty('launchScriptProperty') ? launchScriptProperty : 'default' } } + if (project.hasProperty('layered') && project.getProperty('layered')) { + layered() + } }