Skip to content

Commit

Permalink
Add command to extract application in an optimized structure
Browse files Browse the repository at this point in the history
This commit is a WIP that hacks the layertools jar to provide an
additional extract command that creates the following structure:

my-dir
├── application
│   └── my-app-1.0.0-SNAPSHOT.jar
├── dependencies
│   ├── ...
│   ├── spring-context-6.1.0-RC2.jar
│   ├── spring-context-support-6.1.0-RC2.jar
│   ├── ...
└── run-app.jar

With such a structure in place, the application can start with
`java -jar run-app.jar`.

Classes that were originally in `BOOT-INF/classes` are jared in the
`application` folder with the same name as the original repackaged jar.

Only dependencies that are listed in the `classpath.idx` file are
copied, and the `run-app.jar` contains a MANIFEST that list those libs
in the same order.
  • Loading branch information
snicoll committed Nov 9, 2023
1 parent 690cfa2 commit a67215a
Show file tree
Hide file tree
Showing 7 changed files with 765 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* Copyright 2012-2023 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.jarmode.layertools;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.FileTime;
import java.util.List;
import java.util.Map;
import java.util.jar.Attributes.Name;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import org.springframework.boot.jarmode.layertools.JarStructure.Entry;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;

/**
* Hacking.
*
* @author Stephane Nicoll
*/
class ExtractJarCommand extends Command {

static final Option DESTINATION_OPTION = Option.of("destination", "string", "The destination to extract files to");

private final Context context;

private final JarStructure jarStructure;

ExtractJarCommand(Context context) {
this(context, IndexedJarStructure.get(context));
}

ExtractJarCommand(Context context, JarStructure jarStructure) {
super("extract2", "Extracts the application in optimized structure", Options.of(DESTINATION_OPTION),
Parameters.none());
this.context = context;
this.jarStructure = jarStructure;
}

@Override
protected void run(Map<Option, String> options, List<String> parameters) {
try {
Path destination = options.containsKey(DESTINATION_OPTION) ? Paths.get(options.get(DESTINATION_OPTION))
: this.context.getWorkingDir().toPath();
Path application = destination.resolve("application");
mkDirs(application);
Path applicationJarFile = application.resolve(this.context.getArchiveFile().getName());

Path dependencies = destination.resolve("dependencies");
mkDirs(dependencies);

try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(applicationJarFile),
this.jarStructure.createApplicationJarManifest())) {
try (ZipInputStream zip = new ZipInputStream(new FileInputStream(this.context.getArchiveFile()))) {
ZipEntry zipEntry = zip.getNextEntry();
Assert.state(zipEntry != null, "File '" + this.context.getArchiveFile().toString()
+ "' is not compatible with layertools; ensure jar file is valid and launch script is not enabled");
while (zipEntry != null) {
Entry resolvedEntry = this.jarStructure.resolve(zipEntry);
if (resolvedEntry != null) {
if (resolvedEntry.library()) {
String location = "dependencies/" + resolvedEntry.location();
write(zip, zipEntry, destination, location);
}
else {
JarEntry jarEntry = createJarEntry(resolvedEntry, zipEntry);
output.putNextEntry(jarEntry);
StreamUtils.copy(zip, output);
output.closeEntry();
}
}
zipEntry = zip.getNextEntry();
}
}
}

// create the run-app.jar
createRunAppJar(destination, applicationJarFile);

}
catch (IOException ex) {
throw new IllegalStateException(ex);
}

}

private void createRunAppJar(Path destination, Path applicationJarFile) throws IOException {
Manifest manifest = this.jarStructure.createRunJarManifest((dependency) -> "dependencies/" + dependency);
String libs = manifest.getMainAttributes().getValue(Name.CLASS_PATH);
String applicationJar = destination.relativize(applicationJarFile).toString();
manifest.getMainAttributes().put(Name.CLASS_PATH, String.join(" ", applicationJar, libs));
Path runJar = destination.resolve("run-app.jar");
try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(runJar), manifest)) {
// EMPTY
}
}

private void write(ZipInputStream zip, ZipEntry entry, Path destination, String name) throws IOException {
String canonicalOutputPath = destination.toFile().getCanonicalPath() + File.separator;
Path file = destination.resolve(name);
String canonicalEntryPath = file.toFile().getCanonicalPath();
Assert.state(canonicalEntryPath.startsWith(canonicalOutputPath),
() -> "Entry '" + entry.getName() + "' would be written to '" + canonicalEntryPath
+ "'. This is outside the output location of '" + canonicalOutputPath
+ "'. Verify the contents of your archive.");
mkParentDirs(file);
try (OutputStream out = Files.newOutputStream(file)) {
StreamUtils.copy(zip, out);
}
try {
Files.getFileAttributeView(file, BasicFileAttributeView.class)
.setTimes(entry.getLastModifiedTime(), entry.getLastAccessTime(), entry.getCreationTime());
}
catch (IOException ex) {
// File system does not support setting time attributes. Continue.
}
}

private static JarEntry createJarEntry(Entry resolvedEntry, ZipEntry originalEntry) {
JarEntry jarEntry = new JarEntry(resolvedEntry.location());
FileTime lastModifiedTime = originalEntry.getLastModifiedTime();
if (lastModifiedTime != null) {
jarEntry.setLastModifiedTime(lastModifiedTime);
}
FileTime lastAccessTime = originalEntry.getLastAccessTime();
if (lastAccessTime != null) {
jarEntry.setLastAccessTime(lastAccessTime);
}
FileTime creationTime = originalEntry.getCreationTime();
if (creationTime != null) {
jarEntry.setCreationTime(creationTime);
}
return jarEntry;
}

