Use a more compact layers.idx format

Update the `layers.idx` format so that it is more compact and can be
parsed by third-parties as YAML.

Closes gh-20860
pull/20874/head
Phillip Webb 5 years ago
parent 36fd2ed249
commit 65672a1150

@ -31,8 +31,9 @@ import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.jar.JarFile; import java.util.jar.JarFile;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
@ -103,30 +104,25 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
List<String> layerNames = Arrays.asList("dependencies", "spring-boot-loader", "snapshot-dependencies", List<String> layerNames = Arrays.asList("dependencies", "spring-boot-loader", "snapshot-dependencies",
"application"); "application");
assertThat(indexedLayers.keySet()).containsExactlyElementsOf(layerNames); assertThat(indexedLayers.keySet()).containsExactlyElementsOf(layerNames);
List<String> expectedDependencies = new ArrayList<>(); Set<String> expectedDependencies = new TreeSet<>();
expectedDependencies.add("BOOT-INF/lib/commons-lang3-3.9.jar"); 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-core-5.2.5.RELEASE.jar");
expectedDependencies.add("BOOT-INF/lib/spring-jcl-5.2.5.RELEASE.jar"); expectedDependencies.add("BOOT-INF/lib/spring-jcl-5.2.5.RELEASE.jar");
List<String> expectedSnapshotDependencies = new ArrayList<>(); Set<String> expectedSnapshotDependencies = new TreeSet<>();
expectedSnapshotDependencies.add("BOOT-INF/lib/commons-io-2.7-SNAPSHOT.jar"); expectedSnapshotDependencies.add("BOOT-INF/lib/commons-io-2.7-SNAPSHOT.jar");
(layerToolsJar.contains("SNAPSHOT") ? expectedSnapshotDependencies : expectedDependencies).add(layerToolsJar); (layerToolsJar.contains("SNAPSHOT") ? expectedSnapshotDependencies : expectedDependencies).add(layerToolsJar);
assertThat(indexedLayers.get("dependencies")).containsExactlyElementsOf(expectedDependencies); assertThat(indexedLayers.get("dependencies")).containsExactlyElementsOf(expectedDependencies);
assertThat(indexedLayers.get("spring-boot-loader")) assertThat(indexedLayers.get("spring-boot-loader")).containsExactly("org/");
.allMatch(Pattern.compile("org/springframework/boot/loader/.+\\.class").asPredicate());
assertThat(indexedLayers.get("snapshot-dependencies")).containsExactlyElementsOf(expectedSnapshotDependencies); assertThat(indexedLayers.get("snapshot-dependencies")).containsExactlyElementsOf(expectedSnapshotDependencies);
assertThat(indexedLayers.get("application")).containsExactly("META-INF/MANIFEST.MF", assertThat(indexedLayers.get("application")).containsExactly("BOOT-INF/classes/", "BOOT-INF/classpath.idx",
"BOOT-INF/classes/example/Main.class", "BOOT-INF/classes/static/file.txt", "BOOT-INF/classpath.idx", "BOOT-INF/layers.idx", "META-INF/");
"BOOT-INF/layers.idx");
BuildResult listLayers = this.gradleBuild.build("listLayers"); BuildResult listLayers = this.gradleBuild.build("listLayers");
assertThat(listLayers.task(":listLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(listLayers.task(":listLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
String listLayersOutput = listLayers.getOutput(); String listLayersOutput = listLayers.getOutput();
assertThat(new BufferedReader(new StringReader(listLayersOutput)).lines()).containsSequence(layerNames); assertThat(new BufferedReader(new StringReader(listLayersOutput)).lines()).containsSequence(layerNames);
BuildResult extractLayers = this.gradleBuild.build("extractLayers"); BuildResult extractLayers = this.gradleBuild.build("extractLayers");
assertThat(extractLayers.task(":extractLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(extractLayers.task(":extractLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
Map<String, List<String>> extractedLayers = readExtractedLayers(this.gradleBuild.getProjectDir(), layerNames); assertExtractedLayers(layerNames, indexedLayers);
assertThat(extractedLayers.keySet()).isEqualTo(indexedLayers.keySet());
extractedLayers.forEach(
(name, contents) -> assertThat(contents).containsExactlyInAnyOrderElementsOf(indexedLayers.get(name)));
} }
@TestTemplate @TestTemplate
@ -151,7 +147,7 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
List<String> layerNames = Arrays.asList("dependencies", "commons-dependencies", "snapshot-dependencies", List<String> layerNames = Arrays.asList("dependencies", "commons-dependencies", "snapshot-dependencies",
"static", "app"); "static", "app");
assertThat(indexedLayers.keySet()).containsExactlyElementsOf(layerNames); assertThat(indexedLayers.keySet()).containsExactlyElementsOf(layerNames);
List<String> expectedDependencies = new ArrayList<>(); Set<String> expectedDependencies = new TreeSet<>();
expectedDependencies.add("BOOT-INF/lib/spring-core-5.2.5.RELEASE.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"); expectedDependencies.add("BOOT-INF/lib/spring-jcl-5.2.5.RELEASE.jar");
List<String> expectedSnapshotDependencies = new ArrayList<>(); List<String> expectedSnapshotDependencies = new ArrayList<>();
@ -160,23 +156,48 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
assertThat(indexedLayers.get("dependencies")).containsExactlyElementsOf(expectedDependencies); assertThat(indexedLayers.get("dependencies")).containsExactlyElementsOf(expectedDependencies);
assertThat(indexedLayers.get("commons-dependencies")).containsExactly("BOOT-INF/lib/commons-lang3-3.9.jar"); assertThat(indexedLayers.get("commons-dependencies")).containsExactly("BOOT-INF/lib/commons-lang3-3.9.jar");
assertThat(indexedLayers.get("snapshot-dependencies")).containsExactlyElementsOf(expectedSnapshotDependencies); assertThat(indexedLayers.get("snapshot-dependencies")).containsExactlyElementsOf(expectedSnapshotDependencies);
assertThat(indexedLayers.get("static")).containsExactly("BOOT-INF/classes/static/file.txt"); assertThat(indexedLayers.get("static")).containsExactly("BOOT-INF/classes/static/");
List<String> appLayer = new ArrayList<>(indexedLayers.get("app")); List<String> appLayer = new ArrayList<>(indexedLayers.get("app"));
List<String> nonLoaderEntries = Arrays.asList("META-INF/MANIFEST.MF", "BOOT-INF/classes/example/Main.class", Set<String> nonLoaderEntries = new TreeSet<>();
"BOOT-INF/classpath.idx", "BOOT-INF/layers.idx"); nonLoaderEntries.add("BOOT-INF/classes/example/");
nonLoaderEntries.add("BOOT-INF/classpath.idx");
nonLoaderEntries.add("BOOT-INF/layers.idx");
nonLoaderEntries.add("META-INF/");
assertThat(appLayer).containsSubsequence(nonLoaderEntries); assertThat(appLayer).containsSubsequence(nonLoaderEntries);
appLayer.removeAll(nonLoaderEntries); appLayer.removeAll(nonLoaderEntries);
assertThat(appLayer).allMatch(Pattern.compile("org/springframework/boot/loader/.+\\.class").asPredicate()); assertThat(appLayer).containsExactly("org/");
BuildResult listLayers = this.gradleBuild.build("listLayers"); BuildResult listLayers = this.gradleBuild.build("listLayers");
assertThat(listLayers.task(":listLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(listLayers.task(":listLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
String listLayersOutput = listLayers.getOutput(); String listLayersOutput = listLayers.getOutput();
assertThat(new BufferedReader(new StringReader(listLayersOutput)).lines()).containsSequence(layerNames); assertThat(new BufferedReader(new StringReader(listLayersOutput)).lines()).containsSequence(layerNames);
BuildResult extractLayers = this.gradleBuild.build("extractLayers"); BuildResult extractLayers = this.gradleBuild.build("extractLayers");
assertThat(extractLayers.task(":extractLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); assertThat(extractLayers.task(":extractLayers").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertExtractedLayers(layerNames, indexedLayers);
}
private void assertExtractedLayers(List<String> layerNames, Map<String, List<String>> indexedLayers)
throws IOException {
Map<String, List<String>> extractedLayers = readExtractedLayers(this.gradleBuild.getProjectDir(), layerNames); Map<String, List<String>> extractedLayers = readExtractedLayers(this.gradleBuild.getProjectDir(), layerNames);
assertThat(extractedLayers.keySet()).isEqualTo(indexedLayers.keySet()); assertThat(extractedLayers.keySet()).isEqualTo(indexedLayers.keySet());
extractedLayers.forEach( extractedLayers.forEach((name, contents) -> {
(name, contents) -> assertThat(contents).containsExactlyInAnyOrderElementsOf(indexedLayers.get(name))); List<String> index = indexedLayers.get(name);
List<String> unexpected = new ArrayList<>();
for (String file : contents) {
if (!isInIndex(index, file)) {
unexpected.add(name);
}
}
assertThat(unexpected).isEmpty();
});
}
private boolean isInIndex(List<String> index, String file) {
for (String candidate : index) {
if (file.equals(candidate) || candidate.endsWith("/") && file.startsWith(candidate)) {
return true;
}
}
return false;
} }
private void writeMainClass() { private void writeMainClass() {
@ -213,11 +234,21 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
} }
private Map<String, List<String>> readLayerIndex(JarFile jarFile) throws IOException { private Map<String, List<String>> readLayerIndex(JarFile jarFile) throws IOException {
Map<String, List<String>> index = new LinkedHashMap<>();
ZipEntry indexEntry = jarFile.getEntry("BOOT-INF/layers.idx"); ZipEntry indexEntry = jarFile.getEntry("BOOT-INF/layers.idx");
try (BufferedReader reader = new BufferedReader(new InputStreamReader(jarFile.getInputStream(indexEntry)))) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(jarFile.getInputStream(indexEntry)))) {
return reader.lines().map((line) -> line.split(" ")) String line = reader.readLine();
.collect(Collectors.groupingBy((layerAndPath) -> layerAndPath[0], LinkedHashMap::new, String layer = null;
Collectors.mapping((layerAndPath) -> layerAndPath[1], Collectors.toList()))); while (line != null) {
if (line.startsWith("- ")) {
layer = line.substring(3, line.length() - 2);
}
else if (line.startsWith(" - ")) {
index.computeIfAbsent(layer, (key) -> new ArrayList<>()).add(line.substring(5, line.length() - 1));
}
line = reader.readLine();
}
return index;
} }
} }

@ -20,6 +20,7 @@ import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@ -103,12 +104,27 @@ class BootJarTests extends AbstractBootArchiveTests<TestBootJar> {
List<String> index = entryLines(jarFile, "BOOT-INF/layers.idx"); List<String> index = entryLines(jarFile, "BOOT-INF/layers.idx");
assertThat(getLayerNames(index)).containsExactly("dependencies", "spring-boot-loader", assertThat(getLayerNames(index)).containsExactly("dependencies", "spring-boot-loader",
"snapshot-dependencies", "application"); "snapshot-dependencies", "application");
assertThat(index).contains("dependencies BOOT-INF/lib/first-library.jar", String layerToolsJar = "BOOT-INF/lib/" + JarModeLibrary.LAYER_TOOLS.getName();
"dependencies BOOT-INF/lib/second-library.jar", List<String> expected = new ArrayList<>();
"snapshot-dependencies BOOT-INF/lib/third-library-SNAPSHOT.jar", expected.add("- \"dependencies\":");
"application BOOT-INF/classes/com/example/Application.class", expected.add(" - \"BOOT-INF/lib/first-library.jar\"");
"application BOOT-INF/classes/application.properties", expected.add(" - \"BOOT-INF/lib/second-library.jar\"");
"application BOOT-INF/classes/static/test.css"); if (!layerToolsJar.contains("SNAPSHOT")) {
expected.add(" - \"" + layerToolsJar + "\"");
}
expected.add("- \"spring-boot-loader\":");
expected.add(" - \"org/\"");
expected.add("- \"snapshot-dependencies\":");
if (layerToolsJar.contains("SNAPSHOT")) {
expected.add(" - \"" + layerToolsJar + "\"");
}
expected.add(" - \"BOOT-INF/lib/third-library-SNAPSHOT.jar\"");
expected.add("- \"application\":");
expected.add(" - \"BOOT-INF/classes/\"");
expected.add(" - \"BOOT-INF/classpath.idx\"");
expected.add(" - \"BOOT-INF/layers.idx\"");
expected.add(" - \"META-INF/\"");
assertThat(index).containsExactlyElementsOf(expected);
} }
} }
@ -134,12 +150,25 @@ class BootJarTests extends AbstractBootArchiveTests<TestBootJar> {
List<String> index = entryLines(jarFile, "BOOT-INF/layers.idx"); List<String> index = entryLines(jarFile, "BOOT-INF/layers.idx");
assertThat(getLayerNames(index)).containsExactly("my-deps", "my-internal-deps", "my-snapshot-deps", assertThat(getLayerNames(index)).containsExactly("my-deps", "my-internal-deps", "my-snapshot-deps",
"resources", "application"); "resources", "application");
assertThat(index).contains("my-internal-deps BOOT-INF/lib/first-library.jar", String layerToolsJar = "BOOT-INF/lib/" + JarModeLibrary.LAYER_TOOLS.getName();
"my-internal-deps BOOT-INF/lib/second-library.jar", List<String> expected = new ArrayList<>();
"my-snapshot-deps BOOT-INF/lib/third-library-SNAPSHOT.jar", expected.add("- \"my-deps\":");
"application BOOT-INF/classes/com/example/Application.class", expected.add(" - \"" + layerToolsJar + "\"");
"application BOOT-INF/classes/application.properties", expected.add("- \"my-internal-deps\":");
"resources BOOT-INF/classes/static/test.css"); expected.add(" - \"BOOT-INF/lib/first-library.jar\"");
expected.add(" - \"BOOT-INF/lib/second-library.jar\"");
expected.add("- \"my-snapshot-deps\":");
expected.add(" - \"BOOT-INF/lib/third-library-SNAPSHOT.jar\"");
expected.add("- \"resources\":");
expected.add(" - \"BOOT-INF/classes/static/\"");
expected.add("- \"application\":");
expected.add(" - \"BOOT-INF/classes/application.properties\"");
expected.add(" - \"BOOT-INF/classes/com/\"");
expected.add(" - \"BOOT-INF/classpath.idx\"");
expected.add(" - \"BOOT-INF/layers.idx\"");
expected.add(" - \"META-INF/\"");
expected.add(" - \"org/\"");
assertThat(index).containsExactlyElementsOf(expected);
} }
} }
@ -261,11 +290,13 @@ class BootJarTests extends AbstractBootArchiveTests<TestBootJar> {
} }
private Set<String> getLayerNames(List<String> index) { private Set<String> getLayerNames(List<String> index) {
return index.stream().map(this::getLayerName).collect(Collectors.toCollection(LinkedHashSet::new)); Set<String> layerNames = new LinkedHashSet<>();
} for (String line : index) {
if (line.startsWith("- ")) {
private String getLayerName(String indexLine) { layerNames.add(line.substring(3, line.length() - 2));
return indexLine.substring(0, indexLine.indexOf(" ")); }
}
return layerNames;
} }
@Override @Override

