Coverage Summary for Class: KitodoServiceLoader (org.kitodo.serviceloader)
Class |
Class, %
|
Method, %
|
Line, %
|
KitodoServiceLoader |
100%
(1/1)
|
69,2%
(9/13)
|
46,6%
(68/146)
|
/*
* (c) Kitodo. Key to digital objects e. V. <contact@kitodo.org>
*
* This file is part of the Kitodo project.
*
* It is licensed under GNU General Public License version 3 or later.
*
* For the full copyright and license information, please read the
* GPL3-License.txt file that was distributed with this source code.
*/
package org.kitodo.serviceloader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.DirectoryStream;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collection;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import javax.faces.context.FacesContext;
import javax.servlet.http.HttpSession;
import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.kitodo.config.KitodoConfig;
public class KitodoServiceLoader<T> {
private Class<T> clazz;
private String modulePath = "";
/**
* <p>The class loader chain keeps track of the respective newest class loader
* created for loading new jar files. Previously loaded jar files are
* found by delegating requests to each parent class loader, and finally,
* to the webapp and system class loader. See:</p>
*
* <p>http://tomcat.apache.org/tomcat-9.0-doc/class-loader-howto.html</p>
*
* <p>Module/Plugin classes loaded from jar files can only be accessed through
* this class loader.</p>
*
* <p>In the future, a refresh mechanism could be implemented by throwing away
* this chain, starting a new one and reloading all jars.</p>
*/
private static ClassLoader classLoaderChain = Thread.currentThread().getContextClassLoader();
/**
* Already loaded jars are remembered by their file path, and thus, not
* loaded multiple times during runtime.
*/
private static final Set<String> loadedJars = new HashSet<>();
private static final String POM_PROPERTIES_FILE = "pom.properties";
private static final String ARTIFACT_ID_PROPERTY = "artifactId";
private static final String TEMP_DIR_PREFIX = "kitodo_";
private static final String META_INF_FOLDER = "META-INF";
private static final String RESOURCES_FOLDER = "resources";
private static final String PAGES_FOLDER = "pages";
private static final String JAR = "*.jar";
private static final String ERROR = "Classpath could not be accessed";
private static final Path SYSTEM_TEMP_FOLDER = FileSystems.getDefault()
.getPath(System.getProperty("java.io.tmpdir"));
private static final Logger logger = LogManager.getLogger(KitodoServiceLoader.class);
/**
* Constructor for KitodoServiceLoader.
*
* @param clazz
* interface class of module to load
*/
public KitodoServiceLoader(Class<T> clazz) {
String modulesDirectory = KitodoConfig.getKitodoModulesDirectory();
this.clazz = clazz;
File kitodoModules = new File(modulesDirectory).getAbsoluteFile();
if (kitodoModules.exists()) {
this.modulePath = modulesDirectory;
} else {
logger.error("Specified module folder does not exist: {}", kitodoModules);
}
}
private ServiceLoader<T> getClassLoader() {
loadModulesIntoClasspath();
loadBeans();
loadFrontendFilesIntoCore();
// services and their classes need to be loaded from the class loader
// chain instead of the default class loader
return ServiceLoader.load(clazz, KitodoServiceLoader.classLoaderChain);
}
/**
* Loads a module from the classpath which implements the constructed clazz.
* Frontend files of all modules will be loaded into the core module.
*
* @return A module with type T.
*/
public T loadModule() {
ServiceLoader<T> loader = getClassLoader();
Iterator<T> loaderIterator = loader.iterator();
if (!loaderIterator.hasNext()) {
logger.error("Couldn't find a module for {}!", clazz);
}
return loaderIterator.next();
}
/**
* Loads and returns all modules from the classpath which implement the constructed clazz.
* @return List of modules with type T
*/
public List<T> loadModules() {
ServiceLoader<T> loader = getClassLoader();
LinkedList<T> modules = new LinkedList<>();
loader.iterator().forEachRemaining(modules::add);
return modules;
}
/**
* Loads bean classes and registers them to the FacesContext. Afterwards
* they can be used in all frontend files
*/
private void loadBeans() {
Path moduleFolder = FileSystems.getDefault().getPath(modulePath);
try (DirectoryStream<Path> stream = Files.newDirectoryStream(moduleFolder, JAR)) {
for (Path f : stream) {
try (JarFile jarFile = new JarFile(f.toString())) {
if (hasFrontendFiles(jarFile)) {
Enumeration<JarEntry> entries = jarFile.entries();
URL[] urls = {new URL("jar:file:" + f.toString() + "!/") };
try (URLClassLoader cl = URLClassLoader.newInstance(urls)) {
while (entries.hasMoreElements()) {
JarEntry je = entries.nextElement();
/*
* IMPORTANT: Naming convention: the name of the
* java class has to be in upper camel case or
* "pascal case" and must be equal to the file
* name of the corresponding facelet file
* concatenated with the word "Form".
*
* Example: template filename "sample.xhtml" =>
* "SampleForm.java"
*
* That is the reason for the following check
* (e.g. whether the JarEntry name ends with
* "Form.class")
*/
if (je.isDirectory() || !je.getName().endsWith("Form.class")) {
continue;
}
String className = je.getName().substring(0, je.getName().length() - 6);
className = className.replace('/', '.');
Class<?> aClass = cl.loadClass(className);
String beanName = className.substring(className.lastIndexOf('.') + 1).trim();
FacesContext facesContext = FacesContext.getCurrentInstance();
HttpSession session = (HttpSession) facesContext.getExternalContext().getSession(false);
Object newInstance = aClass.getDeclaredConstructor().newInstance();
session.getServletContext().setAttribute(beanName, newInstance);
}
}
}
}
}
} catch (Exception e) {
logger.error(ERROR, e.getMessage());
}
}
/**
* If the found jar files have frontend files, they will be extracted and
* copied into the frontend folder of the core module. Before copying,
* existing frontend files of the same module will be deleted from the core
* module. Afterwards the created temporary folder will be deleted as well.
*/
private void loadFrontendFilesIntoCore() {
Path moduleFolder = FileSystems.getDefault().getPath(modulePath);
try (DirectoryStream<Path> stream = Files.newDirectoryStream(moduleFolder, JAR)) {
for (Path f : stream) {
File loc = new File(f.toString());
try (JarFile jarFile = new JarFile(loc)) {
if (hasFrontendFiles(jarFile)) {
Path temporaryFolder = Files.createTempDirectory(SYSTEM_TEMP_FOLDER, TEMP_DIR_PREFIX);
File tempDir = new File(Paths.get(temporaryFolder.toUri()).toAbsolutePath().toString());
extractFrontEndFiles(loc.getAbsolutePath(), tempDir);
String moduleName = extractModuleName(tempDir);
if (moduleName.isEmpty()) {
logger.info("No module found in JarFile '{}'.", jarFile.getName());
} else {
FacesContext facesContext = FacesContext.getCurrentInstance();
HttpSession session = (HttpSession) facesContext.getExternalContext().getSession(false);
String filePath = session.getServletContext().getRealPath(File.separator + PAGES_FOLDER)
+ File.separator + moduleName;
FileUtils.deleteDirectory(new File(filePath));
String resourceFolder = String.join(File.separator,
Arrays.asList(tempDir.getAbsolutePath(), META_INF_FOLDER, RESOURCES_FOLDER));
copyFrontEndFiles(resourceFolder, filePath);
}
FileUtils.deleteDirectory(tempDir);
}
}
}
} catch (Exception e) {
logger.error(ERROR, e.getMessage());
}
}
/**
* Extracts the module name of the current module by finding the
* pom.properties in the given temporary folder
*
* @param temporaryFolder
* folder in which the pom.properties file will be searched for
*
* @return String
*/
private String extractModuleName(File temporaryFolder) throws IOException {
String moduleName = "";
File properties = findFile(POM_PROPERTIES_FILE, temporaryFolder);
try (InputStream input = new FileInputStream(properties)) {
Properties prop = new Properties();
prop.load(input);
moduleName = prop.getProperty(ARTIFACT_ID_PROPERTY);
} catch (FileNotFoundException e) {
logger.error(e.getMessage());
}
return moduleName;
}
/**
* Copies extracted frontend files by a given source folder name to the
* destination Folder given by a destination folder name.
*
* @param sourceFolder
* copies all extracted frontend files
* @param destinationFolder
* jarFile that will be checked for frontend files
*/
private void copyFrontEndFiles(String sourceFolder, String destinationFolder) throws IOException {
FileUtils.copyDirectory(new File(sourceFolder), new File(destinationFolder));
}
/**
* Checks, whether a passed jarFile has frontend files or not. Returns true,
* when the jar contains a folder with the name "resources".
*
* @param jarPath
* jarFile that will be checked for frontend files
* @param destinationFolder
* destination path, where the frontend files will be extracted
* to
*
*/
private void extractFrontEndFiles(String jarPath, File destinationFolder) throws IOException {
if (!destinationFolder.exists()) {
destinationFolder.mkdir();
}
try (JarFile jar = new JarFile(jarPath)) {
Enumeration<JarEntry> jarEntries = jar.entries();
while (jarEntries.hasMoreElements()) {
JarEntry currentJarEntry = jarEntries.nextElement();
if (currentJarEntry.getName().contains(RESOURCES_FOLDER)
|| currentJarEntry.getName().contains(POM_PROPERTIES_FILE)) {
File resourceFile = new File(destinationFolder + File.separator + currentJarEntry.getName());
if (!resourceFile.toPath().normalize().startsWith(destinationFolder.toPath())) {
throw new IOException("ZIP file damaged! Invalid entry: " + currentJarEntry.getName());
}
if (currentJarEntry.isDirectory()) {
resourceFile.mkdirs();
continue;
}
if (currentJarEntry.getName().contains(POM_PROPERTIES_FILE)) {
resourceFile.getParentFile().mkdirs();
}
try (InputStream inputStream = jar.getInputStream(currentJarEntry);
FileOutputStream fos = new FileOutputStream(resourceFile)) {
while (inputStream.available() > 0) {
fos.write(inputStream.read());
}
}
}
}
}
}
/**
* Checks, whether a passed jarFile has frontend files or not. Returns true,
* when the jar contains a folder with the name "resources"
*
* @param jarFile
* jarFile that will be checked for frontend files
*
* @return boolean
*/
private boolean hasFrontendFiles(JarFile jarFile) {
Enumeration<JarEntry> enums = jarFile.entries();
while (enums.hasMoreElements()) {
JarEntry jarEntry = enums.nextElement();
if (jarEntry.getName().contains(RESOURCES_FOLDER) && jarEntry.isDirectory()) {
return true;
}
}
return false;
}
/**
* Tries to find a file by a given file name in a folder by folder name.
*
* @param name
* file name that will be searched for
* @param folder
* folder that will be searched
*
* @return File
*
* @throws FileNotFoundException
* when File with given name could not be found in given folder
*
*/
private File findFile(String name, File folder) throws FileNotFoundException {
Collection<File> files = FileUtils.listFiles(folder, null, true);
for (File currentFile : files) {
if (currentFile.getName().equals(name)) {
return currentFile;
}
}
throw new FileNotFoundException(
"ERROR: file '" + name + "' not found in folder '" + folder.getAbsolutePath() + "'!");
}
/**
* <p>Loads jars from the modules directory by creating a separate class
* loader each time jars are loaded, connected in a chain of class loaders
* through their parent relationship.
* A ServiceLoader can find them when using the most recent class loader
* added to the chain of class loaders.</p>
*
* <p>If used inappropriately, this may lead to unexpected behaviour, e.g.,
* when referring to the same singleton from multiple modules, since
* classes could be loaded twice.</p>
*
* <p>If several modules depend on each other (load classes from another
* module), both modules have to be present at the same time when loading
* happens. Otherwise, the order at which jars are loaded could break
* things, since new classes will not be visible to jars loaded by an
* earlier class loader created at an earlier time.</p>
*/
private void loadModulesIntoClasspath() {
Path moduleFolder = FileSystems.getDefault().getPath(modulePath);
try (DirectoryStream<Path> stream = Files.newDirectoryStream(moduleFolder, JAR)) {
// collect urls of new jars present in the module directory
Set<URL> jarsToBeAdded = new HashSet<>();
for (Path f : stream) {
File loc = new File(f.toString());
URL url = loc.toURI().toURL();
if (!KitodoServiceLoader.loadedJars.contains(url.toString())) {
jarsToBeAdded.add(url);
}
}
// create a single URL class loader with all jars
// such that plugins can load classes from each other
if (jarsToBeAdded.size() > 0) {
for (URL url : jarsToBeAdded) {
logger.info("Loading module jar file from path " + url.toString());
KitodoServiceLoader.loadedJars.add(url.toString());
}
URL[] urls = new URL[jarsToBeAdded.size()];
jarsToBeAdded.toArray(urls);
classLoaderChain = new URLClassLoader(urls, KitodoServiceLoader.classLoaderChain);
}
} catch (IOException e) {
logger.error(ERROR, e.getMessage());
}
}
}