org.springframework.boot.loader.tools.Repackager.java Source code

Java tutorial

Introduction

Here is the source code for org.springframework.boot.loader.tools.Repackager.java

Source

/*
 * Copyright 2012-2019 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.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.TimeUnit;
import java.util.jar.JarFile;
import java.util.jar.Manifest;

import org.apache.commons.compress.archivers.jar.JarArchiveEntry;

import org.springframework.boot.loader.tools.JarWriter.EntryTransformer;
import org.springframework.boot.loader.tools.JarWriter.UnpackHandler;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

/**
 * Utility class that can be used to repackage an archive so that it can be executed using
 * '{@literal java -jar}'.
 *
 * @author Phillip Webb
 * @author Andy Wilkinson
 * @author Stephane Nicoll
 * @since 1.0.0
 */
public class Repackager {

    private static final String MAIN_CLASS_ATTRIBUTE = "Main-Class";

    private static final String START_CLASS_ATTRIBUTE = "Start-Class";

    private static final String BOOT_VERSION_ATTRIBUTE = "Spring-Boot-Version";

    private static final String BOOT_LIB_ATTRIBUTE = "Spring-Boot-Lib";

    private static final String BOOT_CLASSES_ATTRIBUTE = "Spring-Boot-Classes";

    private static final byte[] ZIP_FILE_HEADER = new byte[] { 'P', 'K', 3, 4 };

    private static final long FIND_WARNING_TIMEOUT = TimeUnit.SECONDS.toMillis(10);

    private static final String SPRING_BOOT_APPLICATION_CLASS_NAME = "org.springframework.boot.autoconfigure.SpringBootApplication";

    private List<MainClassTimeoutWarningListener> mainClassTimeoutListeners = new ArrayList<>();

    private String mainClass;

    private boolean backupSource = true;

    private final File source;

    private Layout layout;

    private LayoutFactory layoutFactory;

    public Repackager(File source) {
        this(source, null);
    }

    public Repackager(File source, LayoutFactory layoutFactory) {
        if (source == null) {
            throw new IllegalArgumentException("Source file must be provided");
        }
        if (!source.exists() || !source.isFile()) {
            throw new IllegalArgumentException(
                    "Source must refer to an existing file, got " + source.getAbsolutePath());
        }
        this.source = source.getAbsoluteFile();
        this.layoutFactory = layoutFactory;
    }

    /**
     * Add a listener that will be triggered to display a warning if searching for the
     * main class takes too long.
     * @param listener the listener to add
     */
    public void addMainClassTimeoutWarningListener(MainClassTimeoutWarningListener listener) {
        this.mainClassTimeoutListeners.add(listener);
    }

    /**
     * Sets the main class that should be run. If not specified the value from the
     * MANIFEST will be used, or if no manifest entry is found the archive will be
     * searched for a suitable class.
     * @param mainClass the main class name
     */
    public void setMainClass(String mainClass) {
        this.mainClass = mainClass;
    }

    /**
     * Sets if source files should be backed up when they would be overwritten.
     * @param backupSource if source files should be backed up
     */
    public void setBackupSource(boolean backupSource) {
        this.backupSource = backupSource;
    }

    /**
     * Sets the layout to use for the jar. Defaults to {@link Layouts#forFile(File)}.
     * @param layout the layout
     */
    public void setLayout(Layout layout) {
        if (layout == null) {
            throw new IllegalArgumentException("Layout must not be null");
        }
        this.layout = layout;
    }

    /**
     * Sets the layout factory for the jar. The factory can be used when no specific
     * layout is specified.
     * @param layoutFactory the layout factory to set
     */
    public void setLayoutFactory(LayoutFactory layoutFactory) {
        this.layoutFactory = layoutFactory;
    }

    /**
     * Repackage the source file so that it can be run using '{@literal java -jar}'.
     * @param libraries the libraries required to run the archive
     * @throws IOException if the file cannot be repackaged
     */
    public void repackage(Libraries libraries) throws IOException {
        repackage(this.source, libraries);
    }

    /**
     * Repackage to the given destination so that it can be launched using '
     * {@literal java -jar}'.
     * @param destination the destination file (may be the same as the source)
     * @param libraries the libraries required to run the archive
     * @throws IOException if the file cannot be repackaged
     */
    public void repackage(File destination, Libraries libraries) throws IOException {
        repackage(destination, libraries, null);
    }

