Put project deps in app layer and make customization easier

Previously, when building a layered jar with Gradle, project
dependencies were treated the same as any other dependency, being
included in the dependencies or snapshot dependencies layer based
on their version.

This commit updates the default layering when using Gradle to include
project dependencies in the application layer by default. The DSL has
also been updated to allow their layer to be customized using new
includeProjectDependencies() and excludeProjectDependencies() methods
rather than relying on including and excluding them via a
group:artifact:version pattern.

Closes gh-23431
This commit is contained in:
Andy Wilkinson 2020-10-15 12:19:18 +01:00
parent ad6ea94112
commit 84f96033c5
11 changed files with 292 additions and 35 deletions

View File

@ -282,10 +282,10 @@ Layered jars use the same layout as regular boot packaged jars, but include an a
By default, the following layers are defined:
* `dependencies` for any dependency whose version does not contain `SNAPSHOT`.
* `dependencies` for any non-project dependency whose version does not contain `SNAPSHOT`.
* `spring-boot-loader` for the jar loader classes.
* `snapshot-dependencies` for any dependency whose version contains `SNAPSHOT`.
* `application` for application classes and resources.
* `snapshot-dependencies` for any non-project dependency whose version contains `SNAPSHOT`.
* `application` for project dependencies, application 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`, `spring-boot-loader`, `snapshot-dependencies`, `application`.
@ -355,13 +355,15 @@ Any content not claimed by an earlier `intoLayer` closure remains available for
The `intoLayer` closure claims content using nested `include` and `exclude` calls.
The `application` closure uses Ant-style patch matching for include/exclude parameters.
The `dependencies` section uses `group:artifact[:version]` patterns.
It also provides `includeProjectDependencies()` and `excludeProjectDependencies()` methods that can be used to include or exclude project dependencies.
If no `include` call is made, then all content (not claimed by an earlier closure) is considered.
If no `exclude` call is made, then no exclusions are applied.
Looking at the `dependencies` closure in the example above, we can see that the first `intoLayer` will claim all SNAPSHOT dependencies for the `snapshot-dependencies` layer.
The subsequent `intoLayer` will claim anything left (in this case, any dependency that is not a SNAPSHOT) for the `dependencies` layer.
Looking at the `dependencies` closure in the example above, we can see that the first `intoLayer` will claim all project dependencies for the `application` layer.
The next `intoLayer` will claim all SNAPSHOT dependencies for the `snapshot-dependencies` layer.
The third and final `intoLayer` will claim anything left (in this case, any dependency that is not a project dependency or a SNAPSHOT) for the `dependencies` layer.
The `application` closure has similar rules.
First claiming `org/springframework/boot/loader/**` content for the `spring-boot-loader` layer.

View File

@ -17,6 +17,9 @@ bootJar {
intoLayer("application")
}
dependencies {
intoLayer("application") {
includeProjectDependencies()
}
intoLayer("snapshot-dependencies") {
include "*:*:*SNAPSHOT"
}

View File

@ -21,9 +21,9 @@ import java.io.File;
import org.gradle.api.file.FileCopyDetails;
import org.gradle.api.specs.Spec;
import org.springframework.boot.gradle.tasks.bundling.ResolvedDependencies.DependencyDescriptor;
import org.springframework.boot.loader.tools.Layer;
import org.springframework.boot.loader.tools.Library;
import org.springframework.boot.loader.tools.LibraryCoordinates;
/**
* Resolver backed by a {@link LayeredSpec} that provides the destination {@link Layer}
@ -76,8 +76,10 @@ class LayerResolver {
private Library asLibrary(FileCopyDetails details) {
File file = details.getFile();
LibraryCoordinates coordinates = this.resolvedDependencies.find(file);
return new Library(null, file, null, coordinates, false);
DependencyDescriptor dependency = this.resolvedDependencies.find(file);
return (dependency != null)
? new Library(null, file, null, dependency.getCoordinates(), false, dependency.isProjectDependency())
: new Library(file, null);
}
}

View File

@ -227,16 +227,19 @@ public class LayeredSpec {
private final List<IntoLayerSpec> intoLayers;
private final Function<String, IntoLayerSpec> specFactory;
boolean isEmpty() {
return this.intoLayers.isEmpty();
}
IntoLayersSpec(IntoLayerSpec... spec) {
IntoLayersSpec(Function<String, IntoLayerSpec> specFactory, IntoLayerSpec... spec) {
this.intoLayers = new ArrayList<>(Arrays.asList(spec));
this.specFactory = specFactory;
}
public void intoLayer(String layer) {
this.intoLayers.add(new IntoLayerSpec(layer));
this.intoLayers.add(this.specFactory.apply(layer));
}
public void intoLayer(String layer, Closure<?> closure) {
@ -244,14 +247,13 @@ public class LayeredSpec {
}
public void intoLayer(String layer, Action<IntoLayerSpec> action) {
IntoLayerSpec spec = new IntoLayerSpec(layer);
IntoLayerSpec spec = this.specFactory.apply(layer);
action.execute(spec);
this.intoLayers.add(spec);
}
<T> List<ContentSelector<T>> asSelectors(Function<String, ContentFilter<T>> filterFactory) {
return this.intoLayers.stream().map((content) -> content.asSelector(filterFactory))
.collect(Collectors.toList());
<T> List<ContentSelector<T>> asSelectors(Function<IntoLayerSpec, ContentSelector<T>> selectorFactory) {
return this.intoLayers.stream().map(selectorFactory).collect(Collectors.toList());
}
}
@ -279,8 +281,8 @@ public class LayeredSpec {
/**
* Adds patterns that control the content that is included in the layer. If no
* includes are specified then all content is included. If includes are specified
* then content must match an inclusion pattern and not match any exclusion
* patterns to be included.
* then content must match an inclusion and not match any exclusions to be
* included.
* @param patterns the patterns to be included
*/
public void include(String... patterns) {
@ -291,7 +293,7 @@ public class LayeredSpec {
* Adds patterns that control the content that is excluded from the layer. If no
* excludes a specified no content is excluded. If exclusions are specified then
* any content that matches an exclusion will be excluded irrespective of whether
* it matches an include pattern.
* it matches an include.
* @param patterns the patterns to be excluded
*/
public void exclude(String... patterns) {
@ -303,6 +305,76 @@ public class LayeredSpec {
return new IncludeExcludeContentSelector<>(layer, this.includes, this.excludes, filterFactory);
}
String getIntoLayer() {
return this.intoLayer;
}
List<String> getIncludes() {
return this.includes;
}
List<String> getExcludes() {
return this.excludes;
}
}
/**
* Spec that controls the dependencies that should be part of a particular layer.
*
* @since 2.4.0
*/
public static class DependenciesIntoLayerSpec extends IntoLayerSpec {
private boolean includeProjectDependencies;
private boolean excludeProjectDependencies;
/**
* Creates a new {@code IntoLayerSpec} that will control the content of the given
* layer.
* @param intoLayer the layer
*/
public DependenciesIntoLayerSpec(String intoLayer) {
super(intoLayer);
}
/**
* Configures the layer to include project dependencies. If no includes are
* specified then all content is included. If includes are specified then content
* must match an inclusion and not match any exclusions to be included.
*/
public void includeProjectDependencies() {
this.includeProjectDependencies = true;
}
/**
* Configures the layer to exclude project dependencies. If no excludes a
* specified no content is excluded. If exclusions are specified then any content
* that matches an exclusion will be excluded irrespective of whether it matches
* an include.
*/
public void excludeProjectDependencies() {
this.excludeProjectDependencies = true;
}
ContentSelector<Library> asLibrarySelector(Function<String, ContentFilter<Library>> filterFactory) {
Layer layer = new Layer(getIntoLayer());
List<ContentFilter<Library>> includeFilters = getIncludes().stream().map(filterFactory)
.collect(Collectors.toList());
if (this.includeProjectDependencies) {
includeFilters = new ArrayList<>(includeFilters);
includeFilters.add(Library::isLocal);
}
List<ContentFilter<Library>> excludeFilters = getExcludes().stream().map(filterFactory)
.collect(Collectors.toList());
if (this.excludeProjectDependencies) {
excludeFilters = new ArrayList<>(includeFilters);
excludeFilters.add(Library::isLocal);
}
return new IncludeExcludeContentSelector<>(layer, includeFilters, excludeFilters);
}
}
/**
@ -317,11 +389,20 @@ public class LayeredSpec {
* included
*/
public ApplicationSpec(IntoLayerSpec... contents) {
super(contents);
super(new IntoLayerSpecFactory(), contents);
}
List<ContentSelector<String>> asSelectors() {
return asSelectors(ApplicationContentFilter::new);
return asSelectors((spec) -> spec.asSelector(ApplicationContentFilter::new));
}
private static final class IntoLayerSpecFactory implements Function<String, IntoLayerSpec>, Serializable {
@Override
public IntoLayerSpec apply(String layer) {
return new IntoLayerSpec(layer);
}
}
}
@ -329,18 +410,28 @@ public class LayeredSpec {
/**
* An {@link IntoLayersSpec} that controls the layers to which dependencies belong.
*/
public static class DependenciesSpec extends IntoLayersSpec {
public static class DependenciesSpec extends IntoLayersSpec implements Serializable {
/**
* Creates a new {@code DependenciesSpec} with the given {@code contents}.
* @param contents specs for the layers in which dependencies should be included
*/
public DependenciesSpec(IntoLayerSpec... contents) {
super(contents);
public DependenciesSpec(DependenciesIntoLayerSpec... contents) {
super(new IntoLayerSpecFactory(), contents);
}
List<ContentSelector<Library>> asSelectors() {
return asSelectors(LibraryContentFilter::new);
return asSelectors(
(spec) -> ((DependenciesIntoLayerSpec) spec).asLibrarySelector(LibraryContentFilter::new));
}
private static final class IntoLayerSpecFactory implements Function<String, IntoLayerSpec>, Serializable {
@Override
public IntoLayerSpec apply(String layer) {
return new DependenciesIntoLayerSpec(layer);
}
}
}

View File

@ -19,9 +19,12 @@ package org.springframework.boot.gradle.tasks.bundling;
import java.io.File;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.ModuleVersionIdentifier;
import org.gradle.api.artifacts.ProjectDependency;
import org.gradle.api.artifacts.ResolvedArtifact;
import org.gradle.api.artifacts.ResolvedConfiguration;
@ -42,15 +45,19 @@ class ResolvedDependencies {
private final Map<Configuration, ResolvedConfigurationDependencies> configurationDependencies = new LinkedHashMap<>();
void processConfiguration(Configuration configuration) {
Set<String> projectDependencyIds = configuration.getAllDependencies().withType(ProjectDependency.class).stream()
.map((projectDependency) -> projectDependency.getGroup() + ":" + projectDependency.getName() + ":"
+ projectDependency.getVersion())
.collect(Collectors.toSet());
this.configurationDependencies.put(configuration,
new ResolvedConfigurationDependencies(configuration.getResolvedConfiguration()));
new ResolvedConfigurationDependencies(projectDependencyIds, configuration.getResolvedConfiguration()));
}
LibraryCoordinates find(File file) {
DependencyDescriptor find(File file) {
for (ResolvedConfigurationDependencies dependencies : this.configurationDependencies.values()) {
LibraryCoordinates coordinates = dependencies.find(file);
if (coordinates != null) {
return coordinates;
DependencyDescriptor dependency = dependencies.find(file);
if (dependency != null) {
return dependency;
}
}
return null;
@ -61,19 +68,23 @@ class ResolvedDependencies {
*/
private static class ResolvedConfigurationDependencies {
private final Map<File, LibraryCoordinates> artifactCoordinates = new LinkedHashMap<>();
private final Map<File, DependencyDescriptor> dependencies = new LinkedHashMap<>();
ResolvedConfigurationDependencies(ResolvedConfiguration resolvedConfiguration) {
ResolvedConfigurationDependencies(Set<String> projectDependencyIds,
ResolvedConfiguration resolvedConfiguration) {
if (!resolvedConfiguration.hasError()) {
for (ResolvedArtifact resolvedArtifact : resolvedConfiguration.getResolvedArtifacts()) {
this.artifactCoordinates.put(resolvedArtifact.getFile(),
new ModuleVersionIdentifierLibraryCoordinates(resolvedArtifact.getModuleVersion().getId()));
ModuleVersionIdentifier id = resolvedArtifact.getModuleVersion().getId();
boolean projectDependency = projectDependencyIds
.contains(id.getGroup() + ":" + id.getName() + ":" + id.getVersion());
this.dependencies.put(resolvedArtifact.getFile(), new DependencyDescriptor(
new ModuleVersionIdentifierLibraryCoordinates(id), projectDependency));
}
}
}
LibraryCoordinates find(File file) {
return this.artifactCoordinates.get(file);
DependencyDescriptor find(File file) {
return this.dependencies.get(file);
}
}
@ -111,4 +122,28 @@ class ResolvedDependencies {
}
/**
* Describes a dependency in a {@link ResolvedConfiguration}.
*/
static final class DependencyDescriptor {
private final LibraryCoordinates coordinates;
private final boolean projectDependency;
private DependencyDescriptor(LibraryCoordinates coordinates, boolean projectDependency) {
this.coordinates = coordinates;
this.projectDependency = projectDependency;
}
LibraryCoordinates getCoordinates() {
return this.coordinates;
}
boolean isProjectDependency() {
return this.projectDependency;
}
}
}

View File

@ -140,6 +140,50 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
assertExtractedLayers(layerNames, indexedLayers);
}
@TestTemplate
void multiModuleImplicitLayers() throws IOException {
writeSettingsGradle();
writeMainClass();
writeResource();
assertThat(this.gradleBuild.build("bootJar").task(":bootJar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
Map<String, List<String>> indexedLayers;
String layerToolsJar = "BOOT-INF/lib/" + JarModeLibrary.LAYER_TOOLS.getName();
try (JarFile jarFile = new JarFile(new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0])) {
assertThat(jarFile.getEntry(layerToolsJar)).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/lib/alpha-1.2.3.jar")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/lib/bravo-1.2.3.jar")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/lib/commons-lang3-3.9.jar")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/lib/spring-core-5.2.5.RELEASE.jar")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/lib/spring-jcl-5.2.5.RELEASE.jar")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/lib/library-1.0-SNAPSHOT.jar")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/classes/example/Main.class")).isNotNull();
assertThat(jarFile.getEntry("BOOT-INF/classes/static/file.txt")).isNotNull();
indexedLayers = readLayerIndex(jarFile);
}
List<String> layerNames = Arrays.asList("dependencies", "spring-boot-loader", "snapshot-dependencies",
"application");
assertThat(indexedLayers.keySet()).containsExactlyElementsOf(layerNames);
Set<String> expectedDependencies = new TreeSet<>();
expectedDependencies.add("BOOT-INF/lib/commons-lang3-3.9.jar");
expectedDependencies.add("BOOT-INF/lib/spring-core-5.2.5.RELEASE.jar");
expectedDependencies.add("BOOT-INF/lib/spring-jcl-5.2.5.RELEASE.jar");
Set<String> expectedSnapshotDependencies = new TreeSet<>();
expectedSnapshotDependencies.add("BOOT-INF/lib/library-1.0-SNAPSHOT.jar");
(layerToolsJar.contains("SNAPSHOT") ? expectedSnapshotDependencies : expectedDependencies).add(layerToolsJar);
assertThat(indexedLayers.get("dependencies")).containsExactlyElementsOf(expectedDependencies);
assertThat(indexedLayers.get("spring-boot-loader")).containsExactly("org/");
assertThat(indexedLayers.get("snapshot-dependencies")).containsExactlyElementsOf(expectedSnapshotDependencies);
assertThat(indexedLayers.get("application")).containsExactly("BOOT-INF/classes/", "BOOT-INF/classpath.idx",
"BOOT-INF/layers.idx", "BOOT-INF/lib/alpha-1.2.3.jar", "BOOT-INF/lib/bravo-1.2.3.jar", "META-INF/");
BuildResult listLayers = this.gradleBuild.build("listLayers");
assertThat(listLayers.task(":listLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
String listLayersOutput = listLayers.getOutput();
assertThat(new BufferedReader(new StringReader(listLayersOutput)).lines()).containsSequence(layerNames);
BuildResult extractLayers = this.gradleBuild.build("extractLayers");
assertThat(extractLayers.task(":extractLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertExtractedLayers(layerNames, indexedLayers);
}
@TestTemplate
void customLayers() throws IOException {
writeMainClass();

View File

@ -29,8 +29,11 @@ import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import org.gradle.api.Action;
import org.gradle.api.DomainObjectSet;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.DependencySet;
import org.gradle.api.artifacts.ModuleVersionIdentifier;
import org.gradle.api.artifacts.ProjectDependency;
import org.gradle.api.artifacts.ResolvableDependencies;
import org.gradle.api.artifacts.ResolvedArtifact;
import org.gradle.api.artifacts.ResolvedConfiguration;
@ -41,6 +44,7 @@ import org.gradle.api.artifacts.component.ProjectComponentIdentifier;
import org.junit.jupiter.api.Test;
import org.springframework.boot.loader.tools.JarModeLibrary;
import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@ -56,6 +60,7 @@ import static org.mockito.Mockito.mock;
* @author Scott Frederick
* @author Paddy Drury
*/
@ClassPathExclusions("kotlin-daemon-client-*")
class BootJarTests extends AbstractBootArchiveTests<BootJar> {
BootJarTests() {
@ -307,6 +312,10 @@ class BootJarTests extends AbstractBootArchiveTests<BootJar> {
given(configuration.getResolvedConfiguration()).willReturn(resolvedConfiguration);
ResolvableDependencies resolvableDependencies = mock(ResolvableDependencies.class);
given(configuration.getIncoming()).willReturn(resolvableDependencies);
DependencySet dependencies = mock(DependencySet.class);
DomainObjectSet<ProjectDependency> projectDependencies = mock(DomainObjectSet.class);
given(dependencies.withType(ProjectDependency.class)).willReturn(projectDependencies);
given(configuration.getAllDependencies()).willReturn(dependencies);
willAnswer((invocation) -> {
invocation.getArgument(0, Action.class).execute(resolvableDependencies);
return null;

View File

@ -20,7 +20,7 @@ bootJar {
}
dependencies {
intoLayer("subproject-dependencies") {
include "org.example.projects:*"
includeProjectDependencies()
}
intoLayer("snapshot-dependencies") {
include "*:*:*SNAPSHOT"

View File

@ -0,0 +1,39 @@
plugins {
id 'java'
id 'org.springframework.boot' version '{version}'
}
subprojects {
apply plugin: 'java'
group = 'org.example.projects'
version = '1.2.3'
}
bootJar {
mainClass = 'com.example.Application'
}
repositories {
mavenCentral()
maven { url "file:repository" }
}
dependencies {
implementation(project(':alpha'))
implementation(project(':bravo'))
implementation("com.example:library:1.0-SNAPSHOT")
implementation("org.apache.commons:commons-lang3:3.9")
implementation("org.springframework:spring-core:5.2.5.RELEASE")
}
task listLayers(type: JavaExec) {
classpath = bootJar.outputs.files
systemProperties = [ "jarmode": "layertools" ]
args "list"
}
task extractLayers(type: JavaExec) {
classpath = bootJar.outputs.files
systemProperties = [ "jarmode": "layertools" ]
args "extract"
}

View File

@ -36,6 +36,9 @@ class ImplicitLayerResolver extends StandardLayers {
@Override
public Layer getLayer(Library library) {
if (library.isLocal()) {
return APPLICATION;
}
if (library.getName().contains("SNAPSHOT.")) {
return SNAPSHOT_DEPENDENCIES;
}

View File

@ -41,6 +41,8 @@ public class Library {
private final boolean unpackRequired;
private final boolean local;
/**
* Create a new {@link Library}.
* @param file the source file
@ -82,11 +84,29 @@ public class Library {
* @param unpackRequired if the library needs to be unpacked before it can be used
*/
public Library(String name, File file, LibraryScope scope, LibraryCoordinates coordinates, boolean unpackRequired) {
this(name, file, scope, coordinates, unpackRequired, false);
}
/**
* Create a new {@link Library}.
* @param name the name of the library as it should be written or {@code null} to use
* the file name
* @param file the source file
* @param scope the scope of the library
* @param coordinates the library coordinates or {@code null}
* @param unpackRequired if the library needs to be unpacked before it can be used
* @param local if the library is local (part of the same build) to the application
* that is being packaged
* @since 2.4.0
*/
public Library(String name, File file, LibraryScope scope, LibraryCoordinates coordinates, boolean unpackRequired,
boolean local) {
this.name = (name != null) ? name : file.getName();
this.file = file;
this.scope = scope;
this.coordinates = coordinates;
this.unpackRequired = unpackRequired;
this.local = local;
}
/**
@ -143,4 +163,13 @@ public class Library {
return this.file.lastModified();
}
/**
* Return if the library is local (part of the same build) to the application that is
* being packaged.
* @return if the library is local
*/
public boolean isLocal() {
return this.local;
}
}