@ -31,6 +31,7 @@ import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.util.StreamUtils; import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/** /**
* {@link Layers} implementation backed by a {@code BOOT-INF/layers.idx} file. * {@link Layers} implementation backed by a {@code BOOT-INF/layers.idx} file.
@ -43,12 +44,19 @@ class IndexedLayers implements Layers {
private MultiValueMap<String, String> layers = new LinkedMultiValueMap<>(); private MultiValueMap<String, String> layers = new LinkedMultiValueMap<>();
IndexedLayers(String indexFile) { IndexedLayers(String indexFile) {
String[] lines = indexFile.split("\n"); String[] lines = Arrays.stream(indexFile.split("\n")).filter(StringUtils::hasText).toArray(String[]::new);
Arrays.stream(lines).map(String::trim).filter((line) -> !line.isEmpty()).forEach((line) -> { String layer = null;
String[] content = line.split(" "); for (String line : lines) {
Assert.state(content.length == 2, "Layer index file is malformed"); if (line.startsWith("- ")) {
this.layers.add(content[0], content[1]); layer = line.substring(3, line.length() - 2);
}); }
else if (line.startsWith(" - ")) {
this.layers.add(layer, line.substring(5, line.length() - 1));
}
else {
throw new IllegalStateException("Layer index file is malformed");
}
}
Assert.state(!this.layers.isEmpty(), "Empty layer index file loaded"); Assert.state(!this.layers.isEmpty(), "Empty layer index file loaded");
} }
@ -59,10 +67,15 @@ class IndexedLayers implements Layers {
@Override @Override
public String getLayer(ZipEntry entry) { public String getLayer(ZipEntry entry) {
String name = entry.getName(); return getLayer(entry.getName());
for (Map.Entry<String, List<String>> indexEntry : this.layers.entrySet()) { }
if (indexEntry.getValue().contains(name)) {
return indexEntry.getKey(); private String getLayer(String name) {
for (Map.Entry<String, List<String>> entry : this.layers.entrySet()) {
for (String candidate : entry.getValue()) {
if (candidate.equals(name) || (candidate.endsWith("/") && name.startsWith(candidate))) {
return entry.getKey();
}
} }
} }
throw new IllegalStateException("No layer defined in index for file '" + name + "'"); throw new IllegalStateException("No layer defined in index for file '" + name + "'");

@ -77,10 +77,13 @@ class HelpCommandTests {
JarEntry indexEntry = new JarEntry("BOOT-INF/layers.idx"); JarEntry indexEntry = new JarEntry("BOOT-INF/layers.idx");
jarOutputStream.putNextEntry(indexEntry); jarOutputStream.putNextEntry(indexEntry);
Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8); Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8);
writer.write("0001 BOOT-INF/lib/a.jar\n"); writer.write("- \"0001\":\n");
writer.write("0001 BOOT-INF/lib/b.jar\n"); writer.write(" - \"BOOT-INF/lib/a.jar\"\n");
writer.write("0002 BOOT-INF/lib/c.jar\n"); writer.write(" - \"BOOT-INF/lib/b.jar\"\n");
writer.write("0003 BOOT-INF/lib/d.jar\n"); writer.write("- \"0002\":\n");
writer.write(" - \"0002 BOOT-INF/lib/c.jar\"\n");
writer.write("- \"0003\":\n");
writer.write(" - \"BOOT-INF/lib/d.jar\"\n");
writer.flush(); writer.flush();
} }
return file; return file;

@ -69,6 +69,13 @@ class IndexedLayersTests {
.withMessage("No layer defined in index for file " + "'file.jar'"); .withMessage("No layer defined in index for file " + "'file.jar'");
} }
@Test
void getLayerWhenMatchesFolderReturnsLayer() throws Exception {
IndexedLayers layers = new IndexedLayers(getIndex());
assertThat(layers.getLayer(mockEntry("META-INF/MANIFEST.MF"))).isEqualTo("application");
assertThat(layers.getLayer(mockEntry("META-INF/a/sub/folder/and/a/file"))).isEqualTo("application");
}
private String getIndex() throws Exception { private String getIndex() throws Exception {
ClassPathResource resource = new ClassPathResource("test-layers.idx", getClass()); ClassPathResource resource = new ClassPathResource("test-layers.idx", getClass());
InputStreamReader reader = new InputStreamReader(resource.getInputStream()); InputStreamReader reader = new InputStreamReader(resource.getInputStream());

@ -85,10 +85,13 @@ class LayerToolsJarModeTests {
JarEntry indexEntry = new JarEntry("BOOT-INF/layers.idx"); JarEntry indexEntry = new JarEntry("BOOT-INF/layers.idx");
jarOutputStream.putNextEntry(indexEntry); jarOutputStream.putNextEntry(indexEntry);
Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8); Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8);
writer.write("0001 BOOT-INF/lib/a.jar\n"); writer.write("- \"0001\":\n");
writer.write("0001 BOOT-INF/lib/b.jar\n"); writer.write(" - \"BOOT-INF/lib/a.jar\"\n");
writer.write("0002 BOOT-INF/lib/c.jar\n"); writer.write(" - \"BOOT-INF/lib/b.jar\"\n");
writer.write("0003 BOOT-INF/lib/d.jar\n"); writer.write("- \"0002\":\n");
writer.write(" - \"0002 BOOT-INF/lib/c.jar\"\n");
writer.write("- \"0003\":\n");
writer.write(" - \"BOOT-INF/lib/d.jar\"\n");
writer.flush(); writer.flush();
} }
return file; return file;

@ -98,10 +98,13 @@ class ListCommandTests {
JarEntry indexEntry = new JarEntry("BOOT-INF/layers.idx"); JarEntry indexEntry = new JarEntry("BOOT-INF/layers.idx");
out.putNextEntry(indexEntry); out.putNextEntry(indexEntry);
Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8); Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
writer.write("0001 BOOT-INF/lib/a.jar\n"); writer.write("- \"0001\":\n");
writer.write("0001 BOOT-INF/lib/b.jar\n"); writer.write(" - \"BOOT-INF/lib/a.jar\"\n");
writer.write("0002 BOOT-INF/lib/c.jar\n"); writer.write(" - \"BOOT-INF/lib/b.jar\"\n");
writer.write("0003 BOOT-INF/lib/d.jar\n"); writer.write("- \"0002\":\n");
writer.write(" - \"0002 BOOT-INF/lib/c.jar\"\n");
writer.write("- \"0003\":\n");
writer.write(" - \"BOOT-INF/lib/d.jar\"\n");
writer.flush(); writer.flush();
} }

@ -1,3 +1,6 @@
test BOOT-INF/lib/a.jar - "test":
test BOOT-INF/lib/b.jar - "BOOT-INF/lib/a.jar"
application BOOT-INF/classes/Demo.class - "BOOT-INF/lib/b.jar"
- "application":
- "BOOT-INF/classes/Demo.class"
- "META-INF/"

@ -21,23 +21,46 @@ import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.OutputStreamWriter; import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
/** /**
* Index describing the layer to which each entry in a jar belongs. * Index describing the layer to which each entry in a jar belongs. Index files are simple
* text files that should be read from top to bottom. Each file defines the layers and
* their content. Layers names are written as quoted strings prefixed by a dash space
* ({@code "- "}) and with a colon ({@code ":"}) suffix. Layer content is either a file or
* folder name written as a quoted string prefixed by space space dash space
* ({@code " - "}). A folder name ends with {@code /}, a file name does not. When a
* folder name is used it means that all files inside that folder are in the same layer.
* <p>
* Index files are designed to be compatible with YAML and may be read into a list of
* `Map&lt;String, List&lt;String&gt;&gt;` instances.
* *
* @author Madhura Bhave * @author Madhura Bhave
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Phillip Webb
* @since 2.3.0 * @since 2.3.0
*/ */
public class LayersIndex { public class LayersIndex {
private final Iterable<Layer> layers; private final Iterable<Layer> layers;
private final MultiValueMap<Layer, String> index = new LinkedMultiValueMap<>(); private final Node root = new Node();
/**
* Create a new {@link LayersIndex} backed by the given layers.
* @param layers the layers in the index
*/
public LayersIndex(Layer... layers) {
this(Arrays.asList(layers));
}
/** /**
* Create a new {@link LayersIndex} backed by the given layers. * Create a new {@link LayersIndex} backed by the given layers.
@ -53,7 +76,12 @@ public class LayersIndex {
* @param name the name of the item * @param name the name of the item
*/ */
public void add(Layer layer, String name) { public void add(Layer layer, String name) {
this.index.add(layer, name); String[] segments = name.split("/");
Node node = this.root;
for (int i = 0; i < segments.length; i++) {
boolean isFolder = i < (segments.length - 1);
node = node.updateOrAddNode(segments[i], isFolder, layer);
}
} }
/** /**
@ -62,19 +90,67 @@ public class LayersIndex {
* @throws IOException on IO error * @throws IOException on IO error
*/ */
public void writeTo(OutputStream out) throws IOException { public void writeTo(OutputStream out) throws IOException {
MultiValueMap<Layer, String> index = new LinkedMultiValueMap<>();
this.root.buildIndex("", index);
index.values().forEach(Collections::sort);
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8)); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
for (Layer layer : this.layers) { for (Layer layer : this.layers) {
List<String> names = this.index.get(layer); List<String> names = index.get(layer);
if (names != null) { if (names != null && !names.isEmpty()) {
writer.write("- \"" + layer + "\":\n");
for (String name : names) { for (String name : names) {
writer.write(layer.toString()); writer.write(" - \"" + name + "\"\n");
writer.write(" ");
writer.write(name);
writer.write("\n");
} }
} }
} }
writer.flush(); writer.flush();
}
/**
* A node within the index represeting a single path segment.
*/
private static class Node {
private final String name;
private final Set<Layer> layers;
private final List<Node> children = new ArrayList<>();
Node() {
this.name = "";
this.layers = new HashSet<>();
}
Node(String name, Layer layer, Node parent) {
this.name = name;
this.layers = new HashSet<>(Collections.singleton(layer));
}
Node updateOrAddNode(String segment, boolean isFolder, Layer layer) {
String name = segment + (isFolder ? "/" : "");
for (Node child : this.children) {
if (name.equals(child.name)) {
child.layers.add(layer);
return child;
}
}
Node child = new Node(name, layer, this);
this.children.add(child);
return child;
}
void buildIndex(String path, MultiValueMap<Layer, String> index) {
String name = path + this.name;
if (this.layers.size() == 1) {
index.add(this.layers.iterator().next(), name);
}
else {
for (Node child : this.children) {
child.buildIndex(name, index);
}
}
}
} }