    /**
     * Repackage to the given destination so that it can be launched using '
     * {@literal java -jar}'.
     * @param destination the destination file (may be the same as the source)
     * @param libraries the libraries required to run the archive
     * @param launchScript an optional launch script prepended to the front of the jar
     * @throws IOException if the file cannot be repackaged
     * @since 1.3.0
     */
    public void repackage(File destination, Libraries libraries, LaunchScript launchScript) throws IOException {
        if (destination == null || destination.isDirectory()) {
            throw new IllegalArgumentException("Invalid destination");
        }
        if (libraries == null) {
            throw new IllegalArgumentException("Libraries must not be null");
        }
        if (this.layout == null) {
            this.layout = getLayoutFactory().getLayout(this.source);
        }
        destination = destination.getAbsoluteFile();
        File workingSource = this.source;
        if (alreadyRepackaged() && this.source.equals(destination)) {
            return;
        }
        if (this.source.equals(destination)) {
            workingSource = getBackupFile();
            workingSource.delete();
            renameFile(this.source, workingSource);
        }
        destination.delete();
        try {
            try (JarFile jarFileSource = new JarFile(workingSource)) {
                repackage(jarFileSource, destination, libraries, launchScript);
            }
        } finally {
            if (!this.backupSource && !this.source.equals(workingSource)) {
                deleteFile(workingSource);
            }
        }
    }

    private LayoutFactory getLayoutFactory() {
        if (this.layoutFactory != null) {
            return this.layoutFactory;
        }
        List<LayoutFactory> factories = SpringFactoriesLoader.loadFactories(LayoutFactory.class, null);
        if (factories.isEmpty()) {
            return new DefaultLayoutFactory();
        }
        Assert.state(factories.size() == 1, "No unique LayoutFactory found");
        return factories.get(0);
    }

    /**
     * Return the {@link File} to use to backup the original source.
     * @return the file to use to backup the original source
     */
    public final File getBackupFile() {
        return new File(this.source.getParentFile(), this.source.getName() + ".original");
    }

    private boolean alreadyRepackaged() throws IOException {
        try (JarFile jarFile = new JarFile(this.source)) {
            Manifest manifest = jarFile.getManifest();
            return (manifest != null && manifest.getMainAttributes().getValue(BOOT_VERSION_ATTRIBUTE) != null);
        }
    }

