Java tutorial
/* * Copyright (c) 2013-2016 Cinchapi Inc. * * 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 * * http://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 com.cinchapi.concourse.server.plugin; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.net.URLClassLoader; import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentMap; import java.util.zip.ZipException; import org.apache.commons.lang.StringUtils; import org.reflections.Reflections; import org.reflections.util.ClasspathHelper; import org.reflections.util.ConfigurationBuilder; import com.cinchapi.concourse.server.io.FileSystem; import com.cinchapi.concourse.server.io.process.JavaApp; import com.cinchapi.concourse.server.io.process.PrematureShutdownHandler; import com.cinchapi.concourse.server.plugin.Plugin.Instruction; import com.cinchapi.concourse.server.plugin.io.SharedMemory; import com.cinchapi.concourse.thrift.AccessToken; import com.cinchapi.concourse.thrift.ComplexTObject; import com.cinchapi.concourse.thrift.TransactionToken; import com.cinchapi.concourse.util.ByteBuffers; import com.cinchapi.concourse.util.ConcurrentMaps; import com.cinchapi.concourse.util.Logger; import com.cinchapi.concourse.util.MorePaths; import com.cinchapi.concourse.util.Reflection; import com.cinchapi.concourse.util.Resources; import com.cinchapi.concourse.util.Serializables; import com.cinchapi.concourse.util.ZipFiles; import com.google.common.base.Throwables; import com.google.common.collect.HashBasedTable; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.collect.Table; import com.google.gson.JsonObject; import com.google.gson.JsonParser; /** * <p> * A {@link PluginManager} is responsible for handling all things (i.e. * starting, stopping, etc) related to plugins. * </p> * <h1>Working with Plugins</h1> * <p> * Any class that extends {@link Plugin} is considered a plugin. Plugins run in * a separate JVM, but are completely and transparently managed by this * {@link PluginManager class} via the utilities provided in the {@link JavaApp} * class. * </p> * <h2>Bundles</h2> * <p> * One or more plugins are packaged together in {@code bundles}. A bundle is * essentially a zip archive that contains the Plugin class(es) and all * necessary dependencies. All the plugins in a bundle are * {@link #installBundle(String) installed} and {@link #uninstallBundle(String) * uninstalled} together. * </p> * <p> * Usually, a bundle will only contain a single plugin; however, it is possible * to bundle multiple plugins if they all have same dependencies. When a bundle * contains multiple plugins, each of the plugins is still launch and managed in * a separate JVM * </p> * <h2>Plugin Lifecycle</h2> * <p> * When the PluginManager starts, it goes through all the bundles and * {@link #activate(String) activates} each Plugin. Part of the activation * routine is {@link #launch(String, Path, Class, List) launching} the plugin in * the external JVM. * </p> * <h2>Plugin Communication</h2> * <p> * Plugins communicate with Concourse Server via {@link SharedMemory} streams * setup by the {@link PluginManager}. Each plugin has two streams: * <ol> * <li>A {@code fromServer} stream that serves as a communication channel for * messages that come from Concourse Server and are read by the Plugin. * Internally, the plugin sets up an event loop that reads messages on the * {@code fromServer} stream and dispatches {@link Instruction#REQUEST requests} * to asynchronous worker threads while {@link Instruction#RESPONSE responses} * are placed on a message queue that is read by the local worker threads.</li> * <li>A {@code fromPlugin} stream that serves as a communication channel for * messages that come from Plugin and are read by the {@link PluginManager} on * behalf of Concourse Server. Internally, the PluginManager sets up an * {@link #startEventLoop(String) event loop} that reads messages on the * {@code fromPlugn} stream and dispatches {@link Instruction#REQUEST requests} * to asynchronous worker threads while {@link Instruction#RESPONSE responses} * are placed on a {@link PluginInfoColumn#FROM_PLUGIN_RESPONSES message queue} * that is read by the local worker threads.</li> * </ol> * </p> * <p> * Plugins are only allowed to communicate with Concourse Server (e.g. they * cannot communicate directly with other plugins). * </p> * <h1>Invoking Plugin Methods</h1> * <p> * Arbitrary plugin methods can be invoked using the * {@link #invoke(String, String, List, AccessToken, TransactionToken, String)} * method. The {@link PluginManager} passes these requests to the appropriate * plugin JVM via the {@code fromServer} {@link SharedMemory stream} that was * setup when the plugin launched. * </p> * <h1>Invoking Server Methods</h1> * <p> * Plugins are allowed to invoke server methods by placing the appropriate * request on the {@code fromPlugin} stream. When that happens, the * PluginManager will send responses back to the plugin on the * {@code fromServer} channel. * </p> * <p> * <em>It is worth noting that plugins can indirectly communicate with one another * by sending a request to the server to invoke another plugin method.</em> * </p> * <h1>Real Time Plugins</h1> * <p> * Any plugin that extends {@link RealTimePlugin} will initially receive a * {@link SharedMemory} segment for real time communication data streams. This * is a one-way stream. Plugins are responsible for decide when and how to * respond to data that is streamed over. * </p> * * @author Jeff Nelson */ @SuppressWarnings({ "unchecked", "rawtypes" }) public class PluginManager { /** * The number of bytes in a MiB. */ private static final long BYTES_PER_MB = 1048576; /** * The name of the manifest file that should be included with every plugin. */ private static String MANIFEST_FILE = "manifest.json"; /** * A collection of jar files that exist on the server's native classpath. We * keep track of these so that we don't unnecessarily search them for * plugins. */ private static Set<String> SYSTEM_JARS; static { SYSTEM_JARS = Sets.newHashSet(); ClassLoader cl = PluginManager.class.getClassLoader(); if (cl instanceof URLClassLoader) { for (URL url : ((URLClassLoader) cl).getURLs()) { String filename = MorePaths.get(url.getFile()).getFileName().toString(); if (filename.endsWith(".jar")) { SYSTEM_JARS.add(filename); } } } Reflections.log = null; } /** * The directory of plugins that are managed by this {@link PluginManager}. */ private final String home; // TODO make the plugin launcher watch the directory for changes/additions // and when new plugins are added, it should launch them /** * A table that contains metadata about the plugins managed herewithin. * (endpoint_class (id) | plugin | shared_memory_path | status | * app_instance) */ private final Table<String, PluginInfoColumn, Object> router = HashBasedTable.create(); /** * All the {@link SharedMemory streams} for which real time data updates are * sent. */ private final Set<SharedMemory> streams = Sets.newSetFromMap(Maps.<SharedMemory, Boolean>newConcurrentMap()); /** * The template to use when creating {@link JavaApp external java processes} * to run the plugin code. */ private String template; /** * Construct a new instance. * * @param directory */ public PluginManager(String directory) { this.home = directory; Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { @Override public void run() { stop(); } })); } /** * Install the plugin bundle located within a zip file to the {@link #home} * directory. * * @param bundle the path to the plugin bundle */ public void installBundle(String bundle) { String basename = com.google.common.io.Files.getNameWithoutExtension(bundle); String name = null; try { String manifest = ZipFiles.getEntryContent(bundle, basename + File.separator + MANIFEST_FILE); JsonObject json = (JsonObject) new JsonParser().parse(manifest); name = json.get("bundleName").getAsString(); ZipFiles.unzip(bundle, home); File src = new File(home + File.separator + basename); File dest = new File(home + File.separator + name); src.renameTo(dest); Logger.info("Installed the plugins in {} at {}", bundle, dest.getAbsolutePath()); activate(name); } catch (Exception e) { Throwable cause = null; if ((cause = e.getCause()) != null && cause instanceof ZipException) { throw new RuntimeException(bundle + " is not a valid plugin bundle"); } else { if (name != null) { // Likely indicates that there was a problem with // activation, so run uninstall path so things are not in a // weird state uninstallBundle(name); } throw e; // re-throw exception so CLI fails } } } /** * Return the names of all the plugins available in the {@link #home} * directory. * * @return the available plugins */ public Set<String> listBundles() { return FileSystem.getSubDirs(home); } /** * Invoke {@code method} that is defined in the plugin endpoint inside of * {@clazz}. The provided {@code creds}, {@code transaction} token and * {@code environment} are used to ensure proper alignment with the * corresponding client session on the server. * * @param clazz the {@link Plugin} endpoint class * @param method the name of the method to invoke * @param args a list of arguments to pass to the method * @param creds the {@link AccessToken} submitted to ConcourseServer via the * invokePlugin method * @param transaction the {@link TransactionToken} submitted to * ConcourseServer via the invokePlugin method * @param environment the environment submitted to ConcourseServer via the * invokePlugin method * @return the response from the plugin */ public ComplexTObject invoke(String clazz, String method, List<ComplexTObject> args, final AccessToken creds, TransactionToken transaction, String environment) { SharedMemory fromServer = (SharedMemory) router.get(clazz, PluginInfoColumn.FROM_SERVER); RemoteMethodRequest request = new RemoteMethodRequest(method, creds, transaction, environment, args); ByteBuffer data0 = Serializables.getBytes(request); ByteBuffer data = ByteBuffer.allocate(data0.capacity() + 4); data.putInt(Plugin.Instruction.REQUEST.ordinal()); data.put(data0); fromServer.write(ByteBuffers.rewind(data)); ConcurrentMap<AccessToken, RemoteMethodResponse> fromPluginResponses = (ConcurrentMap<AccessToken, RemoteMethodResponse>) router .get(clazz, PluginInfoColumn.FROM_PLUGIN_RESPONSES); RemoteMethodResponse response = ConcurrentMaps.waitAndRemove(fromPluginResponses, creds); if (!response.isError()) { return response.response; } else { throw Throwables.propagate(response.error); } } /** * Start the plugin manager. */ public void start() { this.template = FileSystem.read(Resources.getAbsolutePath("/META-INF/PluginLauncher.tpl")); for (String plugin : FileSystem.getSubDirs(home)) { activate(plugin); } } /** * Stop the plugin manager and shutdown any managed plugins that are * running. */ public void stop() { for (String id : router.rowKeySet()) { JavaApp app = (JavaApp) router.get(id, PluginInfoColumn.APP_INSTANCE); app.destroy(); } router.clear(); } /** * Uninstall the plugin {@code bundle} * * @param bundle the name of the plugin bundle */ public void uninstallBundle(String bundle) { // TODO implement me /* * make sure all the plugins in the bundle are stopped * delete the bundle directory */ FileSystem.deleteDirectory(home + File.separator + bundle); } /** * Get all the {@link Plugin plugins} in the {@code bundle} and * {@link #launch(String, Path, Class, List) launch} them each in a separate * JVM. * * @param bundle the path to a bundle directory, which is a sub-directory of * the {@link #home} directory. */ protected void activate(String bundle) { activate(bundle, false); } /** * Get all the {@link Plugin plugins} in the {@code bundle} and * {@link #launch(String, Path, Class, List) launch} them each in a separate * JVM. * * @param bundle the path to a bundle directory, which is a sub-directory of * the {@link #home} directory. * @param runAfterInstallHook a flag that indicates whether the * {@link Plugin#afterInstall()} hook should be run */ protected void activate(String bundle, boolean runAfterInstallHook) { try { String lib = home + File.separator + bundle + File.separator + "lib" + File.separator; Path prefs = Paths.get(home, bundle, PluginConfiguration.PLUGIN_PREFS_FILENAME); Iterator<Path> content = Files.newDirectoryStream(Paths.get(lib)).iterator(); // Go through all the jars in the plugin's lib directory and compile // the appropriate classpath while identifying jars that might // contain plugin endpoints. List<URL> urls = Lists.newArrayList(); List<String> classpath = Lists.newArrayList(); while (content.hasNext()) { String filename = content.next().getFileName().toString(); URL url = new File(lib + filename).toURI().toURL(); if (!SYSTEM_JARS.contains(filename)) { // NOTE: by checking for exact name matches, we will // accidentally include system jars that contain different // versions. urls.add(url); } classpath.add(url.getFile()); } // Create a ClassLoader that only contains jars with possible plugin // endpoints and search for any applicable classes. URLClassLoader loader = new URLClassLoader(urls.toArray(new URL[0]), null); Class parent = loader.loadClass(Plugin.class.getName()); Class realTimeParent = loader.loadClass(RealTimePlugin.class.getName()); Reflections reflection = new Reflections(new ConfigurationBuilder().addClassLoader(loader) .addUrls(ClasspathHelper.forClassLoader(loader))); Set<Class<?>> plugins = reflection.getSubTypesOf(parent); for (final Class<?> plugin : plugins) { if (runAfterInstallHook) { Object instance = Reflection.newInstance(plugin); Reflection.call(instance, "afterInstall"); } launch(bundle, prefs, plugin, classpath); startEventLoop(plugin.getName()); if (realTimeParent.isAssignableFrom(plugin)) { initRealTimeStream(plugin.getName()); } } } catch (IOException | ClassNotFoundException e) { Logger.error("An error occurred while trying to activate the plugin bundle '{}'", bundle, e); throw Throwables.propagate(e); } } /** * Create a {@link SharedMemory} segment over which the PluginManager will * stream real-time {@link Packet packets} that contain writes. * * @param id the plugin id */ private void initRealTimeStream(String id) { String streamFile = FileSystem.tempFile(); SharedMemory stream = new SharedMemory(streamFile); ByteBuffer payload = ByteBuffers.fromString(streamFile); ByteBuffer message = ByteBuffer.allocate(payload.capacity() + 4); message.putInt(Instruction.MESSAGE.ordinal()); message.put(payload); SharedMemory fromServer = (SharedMemory) router.get(id, PluginInfoColumn.FROM_SERVER); fromServer.write(ByteBuffers.rewind(message)); streams.add(stream); } /** * Launch the {@code plugin} from {@code dist} within a separate JVM * configured with the specified {@code classpath} and the values from the * {@code prefs} file. * * @param bundle the bundle directory that contains the plugin libraries * @param prefs the {@link Path} to the config file * @param plugin the class to launch in a separate JVM * @param classpath the classpath for the separate JVM */ private void launch(final String bundle, final Path prefs, final Class<?> plugin, final List<String> classpath) { // Write an arbitrary main class that'll construct the Plugin and run it String launchClass = plugin.getName(); String launchClassShort = plugin.getSimpleName(); String fromServer = FileSystem.tempFile(); String fromPlugin = FileSystem.tempFile(); String source = template.replace("INSERT_IMPORT_STATEMENT", launchClass) .replace("INSERT_FROM_SERVER", fromServer).replace("INSERT_FROM_PLUGIN", fromPlugin) .replace("INSERT_CLASS_NAME", launchClassShort); // Create an external JavaApp in which the Plugin will run. Get the // plugin config to size the JVM properly. PluginConfiguration config = Reflection.newInstance(StandardPluginConfiguration.class, prefs); long heapSize = config.getHeapSize() / BYTES_PER_MB; String[] options = new String[] { "-Xms" + heapSize + "M", "-Xmx" + heapSize + "M" }; JavaApp app = new JavaApp(StringUtils.join(classpath, JavaApp.CLASSPATH_SEPARATOR), source, options); app.run(); if (app.isRunning()) { Logger.info("Starting plugin '{}' from bundle '{}'", launchClass, bundle); } app.onPrematureShutdown(new PrematureShutdownHandler() { @Override public void run(InputStream out, InputStream err) { Logger.warn("Plugin '{}' unexpectedly crashed. " + "Restarting now...", plugin); // TODO: it would be nice to just restart the same JavaApp // instance (e.g. app.restart();) launch(bundle, prefs, plugin, classpath); } }); // Store metadata about the Plugin String id = launchClass; router.put(id, PluginInfoColumn.PLUGIN_BUNDLE, bundle); router.put(id, PluginInfoColumn.FROM_SERVER, new SharedMemory(fromServer)); router.put(id, PluginInfoColumn.FROM_PLUGIN, new SharedMemory(fromPlugin)); router.put(id, PluginInfoColumn.STATUS, PluginStatus.ACTIVE); router.put(id, PluginInfoColumn.APP_INSTANCE, app); router.put(id, PluginInfoColumn.FROM_PLUGIN_RESPONSES, Maps.<AccessToken, RemoteMethodResponse>newConcurrentMap()); } /** * Start a {@link Thread} that serves as an event loop; processing both * requests and responses {@code #fromPlugin}. * <p> * Requests are forked to a {@link RemoteInvocationThread} for processing. * </p> * <p> * Responses are placed on the appropriate * {@link PluginInfoColumn#FROM_PLUGIN_RESPONSES queue} and listeners are * notified. * </p> * * @param id the plugin id * @return the event loop thread */ private Thread startEventLoop(String id) { final SharedMemory requests = (SharedMemory) router.get(id, PluginInfoColumn.FROM_PLUGIN); final SharedMemory responses = (SharedMemory) router.get(id, PluginInfoColumn.FROM_SERVER); final ConcurrentMap<AccessToken, RemoteMethodResponse> fromPluginResponses = (ConcurrentMap<AccessToken, RemoteMethodResponse>) router .get(id, PluginInfoColumn.FROM_PLUGIN_RESPONSES); Thread loop = new Thread(new Runnable() { @Override public void run() { ByteBuffer data; while ((data = requests.read()) != null) { Plugin.Instruction type = ByteBuffers.getEnum(data, Plugin.Instruction.class); data = ByteBuffers.getRemaining(data); if (type == Instruction.REQUEST) { RemoteMethodRequest request = Serializables.read(data, RemoteMethodRequest.class); new RemoteInvocationThread(request, requests, responses, this, true, fromPluginResponses) .start(); } else if (type == Instruction.RESPONSE) { RemoteMethodResponse response = Serializables.read(data, RemoteMethodResponse.class); ConcurrentMaps.putAndSignal(fromPluginResponses, response.creds, response); } else { // STOP break; } } } }); loop.setDaemon(true); loop.start(); return loop; } /** * The columns that are included in the {@link #router} table. * * @author Jeff Nelson */ private enum PluginInfoColumn { /** * A reference to the {@link JavaApp} that manages the external JVM * process for the plugin. */ APP_INSTANCE, /** * A reference to the {@link SharedMemory} stream that is used by the * {@link PluginManager} to listen to messages that come from the * plugin. */ FROM_PLUGIN, /** * A reference to a {@link ConcurrentMap} that associates an * {@link AccessToken} to a {@link RemoteMethodResponse}. This * collection is created for each plugin upon being * {@link PluginManager#launch(String, Path, Class, List) launched}. * Whenever a plugin's {@link PluginManager#startEventLoop(String) event * loop}, * which listen for messages on the associated {@code fromPlugin} * stream, encounters * a {@link Instruction#RESPONSE response} to an * {@link PluginManager#invoke(String, String, List, AccessToken, TransactionToken, String) * invoke} request, the {@link RemoteMethodResponse response} is placed * in the map on which the dispatched {@link RemoteInvocationThread * worker} thread is * {@link ConcurrentMaps#waitAndRemove(ConcurrentMap, Object) waiting}. */ FROM_PLUGIN_RESPONSES, /** * A reference to the {@link SharedMemory} stream that is used by the * {@link PluginManager} to send messages to the plugin. The plugin has * an event loop that listens on this stream. */ FROM_SERVER, /** * The name of the bundle in which the plugin is contained. This is * useful for finding all the plugin that belong to a bundle and need to * be {@link PluginManager#uninstallBundle(String) uninstalled}. */ PLUGIN_BUNDLE, /** * A flag that contains the {@link PluginStatus status} for the plugin. */ STATUS } /** * An enum to capture various statuses that plugins can have. * * @author Jeff Nelson */ private enum PluginStatus { ACTIVE; } }