View Javadoc
1   package io.micronaut.build;
2   
3   import io.methvin.watcher.DirectoryChangeEvent;
4   import io.methvin.watcher.DirectoryWatcher;
5   import io.micronaut.build.services.CompilerService;
6   import io.micronaut.build.services.ExecutorService;
7   import org.apache.maven.execution.MavenSession;
8   import org.apache.maven.model.FileSet;
9   import org.apache.maven.plugin.AbstractMojo;
10  import org.apache.maven.plugin.BuildPluginManager;
11  import org.apache.maven.plugin.MojoExecutionException;
12  import org.apache.maven.plugins.annotations.LifecyclePhase;
13  import org.apache.maven.plugins.annotations.Mojo;
14  import org.apache.maven.plugins.annotations.Parameter;
15  import org.apache.maven.plugins.annotations.ResolutionScope;
16  import org.apache.maven.project.*;
17  import org.apache.maven.toolchain.Toolchain;
18  import org.apache.maven.toolchain.ToolchainManager;
19  import org.codehaus.plexus.util.DirectoryScanner;
20  import org.codehaus.plexus.util.Os;
21  import org.eclipse.aether.RepositorySystemSession;
22  import org.eclipse.aether.graph.Dependency;
23  import org.eclipse.aether.graph.DependencyFilter;
24  import org.eclipse.aether.util.artifact.JavaScopes;
25  import org.eclipse.aether.util.filter.DependencyFilterUtils;
26  
27  import javax.inject.Inject;
28  import java.io.File;
29  import java.io.IOException;
30  import java.nio.file.Path;
31  import java.util.*;
32  import java.util.stream.Collectors;
33  
34  import static java.nio.file.Files.isDirectory;
35  import static java.nio.file.Files.isReadable;
36  import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
37  
38  /**
39   * <p>Executes a Micronaut application in development mode.</p>
40   *
41   * <p>It watches for changes in the project tree. If there are changes in the {@code pom.xml} file, dependencies will be reloaded. If
42   * the changes are anywhere underneath {@code src/main}, it will recompile the project and restart the application.</p>
43   *
44   * <p>The plugin can handle changes in all the languages supported by Micronaut: Java, Kotlin and Groovy.</p>
45   *
46   * @author Álvaro Sánchez-Mariscal
47   * @since 1.0.0
48   */
49  @SuppressWarnings("unused")
50  @Mojo(name = "run", requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, defaultPhase = LifecyclePhase.PREPARE_PACKAGE)
51  public class RunMojo extends AbstractMojo {
52  
53      private static final int LAST_COMPILATION_THRESHOLD = 500;
54      private static final String JAVA = "java";
55      private static final List<String> DEFAULT_EXCLUDES;
56  
57      static {
58          DEFAULT_EXCLUDES = new ArrayList<>();
59          Collections.addAll(DEFAULT_EXCLUDES, DirectoryScanner.DEFAULTEXCLUDES);
60          Collections.addAll(DEFAULT_EXCLUDES, "**/.idea/**");
61      }
62  
63      private final MavenSession mavenSession;
64      private final ProjectDependenciesResolver resolver;
65      private final ProjectBuilder projectBuilder;
66      private final ToolchainManager toolchainManager;
67      private final String javaExecutable;
68      private final CompilerService compilerService;
69      private final Path projectRootDirectory;
70  
71      /**
72       * The project's target directory.
73       */
74      @Parameter(defaultValue = "${project.build.directory}")
75      private File targetDirectory;
76  
77      /**
78       * The main class of the application, as defined in the
79       * <a href="https://www.mojohaus.org/exec-maven-plugin/java-mojo.html#mainClass">Exec Maven Plugin</a>.
80       */
81      @Parameter(defaultValue = "${exec.mainClass}", required = true)
82      private String mainClass;
83  
84      /**
85       * Whether to start the Micronaut application in debug mode.
86       */
87      @Parameter(property = "mn.debug", defaultValue = "false")
88      private boolean debug;
89  
90      /**
91       * Whether to suspend the execution of the application when running in debug mode.
92       */
93      @Parameter(property = "mn.debug.suspend", defaultValue = "false")
94      private boolean debugSuspend;
95  
96      /**
97       * The port where remote debuggers can be attached to.
98       */
99      @Parameter(property = "mn.debug.port", defaultValue = "5005")
100     private int debugPort;
101 
102     /**
103      * List of inclusion/exclusion paths that should not trigger an application restart. Check the
104      * <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a>
105      * documentation for more details.
106      *
107      * @see <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a>
108      */
109     @Parameter
110     private List<FileSet> watches;
111 
112     /**
113      * <p>List of additional arguments that will be passed to the JVM process, such as Java agent properties.</p>
114      *
115      * <p>When using the command line, user properties will be passed through, eg: <code>mnv mn:run -Dmicronaut.environments=dev</code>.</p>
116      */
117     @Parameter(property = "mn.jvmArgs")
118     private List<String> jvmArguments;
119 
120     /**
121      * List of additional arguments that will be passed to the application, after the class name.
122      */
123     @Parameter(property = "mn.appArgs")
124     private List<String> appArguments;
125 
126     /**
127      * Whether to watch for changes, or finish the execution after the first run.
128      */
129     @Parameter(property = "mn.watch", defaultValue = "true")
130     private boolean watchForChanges;
131 
132     private MavenProject mavenProject;
133     private DirectoryWatcher directoryWatcher;
134     private Process process;
135     private List<Dependency> projectDependencies;
136     private String classpath;
137     private int classpathHash;
138     private long lastCompilation;
139     private Map<String, Path> sourceDirectories;
140 
141     @SuppressWarnings("CdiInjectionPointsInspection")
142     @Inject
143     public RunMojo(MavenProject mavenProject, MavenSession mavenSession, BuildPluginManager pluginManager,
144                    ProjectDependenciesResolver resolver, ProjectBuilder projectBuilder, ToolchainManager toolchainManager,
145                    CompilerService compilerService, ExecutorService executorService) {
146         this.mavenProject = mavenProject;
147         this.mavenSession = mavenSession;
148         this.resolver = resolver;
149         this.projectBuilder = projectBuilder;
150         this.projectRootDirectory = mavenProject.getBasedir().toPath();
151         this.toolchainManager = toolchainManager;
152         this.compilerService = compilerService;
153         this.javaExecutable = findJavaExecutable();
154 
155         resolveDependencies();
156         this.classpathHash = this.classpath.hashCode();
157     }
158 
159     @Override
160     @SuppressWarnings("unchecked")
161     public void execute() throws MojoExecutionException {
162         this.sourceDirectories = compilerService.resolveSourceDirectories();
163         if (compilerService.needsCompilation()) {
164             compilerService.compileProject(true);
165         }
166 
167         try {
168             runApplication();
169             Thread shutdownHook = new Thread(this::killProcess);
170             Runtime.getRuntime().addShutdownHook(shutdownHook);
171 
172             if (watchForChanges) {
173                 List<Path> pathsToWatch = new ArrayList<>(sourceDirectories.values());
174                 pathsToWatch.add(projectRootDirectory);
175 
176                 if (watches != null && !watches.isEmpty()) {
177                     for (FileSet fs : watches) {
178                         File directory = new File(fs.getDirectory());
179                         if (directory.exists()) {
180                             pathsToWatch.add(directory.toPath());
181                             //If neither includes nor excludes, add a default include
182                             if ((fs.getIncludes() == null || fs.getIncludes().isEmpty()) && (fs.getExcludes() == null || fs.getExcludes().isEmpty())) {
183                                 fs.addInclude("**/*");
184                             }
185                         } else {
186                             if (getLog().isWarnEnabled()) {
187                                 getLog().warn("The specified directory to watch doesn't exist: " + directory.getPath());
188                             }
189                         }
190                     }
191                 }
192 
193                 this.directoryWatcher = DirectoryWatcher
194                         .builder()
195                         .paths(pathsToWatch)
196                         .listener(this::handleEvent)
197                         .build();
198 
199                 if (getLog().isDebugEnabled()) {
200                     getLog().debug("Watching for changes...");
201                 }
202                 this.directoryWatcher.watch();
203             } else if (process != null && process.isAlive()) {
204                 process.waitFor();
205             }
206         } catch (Exception e) {
207             if (getLog().isDebugEnabled()) {
208                 getLog().debug("Exception while watching for changes", e);
209             }
210             throw new MojoExecutionException("Exception while watching for changes", e);
211         } finally {
212             killProcess();
213             cleanup();
214         }
215     }
216 
217     private void handleEvent(DirectoryChangeEvent event) throws IOException {
218         Path path = event.path();
219         Path parent = path.getParent();
220 
221         if (parent.equals(projectRootDirectory)) {
222             if (path.endsWith("pom.xml") && rebuildMavenProject() && resolveDependencies() && classpathHasChanged()) {
223                 if (getLog().isInfoEnabled()) {
224                     getLog().info("Detected POM dependencies change. Restarting application");
225                 }
226                 runApplication();
227             }
228         } else if (matches(path)) {
229             if (getLog().isInfoEnabled()) {
230                 getLog().info("Detected change in " + projectRootDirectory.relativize(path).toString());
231             }
232             boolean compiledOk = compileProject();
233             if (compiledOk) {
234                 runApplication();
235             }
236         }
237     }
238 
239     private boolean matches(Path path) {
240         // Apply default exclusions
241         if (isDefaultExcluded(path) || isDirectory(path, NOFOLLOW_LINKS) || !isReadable(path) || hasBeenCompiledRecently()) {
242             return false;
243         }
244 
245         // Start by checking whether it's a change in any source directory
246         boolean matches = this.sourceDirectories
247                 .values()
248                 .stream()
249                 .anyMatch(path.getParent()::startsWith);
250 
251         String relativePath = projectRootDirectory.relativize(path).toString();
252 
253         if (getLog().isDebugEnabled()) {
254             String belongs = matches ? "belongs" : "does not belong";
255             getLog().debug("Path [" + relativePath + "] " + belongs + " to a source directory");
256         }
257 
258         if (watches != null && !watches.isEmpty()) {
259             // Then process includes
260             if (!matches) {
261                 for (FileSet fileSet : watches) {
262                     if (fileSet.getIncludes() != null && !fileSet.getIncludes().isEmpty()) {
263                         File directory = new File(fileSet.getDirectory());
264                         if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath()))
265                             for (String includePattern : fileSet.getIncludes()) {
266                                 if (DirectoryScanner.match(includePattern, path.toString()) || new File(directory, includePattern).toPath().toAbsolutePath().equals(path)) {
267                                     matches = true;
268                                     if (getLog().isDebugEnabled()) {
269                                         getLog().debug("Path [" + relativePath + "] matched the include pattern [" + includePattern + "] of the directory [" + fileSet.getDirectory() + "]");
270                                     }
271                                     break;
272                                 }
273                             }
274                     }
275                     if (matches) break;
276                 }
277             }
278 
279             // Finally process excludes only if the path is matching
280             if (matches) {
281                 for (FileSet fileSet : watches) {
282                     if (fileSet.getExcludes() != null && !fileSet.getExcludes().isEmpty()) {
283                         File directory = new File(fileSet.getDirectory());
284                         if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) {
285                             for (String excludePattern : fileSet.getExcludes()) {
286                                 if (DirectoryScanner.match(excludePattern, path.toString()) || new File(directory, excludePattern).toPath().toAbsolutePath().equals(path)) {
287                                     matches = false;
288                                     if (getLog().isDebugEnabled()) {
289                                         getLog().debug("Path [" + relativePath + "] matched the exclude pattern [" + excludePattern + "] of the directory [" + fileSet.getDirectory() + "]");
290                                     }
291                                     break;
292                                 }
293                             }
294                         }
295                     }
296                     if (!matches) break;
297                 }
298             }
299         }
300 
301         return matches;
302     }
303 
304     private boolean isDefaultExcluded(Path path) {
305         return path.startsWith(targetDirectory.getAbsolutePath()) ||
306                 DEFAULT_EXCLUDES.stream()
307                         .anyMatch(excludePattern -> DirectoryScanner.match(excludePattern, path.toString()));
308     }
309 
310     private boolean hasBeenCompiledRecently() {
311         return (System.currentTimeMillis() - lastCompilation) < LAST_COMPILATION_THRESHOLD;
312     }
313 
314     private void cleanup() {
315         if (getLog().isDebugEnabled()) {
316             getLog().debug("Cleaning up");
317         }
318         try {
319             directoryWatcher.close();
320         } catch (Exception e) {
321             // Do nothing
322         }
323     }
324 
325     private boolean rebuildMavenProject() {
326         boolean success = true;
327         try {
328             ProjectBuildingRequest projectBuildingRequest = mavenSession.getProjectBuildingRequest();
329             projectBuildingRequest.setResolveDependencies(true);
330             ProjectBuildingResult build = projectBuilder.build(mavenProject.getArtifact(), projectBuildingRequest);
331             MavenProject project = build.getProject();
332             mavenProject = project;
333             mavenSession.setCurrentProject(project);
334         } catch (ProjectBuildingException e) {
335             success = false;
336             if (getLog().isWarnEnabled()) {
337                 getLog().warn("Error while trying to build the Maven project model", e);
338             }
339         }
340         return success;
341     }
342 
343     private boolean resolveDependencies() {
344         boolean success = true;
345         try {
346             DependencyFilter filter = DependencyFilterUtils.classpathFilter(JavaScopes.COMPILE, JavaScopes.RUNTIME);
347             RepositorySystemSession session = mavenSession.getRepositorySession();
348             DependencyResolutionRequest dependencyResolutionRequest = new DefaultDependencyResolutionRequest(mavenProject, session);
349             dependencyResolutionRequest.setResolutionFilter(filter);
350             DependencyResolutionResult result = resolver.resolve(dependencyResolutionRequest);
351             this.projectDependencies = result.getDependencies();
352             buildClasspath();
353         } catch (DependencyResolutionException e) {
354             success = false;
355             if (getLog().isWarnEnabled()) {
356                 getLog().warn("Error while trying to resolve dependencies for the current project", e);
357             }
358         }
359         return success;
360     }
361 
362     private void buildClasspath() {
363         Comparator<Dependency> byGroupId = Comparator.comparing(d -> d.getArtifact().getGroupId());
364         Comparator<Dependency> byArtifactId = Comparator.comparing(d -> d.getArtifact().getArtifactId());
365         classpath = this.projectDependencies.stream()
366                 .sorted(byGroupId.thenComparing(byArtifactId))
367                 .map(dependency -> dependency.getArtifact().getFile().getAbsolutePath())
368                 .collect(Collectors.joining(File.pathSeparator));
369     }
370 
371     private boolean classpathHasChanged() {
372         int oldClasspathHash = this.classpathHash;
373         this.classpathHash = this.classpath.hashCode();
374         return oldClasspathHash != classpathHash;
375 
376     }
377 
378     private void runApplication() throws IOException {
379         String classpathArgument = new File(targetDirectory, "classes" + File.pathSeparator).getAbsolutePath() + this.classpath;
380         List<String> args = new ArrayList<>();
381         args.add(javaExecutable);
382 
383         if (debug) {
384             String suspend = debugSuspend ? "y" : "n";
385             args.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=" + suspend + ",address=" + debugPort);
386         }
387 
388         if (jvmArguments != null && jvmArguments.size() > 0) {
389             args.addAll(jvmArguments);
390         }
391 
392         if (mavenSession.getUserProperties().size() > 0) {
393             mavenSession.getUserProperties().forEach((k, v) -> args.add("-D" + k + "=" + v));
394         }
395 
396         args.add("-classpath");
397         args.add(classpathArgument);
398         args.add("-XX:TieredStopAtLevel=1");
399         args.add("-Dcom.sun.management.jmxremote");
400         args.add(mainClass);
401 
402         if (appArguments != null && appArguments.size() > 0) {
403             args.addAll(appArguments);
404         }
405 
406         if (getLog().isDebugEnabled()) {
407             getLog().debug("Running " + String.join(" ", args));
408         }
409 
410         killProcess();
411         process = new ProcessBuilder(args)
412                 .inheritIO()
413                 .directory(targetDirectory)
414                 .start();
415     }
416 
417     private String findJavaExecutable() {
418         Toolchain toolchain = this.toolchainManager.getToolchainFromBuildContext("jdk", mavenSession);
419         if (toolchain != null) {
420             return toolchain.findTool(JAVA);
421         } else {
422             File javaBinariesDir = new File(new File(System.getProperty("java.home")), "bin");
423             if (Os.isFamily(Os.FAMILY_UNIX)) {
424                 return new File(javaBinariesDir, JAVA).getAbsolutePath();
425             } else if (Os.isFamily(Os.FAMILY_WINDOWS)) {
426                 return new File(javaBinariesDir, "java.exe").getAbsolutePath();
427             } else {
428                 return JAVA;
429             }
430         }
431     }
432 
433     private boolean compileProject() {
434         Optional<Long> lastCompilation = compilerService.compileProject(true);
435         lastCompilation.ifPresent(lc -> this.lastCompilation = lc);
436         return lastCompilation.isPresent();
437     }
438 
439     private void killProcess() {
440         if (getLog().isDebugEnabled()) {
441             getLog().debug("Stopping the background process");
442         }
443         if (process != null && process.isAlive()) {
444             process.destroy();
445             try {
446                 process.waitFor();
447             } catch (InterruptedException e) {
448                 process.destroyForcibly();
449             }
450         }
451     }
452 
453 }