    private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript)
            throws IOException {
        WritableLibraries writeableLibraries = new WritableLibraries(libraries);
        try (JarWriter writer = new JarWriter(destination, launchScript)) {
            writer.writeManifest(buildManifest(sourceJar));
            writeLoaderClasses(writer);
            if (this.layout instanceof RepackagingLayout) {
                writer.writeEntries(sourceJar,
                        new RenamingEntryTransformer(
                                ((RepackagingLayout) this.layout).getRepackagedClassesLocation()),
                        writeableLibraries);
            } else {
                writer.writeEntries(sourceJar, writeableLibraries);
            }
            writeableLibraries.write(writer);
        }
    }

    private void writeLoaderClasses(JarWriter writer) throws IOException {
        if (this.layout instanceof CustomLoaderLayout) {
            ((CustomLoaderLayout) this.layout).writeLoadedClasses(writer);
        } else if (this.layout.isExecutable()) {
            writer.writeLoaderClasses();
        }
    }

    private boolean isZip(File file) {
        try {
            try (FileInputStream fileInputStream = new FileInputStream(file)) {
                return isZip(fileInputStream);
            }
        } catch (IOException ex) {
            return false;
        }
    }

    private boolean isZip(InputStream inputStream) throws IOException {
        for (byte magicByte : ZIP_FILE_HEADER) {
            if (inputStream.read() != magicByte) {
                return false;
            }
        }
        return true;
    }

    private Manifest buildManifest(JarFile source) throws IOException {
        Manifest manifest = source.getManifest();
        if (manifest == null) {
            manifest = new Manifest();
            manifest.getMainAttributes().putValue("Manifest-Version", "1.0");
        }
        manifest = new Manifest(manifest);
        String startClass = this.mainClass;
        if (startClass == null) {
            startClass = manifest.getMainAttributes().getValue(MAIN_CLASS_ATTRIBUTE);
        }
        if (startClass == null) {
            startClass = findMainMethodWithTimeoutWarning(source);
        }
        String launcherClassName = this.layout.getLauncherClassName();
        if (launcherClassName != null) {
            manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, launcherClassName);
            if (startClass == null) {
                throw new IllegalStateException("Unable to find main class");
            }
            manifest.getMainAttributes().putValue(START_CLASS_ATTRIBUTE, startClass);
        } else if (startClass != null) {
            manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, startClass);
        }
        String bootVersion = getClass().getPackage().getImplementationVersion();
        manifest.getMainAttributes().putValue(BOOT_VERSION_ATTRIBUTE, bootVersion);
        manifest.getMainAttributes().putValue(BOOT_CLASSES_ATTRIBUTE,
                (this.layout instanceof RepackagingLayout)
                        ? ((RepackagingLayout) this.layout).getRepackagedClassesLocation()
                        : this.layout.getClassesLocation());
        String lib = this.layout.getLibraryDestination("", LibraryScope.COMPILE);
        if (StringUtils.hasLength(lib)) {
            manifest.getMainAttributes().putValue(BOOT_LIB_ATTRIBUTE, lib);
        }
        return manifest;
    }

    private String findMainMethodWithTimeoutWarning(JarFile source) throws IOException {
        long startTime = System.currentTimeMillis();
        String mainMethod = findMainMethod(source);
        long duration = System.currentTimeMillis() - startTime;
        if (duration > FIND_WARNING_TIMEOUT) {
            for (MainClassTimeoutWarningListener listener : this.mainClassTimeoutListeners) {
                listener.handleTimeoutWarning(duration, mainMethod);
            }
        }
        return mainMethod;
    }

    protected String findMainMethod(JarFile source) throws IOException {
        return MainClassFinder.findSingleMainClass(source, this.layout.getClassesLocation(),
                SPRING_BOOT_APPLICATION_CLASS_NAME);
    }

    private void renameFile(File file, File dest) {
        if (!file.renameTo(dest)) {
            throw new IllegalStateException("Unable to rename '" + file + "' to '" + dest + "'");
        }
    }

    private void deleteFile(File file) {
        if (!file.delete()) {
            throw new IllegalStateException("Unable to delete '" + file + "'");
        }
    }

    /**
     * Callback interface used to present a warning when finding the main class takes too
     * long.
     */
    @FunctionalInterface
    public interface MainClassTimeoutWarningListener {

        /**
         * Handle a timeout warning.
         * @param duration the amount of time it took to find the main method
         * @param mainMethod the main method that was actually found
         */
        void handleTimeoutWarning(long duration, String mainMethod);

    }

    /**
     * An {@code EntryTransformer} that renames entries by applying a prefix.
     */
    private static final class RenamingEntryTransformer implements EntryTransformer {

        private final String namePrefix;

        private RenamingEntryTransformer(String namePrefix) {
            this.namePrefix = namePrefix;
        }

        @Override
        public JarArchiveEntry transform(JarArchiveEntry entry) {
            if (entry.getName().equals("META-INF/INDEX.LIST")) {
                return null;
            }
            if ((entry.getName().startsWith("META-INF/") && !entry.getName().equals("META-INF/aop.xml")
                    && !entry.getName().endsWith(".kotlin_module")) || entry.getName().startsWith("BOOT-INF/")
                    || entry.getName().equals("module-info.class")) {
                return entry;
            }
            JarArchiveEntry renamedEntry = new JarArchiveEntry(this.namePrefix + entry.getName());
            renamedEntry.setTime(entry.getTime());
            renamedEntry.setSize(entry.getSize());
            renamedEntry.setMethod(entry.getMethod());
            if (entry.getComment() != null) {
                renamedEntry.setComment(entry.getComment());
            }
            renamedEntry.setCompressedSize(entry.getCompressedSize());
            renamedEntry.setCrc(entry.getCrc());
            if (entry.getCreationTime() != null) {
                renamedEntry.setCreationTime(entry.getCreationTime());
            }
            if (entry.getExtra() != null) {
                renamedEntry.setExtra(entry.getExtra());
            }
            if (entry.getLastAccessTime() != null) {
                renamedEntry.setLastAccessTime(entry.getLastAccessTime());
            }
            if (entry.getLastModifiedTime() != null) {
                renamedEntry.setLastModifiedTime(entry.getLastModifiedTime());
            }
            return renamedEntry;
        }

    }

    /**
     * An {@link UnpackHandler} that determines that an entry needs to be unpacked if a
     * library that requires unpacking has a matching entry name.
     */
    private final class WritableLibraries implements UnpackHandler {

        private final Map<String, Library> libraryEntryNames = new LinkedHashMap<>();

        private WritableLibraries(Libraries libraries) throws IOException {
            libraries.doWithLibraries((library) -> {
                if (isZip(library.getFile())) {
                    String libraryDestination = Repackager.this.layout.getLibraryDestination(library.getName(),
                            library.getScope());
                    if (libraryDestination != null) {
                        Library existing = this.libraryEntryNames
                                .putIfAbsent(libraryDestination + library.getName(), library);
                        if (existing != null) {
                            throw new IllegalStateException("Duplicate library " + library.getName());
                        }
                    }
                }
            });
        }

        @Override
        public boolean requiresUnpack(String name) {
            Library library = this.libraryEntryNames.get(name);
            return library != null && library.isUnpackRequired();
        }

        @Override
        public String sha1Hash(String name) throws IOException {
            Library library = this.libraryEntryNames.get(name);
            if (library == null) {
                throw new IllegalArgumentException("No library found for entry name '" + name + "'");
            }
            return FileUtils.sha1Hash(library.getFile());
        }

        private void write(JarWriter writer) throws IOException {
            for (Entry<String, Library> entry : this.libraryEntryNames.entrySet()) {
                writer.writeNestedLibrary(entry.getKey().substring(0, entry.getKey().lastIndexOf('/') + 1),
                        entry.getValue());
            }
        }

    }

}