Add support for customizing layers in Gradle

This commit adds configuration to the Spring Boot Gradle plugin that
allows the names and contents of layers to be customized in the build
configuration.

Fixes gh-20296
This commit is contained in:
Scott Frederick
2020-03-10 16:09:44 -07:00
parent c4a55a5fb4
commit f2dadf5a87
23 changed files with 643 additions and 59 deletions

View File

@@ -264,7 +264,7 @@ include::../gradle/packaging/boot-war-properties-launcher.gradle.kts[tags=proper
[[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.
By default, the `bootJar` task 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:
@@ -280,12 +280,16 @@ include::../gradle/packaging/boot-jar-layered.gradle[tags=layered]
include::../gradle/packaging/boot-jar-layered.gradle.kts[tags=layered]
----
The jar will then be split into layer folders which may include:
By default, the following layers are created:
* `application`
* `resources`
* `snapshots-dependencies`
* `dependencies`
* `dependencies` for any dependency whose version does not contain `SNAPSHOT`.
* `snapshot-dependencies` for any dependency whose version contains `SNAPSHOT`.
* `resources` for static resources at the default locations, i.e. `META-INF/resources/`, `resources/`, `static/`, `public/`.
* `application` for any other classes and resources.
The layers order is important as it determines how likely previous layers can be cached when part of the application changes.
The default order is `dependencies`, `snapshot-dependencies`, `resources`, and `application`.
Content that is least likely to change should be added first, followed by layers that are more likely to change.
When you create a layered jar, the `spring-boot-layertools` jar will be added as a dependency to your jar.
With this jar on the classpath, you can launch your application in a special mode which allows the bootstrap code to run something entirely different from your application, for example, something that extracts the layers.
@@ -301,4 +305,33 @@ include::../gradle/packaging/boot-jar-layered-exclude-tools.gradle[tags=layered]
.Kotlin
----
include::../gradle/packaging/boot-jar-layered-exclude-tools.gradle.kts[tags=layered]
----
----
[[packaging-layers-configuration]]
===== Custom Layers configuration
Depending on your application, you may want to tune how layers are created and add new ones.
This can be done using configuration that lists the layers and their order as well as the strategies to apply to libraries and classes.
The following example shows what the implicit layer configuration described above does:
[source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
.Groovy
----
include::../gradle/packaging/boot-jar-layered-custom.gradle[tags=layered]
----
[source,kotlin,indent=0,subs="verbatim,attributes",role="secondary"]
.Kotlin
----
include::../gradle/packaging/boot-jar-layered-custom.gradle.kts[tags=layered]
----
Each `layerContent` closure defines a strategy to include or exclude an entry of the jar in a layer.
When an entry matches a strategy, it is included in the layer and further strategies are ignored.
This is illustrated by the `dependencies` and `application` layers that have a "catch-all" include filter used to add any libraries or classes that were not processed by previous strategies.
The content of a `libraries` layer can be customized using filters to `include` or `exclude` based on the dependency coordinates.
The format is `groupId:artifactId[:version]`.
In the example above, any artifact whose version ends with `SNAPSHOT` is going to be included in the `snapshot-dependencies` layer.
The content of a `classes` layer can be customized using filters to `included` or `exclude` based on location of the entry using Ant-style pattern matching.

View File

@@ -0,0 +1,41 @@
plugins {
id 'java'
id 'org.springframework.boot' version '{version}'
}
bootJar {
mainClassName 'com.example.ExampleApplication'
}
// tag::layered[]
bootJar {
layers {
layers "dependencies", "snapshot-dependencies", "resources", "application"
libraries {
layerContent("snapshot-dependencies") {
coordinates {
include "*:*:*SNAPSHOT"
}
}
layerContent("dependencies") {
coordinates {
include "*:*"
}
}
}
classes {
layerContent("resources") {
locations {
include "META-INF/resources/**", "resources/**"
include "static/**", "public/**"
}
}
layerContent("application") {
locations {
include "**"
}
}
}
}
}
// end::layered[]

View File

@@ -0,0 +1,40 @@
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
java
id("org.springframework.boot") version "{version}"
}
// tag::layered[]
tasks.getByName<BootJar>("bootJar") {
layers {
includeLayerTools = false
layers("dependencies", "snapshot-dependencies", "resources", "application")
libraries {
layerContent("snapshot-dependencies") {
coordinates {
include("*:*:*SNAPSHOT")
}
}
layerContent("dependencies") {
coordinates {
include("*:*")
}
}
}
classes {
layerContent("resources") {
locations {
include("META-INF/resources/**", "resources/**")
include("static/**", "public/**")
}
}
layerContent("application") {
locations {
include("**")
}
}
}
}
}
// end::layered[]

View File

@@ -9,7 +9,7 @@ bootJar {
// tag::layered[]
bootJar {
layered {
layers {
includeLayerTools = false
}
}

View File

@@ -11,7 +11,7 @@ tasks.getByName<BootJar>("bootJar") {
// tag::layered[]
tasks.getByName<BootJar>("bootJar") {
layered {
layers {
includeLayerTools = false
}
}

View File

@@ -9,6 +9,6 @@ bootJar {
// tag::layered[]
bootJar {
layered()
layers()
}
// end::layered[]

View File

@@ -11,6 +11,6 @@ tasks.getByName<BootJar>("bootJar") {
// tag::layered[]
tasks.getByName<BootJar>("bootJar") {
layered()
layers()
}
// end::layered[]

View File

@@ -27,6 +27,7 @@ import org.gradle.api.Action;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.Task;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.file.FileCollection;
import org.gradle.api.internal.artifacts.publish.ArchivePublishArtifact;
import org.gradle.api.plugins.ApplicationPlugin;
@@ -87,11 +88,12 @@ final class JavaPluginAction implements PluginApplicationAction {
bootJar.setDescription(
"Assembles an executable jar archive containing the main classes and their dependencies.");
bootJar.setGroup(BasePlugin.BUILD_GROUP);
bootJar.classpath((Callable<FileCollection>) () -> {
SourceSet mainSourceSet = javaPluginConvention(project).getSourceSets()
.getByName(SourceSet.MAIN_SOURCE_SET_NAME);
return mainSourceSet.getRuntimeClasspath();
});
SourceSet mainSourceSet = javaPluginConvention(project).getSourceSets()
.getByName(SourceSet.MAIN_SOURCE_SET_NAME);
bootJar.classpath((Callable<FileCollection>) () -> mainSourceSet.getRuntimeClasspath());
Configuration runtimeClasspathConfiguration = project.getConfigurations()
.getByName(mainSourceSet.getRuntimeClasspathConfigurationName());
runtimeClasspathConfiguration.getIncoming().afterResolve(bootJar::resolvedDependencies);
bootJar.conventionMapping("mainClassName", new MainClassConvention(project, bootJar::getClasspath));
return bootJar;
}

View File

@@ -24,13 +24,18 @@ import java.io.InputStream;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import org.gradle.api.Action;
import org.gradle.api.artifacts.ArtifactCollection;
import org.gradle.api.artifacts.ResolvableDependencies;
import org.gradle.api.artifacts.result.ResolvedArtifactResult;
import org.gradle.api.file.CopySpec;
import org.gradle.api.file.FileCollection;
import org.gradle.api.file.FileCopyDetails;
@@ -47,6 +52,8 @@ 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;
import org.springframework.boot.loader.tools.LibraryCoordinates;
import org.springframework.boot.loader.tools.layer.CustomLayers;
import org.springframework.util.FileCopyUtils;
/**
@@ -54,6 +61,7 @@ import org.springframework.util.FileCopyUtils;
*
* @author Andy Wilkinson
* @author Madhura Bhave
* @author Scott Frederick
* @since 2.0.0
*/
public class BootJar extends Jar implements BootArchive {
@@ -75,6 +83,8 @@ public class BootJar extends Jar implements BootArchive {
private final List<String> dependencies = new ArrayList<>();
private final Map<String, String> coordinatesByFileName = new HashMap<>();
/**
* Creates a new {@code BootJar} task.
*/
@@ -177,12 +187,37 @@ public class BootJar extends Jar implements BootArchive {
/**
* Configures the archive to have layers.
*/
public void layered() {
public void layers() {
enableLayers();
applyLayers();
}
public void layers(Action<LayerConfiguration> action) {
action.execute(enableLayers());
}
private LayerConfiguration enableLayers() {
if (this.layerConfiguration == null) {
this.layerConfiguration = new LayerConfiguration();
}
return this.layerConfiguration;
}
private void applyLayers() {
if (this.layerConfiguration == null) {
return;
}
if (this.layerConfiguration.getLayers() == null || this.layerConfiguration.getLayers().isEmpty()) {
this.layers = Layers.IMPLICIT;
}
else {
List<Layer> customLayers = this.layerConfiguration.getLayers().stream().map(Layer::new)
.collect(Collectors.toList());
this.layers = new CustomLayers(customLayers, this.layerConfiguration.getClasses(),
this.layerConfiguration.getLibraries());
}
if (this.layerConfiguration.isIncludeLayerTools()) {
this.bootInf.into("lib", (spec) -> spec.from((Callable<File>) () -> {
String jarName = "spring-boot-jarmode-layertools.jar";
@@ -204,24 +239,13 @@ public class BootJar extends Jar implements BootArchive {
this.bootInf.into("", (spec) -> spec.from(createLayersIndex()));
}
public void layered(Action<LayerConfiguration> action) {
action.execute(enableLayers());
applyLayers();
}
private LayerConfiguration enableLayers() {
this.layers = Layers.IMPLICIT;
if (this.layerConfiguration == null) {
this.layerConfiguration = new LayerConfiguration();
}
return this.layerConfiguration;
}
private Layer layerForFileDetails(FileCopyDetails details) {
String path = details.getPath();
if (path.startsWith("BOOT-INF/lib/")) {
return this.layers.getLayer(new Library(details.getFile(), null));
String coordinates = this.coordinatesByFileName.get(details.getName());
LibraryCoordinates libraryCoordinates = (coordinates != null) ? new LibraryCoordinates(coordinates)
: new LibraryCoordinates("?:?:?");
return this.layers.getLayer(new Library(null, details.getFile(), null, false, libraryCoordinates));
}
if (path.startsWith("BOOT-INF/classes/")) {
return this.layers.getLayer(details.getSourcePath());
@@ -248,9 +272,16 @@ public class BootJar extends Jar implements BootArchive {
}
}
private void resolveCoordinatesForFiles(ResolvableDependencies resolvableDependencies) {
ArtifactCollection resolvedArtifactResults = resolvableDependencies.getArtifacts();
Set<ResolvedArtifactResult> artifacts = resolvedArtifactResults.getArtifacts();
artifacts.forEach((artifact) -> this.coordinatesByFileName.put(artifact.getFile().getName(),
artifact.getId().getComponentIdentifier().getDisplayName()));
}
@Input
boolean isLayered() {
return this.layers != null;
return this.layerConfiguration != null;
}
@Override
@@ -285,6 +316,13 @@ public class BootJar extends Jar implements BootArchive {
this.support.setExcludeDevtools(excludeDevtools);
}
public void resolvedDependencies(ResolvableDependencies resolvableDependencies) {
if (resolvableDependencies != null) {
resolveCoordinatesForFiles(resolvableDependencies);
}
applyLayers();
}
/**
* Returns a {@code CopySpec} that can be used to add content to the {@code BOOT-INF}
* directory of the jar.

View File

@@ -16,18 +16,42 @@
package org.springframework.boot.gradle.tasks.bundling;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.gradle.api.Action;
import org.gradle.api.tasks.Input;
import org.springframework.boot.loader.tools.layer.classes.FilteredResourceStrategy;
import org.springframework.boot.loader.tools.layer.classes.LocationFilter;
import org.springframework.boot.loader.tools.layer.classes.ResourceFilter;
import org.springframework.boot.loader.tools.layer.classes.ResourceStrategy;
import org.springframework.boot.loader.tools.layer.library.CoordinateFilter;
import org.springframework.boot.loader.tools.layer.library.FilteredLibraryStrategy;
import org.springframework.boot.loader.tools.layer.library.LibraryFilter;
import org.springframework.boot.loader.tools.layer.library.LibraryStrategy;
import org.springframework.util.Assert;
/**
* Encapsulates the configuration for a layered jar.
*
* @author Madhura Bhave
* @author Scott Frederick
* @since 2.3.0
*/
public class LayerConfiguration {
private boolean includeLayerTools = true;
private List<String> layerNames = new ArrayList<>();
private List<ResourceStrategy> resourceStrategies = new ArrayList<>();
private List<LibraryStrategy> libraryStrategies = new ArrayList<>();
private StrategySpec strategySpec;
/**
* Whether to include the layer tools jar.
* @return true if layer tools is included
@@ -41,4 +65,164 @@ public class LayerConfiguration {
this.includeLayerTools = includeLayerTools;
}
@Input
public List<String> getLayers() {
return this.layerNames;
}
public void layers(String... layers) {
this.layerNames = Arrays.asList(layers);
}
public void layers(List<String> layers) {
this.layerNames = layers;
}
@Input
public List<ResourceStrategy> getClasses() {
return this.resourceStrategies;
}
public void classes(ResourceStrategy... resourceStrategies) {
this.resourceStrategies = Arrays.asList(resourceStrategies);
}
public void classes(Action<LayerConfiguration> config) {
this.strategySpec = StrategySpec.forResources();
config.execute(this);
}
@Input
public List<LibraryStrategy> getLibraries() {
return this.libraryStrategies;
}
public void libraries(LibraryStrategy... strategies) {
this.libraryStrategies = Arrays.asList(strategies);
}
public void libraries(Action<LayerConfiguration> configure) {
this.strategySpec = StrategySpec.forLibraries();
configure.execute(this);
}
public void layerContent(String layerName, Action<LayerConfiguration> config) {
this.strategySpec.newStrategy();
config.execute(this);
if (this.strategySpec.isLibrariesStrategy()) {
this.libraryStrategies.add(new FilteredLibraryStrategy(layerName, this.strategySpec.libraryFilters()));
}
else {
this.resourceStrategies.add(new FilteredResourceStrategy(layerName, this.strategySpec.resourceFilters()));
}
}
public void coordinates(Action<LayerConfiguration> config) {
Assert.state(this.strategySpec.isLibrariesStrategy(),
"The 'coordinates' filter must be used only with libraries");
this.strategySpec.newFilter();
config.execute(this);
this.strategySpec
.addLibraryFilter(new CoordinateFilter(this.strategySpec.includes(), this.strategySpec.excludes()));
}
public void locations(Action<LayerConfiguration> config) {
Assert.state(this.strategySpec.isResourcesStrategy(), "The 'locations' filter must be used only with classes");
this.strategySpec.newFilter();
config.execute(this);
this.strategySpec
.addResourceFilter(new LocationFilter(this.strategySpec.includes(), this.strategySpec.excludes()));
}
public void include(String... includes) {
this.strategySpec.include(includes);
}
public void exclude(String... excludes) {
this.strategySpec.exclude(excludes);
}
private static final class StrategySpec {
private enum TYPE {
LIBRARIES, RESOURCES;
}
private final TYPE type;
private List<LibraryFilter> libraryFilters;
private List<ResourceFilter> resourceFilters;
private List<String> filterIncludes;
private List<String> filterExcludes;
private StrategySpec(TYPE type) {
this.type = type;
}
private boolean isLibrariesStrategy() {
return this.type == TYPE.LIBRARIES;
}
private boolean isResourcesStrategy() {
return this.type == TYPE.RESOURCES;
}
private void newStrategy() {
this.libraryFilters = new ArrayList<>();
this.resourceFilters = new ArrayList<>();
newFilter();
}
private void newFilter() {
this.filterIncludes = new ArrayList<>();
this.filterExcludes = new ArrayList<>();
}
private List<LibraryFilter> libraryFilters() {
return this.libraryFilters;
}
private void addLibraryFilter(LibraryFilter filter) {
this.libraryFilters.add(filter);
}
private List<ResourceFilter> resourceFilters() {
return this.resourceFilters;
}
private void addResourceFilter(ResourceFilter filter) {
this.resourceFilters.add(filter);
}
private List<String> includes() {
return this.filterIncludes;
}
private void include(String... includes) {
this.filterIncludes.addAll(Arrays.asList(includes));
}
private void exclude(String... excludes) {
this.filterIncludes.addAll(Arrays.asList(excludes));
}
private List<String> excludes() {
return this.filterExcludes;
}
private static StrategySpec forLibraries() {
return new StrategySpec(TYPE.LIBRARIES);
}
private static StrategySpec forResources() {
return new StrategySpec(TYPE.RESOURCES);
}
}
}

View File

@@ -16,7 +16,14 @@
package org.springframework.boot.gradle.tasks.bundling;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.jar.JarFile;
import org.gradle.testkit.runner.InvalidRunnerConfigurationException;
import org.gradle.testkit.runner.TaskOutcome;
@@ -38,8 +45,7 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
}
@TestTemplate
void upToDateWhenBuiltTwiceWithLayers()
throws InvalidRunnerConfigurationException, UnexpectedBuildFailure, IOException {
void upToDateWhenBuiltTwiceWithLayers() throws InvalidRunnerConfigurationException, UnexpectedBuildFailure {
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
.isEqualTo(TaskOutcome.SUCCESS);
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
@@ -48,7 +54,7 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
@TestTemplate
void notUpToDateWhenBuiltWithoutLayersAndThenWithLayers()
throws InvalidRunnerConfigurationException, UnexpectedBuildFailure, IOException {
throws InvalidRunnerConfigurationException, UnexpectedBuildFailure {
assertThat(this.gradleBuild.build("bootJar").task(":bootJar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
.isEqualTo(TaskOutcome.SUCCESS);
@@ -56,11 +62,76 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
@TestTemplate
void notUpToDateWhenBuiltWithLayersAndToolsAndThenWithLayersAndWithoutTools()
throws InvalidRunnerConfigurationException, UnexpectedBuildFailure, IOException {
throws InvalidRunnerConfigurationException, UnexpectedBuildFailure {
assertThat(this.gradleBuild.build("-Playered=true", "bootJar").task(":bootJar").getOutcome())
.isEqualTo(TaskOutcome.SUCCESS);
assertThat(this.gradleBuild.build("-Playered=true", "-PexcludeTools=true", "bootJar").task(":bootJar")
.getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
}
@TestTemplate
void implicitLayers() throws IOException {
writeMainClass();
writeResource();
assertThat(this.gradleBuild.build("bootJar").task(":bootJar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
try (JarFile jarFile = new JarFile(new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0])) {
assertThat(jarFile.getEntry("BOOT-INF/layers/dependencies/lib/spring-boot-jarmode-layertools.jar"))
.isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/layers/dependencies/lib/commons-lang3-3.9.jar")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/layers/snapshot-dependencies/lib/commons-io-2.7-SNAPSHOT.jar"))
.isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/layers/application/classes/example/Main.class")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/layers/resources/classes/static/file.txt")).isNotNull();
}
}
@TestTemplate
void customLayers() throws IOException {
writeMainClass();
writeResource();
assertThat(this.gradleBuild.build("bootJar").task(":bootJar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
try (JarFile jarFile = new JarFile(new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0])) {
assertThat(jarFile.getEntry("BOOT-INF/layers/dependencies/lib/spring-boot-jarmode-layertools.jar"))
.isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/layers/commons-dependencies/lib/commons-lang3-3.9.jar")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/layers/snapshot-dependencies/lib/commons-io-2.7-SNAPSHOT.jar"))
.isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/layers/app/classes/example/Main.class")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/layers/static/classes/static/file.txt")).isNotNull();
}
}
private void writeMainClass() {
File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example");
examplePackage.mkdirs();
File main = new File(examplePackage, "Main.java");
try (PrintWriter writer = new PrintWriter(new FileWriter(main))) {
writer.println("package example;");
writer.println();
writer.println("import java.io.IOException;");
writer.println();
writer.println("public class Main {");
writer.println();
writer.println(" public static void main(String[] args) {");
writer.println(" }");
writer.println();
writer.println("}");
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
}
private void writeResource() {
try {
Path path = this.gradleBuild.getProjectDir().toPath()
.resolve(Paths.get("src", "main", "resources", "static", "file.txt"));
Files.createDirectories(path.getParent());
Files.createFile(path);
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
}
}

View File

@@ -20,21 +20,38 @@ import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import org.gradle.api.Action;
import org.gradle.api.artifacts.ArtifactCollection;
import org.gradle.api.artifacts.ResolvableDependencies;
import org.gradle.api.artifacts.component.ComponentArtifactIdentifier;
import org.gradle.api.artifacts.component.ComponentIdentifier;
import org.gradle.api.artifacts.result.ResolvedArtifactResult;
import org.junit.jupiter.api.Test;
import org.springframework.boot.loader.tools.layer.classes.FilteredResourceStrategy;
import org.springframework.boot.loader.tools.layer.classes.LocationFilter;
import org.springframework.boot.loader.tools.layer.library.CoordinateFilter;
import org.springframework.boot.loader.tools.layer.library.FilteredLibraryStrategy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link BootJar}.
*
* @author Andy Wilkinson
* @author Madhura Bhave
* @author Scott Frederick
*/
class BootJarTests extends AbstractBootArchiveTests<BootJar> {
@@ -102,6 +119,36 @@ class BootJarTests extends AbstractBootArchiveTests<BootJar> {
.contains("BOOT-INF/layers/resources/classes/static/test.css");
}
@Test
void whenJarIsLayeredWithCustomStrategiesThenContentsAreMovedToLayerDirectories() throws IOException {
File jar = createLayeredJar((configuration) -> {
configuration.layers("my-deps", "my-internal-deps", "my-snapshot-deps", "resources", "application");
configuration.libraries(createLibraryStrategy("my-snapshot-deps", "com.example:*:*.SNAPSHOT"),
createLibraryStrategy("my-internal-deps", "com.example:*:*"),
createLibraryStrategy("my-deps", "*:*"));
configuration.classes(createResourceStrategy("resources", "static/**"),
createResourceStrategy("application", "**"));
});
List<String> entryNames = getEntryNames(jar);
assertThat(entryNames)
.containsSubsequence("BOOT-INF/layers/my-internal-deps/lib/first-library.jar",
"BOOT-INF/layers/my-internal-deps/lib/second-library.jar")
.contains("BOOT-INF/layers/my-snapshot-deps/lib/third-library-SNAPSHOT.jar")
.containsSubsequence("BOOT-INF/layers/application/classes/com/example/Application.class",
"BOOT-INF/layers/application/classes/application.properties")
.contains("BOOT-INF/layers/resources/classes/static/test.css");
}
private FilteredLibraryStrategy createLibraryStrategy(String layerName, String... includes) {
return new FilteredLibraryStrategy(layerName,
Collections.singletonList(new CoordinateFilter(Arrays.asList(includes), Collections.emptyList())));
}
private FilteredResourceStrategy createResourceStrategy(String layerName, String... includes) {
return new FilteredResourceStrategy(layerName,
Collections.singletonList(new LocationFilter(Arrays.asList(includes), Collections.emptyList())));
}
@Test
void whenJarIsLayeredJarsInLibAreStored() throws IOException {
try (JarFile jarFile = new JarFile(createLayeredJar())) {
@@ -154,13 +201,13 @@ class BootJarTests extends AbstractBootArchiveTests<BootJar> {
}
private File createLayeredJar(Action<LayerConfiguration> action) throws IOException {
addContent();
if (action != null) {
getTask().layered(action);
getTask().layers(action);
}
else {
getTask().layered();
getTask().layers();
}
addContent();
executeTask();
return getTask().getArchiveFile().get().getAsFile();
}
@@ -186,6 +233,33 @@ class BootJarTests extends AbstractBootArchiveTests<BootJar> {
css.createNewFile();
bootJar.classpath(classesJavaMain, resourcesMain, jarFile("first-library.jar"), jarFile("second-library.jar"),
jarFile("third-library-SNAPSHOT.jar"));
Set<ResolvedArtifactResult> resolvedArtifacts = new HashSet<>();
resolvedArtifacts.add(mockLibraryArtifact("first-library.jar", "com.example:first-library:1.0.0"));
resolvedArtifacts.add(mockLibraryArtifact("second-library.jar", "com.example:second-library:1.0.0"));
resolvedArtifacts
.add(mockLibraryArtifact("third-library-SNAPSHOT.jar", "com.example:third-library:1.0.0.SNAPSHOT"));
ArtifactCollection artifacts = mock(ArtifactCollection.class);
given(artifacts.getArtifacts()).willReturn(resolvedArtifacts);
ResolvableDependencies deps = mock(ResolvableDependencies.class);
given(deps.getArtifacts()).willReturn(artifacts);
bootJar.resolvedDependencies(deps);
}
private ResolvedArtifactResult mockLibraryArtifact(String fileName, String coordinates) {
ComponentIdentifier libraryId = mock(ComponentIdentifier.class);
given(libraryId.getDisplayName()).willReturn(coordinates);
ComponentArtifactIdentifier libraryArtifactId = mock(ComponentArtifactIdentifier.class);
given(libraryArtifactId.getComponentIdentifier()).willReturn(libraryId);
ResolvedArtifactResult libraryArtifact = mock(ResolvedArtifactResult.class);
given(libraryArtifact.getFile()).willReturn(new File(fileName));
given(libraryArtifact.getId()).willReturn(libraryArtifactId);
return libraryArtifact;
}
private List<String> entryLines(JarFile jarFile, String entryName) throws IOException {

View File

@@ -0,0 +1,39 @@
plugins {
id 'java'
id 'org.springframework.boot' version '{version}'
}
bootJar {
mainClassName = 'com.example.Application'
layers {
layers "dependencies", "commons-dependencies", "snapshot-dependencies", "static", "app"
libraries {
layerContent("snapshot-dependencies") { coordinates { include "*:*:*SNAPSHOT" } }
layerContent("commons-dependencies") { coordinates { include "org.apache.commons:*" } }
layerContent("dependencies") { coordinates { include "*:*" } }
}
classes {
layerContent("static") {
locations {
include "META-INF/resources/**", "resources/**"
include "static/**", "public/**"
}
}
layerContent("app") {
locations {
include "**"
}
}
}
}
}
repositories {
mavenCentral()
maven { url "https://repository.apache.org/content/repositories/snapshots" }
}
dependencies {
implementation("org.apache.commons:commons-lang3:3.9")
implementation("commons-io:commons-io:2.7-SNAPSHOT")
}

View File

@@ -0,0 +1,19 @@
plugins {
id 'java'
id 'org.springframework.boot' version '{version}'
}
bootJar {
mainClassName = 'com.example.Application'
layers()
}
repositories {
mavenCentral()
maven { url "https://repository.apache.org/content/repositories/snapshots" }
}
dependencies {
implementation("org.apache.commons:commons-lang3:3.9")
implementation("commons-io:commons-io:2.7-SNAPSHOT")
}

View File

@@ -11,7 +11,7 @@ bootJar {
}
}
if (project.hasProperty('layered') && project.getProperty('layered')) {
layered {
layers {
includeLayerTools = project.hasProperty('excludeTools') && project.getProperty('excludeTools') ? false : true
}
}

View File

@@ -16,6 +16,7 @@
package org.springframework.boot.loader.tools;
import java.io.Serializable;
import java.util.regex.Pattern;
import org.springframework.util.Assert;
@@ -28,7 +29,7 @@ import org.springframework.util.Assert;
* @since 2.3.0
* @see Layers
*/
public class Layer {
public class Layer implements Serializable {
private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9-]+$");

View File

@@ -57,6 +57,7 @@ public class CustomLayers implements Layers {
for (ResourceStrategy strategy : this.resourceStrategies) {
Layer matchingLayer = strategy.getMatchingLayer(resourceName);
if (matchingLayer != null) {
validateLayerName(matchingLayer, "Resource '" + resourceName + "'");
return matchingLayer;
}
}
@@ -68,10 +69,18 @@ public class CustomLayers implements Layers {
for (LibraryStrategy strategy : this.libraryStrategies) {
Layer matchingLayer = strategy.getMatchingLayer(library);
if (matchingLayer != null) {
validateLayerName(matchingLayer, "Library '" + library.getName() + "'");
return matchingLayer;
}
}
throw new IllegalStateException("Library '" + library.getName() + "' did not match any layer.");
}
private void validateLayerName(Layer layer, String nameText) {
if (!this.layers.contains(layer)) {
throw new IllegalStateException(nameText + " matched a layer '" + layer
+ "' that is not included in the configured layers " + this.layers + ".");
}
}
}

View File

@@ -16,13 +16,15 @@
package org.springframework.boot.loader.tools.layer.classes;
import java.io.Serializable;
/**
* A filter that can tell if a resource has been included or excluded.
*
* @author Madhura Bhave
* @since 2.3.0
*/
public interface ResourceFilter {
public interface ResourceFilter extends Serializable {
/**
* Return true if the resource is included by the filter.

View File

@@ -16,6 +16,8 @@
package org.springframework.boot.loader.tools.layer.classes;
import java.io.Serializable;
import org.springframework.boot.loader.tools.Layer;
/**
@@ -24,7 +26,7 @@ import org.springframework.boot.loader.tools.Layer;
* @author Madhura Bhave
* @since 2.3.0
*/
public interface ResourceStrategy {
public interface ResourceStrategy extends Serializable {
/**
* Return a {@link Layer} for the given resource. If no matching layer is found,

View File

@@ -16,6 +16,8 @@
package org.springframework.boot.loader.tools.layer.library;
import java.io.Serializable;
import org.springframework.boot.loader.tools.Library;
/**
@@ -24,7 +26,7 @@ import org.springframework.boot.loader.tools.Library;
* @author Madhura Bhave
* @since 2.3.0
*/
public interface LibraryFilter {
public interface LibraryFilter extends Serializable {
/**
* Return true if the {@link Library} is included by the filter.

View File

@@ -16,6 +16,8 @@
package org.springframework.boot.loader.tools.layer.library;
import java.io.Serializable;
import org.springframework.boot.loader.tools.Layer;
import org.springframework.boot.loader.tools.Library;
@@ -25,7 +27,7 @@ import org.springframework.boot.loader.tools.Library;
* @author Madhura Bhave
* @since 2.3.0
*/
public interface LibraryStrategy {
public interface LibraryStrategy extends Serializable {
/**
* Return a {@link Layer} for the given {@link Library}. If no matching layer is

View File

@@ -73,6 +73,18 @@ class CustomLayersTests {
assertThatIllegalStateException().isThrownBy(() -> customLayers.getLayer("com/acme"));
}
@Test
void layerForResourceIsNotInListedLayers() {
FilteredResourceStrategy resourceStrategy = new FilteredResourceStrategy("test-not-listed", Collections
.singletonList(new LocationFilter(Collections.singletonList("META-INF/**"), Collections.emptyList())));
Layer targetLayer = new Layer("test");
CustomLayers customLayers = new CustomLayers(Collections.singletonList(targetLayer),
Collections.singletonList(resourceStrategy), Collections.emptyList());
assertThatIllegalStateException().isThrownBy(() -> customLayers.getLayer("META-INF/manifest.mf"))
.withMessageContaining("META-INF/manifest.mf").withMessageContaining("test-not-listed")
.withMessageContaining("[test]");
}
@Test
void layerForLibraryIsFound() {
FilteredLibraryStrategy libraryStrategy = new FilteredLibraryStrategy("test", Collections
@@ -92,9 +104,22 @@ class CustomLayersTests {
assertThatIllegalStateException().isThrownBy(() -> customLayers.getLayer(mockLibrary("org.another:test")));
}
@Test
void layerForLibraryIsNotInListedLayers() {
FilteredLibraryStrategy libraryStrategy = new FilteredLibraryStrategy("test-not-listed", Collections
.singletonList(new CoordinateFilter(Collections.singletonList("com.acme:*"), Collections.emptyList())));
Layer targetLayer = new Layer("test");
CustomLayers customLayers = new CustomLayers(Collections.singletonList(targetLayer), Collections.emptyList(),
Collections.singletonList(libraryStrategy));
assertThatIllegalStateException().isThrownBy(() -> customLayers.getLayer(mockLibrary("com.acme:test")))
.withMessageContaining("com.acme:test").withMessageContaining("test-not-listed")
.withMessageContaining("[test]");
}
private Library mockLibrary(String coordinates) {
Library library = mock(Library.class);
given(library.getCoordinates()).willReturn(new LibraryCoordinates(coordinates));
given(library.getName()).willReturn(coordinates);
return library;
}

View File

@@ -100,14 +100,14 @@ To use this feature, the layering feature must be enabled:
By default, the following layers are created:
* `application` for any other classes and resources.
* `resources` for static resources at the default locations, i.e. `META-INF/resources/`, `resources/`, `static/`, `public/`.
* `dependencies` for any dependency whose version does not contain `SNAPSHOT`.
* `snapshot-dependencies` for any dependency whose version contains `SNAPSHOT`.
* `dependencies` for any other dependency.
* `resources` for static resources at the default locations, i.e. `META-INF/resources/`, `resources/`, `static/`, `public/`.
* `application` for any other classes and resources.
The layers order is important as it determines how likely previous layers can be cached when part of the application changes.
The default order is `application`, `resources`, `snapshot-dependencies` and `dependencies`.
Content that is likely to change should be added first, followed by layers that are less likely to change.
The default order is `dependencies`, `snapshot-dependencies`, `resources`, and `application`.
Content that is least likely to change should be added first, followed by layers that are more likely to change.
@@ -147,11 +147,11 @@ The following example shows what the implicit layer configuration described abov
xsi:schemaLocation="http://www.springframework.org/schema/boot/layers
https://www.springframework.org/schema/boot/layers/layers-configuration.xsd">
<layers>
<layer>application</layer>
<layer>resources</layer>
<layer>snapshots</layer>
<layer>dependencies</layer>
</layers>
<layer>snapshot-dependencies</layer>
<layer>resources</layer>
<layer>application</layer>
</layers>
<libraries>
<layer-content layer="snapshot-dependencies">
<coordinates>
@@ -182,15 +182,15 @@ The following example shows what the implicit layer configuration described abov
</layers-configuration>
----
Each `layer-content` element defines a strategy to include an entry of the jar in a layer.
Each `layer-content` element defines a strategy to include or exclude an entry of the jar in a layer.
When an entry matches a strategy, it is included in the layer and further strategies are ignored.
This is illustrated by the `dependencies` and `application` layers that have a "catch-all" include filter used to add any libraries or classes that were not processed by previous strategies.
The content of a libraries layer can be customized using filters on the coordinates.
The content of a `libraries` layer can be customized using filters to `include` or `exclude` based on the dependency coordinates.
The format is `groupId:artifactId[:version]`.
In the example above, any artifact whose version ends with `SNAPSHOT` is going to be included in the `snapshot-dependencies` layer.
The content of a classes layer can be customized using filters on location of the entry using Ant-style pattern matching.
The content of a `classes` layer can be customized using filters to `include` or `exclude` based on location of the entry using Ant-style pattern matching.