private static void mkParentDirs(Path file) throws IOException {
mkDirs(file.getParent());
}

private static void mkDirs(Path file) throws IOException {
Files.createDirectories(file);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Copyright 2012-2023 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.jarmode.layertools;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.NoSuchFileException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.UnaryOperator;
import java.util.jar.Attributes;
import java.util.jar.Attributes.Name;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;

import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;

/**
* {@link JarStructure} implementation backed by a {@code classpath.idx} file.
*
* @author Stephane Nicoll
*/
class IndexedJarStructure implements JarStructure {

private final Manifest originalManifest;

private final String libLocation;

private final String classesLocation;

private final List<String> classpathEntries;

IndexedJarStructure(Manifest originalManifest, String indexFile) {
this.originalManifest = originalManifest;
this.libLocation = getLocation(originalManifest, "Spring-Boot-Lib");
this.classesLocation = getLocation(originalManifest, "Spring-Boot-Classes");
this.classpathEntries = readIndexFile(indexFile);
}

private static String getLocation(Manifest manifest, String attribute) {
String location = getMandatoryAttribute(manifest, attribute);
if (!location.endsWith("/")) {
location = location + "/";
}
return location;
}

private static List<String> readIndexFile(String indexFile) {
String[] lines = Arrays.stream(indexFile.split("\n"))
.map((line) -> line.replace("\r", ""))
.filter(StringUtils::hasText)
.toArray(String[]::new);
List<String> classpathEntries = new ArrayList<>();
for (String line : lines) {
if (line.startsWith("- ")) {
classpathEntries.add(line.substring(3, line.length() - 1));
}
else {
throw new IllegalStateException("Classpath index file is malformed");
}
}
Assert.state(!classpathEntries.isEmpty(), "Empty classpath index file loaded");
return classpathEntries;
}

@Override
public Entry resolve(ZipEntry entry) {
if (entry.isDirectory()) {
return null;
}
String name = entry.getName();
if (this.classpathEntries.contains(name)) {
return new Entry(name, toStructureDependency(name), true);
}
else if (name.startsWith(this.classesLocation)) {
return new Entry(name, name.substring(this.classesLocation.length()), false);
}
return null;
}

@Override
public Manifest createRunJarManifest(UnaryOperator<String> libEntry) {
Manifest manifest = new Manifest();
Attributes attributes = manifest.getMainAttributes();
attributes.put(Name.MANIFEST_VERSION, "1.0");
attributes.put(Name.MAIN_CLASS, getMandatoryAttribute(this.originalManifest, "Start-Class"));
attributes.put(Name.CLASS_PATH,
this.classpathEntries.stream()
.map(this::toStructureDependency)
.map(libEntry)
.collect(Collectors.joining(" ")));
return manifest;
}

@Override
public Manifest createApplicationJarManifest() {
Manifest manifest = new Manifest();
Attributes attributes = manifest.getMainAttributes();
attributes.put(Name.MANIFEST_VERSION, "1.0");
copy(this.originalManifest, manifest, "Implementation-Title");
copy(this.originalManifest, manifest, "Implementation-Version");
return manifest;
}

private static void copy(Manifest left, Manifest right, String attribute) {
String value = left.getMainAttributes().getValue(attribute);
if (value != null) {
right.getMainAttributes().putValue(attribute, value);
}
}

private String toStructureDependency(String libEntryName) {
Assert.state(libEntryName.startsWith(this.libLocation), "Invalid library location " + libEntryName);
return libEntryName.substring(this.libLocation.length());
}

private static String getMandatoryAttribute(Manifest manifest, String attribute) {
String value = manifest.getMainAttributes().getValue(attribute);
if (value == null) {
throw new IllegalStateException("Manifest attribute '" + attribute + "' is mandatory");
}
return value;
}

static IndexedJarStructure get(Context context) {
try {
try (JarFile jarFile = new JarFile(context.getArchiveFile())) {
Manifest manifest = jarFile.getManifest();
String location = getMandatoryAttribute(manifest, "Spring-Boot-Classpath-Index");
ZipEntry entry = jarFile.getEntry(location);
if (entry != null) {
String indexFile = StreamUtils.copyToString(jarFile.getInputStream(entry), StandardCharsets.UTF_8);
return new IndexedJarStructure(manifest, indexFile);
}
}
return null;
}
catch (FileNotFoundException | NoSuchFileException ex) {
return null;
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2012-2023 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.jarmode.layertools;

import java.util.function.UnaryOperator;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;

/**
* Provide information about a fat jar structure that is meant to be extracted.
*
* @author Stephane Nicoll
*/
interface JarStructure {

/**
* Resolve the specified {@link ZipEntry}, return {@code null} if the entry should not
* be handled.
* @param entry the entry to handle
* @return the resolved {@link Entry}
*/
Entry resolve(ZipEntry entry);

/**
* Create the {@link Manifest} for the {@code run-jar} jar, applying the specified
* operator on each classpath entry.
* @param libEntry the operator to apply on each classpath entry
* @return the manifest to use for the {@code run-jar} jar
*/
Manifest createRunJarManifest(UnaryOperator<String> libEntry);

/**
* Create the {@link Manifest} for the jar that is created with classes and resources.
* @return the manifest to use for the {@code application} jar
*/
Manifest createApplicationJarManifest();

/**
* An entry to handle in the exploded structure.
*
* @param originalLocation the original location
* @param location the relative location
* @param library whether the entry refers to a library or a classpath resource
*/
record Entry(String originalLocation, String location, boolean library) {
}

}
Loading

0 comments on commit a67215a

Please sign in to comment.