View Javadoc
1   /*
2    * Copyright 2017-2022 original authors
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    * https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package io.micronaut.build;
17  
18  import io.methvin.watcher.DirectoryChangeEvent;
19  import io.methvin.watcher.DirectoryWatcher;
20  import io.micronaut.build.aot.AotAnalysisMojo;
21  import io.micronaut.build.services.CompilerService;
22  import io.micronaut.build.services.DependencyResolutionService;
23  import io.micronaut.build.services.ExecutorService;
24  import io.micronaut.build.testresources.AbstractTestResourcesMojo;
25  import io.micronaut.build.testresources.TestResourcesHelper;
26  import io.micronaut.testresources.buildtools.ServerUtils;
27  import org.apache.maven.execution.MavenSession;
28  import org.apache.maven.model.FileSet;
29  import org.apache.maven.plugin.BuildPluginManager;
30  import org.apache.maven.plugin.MojoExecutionException;
31  import org.apache.maven.plugins.annotations.*;
32  import org.apache.maven.project.*;
33  import org.apache.maven.toolchain.ToolchainManager;
34  import org.codehaus.plexus.util.AbstractScanner;
35  import org.codehaus.plexus.util.cli.CommandLineUtils;
36  import org.eclipse.aether.artifact.Artifact;
37  import org.eclipse.aether.graph.Dependency;
38  import org.eclipse.aether.resolution.ArtifactResult;
39  import org.eclipse.aether.resolution.DependencyResolutionException;
40  import org.eclipse.aether.util.artifact.JavaScopes;
41  
42  import javax.inject.Inject;
43  import java.io.File;
44  import java.nio.file.Files;
45  import java.nio.file.Path;
46  import java.util.*;
47  import java.util.stream.Stream;
48  
49  import static io.micronaut.build.MojoUtils.findJavaExecutable;
50  import static io.micronaut.build.services.DependencyResolutionService.testResourcesModuleToAetherArtifact;
51  import static java.nio.file.Files.isDirectory;
52  import static java.nio.file.Files.isReadable;
53  import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
54  
55  /**
56   * <p>Executes a Micronaut application in development mode.</p>
57   *
58   * <p>It watches for changes in the project tree. If there are changes in the {@code pom.xml} file, dependencies will be reloaded. If
59   * the changes are anywhere underneath {@code src/main}, it will recompile the project and restart the application.</p>
60   *
61   * <p>The plugin can handle changes in all the languages supported by Micronaut: Java, Kotlin and Groovy.</p>
62   *
63   * @author Álvaro Sánchez-Mariscal
64   * @since 1.0.0
65   */
66  @SuppressWarnings("unused")
67  @Mojo(name = "run", requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, defaultPhase = LifecyclePhase.PREPARE_PACKAGE)
68  @Execute(phase = LifecyclePhase.PROCESS_CLASSES)
69  public class RunMojo extends AbstractTestResourcesMojo {
70  
71      public static final String MN_APP_ARGS = "mn.appArgs";
72      public static final String EXEC_MAIN_CLASS = "${exec.mainClass}";
73      public static final String RESOURCES_DIR = "src/main/resources";
74      public static final String THIS_PLUGIN = "io.micronaut.build:micronaut-maven-plugin";
75  
76      private static final int LAST_COMPILATION_THRESHOLD = 500;
77      private static final List<String> DEFAULT_EXCLUDES;
78  
79      static {
80          DEFAULT_EXCLUDES = new ArrayList<>();
81          Collections.addAll(DEFAULT_EXCLUDES, AbstractScanner.DEFAULTEXCLUDES);
82          Collections.addAll(DEFAULT_EXCLUDES, "**/.idea/**");
83      }
84  
85      private final MavenSession mavenSession;
86      private final ProjectBuilder projectBuilder;
87      private final ToolchainManager toolchainManager;
88      private final String javaExecutable;
89      private final DependencyResolutionService dependencyResolutionService;
90      private final CompilerService compilerService;
91      private final ExecutorService executorService;
92      private final Path projectRootDirectory;
93  
94      /**
95       * The project's target directory.
96       */
97      @Parameter(defaultValue = "${project.build.directory}")
98      private File targetDirectory;
99  
100     /**
101      * The main class of the application, as defined in the
102      * <a href="https://www.mojohaus.org/exec-maven-plugin/java-mojo.html#mainClass">Exec Maven Plugin</a>.
103      */
104     @Parameter(defaultValue = EXEC_MAIN_CLASS, required = true)
105     private String mainClass;
106 
107     /**
108      * Whether to start the Micronaut application in debug mode.
109      */
110     @Parameter(property = "mn.debug", defaultValue = "false")
111     private boolean debug;
112 
113     /**
114      * Whether to suspend the execution of the application when running in debug mode.
115      */
116     @Parameter(property = "mn.debug.suspend", defaultValue = "false")
117     private boolean debugSuspend;
118 
119     /**
120      * The port where remote debuggers can be attached to.
121      */
122     @Parameter(property = "mn.debug.port", defaultValue = "5005")
123     private int debugPort;
124 
125     /**
126      * The host where remote debuggers can connect.
127      */
128     @Parameter(property = "mn.debug.host", defaultValue = "127.0.0.1")
129     private String debugHost;
130 
131     /**
132      * List of inclusion/exclusion paths that should not trigger an application restart. Check the
133      * <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a>
134      * documentation for more details.
135      *
136      * @see <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a>
137      */
138     @Parameter
139     private List<FileSet> watches;
140 
141     /**
142      * <p>List of additional arguments that will be passed to the JVM process, such as Java agent properties.</p>
143      *
144      * <p>When using the command line, user properties will be passed through, eg: <code>mnv mn:run -Dmicronaut.environments=dev</code>.</p>
145      */
146     @Parameter(property = "mn.jvmArgs")
147     private String jvmArguments;
148 
149     /**
150      * List of additional arguments that will be passed to the application, after the class name.
151      */
152     @Parameter(property = MN_APP_ARGS)
153     private String appArguments;
154 
155     /**
156      * Whether to watch for changes, or finish the execution after the first run.
157      */
158     @Parameter(property = "mn.watch", defaultValue = "true")
159     private boolean watchForChanges;
160 
161     /**
162      * Whether to enable or disable Micronaut AOT.
163      */
164     @Parameter(property = "micronaut.aot.enabled", defaultValue = "false")
165     private boolean aotEnabled;
166 
167     private MavenProject mavenProject;
168     private DirectoryWatcher directoryWatcher;
169     private Process process;
170     private String classpath;
171     private int classpathHash;
172     private long lastCompilation;
173     private Map<String, Path> sourceDirectories;
174     private TestResourcesHelper testResourcesHelper;
175 
176     @SuppressWarnings("CdiInjectionPointsInspection")
177     @Inject
178     public RunMojo(MavenProject mavenProject,
179                    MavenSession mavenSession,
180                    BuildPluginManager pluginManager,
181                    ProjectBuilder projectBuilder,
182                    ToolchainManager toolchainManager,
183                    CompilerService compilerService,
184                    ExecutorService executorService,
185                    DependencyResolutionService dependencyResolutionService) {
186         this.mavenProject = mavenProject;
187         this.mavenSession = mavenSession;
188         this.projectBuilder = projectBuilder;
189         this.projectRootDirectory = mavenProject.getBasedir().toPath();
190         this.toolchainManager = toolchainManager;
191         this.compilerService = compilerService;
192         this.executorService = executorService;
193         this.javaExecutable = findJavaExecutable(toolchainManager, mavenSession);
194         this.dependencyResolutionService = dependencyResolutionService;
195     }
196 
197     @Override
198     public void execute() throws MojoExecutionException {
199         testResourcesHelper = new TestResourcesHelper(testResourcesEnabled, keepAlive, shared, buildDirectory,
200                                                       explicitPort, clientTimeout, mavenProject, mavenSession,
201                                                       dependencyResolutionService, toolchainManager, testResourcesVersion,
202                                                       classpathInference, testResourcesDependencies, sharedServerNamespace);
203         resolveDependencies();
204         this.sourceDirectories = compilerService.resolveSourceDirectories();
205 
206         try {
207             maybeStartTestResourcesServer();
208             runApplication();
209             Thread shutdownHook = new Thread(this::killProcess);
210             Runtime.getRuntime().addShutdownHook(shutdownHook);
211 
212             if (process != null && process.isAlive()) {
213                 if (watchForChanges) {
214                     List<Path> pathsToWatch = new ArrayList<>(sourceDirectories.values());
215                     pathsToWatch.add(projectRootDirectory);
216                     pathsToWatch.add(projectRootDirectory.resolve(RESOURCES_DIR));
217 
218                     if (watches != null && !watches.isEmpty()) {
219                         for (FileSet fs : watches) {
220                             File directory = new File(fs.getDirectory());
221                             if (directory.exists()) {
222                                 pathsToWatch.add(directory.toPath());
223                                 //If neither includes nor excludes, add a default include
224                                 if ((fs.getIncludes() == null || fs.getIncludes().isEmpty()) && (fs.getExcludes() == null || fs.getExcludes().isEmpty())) {
225                                     fs.addInclude("**/*");
226                                 }
227                             } else {
228                                 if (getLog().isWarnEnabled()) {
229                                     getLog().warn("The specified directory to watch doesn't exist: " + directory.getPath());
230                                 }
231                             }
232                         }
233                     }
234 
235                     this.directoryWatcher = DirectoryWatcher
236                             .builder()
237                             .paths(pathsToWatch)
238                             .listener(this::handleEvent)
239                             .build();
240 
241                     if (getLog().isDebugEnabled()) {
242                         getLog().debug("Watching for changes...");
243                     }
244                     this.directoryWatcher.watch();
245                 } else if (process != null && process.isAlive()) {
246                     process.waitFor();
247                 }
248             }
249         } catch (InterruptedException e) {
250             Thread.currentThread().interrupt();
251         } catch (Exception e) {
252             if (getLog().isDebugEnabled()) {
253                 getLog().debug("Exception while watching for changes", e);
254             }
255             throw new MojoExecutionException("Exception while watching for changes", e);
256         } finally {
257             killProcess();
258             cleanup();
259         }
260     }
261 
262     private void handleEvent(DirectoryChangeEvent event) {
263         Path path = event.path();
264         Path parent = path.getParent();
265 
266         if (parent.equals(projectRootDirectory)) {
267             if (path.endsWith("pom.xml") && rebuildMavenProject() && resolveDependencies() && classpathHasChanged()) {
268                 if (getLog().isInfoEnabled()) {
269                     getLog().info("Detected POM dependencies change. Restarting application");
270                 }
271                 try {
272                     runApplication();
273                 } catch (Exception e) {
274                     getLog().error("Unable to run application: " + e.getMessage(), e);
275                 }
276             }
277         } else if (matches(path)) {
278             if (getLog().isInfoEnabled()) {
279                 getLog().info("Detected change in " + projectRootDirectory.relativize(path));
280             }
281             boolean compiledOk = compileProject();
282             if (compiledOk) {
283                 try {
284                     runApplication();
285                 } catch (Exception e) {
286                     getLog().error("Unable to run application: " + e.getMessage(), e);
287                 }
288             }
289         }
290     }
291 
292     private boolean matches(Path path) {
293         // Apply default exclusions
294         if (isDefaultExcluded(path) || isDirectory(path, NOFOLLOW_LINKS) || !isReadable(path) || hasBeenCompiledRecently()) {
295             return false;
296         }
297 
298         // Start by checking whether it's a change in any source directory
299         Collection<Path> values = this.sourceDirectories.values();
300         Collection<Path> pathsToCheck = new ArrayList<>(values.size() + 1);
301         pathsToCheck.addAll(values);
302         pathsToCheck.add(projectRootDirectory.resolve(RESOURCES_DIR));
303         boolean matches = pathsToCheck.stream().anyMatch(path.getParent()::startsWith);
304 
305         String relativePath = projectRootDirectory.relativize(path).toString();
306 
307         if (getLog().isDebugEnabled()) {
308             String belongs = matches ? "belongs" : "does not belong";
309             getLog().debug("Path [" + relativePath + "] " + belongs + " to a source directory");
310         }
311 
312         if (watches != null && !watches.isEmpty()) {
313             // Then process includes
314             if (!matches) {
315                 for (FileSet fileSet : watches) {
316                     if (fileSet.getIncludes() != null && !fileSet.getIncludes().isEmpty()) {
317                         File directory = new File(fileSet.getDirectory());
318                         if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) {
319                             for (String includePattern : fileSet.getIncludes()) {
320                                 if (AbstractScanner.match(includePattern, path.toString()) || new File(directory, includePattern).toPath().toAbsolutePath().equals(path)) {
321                                     matches = true;
322                                     if (getLog().isDebugEnabled()) {
323                                         getLog().debug("Path [" + relativePath + "] matched the include pattern [" + includePattern + "] of the directory [" + fileSet.getDirectory() + "]");
324                                     }
325                                     break;
326                                 }
327                             }
328                         }
329                     }
330                     if (matches) {
331                         break;
332                     }
333                 }
334             }
335 
336             // Finally, process excludes only if the path is matching
337             if (matches) {
338                 for (FileSet fileSet : watches) {
339                     if (fileSet.getExcludes() != null && !fileSet.getExcludes().isEmpty()) {
340                         File directory = new File(fileSet.getDirectory());
341                         if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) {
342                             for (String excludePattern : fileSet.getExcludes()) {
343                                 if (AbstractScanner.match(excludePattern, path.toString()) || new File(directory, excludePattern).toPath().toAbsolutePath().equals(path)) {
344                                     matches = false;
345                                     if (getLog().isDebugEnabled()) {
346                                         getLog().debug("Path [" + relativePath + "] matched the exclude pattern [" + excludePattern + "] of the directory [" + fileSet.getDirectory() + "]");
347                                     }
348                                     break;
349                                 }
350                             }
351                         }
352                     }
353                     if (!matches) {
354                         break;
355                     }
356                 }
357             }
358         }
359 
360         return matches;
361     }
362 
363     private boolean isDefaultExcluded(Path path) {
364         boolean excludeTargetDirectory = true;
365         if (this.watches != null && !this.watches.isEmpty()) {
366             for (FileSet fileSet : this.watches) {
367                 if (fileSet.getDirectory().equals(this.targetDirectory.getName())) {
368                     excludeTargetDirectory = false;
369                 }
370             }
371         }
372         return (excludeTargetDirectory && path.startsWith(targetDirectory.getAbsolutePath())) ||
373                 DEFAULT_EXCLUDES.stream()
374                         .anyMatch(excludePattern -> AbstractScanner.match(excludePattern, path.toString()));
375     }
376 
377     private boolean hasBeenCompiledRecently() {
378         return (System.currentTimeMillis() - lastCompilation) < LAST_COMPILATION_THRESHOLD;
379     }
380 
381     private void cleanup() {
382         if (getLog().isDebugEnabled()) {
383             getLog().debug("Cleaning up");
384         }
385         try {
386             directoryWatcher.close();
387             maybeStopTestResourcesServer();
388         } catch (Exception e) {
389             // Do nothing
390         }
391     }
392 
393     private boolean rebuildMavenProject() {
394         boolean success = true;
395         try {
396             ProjectBuildingRequest projectBuildingRequest = mavenSession.getProjectBuildingRequest();
397             projectBuildingRequest.setResolveDependencies(true);
398             ProjectBuildingResult build = projectBuilder.build(mavenProject.getArtifact(), projectBuildingRequest);
399             MavenProject project = build.getProject();
400             mavenProject = project;
401             mavenSession.setCurrentProject(project);
402         } catch (ProjectBuildingException e) {
403             success = false;
404             if (getLog().isWarnEnabled()) {
405                 getLog().warn("Error while trying to build the Maven project model", e);
406             }
407         }
408         return success;
409     }
410 
411     private boolean resolveDependencies() {
412         try {
413             List<Dependency> dependencies = compilerService.resolveDependencies(JavaScopes.COMPILE, JavaScopes.RUNTIME);
414             if (testResourcesEnabled) {
415                 Artifact clientArtifact = testResourcesModuleToAetherArtifact("client", testResourcesVersion);
416                 Dependency dependency = new Dependency(clientArtifact, JavaScopes.RUNTIME);
417                 try {
418                     List<ArtifactResult> results = dependencyResolutionService.artifactResultsFor(Stream.of(clientArtifact), true);
419                     results.forEach(r -> {
420                         if (r.isResolved()) {
421                             dependencies.add(new Dependency(r.getArtifact(), JavaScopes.RUNTIME));
422                         }
423                     });
424                 } catch (DependencyResolutionException e) {
425                     getLog().warn("Unable to resolve test resources client dependencies", e);
426                 }
427             }
428             if (dependencies.isEmpty()) {
429                 return false;
430             } else {
431                 this.classpath = compilerService.buildClasspath(dependencies);
432                 return true;
433             }
434         } finally {
435             if (classpath != null) {
436                 this.classpathHash = this.classpath.hashCode();
437             }
438         }
439     }
440 
441     private boolean classpathHasChanged() {
442         int oldClasspathHash = this.classpathHash;
443         this.classpathHash = this.classpath.hashCode();
444         return oldClasspathHash != classpathHash;
445 
446     }
447 
448     private void runApplication() throws Exception {
449         runAotIfNeeded();
450         String classpathArgument = new File(targetDirectory, "classes" + File.pathSeparator).getAbsolutePath() + this.classpath;
451         if (testResourcesEnabled) {
452             Path testResourcesSettingsDirectory = shared ? ServerUtils.getDefaultSharedSettingsPath(sharedServerNamespace) :
453                     AbstractTestResourcesMojo.serverSettingsDirectoryOf(targetDirectory.toPath());
454             if (Files.isDirectory(testResourcesSettingsDirectory)) {
455                 classpathArgument += File.pathSeparator + testResourcesSettingsDirectory.toAbsolutePath();
456             }
457         }
458         List<String> args = new ArrayList<>();
459         args.add(javaExecutable);
460 
461         if (debug) {
462             String suspend = debugSuspend ? "y" : "n";
463             args.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=" + suspend + ",address=" + debugHost + ":" + debugPort);
464         }
465 
466         if (jvmArguments != null && !jvmArguments.isEmpty()) {
467             final String[] strings = CommandLineUtils.translateCommandline(jvmArguments);
468             args.addAll(Arrays.asList(strings));
469         }
470 
471         if (!mavenSession.getUserProperties().isEmpty()) {
472             mavenSession.getUserProperties().forEach((k, v) -> args.add("-D" + k + "=" + v));
473         }
474 
475         args.add("-classpath");
476         args.add(classpathArgument);
477         args.add("-XX:TieredStopAtLevel=1");
478         args.add("-Dcom.sun.management.jmxremote");
479         args.add(mainClass);
480 
481         if (appArguments != null && !appArguments.isEmpty()) {
482             final String[] strings = CommandLineUtils.translateCommandline(appArguments);
483             args.addAll(Arrays.asList(strings));
484         }
485 
486         if (getLog().isDebugEnabled()) {
487             getLog().debug("Running " + String.join(" ", args));
488         }
489 
490         killProcess();
491         process = new ProcessBuilder(args)
492                 .inheritIO()
493                 .directory(targetDirectory)
494                 .start();
495     }
496 
497     private void runAotIfNeeded() {
498         if (aotEnabled) {
499             try {
500                 executorService.executeGoal(THIS_PLUGIN, AotAnalysisMojo.NAME);
501             } catch (MojoExecutionException e) {
502                 getLog().error(e);
503             }
504         }
505     }
506 
507     private void maybeStartTestResourcesServer() throws MojoExecutionException {
508         testResourcesHelper.start();
509     }
510 
511     private void maybeStopTestResourcesServer() throws MojoExecutionException {
512         testResourcesHelper.stop();
513     }
514 
515     private boolean compileProject() {
516         Optional<Long> lastCompilationMillis = compilerService.compileProject(true);
517         lastCompilationMillis.ifPresent(lc -> this.lastCompilation = lc);
518         return lastCompilationMillis.isPresent();
519     }
520 
521     private void killProcess() {
522         if (getLog().isDebugEnabled()) {
523             getLog().debug("Stopping the background process");
524         }
525         if (process != null && process.isAlive()) {
526             process.destroy();
527             try {
528                 process.waitFor();
529             } catch (InterruptedException e) {
530                 process.destroyForcibly();
531                 Thread.currentThread().interrupt();
532             }
533         }
534     }
535 
536 }