@ -268,84 +268,20 @@ abstract class AbstractPackagerTests<P extends Packager> {
assertThat(hasPackagedEntry("BOOT-INF/layers.idx")).isTrue(); assertThat(hasPackagedEntry("BOOT-INF/layers.idx")).isTrue();
String layersIndex = getPackagedEntryContent("BOOT-INF/layers.idx"); String layersIndex = getPackagedEntryContent("BOOT-INF/layers.idx");
List<String> expectedLayers = new ArrayList<>(); List<String> expectedLayers = new ArrayList<>();
getExpectedLayers(expectedLayers); expectedLayers.add("- 'default':");
expectedLayers.add("default " + "BOOT-INF/classpath.idx"); expectedLayers.add(" - 'BOOT-INF/classes/'");
expectedLayers.add("default " + "BOOT-INF/layers.idx"); expectedLayers.add(" - 'BOOT-INF/classpath.idx'");
expectedLayers.add("0001 " + "BOOT-INF/lib/" + libJarFile1.getName()); expectedLayers.add(" - 'BOOT-INF/layers.idx'");
expectedLayers.add("0002 " + "BOOT-INF/lib/" + libJarFile2.getName()); expectedLayers.add(" - 'META-INF/'");
expectedLayers.add("0003 " + "BOOT-INF/lib/" + libJarFile3.getName()); expectedLayers.add(" - 'org/'");
assertThat(Arrays.asList(layersIndex.split("\\n"))).containsExactly(expectedLayers.toArray(new String[0])); expectedLayers.add("- '0001':");
} expectedLayers.add(" - 'BOOT-INF/lib/" + libJarFile1.getName() + "'");
expectedLayers.add("- '0002':");
private void getExpectedLayers(List<String> expectedLayers) { expectedLayers.add(" - 'BOOT-INF/lib/" + libJarFile2.getName() + "'");
expectedLayers.add("default META-INF/MANIFEST.MF"); expectedLayers.add("- '0003':");
expectedLayers.add("default org/springframework/boot/loader/ClassPathIndexFile.class"); expectedLayers.add(" - 'BOOT-INF/lib/" + libJarFile3.getName() + "'");
expectedLayers.add("default org/springframework/boot/loader/ExecutableArchiveLauncher.class"); assertThat(layersIndex.split("\\n"))
expectedLayers.add("default org/springframework/boot/loader/JarLauncher.class"); .containsExactly(expectedLayers.stream().map((s) -> s.replace('\'', '"')).toArray(String[]::new));
expectedLayers.add(
"default org/springframework/boot/loader/LaunchedURLClassLoader$UseFastConnectionExceptionsEnumeration.class");
expectedLayers.add("default org/springframework/boot/loader/LaunchedURLClassLoader.class");
expectedLayers.add("default org/springframework/boot/loader/Launcher.class");
expectedLayers.add("default org/springframework/boot/loader/MainMethodRunner.class");
expectedLayers.add("default org/springframework/boot/loader/PropertiesLauncher$1.class");
expectedLayers.add("default org/springframework/boot/loader/PropertiesLauncher$ArchiveEntryFilter.class");
expectedLayers
.add("default org/springframework/boot/loader/PropertiesLauncher$PrefixMatchingArchiveFilter.class");
expectedLayers.add("default org/springframework/boot/loader/PropertiesLauncher.class");
expectedLayers.add("default org/springframework/boot/loader/WarLauncher.class");
expectedLayers.add("default org/springframework/boot/loader/archive/Archive$Entry.class");
expectedLayers.add("default org/springframework/boot/loader/archive/Archive$EntryFilter.class");
expectedLayers.add("default org/springframework/boot/loader/archive/Archive.class");
expectedLayers.add("default org/springframework/boot/loader/archive/ExplodedArchive$AbstractIterator.class");
expectedLayers.add("default org/springframework/boot/loader/archive/ExplodedArchive$ArchiveIterator.class");
expectedLayers.add("default org/springframework/boot/loader/archive/ExplodedArchive$EntryIterator.class");
expectedLayers.add("default org/springframework/boot/loader/archive/ExplodedArchive$FileEntry.class");
expectedLayers
.add("default org/springframework/boot/loader/archive/ExplodedArchive$SimpleJarFileArchive.class");
expectedLayers.add("default org/springframework/boot/loader/archive/ExplodedArchive.class");
expectedLayers.add("default org/springframework/boot/loader/archive/JarFileArchive$AbstractIterator.class");
expectedLayers.add("default org/springframework/boot/loader/archive/JarFileArchive$EntryIterator.class");
expectedLayers.add("default org/springframework/boot/loader/archive/JarFileArchive$JarFileEntry.class");
expectedLayers
.add("default org/springframework/boot/loader/archive/JarFileArchive$NestedArchiveIterator.class");
expectedLayers.add("default org/springframework/boot/loader/archive/JarFileArchive.class");
expectedLayers.add("default org/springframework/boot/loader/data/RandomAccessData.class");
expectedLayers.add("default org/springframework/boot/loader/data/RandomAccessDataFile$1.class");
expectedLayers.add("default org/springframework/boot/loader/data/RandomAccessDataFile$DataInputStream.class");
expectedLayers.add("default org/springframework/boot/loader/data/RandomAccessDataFile$FileAccess.class");
expectedLayers.add("default org/springframework/boot/loader/data/RandomAccessDataFile.class");
expectedLayers.add("default org/springframework/boot/loader/jar/AsciiBytes.class");
expectedLayers.add("default org/springframework/boot/loader/jar/Bytes.class");
expectedLayers.add("default org/springframework/boot/loader/jar/CentralDirectoryEndRecord$1.class");
expectedLayers.add("default org/springframework/boot/loader/jar/CentralDirectoryEndRecord$Zip64End.class");
expectedLayers.add("default org/springframework/boot/loader/jar/CentralDirectoryEndRecord$Zip64Locator.class");
expectedLayers.add("default org/springframework/boot/loader/jar/CentralDirectoryEndRecord.class");
expectedLayers.add("default org/springframework/boot/loader/jar/CentralDirectoryFileHeader.class");
expectedLayers.add("default org/springframework/boot/loader/jar/CentralDirectoryParser.class");
expectedLayers.add("default org/springframework/boot/loader/jar/CentralDirectoryVisitor.class");
expectedLayers.add("default org/springframework/boot/loader/jar/FileHeader.class");
expectedLayers.add("default org/springframework/boot/loader/jar/Handler.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarEntry.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarEntryFilter.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarFile$1.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarFile$2.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarFile$JarFileType.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarFile.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarFileEntries$1.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarFileEntries$EntryIterator.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarFileEntries.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarURLConnection$1.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarURLConnection$2.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarURLConnection$CloseAction.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarURLConnection$JarEntryName.class");
expectedLayers.add("default org/springframework/boot/loader/jar/JarURLConnection.class");
expectedLayers.add("default org/springframework/boot/loader/jar/StringSequence.class");
expectedLayers.add("default org/springframework/boot/loader/jar/ZipInflaterInputStream.class");
expectedLayers.add("default org/springframework/boot/loader/jarmode/JarMode.class");
expectedLayers.add("default org/springframework/boot/loader/jarmode/JarModeLauncher.class");
expectedLayers.add("default org/springframework/boot/loader/jarmode/TestJarMode.class");
expectedLayers.add("default org/springframework/boot/loader/util/SystemPropertyUtils.class");
expectedLayers.add("default BOOT-INF/classes/a/b/C.class");
} }
@Test @Test
@ -360,8 +296,13 @@ abstract class AbstractPackagerTests<P extends Packager> {
assertThat(Arrays.asList(classpathIndex.split("\\n"))).containsExactly("spring-boot-jarmode-layertools.jar"); assertThat(Arrays.asList(classpathIndex.split("\\n"))).containsExactly("spring-boot-jarmode-layertools.jar");
assertThat(hasPackagedEntry("BOOT-INF/layers.idx")).isTrue(); assertThat(hasPackagedEntry("BOOT-INF/layers.idx")).isTrue();
String layersIndex = getPackagedEntryContent("BOOT-INF/layers.idx"); String layersIndex = getPackagedEntryContent("BOOT-INF/layers.idx");
assertThat(Arrays.stream(layersIndex.split("\\n")).map((n) -> n.split(" ")[0]).distinct()) List<String> expectedLayers = new ArrayList<>();
.containsExactly("default"); expectedLayers.add("- 'default':");
expectedLayers.add(" - 'BOOT-INF/'");
expectedLayers.add(" - 'META-INF/'");
expectedLayers.add(" - 'org/'");
assertThat(layersIndex.split("\\n"))
.containsExactly(expectedLayers.stream().map((s) -> s.replace('\'', '"')).toArray(String[]::new));
} }
@Test @Test

