diff --git a/pom.xml b/pom.xml index 6d5f843a..c119b433 100644 --- a/pom.xml +++ b/pom.xml @@ -258,6 +258,23 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + default-testCompile + + testCompile + + test-compile + + true + + + + + org.codehaus.mojo animal-sniffer-maven-plugin diff --git a/src/main/java/org/codehaus/mojo/exec/ExecJavaMojo.java b/src/main/java/org/codehaus/mojo/exec/ExecJavaMojo.java index 9175adf1..3aeeadc7 100644 --- a/src/main/java/org/codehaus/mojo/exec/ExecJavaMojo.java +++ b/src/main/java/org/codehaus/mojo/exec/ExecJavaMojo.java @@ -4,13 +4,16 @@ import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; +import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; import java.net.URLClassLoader; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Properties; @@ -18,6 +21,8 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.ForkJoinPool; +import java.util.function.BiConsumer; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -29,7 +34,10 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; +import org.codehaus.plexus.PlexusContainer; +import org.codehaus.plexus.component.repository.exception.ComponentLookupException; import org.eclipse.aether.RepositorySystem; +import org.eclipse.aether.artifact.DefaultArtifact; import org.eclipse.aether.collection.CollectRequest; import org.eclipse.aether.graph.Dependency; import org.eclipse.aether.graph.DependencyFilter; @@ -37,8 +45,13 @@ import org.eclipse.aether.resolution.DependencyRequest; import org.eclipse.aether.resolution.DependencyResolutionException; import org.eclipse.aether.resolution.DependencyResult; +import org.eclipse.aether.resolution.VersionRangeRequest; +import org.eclipse.aether.resolution.VersionRangeResolutionException; +import org.eclipse.aether.resolution.VersionRangeResult; import org.eclipse.aether.util.filter.DependencyFilterUtils; +import static java.util.stream.Collectors.toList; + /** * Executes the supplied java class in the current VM with the enclosing project's dependencies as classpath. * @@ -58,6 +71,20 @@ public class ExecJavaMojo extends AbstractExecMojo { * The main class to execute.
* With Java 9 and above you can prefix it with the modulename, e.g. com.greetings/com.greetings.Main * Without modulename the classpath will be used, with modulename a new modulelayer will be created. + *

+ * Note that you can also provide a {@link Runnable} fully qualified name. + * The runnable can get constructor injections either by type if you have maven in your classpath (can be provided) + * or by name (ensure to enable {@code -parameters} Java compiler option) for loose coupling. + * Current support loose injections are: + *

* * @since 1.0 */ @@ -196,10 +223,11 @@ public class ExecJavaMojo extends AbstractExecMojo { /** * Whether to try and prohibit the called Java program from terminating the JVM (and with it the whole Maven build) - * by calling {@link System#exit(int)}. When active, a special security manager will intercept those calls. In case - * of an exit code 0 (OK), it will simply log the fact that {@link System#exit(int)} was called. Otherwise, it will - * throw a {@link SystemExitException}, failing the Maven goal as if the called Java code itself had exited with an - * exception. This way, the error is propagated without terminating the whole Maven JVM. In previous versions, users + * by calling {@link System#exit(int)}. When active, loaded classes will replace this call by a custom callback. + * In case of an exit code 0 (OK), it will simply log the fact that {@link System#exit(int)} was called. + * Otherwise, it will throw a {@link SystemExitException}, failing the Maven goal as if the called Java code itself + * had exited with an exception. + * This way, the error is propagated without terminating the whole Maven JVM. In previous versions, users * had to use the {@code exec} instead of the {@code java} goal in such cases, which now with this option is no * longer necessary. * @@ -208,6 +236,9 @@ public class ExecJavaMojo extends AbstractExecMojo { @Parameter(property = "exec.blockSystemExit", defaultValue = "false") private boolean blockSystemExit; + @Component // todo: for maven4 move to Lookup instead + private PlexusContainer container; + /** * Execute goal. * @@ -249,7 +280,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { // See https://bugs.openjdk.org/browse/JDK-8199704 for details about how users might be able to // block // System::exit in post-removal JDKs (still undecided at the time of writing this comment). - Thread bootstrapThread = new Thread( + Thread bootstrapThread = new Thread( // TODO: drop this useless thread 99% of the time threadGroup, () -> { int sepIndex = mainClass.indexOf('/'); @@ -262,15 +293,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { } try { - Class bootClass = - Thread.currentThread().getContextClassLoader().loadClass(bootClassName); - - MethodHandles.Lookup lookup = MethodHandles.lookup(); - - MethodHandle mainHandle = - lookup.findStatic(bootClass, "main", MethodType.methodType(void.class, String[].class)); - - mainHandle.invoke(arguments); + doExec(bootClassName); } catch (IllegalAccessException | NoSuchMethodException | NoSuchMethodError e) { // just pass it on Thread.currentThread() .getThreadGroup() @@ -295,7 +318,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { } }, mainClass + ".main()"); - URLClassLoader classLoader = getClassLoader(); + URLClassLoader classLoader = getClassLoader(); // TODO: enable to cache accross executions bootstrapThread.setContextClassLoader(classLoader); setSystemProperties(); @@ -315,7 +338,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { try { threadGroup.destroy(); - } catch (RuntimeException /* missing method in future java version */ e) { + } catch (RuntimeException | Error /* missing method in future java version */ e) { getLog().warn("Couldn't destroy threadgroup " + threadGroup, e); } } @@ -344,6 +367,160 @@ public void execute() throws MojoExecutionException, MojoFailureException { registerSourceRoots(); } + private void doExec(final String bootClassName) throws Throwable { + Class bootClass = Thread.currentThread().getContextClassLoader().loadClass(bootClassName); + MethodHandles.Lookup lookup = MethodHandles.lookup(); + try { + doMain(lookup.findStatic(bootClass, "main", MethodType.methodType(void.class, String[].class))); + } catch (final NoSuchMethodException nsme) { + if (Runnable.class.isAssignableFrom(bootClass)) { + doRun(bootClass); + } else { + throw nsme; + } + } + } + + private void doMain(final MethodHandle mainHandle) throws Throwable { + mainHandle.invoke(arguments); + } + + private void doRun(final Class bootClass) + throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { + final Class runnableClass = bootClass.asSubclass(Runnable.class); + final Constructor constructor = Stream.of(runnableClass.getDeclaredConstructors()) + .map(i -> (Constructor) i) + .filter(i -> Modifier.isPublic(i.getModifiers())) + .max(Comparator., Integer>comparing(Constructor::getParameterCount)) + .orElseThrow(() -> new IllegalArgumentException("No public constructor found for " + bootClass)); + if (getLog().isDebugEnabled()) { + getLog().debug("Using constructor " + constructor); + } + + Runnable runnable; + try { // todo: enhance that but since injection API is being defined at mvn4 level it is + // good enough + final Object[] args = Stream.of(constructor.getParameters()) + .map(param -> { + try { + return lookupParam(param); + } catch (final ComponentLookupException e) { + getLog().error(e.getMessage(), e); + throw new IllegalStateException(e); + } + }) + .toArray(Object[]::new); + constructor.setAccessible(true); + runnable = constructor.newInstance(args); + } catch (final RuntimeException re) { + if (getLog().isDebugEnabled()) { + getLog().debug( + "Can't inject " + runnableClass + "': " + re.getMessage() + ", will ignore injections", + re); + } + final Constructor declaredConstructor = runnableClass.getDeclaredConstructor(); + declaredConstructor.setAccessible(true); + runnable = declaredConstructor.newInstance(); + } + runnable.run(); + } + + private Object lookupParam(final java.lang.reflect.Parameter param) throws ComponentLookupException { + final String name = param.getName(); + switch (name) { + // loose coupled to maven (wrapped with standard jvm types to not require it) + case "systemProperties": // Properties + return getSession().getSystemProperties(); + case "systemPropertiesUpdater": // BiConsumer + return propertiesUpdater(getSession().getSystemProperties()); + case "userProperties": // Properties + return getSession().getUserProperties(); + case "userPropertiesUpdater": // BiConsumer + return propertiesUpdater(getSession().getUserProperties()); + case "projectProperties": // Properties + return project.getProperties(); + case "projectPropertiesUpdater": // BiConsumer + return propertiesUpdater(project.getProperties()); + case "highestVersionResolver": // Function + return resolveVersion(VersionRangeResult::getHighestVersion); + // standard bindings + case "session": // MavenSession + return getSession(); + case "container": // PlexusContainer + return container; + default: // Any + return lookup(param, name); + } + } + + private Object lookup(final java.lang.reflect.Parameter param, final String name) throws ComponentLookupException { + // try injecting a real instance but loose coupled - will use reflection + if (param.getType() == Object.class && name.contains("_")) { + final ClassLoader loader = Thread.currentThread().getContextClassLoader(); + + try { + final int hintIdx = name.indexOf("__hint_"); + if (hintIdx > 0) { + final String hint = name.substring(hintIdx + "__hint_".length()); + final String typeName = name.substring(0, hintIdx).replace('_', '.'); + return container.lookup(loader.loadClass(typeName), hint); + } + + final String typeName = name.replace('_', '.'); + return container.lookup(loader.loadClass(typeName)); + } catch (final ClassNotFoundException cnfe) { + if (getLog().isDebugEnabled()) { + getLog().debug("Can't load param (" + name + "): " + cnfe.getMessage(), cnfe); + } + // let's try to lookup object, unlikely but not impossible + } + } + + // just lookup by type + return container.lookup(param.getType()); + } + + private Function resolveVersion(final Function fn) { + return ga -> { + final int sep = ga.indexOf(':'); + if (sep < 0) { + throw new IllegalArgumentException("Invalid groupId:artifactId argument: '" + ga + "'"); + } + + final org.eclipse.aether.artifact.Artifact artifact = new DefaultArtifact(ga + ":[0,)"); + final VersionRangeRequest rangeRequest = new VersionRangeRequest(); + rangeRequest.setArtifact(artifact); + try { + if (includePluginDependencies && includeProjectDependencies) { + rangeRequest.setRepositories(Stream.concat( + project.getRemoteProjectRepositories().stream(), + project.getRemotePluginRepositories().stream()) + .distinct() + .collect(toList())); + } else if (includePluginDependencies) { + rangeRequest.setRepositories(project.getRemotePluginRepositories()); + } else if (includeProjectDependencies) { + rangeRequest.setRepositories(project.getRemoteProjectRepositories()); + } + final VersionRangeResult rangeResult = + repositorySystem.resolveVersionRange(getSession().getRepositorySession(), rangeRequest); + return String.valueOf(fn.apply(rangeResult)); + } catch (final VersionRangeResolutionException e) { + throw new IllegalStateException(e); + } + }; + } + + private BiConsumer propertiesUpdater(final Properties props) { + return (k, v) -> { + if (v == null) { + props.remove(k); + } else { + props.setProperty(k, v); + } + }; + } + /** * To avoid the exec:java to consider common pool threads leaked, let's pre-create them. */ diff --git a/src/site/apt/examples/example-java-runnable.vm b/src/site/apt/examples/example-java-runnable.vm new file mode 100644 index 00000000..c0a52df1 --- /dev/null +++ b/src/site/apt/examples/example-java-runnable.vm @@ -0,0 +1,95 @@ + ------ + Use Runnable with exec:java goal + ------ + Romain Manni-Bucau + ------ + 2022-11-14 + ------ + + ~~ Copyright 2022 The Codehaus + ~~ + ~~ 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. + + ~~ NOTE: For help with the syntax of this file, see: + ~~ http://maven.apache.org/doxia/references/apt-format.html + + +You can use since version `3.1.2` a `Runnable` instead of providing a main class to `exec:java`: + +* pom.xml + +------------------- + + ... + + + + org.codehaus.mojo + exec-maven-plugin + ${project.version} + + + ... + + java + + + + + com.example.MyRunnableImplementation + ... + + + + + ... + +------------------- + +The Runnable can be a plain class but can also get constructor injections: + +* `systemProperties`: `Properties`, session system properties +* `systemPropertiesUpdater`: `BiConsumer<String, String>`, session system properties update callback (pass the key/value to update, null value means removal of the key) +* `userProperties`: `Properties`, session user properties +* `userPropertiesUpdater`: `BiConsumer<String, String>`, session user properties update callback (pass the key/value to update, null value means removal of the key) +* `projectProperties`: `Properties`, project properties +* `projectPropertiesUpdater`: `BiConsumer<String, String>`, project properties update callback (pass the key/value to update, null value means removal of the key) +* `highestVersionResolver`: `Function<String, String>`, passing a `groupId:artifactId` you get the latest resolved version from the project repositories. + +Lastly you can inject a custom maven component naming the Runnable constructor parameter with its type and replacing dots by underscores. +If you need to provide a hint you can suffix previous type name by `__hint_$yourhint` (assuming it stays a valid java name). +This kind of parameter injection must be typed `Object`. + +Example: + +------------------- +public class HelloRunnable implements Runnable { + private final Function versionResolver; + private final Properties properties; + private final BiConsumer updater; + + public HelloRunnable( + final Function highestVersionResolver, + final Properties systemProperties, + final BiConsumer systemPropertiesUpdater) { + this.versionResolver = highestVersionResolver; + this.properties = systemProperties; + this.updater = systemPropertiesUpdater; + } + + public void run() { + final String v = properties.getProperty("test.version"); + updater.accept("hello.runnable.output", v + ": " + (versionResolver != null)); + } +} +------------------- diff --git a/src/site/apt/index.apt b/src/site/apt/index.apt index c439f1f6..5d8fd9d2 100644 --- a/src/site/apt/index.apt +++ b/src/site/apt/index.apt @@ -72,3 +72,5 @@ Exec Maven Plugin * {{{./examples/example-exec-using-executabledependency.html} Using executable binary dependencies instead of local executables}} * {{{./examples/example-java-project-properties.html} Forward maven system properties to the main}} + + * {{{./examples/example-java-runnable.html} Execute a Runnable instead of a main}} diff --git a/src/test/java/org/codehaus/mojo/exec/ExecJavaMojoTest.java b/src/test/java/org/codehaus/mojo/exec/ExecJavaMojoTest.java index 28daf2c9..f4fe1589 100644 --- a/src/test/java/org/codehaus/mojo/exec/ExecJavaMojoTest.java +++ b/src/test/java/org/codehaus/mojo/exec/ExecJavaMojoTest.java @@ -20,11 +20,13 @@ import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; +import java.util.Properties; import org.apache.maven.execution.MavenSession; import org.apache.maven.monitor.logging.DefaultLog; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugin.testing.AbstractMojoTestCase; import org.apache.maven.project.MavenProject; import org.apache.maven.project.ProjectBuilder; @@ -47,8 +49,6 @@ public class ExecJavaMojoTest extends AbstractMojoTestCase { @Mock private MavenSession session; - private static final File LOCAL_REPO = new File("src/test/repository"); - private static final int JAVA_VERSION_MAJOR = Integer.parseInt(System.getProperty("java.version").replaceFirst("[.].*", "")); @@ -58,6 +58,25 @@ public class ExecJavaMojoTest extends AbstractMojoTestCase { * pom, "java" ); System.out.println(output); assertEquals( -1, output.trim().indexOf( "ERROR" ) ); } */ + /** + * Check that a simple execution with no arguments and no system properties produces the expected result.
+ * We load the config from a pom file and fill up the MavenProject property ourselves + * + * @throws Exception if any exception occurs + */ + public void testRunnable() throws Exception { + File pom = new File(getBasedir(), "src/test/projects/project19/pom.xml"); + ExecJavaMojo mojo = (ExecJavaMojo) lookupMojo("java", pom); + setUpProject(pom, mojo); + mojo.setLog(new DefaultLog(new ConsoleLogger(Logger.LEVEL_ERROR, "exec:java"))); + doExecute(mojo, null, null); + assertEquals( + "junit: true", + ((MavenSession) getVariableValueFromObject(mojo, "session")) + .getSystemProperties() + .getProperty("hello.runnable.output")); + } + /** * Check that a simple execution with no arguments and no system properties produces the expected result.
* We load the config from a pom file and fill up the MavenProject property ourselves @@ -295,18 +314,12 @@ private String execute(File pom, String goal) throws Exception { private String execute(File pom, String goal, ByteArrayOutputStream stringOutputStream, OutputStream stderr) throws Exception { - ExecJavaMojo mojo; - mojo = (ExecJavaMojo) lookupMojo(goal, pom); + ExecJavaMojo mojo = (ExecJavaMojo) lookupMojo(goal, pom); setUpProject(pom, mojo); MavenProject project = (MavenProject) getVariableValueFromObject(mojo, "project"); - // why isn't this set up by the harness based on the default-value? TODO get to bottom of this! - setVariableValueToObject(mojo, "includeProjectDependencies", Boolean.TRUE); - setVariableValueToObject(mojo, "cleanupDaemonThreads", Boolean.TRUE); - setVariableValueToObject(mojo, "classpathScope", "compile"); - assertNotNull(mojo); assertNotNull(project); @@ -318,22 +331,41 @@ private String execute(File pom, String goal, ByteArrayOutputStream stringOutput // ensure we don't log unnecessary stuff which would interfere with assessing success of tests mojo.setLog(new DefaultLog(new ConsoleLogger(Logger.LEVEL_ERROR, "exec:java"))); + doExecute(mojo, out, err); + + return stringOutputStream.toString(); + } + + private void doExecute(final ExecJavaMojo mojo, final PrintStream out, final PrintStream err) + throws MojoExecutionException, MojoFailureException, InterruptedException { try { mojo.execute(); } finally { // see testUncooperativeThread() for explaination Thread.sleep(300); // time seems about right - System.setOut(out); - System.setErr(err); + if (out != null) { + System.setOut(out); + } + if (err != null) { + System.setErr(err); + } } - - return stringOutputStream.toString(); } private void setUpProject(File pomFile, AbstractMojo mojo) throws Exception { super.setUp(); + // why isn't this set up by the harness based on the default-value? TODO get to bottom of this! + setVariableValueToObject(mojo, "includeProjectDependencies", Boolean.TRUE); + setVariableValueToObject(mojo, "cleanupDaemonThreads", Boolean.TRUE); + setVariableValueToObject(mojo, "classpathScope", "compile"); + + Properties systemProps = new Properties(); + systemProps.setProperty("test.version", "junit"); + MockitoAnnotations.initMocks(this); + setVariableValueToObject(mojo, "session", session); + when(session.getSystemProperties()).thenReturn(systemProps); ProjectBuildingRequest buildingRequest = mock(ProjectBuildingRequest.class); when(session.getProjectBuildingRequest()).thenReturn(buildingRequest); diff --git a/src/test/java/org/codehaus/mojo/exec/HelloRunnable.java b/src/test/java/org/codehaus/mojo/exec/HelloRunnable.java new file mode 100644 index 00000000..8ae7a799 --- /dev/null +++ b/src/test/java/org/codehaus/mojo/exec/HelloRunnable.java @@ -0,0 +1,25 @@ +package org.codehaus.mojo.exec; + +import java.util.Properties; +import java.util.function.BiConsumer; +import java.util.function.Function; + +public class HelloRunnable implements Runnable { + private final Function versionResolver; + private final Properties properties; + private final BiConsumer updater; + + public HelloRunnable( + final Function highestVersionResolver, + final Properties systemProperties, + final BiConsumer systemPropertiesUpdater) { + this.versionResolver = highestVersionResolver; + this.properties = systemProperties; + this.updater = systemPropertiesUpdater; + } + + public void run() { + final String v = properties.getProperty("test.version"); + updater.accept("hello.runnable.output", v + ": " + (versionResolver != null)); + } +} diff --git a/src/test/projects/project19/pom.xml b/src/test/projects/project19/pom.xml new file mode 100644 index 00000000..f7eff201 --- /dev/null +++ b/src/test/projects/project19/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + org.cb.maven.plugins.exec + project18 + 0.1 + jar + Maven Exec Plugin + 2005 + + + + Apache License 2 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + ${project.artifactId} project + + + + + + org.codehaus.mojo + exec-maven-plugin + + + test + + java + + + + + org.codehaus.mojo.exec.HelloRunnable + + ../../../../target/test-classes + + + + + + +