001/* 002 * Copyright 2017-2022 original authors 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package io.micronaut.maven; 017 018import io.methvin.watcher.DirectoryChangeEvent; 019import io.methvin.watcher.DirectoryWatcher; 020import io.micronaut.maven.aot.AotAnalysisMojo; 021import io.micronaut.maven.services.CompilerService; 022import io.micronaut.maven.services.DependencyResolutionService; 023import io.micronaut.maven.services.ExecutorService; 024import io.micronaut.maven.testresources.AbstractTestResourcesMojo; 025import io.micronaut.maven.testresources.TestResourcesHelper; 026import io.micronaut.testresources.buildtools.ServerSettings; 027import io.micronaut.testresources.buildtools.ServerUtils; 028import org.apache.maven.execution.MavenSession; 029import org.apache.maven.model.FileSet; 030import org.apache.maven.plugin.BuildPluginManager; 031import org.apache.maven.plugin.MojoExecutionException; 032import org.apache.maven.plugins.annotations.Execute; 033import org.apache.maven.plugins.annotations.LifecyclePhase; 034import org.apache.maven.plugins.annotations.Mojo; 035import org.apache.maven.plugins.annotations.Parameter; 036import org.apache.maven.plugins.annotations.ResolutionScope; 037import org.apache.maven.project.MavenProject; 038import org.apache.maven.project.ProjectBuilder; 039import org.apache.maven.project.ProjectBuildingException; 040import org.apache.maven.project.ProjectBuildingRequest; 041import org.apache.maven.project.ProjectBuildingResult; 042import org.apache.maven.toolchain.ToolchainManager; 043import org.codehaus.plexus.util.AbstractScanner; 044import org.codehaus.plexus.util.cli.CommandLineUtils; 045import org.eclipse.aether.graph.Dependency; 046import org.eclipse.aether.util.artifact.JavaScopes; 047 048import javax.inject.Inject; 049import java.io.File; 050import java.nio.file.Files; 051import java.nio.file.InvalidPathException; 052import java.nio.file.Path; 053import java.util.ArrayList; 054import java.util.Arrays; 055import java.util.Collections; 056import java.util.List; 057import java.util.Optional; 058import java.util.concurrent.atomic.AtomicBoolean; 059import java.util.concurrent.locks.ReentrantLock; 060 061import static io.micronaut.maven.MojoUtils.findJavaExecutable; 062import static io.micronaut.maven.MojoUtils.hasMicronautMavenPlugin; 063import static java.nio.file.Files.isDirectory; 064import static java.nio.file.Files.isReadable; 065import static java.nio.file.LinkOption.NOFOLLOW_LINKS; 066 067/** 068 * <p>Executes a Micronaut application in development mode.</p> 069 * 070 * <p>It watches for changes in the project tree. If there are changes in the {@code pom.xml} file, dependencies will be reloaded. If 071 * the changes are anywhere underneath {@code src/main}, it will recompile the project and restart the application.</p> 072 * 073 * <p>The plugin can handle changes in all the languages supported by Micronaut: Java, Kotlin and Groovy.</p> 074 * 075 * @author Álvaro Sánchez-Mariscal 076 * @since 1.0.0 077 */ 078@SuppressWarnings("unused") 079@Mojo(name = "run", requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, defaultPhase = LifecyclePhase.PREPARE_PACKAGE, aggregator = true) 080@Execute(phase = LifecyclePhase.PROCESS_CLASSES) 081public class RunMojo extends AbstractTestResourcesMojo { 082 083 public static final String MN_APP_ARGS = "mn.appArgs"; 084 public static final String EXEC_MAIN_CLASS = "${exec.mainClass}"; 085 public static final String RESOURCES_DIR = "src/main/resources"; 086 public static final String THIS_PLUGIN = "io.micronaut.maven:micronaut-maven-plugin"; 087 088 private static final List<String> RELEVANT_SRC_DIRS = List.of("resources", "java", "kotlin", "groovy"); 089 private static final int LAST_COMPILATION_THRESHOLD = 500; 090 private static final List<String> DEFAULT_EXCLUDES; 091 092 static { 093 DEFAULT_EXCLUDES = new ArrayList<>(); 094 Collections.addAll(DEFAULT_EXCLUDES, AbstractScanner.DEFAULTEXCLUDES); 095 Collections.addAll(DEFAULT_EXCLUDES, "**/.idea/**", "**/src/test/**"); 096 } 097 098 private final MavenSession mavenSession; 099 private final ProjectBuilder projectBuilder; 100 private final ToolchainManager toolchainManager; 101 private final String javaExecutable; 102 private final DependencyResolutionService dependencyResolutionService; 103 private final CompilerService compilerService; 104 private final ExecutorService executorService; 105 106 /** 107 * The project's target directory. 108 */ 109 private File targetDirectory; 110 111 /** 112 * The main class of the application, as defined in the 113 * <a href="https://www.mojohaus.org/exec-maven-plugin/java-mojo.html#mainClass">Exec Maven Plugin</a>. 114 */ 115 @Parameter(defaultValue = EXEC_MAIN_CLASS) 116 private String mainClass; 117 118 /** 119 * Whether to start the Micronaut application in debug mode. 120 */ 121 @Parameter(property = "mn.debug", defaultValue = "false") 122 private boolean debug; 123 124 /** 125 * Whether to suspend the execution of the application when running in debug mode. 126 */ 127 @Parameter(property = "mn.debug.suspend", defaultValue = "false") 128 private boolean debugSuspend; 129 130 /** 131 * The port where remote debuggers can be attached to. 132 */ 133 @Parameter(property = "mn.debug.port", defaultValue = "5005") 134 private int debugPort; 135 136 /** 137 * The host where remote debuggers can connect. 138 */ 139 @Parameter(property = "mn.debug.host", defaultValue = "127.0.0.1") 140 private String debugHost; 141 142 /** 143 * List of inclusion/exclusion paths that should not trigger an application restart. 144 * For example, you can exclude a particular directory from being watched by adding the following 145 * configuration: 146 * <pre> 147 * <watches> 148 * <watch> 149 * <directory>.some-dir</directory> 150 * <excludes> 151 * <exclude>**/*</exclude> 152 * </excludes> 153 * </watch> 154 * </watches> 155 * </pre> 156 * Check the 157 * <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a> 158 * documentation for more details. 159 * 160 * @see <a href="https://maven.apache.org/ref/3.3.9/maven-model/apidocs/org/apache/maven/model/FileSet.html">FileSet</a> 161 */ 162 @Parameter 163 private List<FileSet> watches; 164 165 /** 166 * <p>List of additional arguments that will be passed to the JVM process, such as Java agent properties.</p> 167 * 168 * <p>When using the command line, user properties will be passed through, eg: <code>mnv mn:run -Dmicronaut.environments=dev</code>.</p> 169 */ 170 @Parameter(property = "mn.jvmArgs") 171 private String jvmArguments; 172 173 /** 174 * List of additional arguments that will be passed to the application, after the class name. 175 */ 176 @Parameter(property = MN_APP_ARGS) 177 private String appArguments; 178 179 /** 180 * Whether to watch for changes, or finish the execution after the first run. 181 */ 182 @Parameter(property = "mn.watch", defaultValue = "true") 183 private boolean watchForChanges; 184 185 /** 186 * Whether to enable or disable Micronaut AOT. 187 */ 188 @Parameter(property = "micronaut.aot.enabled", defaultValue = "false") 189 private boolean aotEnabled; 190 191 // These 2 flags are used in the context of watching for changes 192 // the first one makes sure that only one recompilation is processed at a time 193 private final AtomicBoolean recompileRequested = new AtomicBoolean(); 194 // the second one makes sure that we wait for the server to be started before we restart it 195 // otherwise a process may be kept alive 196 private final ReentrantLock restartLock = new ReentrantLock(); 197 198 private MavenProject runnableProject; 199 private DirectoryWatcher directoryWatcher; 200 private volatile Process process; 201 private String classpath; 202 private int classpathHash; 203 private long lastCompilation; 204 private TestResourcesHelper testResourcesHelper; 205 206 @SuppressWarnings("CdiInjectionPointsInspection") 207 @Inject 208 public RunMojo(MavenSession mavenSession, 209 BuildPluginManager pluginManager, 210 ProjectBuilder projectBuilder, 211 ToolchainManager toolchainManager, 212 CompilerService compilerService, 213 ExecutorService executorService, 214 DependencyResolutionService dependencyResolutionService) { 215 this.mavenSession = mavenSession; 216 this.projectBuilder = projectBuilder; 217 this.toolchainManager = toolchainManager; 218 this.compilerService = compilerService; 219 this.executorService = executorService; 220 this.javaExecutable = findJavaExecutable(toolchainManager, mavenSession); 221 this.dependencyResolutionService = dependencyResolutionService; 222 } 223 224 @Override 225 public void execute() throws MojoExecutionException { 226 try { 227 initialize(); 228 } catch (Exception e) { 229 throw new MojoExecutionException(e.getMessage()); 230 } 231 232 try { 233 maybeStartTestResourcesServer(); 234 runApplication(); 235 Thread shutdownHook = new Thread(this::killProcess); 236 Runtime.getRuntime().addShutdownHook(shutdownHook); 237 238 if (process != null && process.isAlive()) { 239 if (watchForChanges) { 240 var pathsToWatch = new ArrayList<Path>(); 241 for (FileSet fs : watches) { 242 var directory = runnableProject.getBasedir().toPath().resolve(fs.getDirectory()).toAbsolutePath(); 243 if (Files.exists(directory)) { 244 pathsToWatch.add(directory); 245 //If neither includes nor excludes, add a default include 246 if ((fs.getIncludes() == null || fs.getIncludes().isEmpty()) && (fs.getExcludes() == null || fs.getExcludes().isEmpty())) { 247 fs.addInclude("**/*"); 248 } 249 } 250 } 251 252 this.directoryWatcher = DirectoryWatcher 253 .builder() 254 .paths(pathsToWatch) 255 .listener(this::handleEvent) 256 .build(); 257 258 // We use the working directory as the root path, because 259 // the top-level project information may not be what we 260 // expect, in particular if we run from a submodule, or 261 // that we run from root but with the "-pl" option. 262 // We can safely do this because it's only used to display 263 // information about paths being watched to the user, the 264 // actual paths are unchanged. 265 Path root = Path.of(".").toAbsolutePath(); 266 List<Path> pathList = pathsToWatch.stream() 267 .map(root::relativize) 268 .filter(s -> !s.toString().isEmpty()) 269 .filter(Files::exists) 270 .sorted() 271 .toList(); 272 getLog().info("👀 Watching for changes in " + pathList); 273 this.directoryWatcher.watch(); 274 } else if (process != null && process.isAlive()) { 275 process.waitFor(); 276 } 277 } 278 } catch (InterruptedException e) { 279 Thread.currentThread().interrupt(); 280 } catch (Exception e) { 281 if (getLog().isDebugEnabled()) { 282 getLog().debug("Exception while watching for changes", e); 283 } 284 throw new MojoExecutionException("Exception while watching for changes", e); 285 } finally { 286 killProcess(); 287 cleanup(); 288 } 289 } 290 291 protected final void initialize() { 292 final MavenProject currentProject = mavenSession.getCurrentProject(); 293 if (hasMicronautMavenPlugin(currentProject)) { 294 runnableProject = currentProject; 295 } else { 296 final List<MavenProject> projectsWithPlugin = mavenSession.getProjects().stream() 297 .filter(MojoUtils::hasMicronautMavenPlugin) 298 .toList(); 299 if (projectsWithPlugin.size() == 1) { 300 runnableProject = projectsWithPlugin.get(0); 301 log.info("Running project %s".formatted(runnableProject.getArtifactId())); 302 } else { 303 throw new IllegalStateException("The Micronaut Maven Plugin is declared in the following projects: %s. Please specify the project to run with the -pl option." 304 .formatted(projectsWithPlugin.stream().map(MavenProject::getArtifactId).toList())); 305 } 306 } 307 this.targetDirectory = new File(runnableProject.getBuild().getDirectory()); 308 this.testResourcesHelper = new TestResourcesHelper(testResourcesEnabled, shared, buildDirectory, explicitPort, 309 clientTimeout, serverIdleTimeoutMinutes, runnableProject, mavenSession, dependencyResolutionService, 310 toolchainManager, testResourcesVersion, classpathInference, testResourcesDependencies, 311 sharedServerNamespace, debugServer, false); 312 resolveDependencies(); 313 if (watches == null) { 314 watches = new ArrayList<>(); 315 } 316 // watch pom.xml file changes 317 mavenSession.getAllProjects().stream() 318 .filter(this::isDependencyOfRunnableProject) 319 .map(MavenProject::getBasedir) 320 .map(File::toPath) 321 .forEach(path -> { 322 var fileSet = new FileSet(); 323 fileSet.setDirectory(path.toString()); 324 fileSet.addInclude("pom.xml"); 325 watches.add(fileSet); 326 }); 327 // Add the default watch paths 328 mavenSession.getAllProjects().stream() 329 .filter(this::isDependencyOfRunnableProject) 330 .flatMap(p -> { 331 var basedir = p.getBasedir().toPath(); 332 return RELEVANT_SRC_DIRS.stream().map(dir -> basedir.resolve("src/main/" + dir)); 333 }) 334 .forEach(path -> { 335 var fileSet = new FileSet(); 336 fileSet.setDirectory(path.toString()); 337 fileSet.addInclude("**/*"); 338 watches.add(fileSet); 339 }); 340 } 341 342 private boolean isDependencyOfRunnableProject(MavenProject mavenProject) { 343 return mavenProject.equals(runnableProject) || runnableProject.getDependencies().stream() 344 .anyMatch(d -> d.getGroupId().equals(mavenProject.getGroupId()) && d.getArtifactId().equals(mavenProject.getArtifactId())); 345 } 346 347 protected final void setWatches(List<FileSet> watches) { 348 this.watches = watches; 349 } 350 351 final void handleEvent(DirectoryChangeEvent event) { 352 Path path = event.path(); 353 Path parent = path.getParent(); 354 Path projectRootDirectory = mavenSession.getTopLevelProject().getBasedir().toPath(); 355 356 if (matches(path)) { 357 if (getLog().isInfoEnabled()) { 358 getLog().info(String.format("📝 Detected change in %s. Recompiling/restarting...", projectRootDirectory.relativize(path))); 359 } 360 boolean compiledOk = compileProject(); 361 if (compiledOk) { 362 try { 363 runApplication(); 364 } catch (Exception e) { 365 getLog().error("Unable to run application: " + e.getMessage(), e); 366 } 367 } 368 } 369 } 370 371 private boolean matches(Path path) { 372 // Apply default exclusions 373 if (isDefaultExcluded(path) || isDirectory(path, NOFOLLOW_LINKS) || !isReadable(path) || hasBeenCompiledRecently()) { 374 return false; 375 } 376 377 Path projectRootDirectory = mavenSession.getTopLevelProject().getBasedir().toPath(); 378 379 String relativePath = projectRootDirectory.relativize(path).toString(); 380 381 boolean matches = false; 382 for (FileSet fileSet : watches) { 383 if (fileSet.getIncludes() != null && !fileSet.getIncludes().isEmpty()) { 384 var directory = new File(fileSet.getDirectory()); 385 if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) { 386 for (String includePattern : fileSet.getIncludes()) { 387 if (pathMatches(includePattern, path) || patternEquals(path, includePattern, directory)) { 388 matches = true; 389 if (getLog().isDebugEnabled()) { 390 getLog().debug("Path [" + relativePath + "] matched the include pattern [" + includePattern + "] of the directory [" + fileSet.getDirectory() + "]"); 391 } 392 break; 393 } 394 } 395 } 396 } 397 if (matches) { 398 break; 399 } 400 } 401 402 // Finally, process excludes only if the path is matching 403 if (matches) { 404 for (FileSet fileSet : watches) { 405 if (fileSet.getExcludes() != null && !fileSet.getExcludes().isEmpty()) { 406 File directory = new File(fileSet.getDirectory()); 407 if (directory.exists() && path.getParent().startsWith(directory.getAbsolutePath())) { 408 for (String excludePattern : fileSet.getExcludes()) { 409 if (pathMatches(excludePattern, path) || patternEquals(path, excludePattern, directory)) { 410 matches = false; 411 if (getLog().isDebugEnabled()) { 412 getLog().debug("Path [" + relativePath + "] matched the exclude pattern [" + excludePattern + "] of the directory [" + fileSet.getDirectory() + "]"); 413 } 414 break; 415 } 416 } 417 } 418 } 419 if (!matches) { 420 break; 421 } 422 } 423 } 424 425 return matches; 426 } 427 428 private boolean isDefaultExcluded(Path path) { 429 boolean excludeTargetDirectory = true; 430 if (this.watches != null && !this.watches.isEmpty()) { 431 for (FileSet fileSet : this.watches) { 432 if (fileSet.getDirectory().equals(this.targetDirectory.getName())) { 433 excludeTargetDirectory = false; 434 } 435 } 436 } 437 return (excludeTargetDirectory && path.startsWith(targetDirectory.getAbsolutePath())) || 438 DEFAULT_EXCLUDES.stream() 439 .anyMatch(excludePattern -> pathMatches(excludePattern, path)); 440 } 441 442 private boolean hasBeenCompiledRecently() { 443 return (System.currentTimeMillis() - lastCompilation) < LAST_COMPILATION_THRESHOLD; 444 } 445 446 private void cleanup() { 447 if (getLog().isDebugEnabled()) { 448 getLog().debug("Cleaning up"); 449 } 450 try { 451 directoryWatcher.close(); 452 maybeStopTestResourcesServer(); 453 } catch (Exception e) { 454 // Do nothing 455 } 456 } 457 458 private boolean rebuildMavenProject() { 459 boolean success = true; 460 try { 461 ProjectBuildingRequest projectBuildingRequest = mavenSession.getProjectBuildingRequest(); 462 projectBuildingRequest.setResolveDependencies(true); 463 ProjectBuildingResult build = projectBuilder.build(runnableProject.getArtifact(), projectBuildingRequest); 464 MavenProject project = build.getProject(); 465 runnableProject = project; 466 mavenSession.setCurrentProject(project); 467 } catch (ProjectBuildingException e) { 468 success = false; 469 if (getLog().isWarnEnabled()) { 470 getLog().warn("Error while trying to build the Maven project model", e); 471 } 472 } 473 return success; 474 } 475 476 private boolean resolveDependencies() { 477 try { 478 List<Dependency> dependencies = compilerService.resolveDependencies(runnableProject, JavaScopes.PROVIDED, JavaScopes.COMPILE, JavaScopes.RUNTIME); 479 if (dependencies.isEmpty()) { 480 return false; 481 } else { 482 this.classpath = compilerService.buildClasspath(dependencies); 483 return true; 484 } 485 } finally { 486 if (classpath != null) { 487 this.classpathHash = this.classpath.hashCode(); 488 } 489 } 490 } 491 492 private boolean classpathHasChanged() { 493 int oldClasspathHash = this.classpathHash; 494 this.classpathHash = this.classpath.hashCode(); 495 return oldClasspathHash != classpathHash; 496 497 } 498 499 /** 500 * Runs or restarts the application. Only visible for testing, shouldn't 501 * be called directly. 502 * 503 * @throws Exception if something goes wrong while starting the application 504 */ 505 protected void runApplication() throws Exception { 506 if (restartLock.getQueueLength() >= 1) { 507 // if there's more than one restart request, we'll handle them all at once 508 return; 509 } 510 restartLock.lock(); 511 try { 512 runAotIfNeeded(); 513 String classpathArgument = new File(targetDirectory, "classes" + File.pathSeparator).getAbsolutePath() + this.classpath; 514 515 var args = new ArrayList<String>(); 516 args.add(javaExecutable); 517 518 if (debug) { 519 String suspend = debugSuspend ? "y" : "n"; 520 args.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=" + suspend + ",address=" + debugHost + ":" + debugPort); 521 } 522 523 if (testResourcesEnabled) { 524 Path testResourcesSettingsDirectory = shared ? ServerUtils.getDefaultSharedSettingsPath(sharedServerNamespace) : 525 AbstractTestResourcesMojo.serverSettingsDirectoryOf(targetDirectory.toPath()); 526 Optional<ServerSettings> serverSettings = ServerUtils.readServerSettings(testResourcesSettingsDirectory); 527 serverSettings.ifPresent(settings -> testResourcesHelper.computeSystemProperties(settings) 528 .forEach((k, v) -> args.add("-D" + k + "=" + v))); 529 } 530 531 if (jvmArguments != null && !jvmArguments.isEmpty()) { 532 final String[] strings = CommandLineUtils.translateCommandline(jvmArguments); 533 args.addAll(Arrays.asList(strings)); 534 } 535 536 if (!mavenSession.getUserProperties().isEmpty()) { 537 mavenSession.getUserProperties().forEach((k, v) -> args.add("-D" + k + "=" + v)); 538 } 539 540 if (mainClass == null) { 541 mainClass = runnableProject.getProperties().getProperty("exec.mainClass"); 542 } 543 544 args.add("-classpath"); 545 args.add(classpathArgument); 546 args.add("-XX:TieredStopAtLevel=1"); 547 args.add("-Dcom.sun.management.jmxremote"); 548 args.add(mainClass); 549 550 if (appArguments != null && !appArguments.isEmpty()) { 551 final String[] strings = CommandLineUtils.translateCommandline(appArguments); 552 args.addAll(Arrays.asList(strings)); 553 } 554 555 if (getLog().isDebugEnabled()) { 556 getLog().debug("Running " + String.join(" ", args)); 557 } 558 559 killProcess(); 560 process = new ProcessBuilder(args) 561 .inheritIO() 562 .directory(targetDirectory) 563 .start(); 564 } finally { 565 restartLock.unlock(); 566 } 567 } 568 569 private void runAotIfNeeded() { 570 if (aotEnabled) { 571 try { 572 executorService.executeGoal(THIS_PLUGIN, AotAnalysisMojo.NAME); 573 } catch (MojoExecutionException e) { 574 getLog().error(e.getMessage()); 575 } 576 } 577 } 578 579 private void maybeStartTestResourcesServer() throws MojoExecutionException { 580 testResourcesHelper.start(); 581 } 582 583 private void maybeStopTestResourcesServer() throws MojoExecutionException { 584 testResourcesHelper.stop(false); 585 } 586 587 private boolean compileProject() { 588 // There can be multiple changes detected at the same time, so we want 589 // to keep only one compilation request 590 if (recompileRequested.get()) { 591 return false; 592 } 593 recompileRequested.set(true); 594 try { 595 return doCompile(); 596 } finally { 597 recompileRequested.set(false); 598 } 599 } 600 601 private boolean doCompile() { 602 Optional<Long> lastCompilationMillis = compilerService.compileProject(); 603 lastCompilationMillis.ifPresent(lc -> this.lastCompilation = lc); 604 return lastCompilationMillis.isPresent(); 605 } 606 607 private void killProcess() { 608 if (process != null && process.isAlive()) { 609 if (getLog().isDebugEnabled()) { 610 getLog().debug("Stopping the background process"); 611 } 612 process.destroy(); 613 try { 614 process.waitFor(); 615 } catch (InterruptedException e) { 616 process.destroyForcibly(); 617 Thread.currentThread().interrupt(); 618 } 619 } 620 } 621 622 private static String normalize(Path path) { 623 return path.toString().replace('\\', '/'); 624 } 625 626 private static boolean pathMatches(String pattern, Path path) { 627 return AbstractScanner.match(pattern, normalize(path)); 628 } 629 630 private static boolean patternEquals(Path path, String includePattern, File directory) { 631 try { 632 var testPath = normalize(directory.toPath().resolve(includePattern).toAbsolutePath()); 633 return testPath.equals(normalize(path.toAbsolutePath())); 634 } catch (InvalidPathException ex) { 635 return false; 636 } 637 } 638 639}