@ -0,0 +1,143 @@
/*
* 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.loader.tools;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.assertj.core.api.AbstractObjectAssert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.springframework.util.Assert;
import org.springframework.util.FileCopyUtils;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link LayersIndex}.
*
* @author Phillip Webb
*/
class LayersIndexTests {
private static final Layer LAYER_A = new Layer("a");
private static final Layer LAYER_B = new Layer("b");
private static final Layer LAYER_C = new Layer("c");
private String testMethodName;
@BeforeEach
void setup(TestInfo testInfo) {
this.testMethodName = testInfo.getTestMethod().get().getName();
}
@Test
void writeToWhenSimpleNamesSortsAlphabetically() throws Exception {
LayersIndex index = new LayersIndex(LAYER_A);
index.add(LAYER_A, "cat");
index.add(LAYER_A, "dog");
index.add(LAYER_A, "aardvark");
index.add(LAYER_A, "zerbra");
index.add(LAYER_A, "hamster");
assertThatIndex(index).writesExpectedContent();
}
@Test
void writeToWritesLayersInIteratorOrder() {
LayersIndex index = new LayersIndex(LAYER_B, LAYER_A, LAYER_C);
index.add(LAYER_A, "a1");
index.add(LAYER_A, "a2");
index.add(LAYER_B, "b1");
index.add(LAYER_B, "b2");
index.add(LAYER_C, "c1");
index.add(LAYER_C, "c2");
assertThatIndex(index).writesExpectedContent();
}
@Test
void writeToWhenLayerNotUsedSkipsLayer() {
LayersIndex index = new LayersIndex(LAYER_A, LAYER_B, LAYER_C);
index.add(LAYER_A, "a1");
index.add(LAYER_A, "a2");
index.add(LAYER_C, "c1");
index.add(LAYER_C, "c2");
assertThatIndex(index).writesExpectedContent();
}
@Test
void writeToWhenAllFilesInFolderAreInSameLayerUsesFolder() {
LayersIndex index = new LayersIndex(LAYER_A, LAYER_B, LAYER_C);
index.add(LAYER_A, "a1/b1/c1");
index.add(LAYER_A, "a1/b1/c2");
index.add(LAYER_A, "a1/b2/c1");
index.add(LAYER_B, "a2/b1");
index.add(LAYER_B, "a2/b2");
assertThatIndex(index).writesExpectedContent();
}
@Test
void writeToWhenAllFilesInFolderAreInNotInSameLayerUsesFiles() {
LayersIndex index = new LayersIndex(LAYER_A, LAYER_B, LAYER_C);
index.add(LAYER_A, "a1/b1/c1");
index.add(LAYER_B, "a1/b1/c2");
index.add(LAYER_C, "a1/b2/c1");
index.add(LAYER_A, "a2/b1");
index.add(LAYER_B, "a2/b2");
assertThatIndex(index).writesExpectedContent();
}
private LayersIndexAssert assertThatIndex(LayersIndex index) {
return new LayersIndexAssert(index);
}
private class LayersIndexAssert extends AbstractObjectAssert<LayersIndexAssert, LayersIndex> {
LayersIndexAssert(LayersIndex actual) {
super(actual, LayersIndexAssert.class);
}
void writesExpectedContent() {
try {
String actualContent = getContent();
String name = "LayersIndexTests-" + LayersIndexTests.this.testMethodName + ".txt";
InputStream in = LayersIndexTests.class.getResourceAsStream(name);
Assert.state(in != null, "Can't read " + name);
String expectedContent = new String(FileCopyUtils.copyToByteArray(in), StandardCharsets.UTF_8);
assertThat(actualContent).isEqualTo(expectedContent);
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
private String getContent() throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
this.actual.writeTo(out);
return new String(out.toByteArray(), StandardCharsets.UTF_8);
}
}
}

@ -15,12 +15,14 @@
*/ */
package org.springframework.boot.maven; package org.springframework.boot.maven;
import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.util.Arrays; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.jar.JarFile; import java.util.jar.JarFile;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -31,7 +33,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.loader.tools.FileUtils; import org.springframework.boot.loader.tools.FileUtils;
import org.springframework.boot.loader.tools.JarModeLibrary; import org.springframework.boot.loader.tools.JarModeLibrary;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.FileSystemUtils; import org.springframework.util.FileSystemUtils;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -305,12 +306,9 @@ class JarIntegrationTests extends AbstractArchiveIntegrationTests {
"BOOT-INF/lib/" + JarModeLibrary.LAYER_TOOLS.getCoordinates().getArtifactId()); "BOOT-INF/lib/" + JarModeLibrary.LAYER_TOOLS.getCoordinates().getArtifactId());
try { try {
try (JarFile jarFile = new JarFile(repackaged)) { try (JarFile jarFile = new JarFile(repackaged)) {
ZipEntry entry = jarFile.getEntry("BOOT-INF/layers.idx"); Map<String, List<String>> layerIndex = readLayerIndex(jarFile);
InputStream inputStream = jarFile.getInputStream(entry); assertThat(layerIndex.keySet()).containsExactly("dependencies", "spring-boot-loader",
InputStreamReader reader = new InputStreamReader(inputStream); "snapshot-dependencies", "application");
String[] lines = FileCopyUtils.copyToString(reader).split("\\n");
assertThat(Arrays.stream(lines).map((n) -> n.split(" ")[0]).distinct()).containsExactly(
"dependencies", "spring-boot-loader", "snapshot-dependencies", "application");
} }
} }
catch (IOException ex) { catch (IOException ex) {
@ -337,12 +335,9 @@ class JarIntegrationTests extends AbstractArchiveIntegrationTests {
.hasEntryWithNameStartingWith("BOOT-INF/lib/jar-release") .hasEntryWithNameStartingWith("BOOT-INF/lib/jar-release")
.hasEntryWithNameStartingWith("BOOT-INF/lib/jar-snapshot"); .hasEntryWithNameStartingWith("BOOT-INF/lib/jar-snapshot");
try (JarFile jarFile = new JarFile(repackaged)) { try (JarFile jarFile = new JarFile(repackaged)) {
ZipEntry entry = jarFile.getEntry("BOOT-INF/layers.idx"); Map<String, List<String>> layerIndex = readLayerIndex(jarFile);
InputStream inputStream = jarFile.getInputStream(entry); assertThat(layerIndex.keySet()).containsExactly("my-dependencies-name", "snapshot-dependencies",
InputStreamReader reader = new InputStreamReader(inputStream); "configuration", "application");
String[] lines = FileCopyUtils.copyToString(reader).split("\\n");
assertThat(Arrays.stream(lines).map((n) -> n.split(" ")[0]).distinct()).containsExactly(
"my-dependencies-name", "snapshot-dependencies", "configuration", "application");
} }
}); });
} }
@ -378,4 +373,23 @@ class JarIntegrationTests extends AbstractArchiveIntegrationTests {
return jarHash.get(); return jarHash.get();
} }
private Map<String, List<String>> readLayerIndex(JarFile jarFile) throws IOException {
Map<String, List<String>> index = new LinkedHashMap<>();
ZipEntry indexEntry = jarFile.getEntry("BOOT-INF/layers.idx");
try (BufferedReader reader = new BufferedReader(new InputStreamReader(jarFile.getInputStream(indexEntry)))) {
String line = reader.readLine();
String layer = null;
while (line != null) {
if (line.startsWith("- ")) {
layer = line.substring(3, line.length() - 2);
}
else if (line.startsWith(" - ")) {
index.computeIfAbsent(layer, (key) -> new ArrayList<>()).add(line.substring(5, line.length() - 1));
}
line = reader.readLine();
}
return index;
}
}
} }

Loading…
Cancel
Save