Wednesday, February 27, 2013

Maven Plugin Development


Maven carries out all its implementation using plugins making them highly significant for its operation. But often there are times when a customized plugin implementation might be needed in order to carryout some peculiar build-related tasks. Tasks especially involving jenkins build operations, or command line operations which can be better off using maven than ant scripts. Further plugins can call other plugins and create custom goals to carryout large series of operations. Hence maven plugin development comes handy in creating customized maven plugins.

A maven plugin contains a series of Mojos (goals), with each Mojo being a single Java class containing a series of annotations which tells Maven the way to generate the Plugin descriptor. Every maven plugin must implement the Mojo interface which requires the class to implement getLog(), setLog() and execute() methods. The abstract class AbstractMojo provides the default implementation of getLog() and setLog(), thus only requiring to implement the execute() method. The getLog() method can be used to access the maven logger which has methods info, debug() and error() to log at various levels. The execute() method is the entry point of the plugin execution and provides the customized build-process implementation for the maven plugin.
           The AbstractMojo implementation does require to have a @goal annotation in the class-level javadoc annotation. The goal name specified with the javadoc @goal annotation defines the maven goal name to be used along with the goal prefix in order to execute the plugin. The mojo goal can be used directly in the command line or from the POM by specifying mojo-specific configuration. The @phase annotation if specified, binds the Mojo to a particular phase of the standard build lifecycle e.g. install. It is to be noted that the phases in the maven lifecycle are not called in series from the phase name specified with the @phase annotation in the Maven Mojo. The @execute annotation can be used to specify either phase and lifecycle, or goal to be invoked before the execution of plugin implementation. When the mojo goal is invoked, it will first invoke a parallel lifecycle, ending at the given phase. If a goal is provided instead of a phase, that goal will be executed in isolation. The execution of either will not affect the current project, but instead make available the ${executedProject} expression if required. The @requireProject annotation denotes whether the plugin executes inside a project thus requiring a POM to execute or else can be executed without a POM. By default the @requireProject is set to true, thus requiring to run inside a project. The @requiresOnline annotation mandates the plugin to be executed in the online mode. The Maven Mojo API Specification provides all the available annotations in detail.
      Maven mojo class can also access maven specific parameters such as MavenSession, MavenProject, Maven etc using the maven parameter expressions "${project}", "${session}" or ${maven}. These maven model objects can be used to get the project details in the POM or alter the session to execute another project. Below is the sample maven plugin mojo, which reads another pom, creates a new maven project and alter the session to execute the new project. Also it lists the plugins present in the maven project.

/**
 * @goal sample-task
 * @requiresProject false
 * @execute lifecycle="mvnsamplecycle" phase="generate-sources"
 */
public class SampleMojo extends AbstractMojo {
 /** 
 * The Maven Session Object 
 * @parameter expression="${session}" 
 * @required 
 * @readonly 
 */ 
 private MavenSession session; 

 /**
 * The maven project.
 * @parameter expression="${project}"
 * @readonly
 */
 private MavenProject project;

 public void execute() throws MojoExecutionException, MojoFailureException {

      // Create a new MavenProject instance from the pom.xml and set it as current project.
      MavenXpp3Reader mavenreader = new MavenXpp3Reader();
      File file = new File("../../pom.xml");
      FileReader reader = new FileReader(file);
      Model model = mavenreader.read(reader);
      model.setPomFile("../../pom.xml");
         
      MavenProject newProject = new MavenProject(model);
      project.setBuild(newProject.getBuild());
      project.setExecutionProject(newProject);
      project.setFile(file);
      session.setCurrentProject(newProject);
      session.setUsingPOMsFromFilesystem(true);

      // Create a new MavenSession instance and set it to execute the new maven project.
      ReactorManager reactorManager = new ReactorManager(session.getSortedProjects());
      MavenSession newsession = new MavenSession( session.getContainer(), session.getSettings(), session.getLocalRepository(),
      session.getEventDispatcher(), reactorManager, session.getGoals(),
      session.getExecutionRootDirectory()+ "/" + app, session.getExecutionProperties(), session.getUserProperties(), new Date());
         
      newsession.setUsingPOMsFromFilesystem(true);
      session = newsession;
      project.setParent(newProject);
      project.addProjectReference(newProject);
      project.setBasedir(new File(app));

      // List all the plugins in the project pom.
      List plugins = getProject().getBuildPlugins();

      for (Iterator iterator = plugins.iterator(); iterator.hasNext();) {
         Plugin plugin = (Plugin) iterator.next();
         if(key.equalsIgnoreCase(plugin.getKey())) {
             getLog().info("plugin = " + plugin);
         }
      }
 }
}

Below are the required dependencies for the Maven Plugin. Note that the last three dependencies along with the maven-invoker are optional and used to access the Maven Object Model with the objects, MavenSession, MavenProject etc.
 
    
      org.apache.maven
      maven-plugin-api
      2.0
    
    
      commons-io
      commons-io
      2.1
    

    
    
      org.apache.maven.shared
      maven-invoker
      2.1.1
    
    
      org.codehaus.plexus
      plexus-component-annotations
      1.5.5
    
    
      org.codehaus.plexus
      plexus-utils
      3.0.8
    
 

 
   
     ...................................
     
       maven-plugin-plugin
       2.3
       
           samples
       
     
     ...................................
   


Maven Lifecycle

The process of building and distributing a particular artifact (project) is defined as the Maven build lifecycle. There are three built-in build lifecycles: default, clean and site. The default lifecycle handles the project deployment, the clean lifecycle handles project cleaning, while the site lifecycle handles the creation of project's site documentation. Each of the build lifecycles is defined by a different list of build phases, wherein a build phase represents a stage in the lifecycle. The build phases listed in the lifecycle are executed sequentially to complete the build lifecycle. On executing the specified build phase in the command line, it will execute not only that build phase, but also every build phase prior to the called build phase in the lifecycle. This works for multi-module scenario too. The build phase carries out its operations by declaring goals bound to it.

A goal represents a specific task (finer than a build phase) which contributes to the building and managing of a project. It may be bound to zero or more build phases. A goal not bound to any build phase could be executed outside of the build lifecycle by direct invocation. The order of execution depends on the order in which the goal(s) and the build phase(s) are invoked. Moreover, if a goal is bound to one or more build phases, that goal will be called in all those phases. Furthermore, a build phase can also have zero or more goals bound to it. If a build phase has no goals bound to it, that build phase will not execute. But if it has one or more goals bound to it, it will execute all those goals mostly in the same order of declaration as in the POM.
Goals can be bound to a particular lifecycle phase by configuring a plugin in the project. The goals that are configured will be added to the goals already bound to the lifecycle from the selected phase. If more than one goal is bound to a particular phase, the order used is that those from the selected phase are executed first, followed by those configured in the POM. Note that the <executions> element can be used to gain more control over the order of particular goals. It can also run the same goal multiple times with different configuration if required. Separate executions can also be given an ID so that during inheritance or the application of profiles, it can be controlled whether the goal configuration is merged or turned into an additional execution. When multiple executions are given that match a particular phase, they are executed in the order specified in the POM, with inherited executions running first.

  
    process-classes
    
      
        jcoverage:instrument
      
    
  
  
  
    test
    
      
        surefire:test
        
          
          ${project.build.directory}/generated-classes/jcoverage
          true
        
      
    
  


Report Plugin

Writing a Report plugin is similar to the Mojo plugin were we extend the AbstractMavenReport class instead of AbstractMojo class. The report plugin can be added to the plugins of the reporting section to generate the report with the Maven site. The goal to be executed is specified in the report tag in the reportSet section which control the execution of the goals. The methods getProject(), getOutputDirectory(), getSiteRenderer(), getDescription(), getName(), getOutputName(), getBundle() and executeReport() are required to be overridden.

Note: In order to create the report without using Doxia, e.g. via XSL transformation from some XML file, add the following method to the report Mojo:
public boolean isExternalReport() {
    return true;
}

Following dependencies are required for maven report plugin

    org.apache.maven.reporting
    maven-reporting-api
    2.0.8

 

    org.apache.maven.reporting
    maven-reporting-impl
    2.0.4.3

 

    org.codehaus.plexus
    plexus-utils
    2.0.1


AbstractMavenReportRenderer is used to handle the basic operations with the Doxia sink to setup the head, title and body of the html report. The renderBody method is implemented to fill in the middle of the report by using the utilities for sections and tables in Doxia. To use Doxia Sink-API we import the org.apache.maven.doxia.sink.Sink class and call the getSink() method to get its instance. Then we use the doix api as in the below example to header, title and body. The starting tag is denoted by xxx() while the end tag is denoted by xxx_() similar to html tags. The rawText() method outputs exactly specified text while the text() method adds escaping characters. The sectionning is strict which means that section level 2 must be nested in section 1 and so forth. Below sample report mojo override the required methods and provide a sample usage of Doxia API.

public class ReportMojo extends AbstractMavenReport {

 /**
 * Report output directory.
 * @parameter expression="${project.reporting.outputDirectory}"
 * @required
 * @readonly
 */
 private String outputDirectory;

 /**
 * Maven Project Object.
 * @parameter default-value="${project}"
 * @required
 * @readonly
 */
 private MavenProject project;
 
 /**
 * Maven Report Renderer.
 * @component
 * @required
 * @readonly
 */
 private Renderer siteRenderer;

 protected MavenProject getProject() {
  return project;
 }

 protected String getOutputDirectory() {
  return outputDirectory;
 }

 protected Renderer getSiteRenderer() {
  return siteRenderer;
 }

 public String getDescription(Locale locale) {
  return getBundle(locale).getString("report.description");
 }

 public String getName(Locale locale) {
  return getBundle(locale).getString("report.title");
 }

 public String getOutputName() {
  return "sample-report";
 }

 private ResourceBundle getBundle(Locale locale) {
  return ResourceBundle.getBundle("sample-report", locale, this.getClass().getClassLoader());
 }

 @Override
 protected void executeReport(Locale locale) throws MavenReportException {

     Sink sink = getSink();
     sink.head();
     sink.title();
     sink.text( getBundle(locale).getString("report.title") );
     sink.title_();
     sink.head_();
   
     sink.body();
     sink.section1();
     sink.sectionTitle1();
     sink.text( String.format(getBundle(locale).getString("report.header"), version) );
     sink.sectionTitle1_();
     sink.section1_();
      
     sink.lineBreak();

     sink.table();
     sink.tableRow();
     sink.tableHeaderCell( );
     sink.bold();
     sink.text( "Id" );
     sink.bold_();
     sink.tableHeaderCell_();
     sink.tableRow_();

     sink.tableRow();
     sink.tableCell();
     sink.link( "http://some_url" );
     sink.text( "123" );
     sink.link_();
     sink.tableCell_();
     sink.tableRow_();
     sink.table_();
      
     sink.body_();
     sink.flush();
     sink.close();
 }

MultiPage Report Plugin

Often times there is a need to create maven reports with multiple pages. But the maven report plugin only provides a single instance of doxia sink to create an html page. If we try to copy the implementation of the execute() method in AbstractMavenReport class and try to loop it with different filenames then we do get the required multiple pages but it only works when the report plugin is executed directly without the maven site. The maven site plugin does not calls the execute() method but calls the actual implementation of the executeReport(Locale) method. Hence such logic does not work for the mvn site but works for direct execution of the plugin. The ReportDocumentRenderer from maven-site-plugin creates the SiteRendererSink and calls report.generate(sink,locale) which in turn calls executeReport(Locale) method. Using the createSink() method fails in such case. There is no way to create more SiteRendererSinks within the report, because those sinks are from a different classloader. Maven does provide the AbstractMavenMultiPageReport class to implement but it also does not provide any way to create multiple sink instances. After we upgrade to the maven-report-plugin 3.0 we have a new method in AbstractMavenReport class called getSinkFactory(). It allows to create new sink instances when executeReport method is called from the site-plugin which initializes the factory instance. In case of the direct execution of the multipage report plugin, the execute() method of AbstractMavenReport class has no implementation for initializing the factory method neither any setter to set the factory. Hence in such case we use the dirty hack and copy the execute method implementation in the executeReport method of the multipage report class to create a new sink instance. For accessing the getFactory method we upgrade the maven-reporting-api to 3.0 as follows:

    org.apache.maven.reporting
    maven-reporting-api
    3.0
    
        
            org.apache.maven.doxia
            doxia-sink-api
        
     



    org.apache.maven.doxia
    doxia-sink-api
    1.3



    org.apache.maven.reporting
    maven-reporting-impl
    2.2


Following code provides an overview of the implementation with an example of generating a multipage report:
public class MultiPageReportMojo extends AbstractMavenReport {

  .......................

  /**
   * Copied implementation from {@link AbstractMavenReport}. Generates the index page and 
   * report pages for all the environments. If the {@link SinkFactory} is null 
   * (when invoked directly) then creates a new {@link SiteRendererSink} object using 
   * {@link RenderingContext}. If the {@link SinkFactory} is not null (usually for mvn site) 
   * then uses its createSink() method to create a new {@link Sink} object. 
   * @see org.apache.maven.reporting.AbstractMavenReport#execute()
   */
   @Override
   protected void executeReport(Locale locale) throws MavenReportException {

    List<String> envList = Arrays.asList("local", "devl", "qual", "cert", "prod");
  
    // index method uses getSink() method from AbstractMavenReport class to directly access 
    // the sink and render the index page.
    executeReportIndex(locale, envList);
  
    for (String env : envList) {
   
      File outputDirectory = new File( getOutputDirectory() );
      Writer writer = null;
   
      try {
    
         String filename = outputPrefix + env + ".html";
         SinkFactory factory = getSinkFactory(); 

         if(factory == null) {
     
           SiteRenderingContext siteContext = new SiteRenderingContext();
           siteContext.setDecoration( new DecorationModel() );
           siteContext.setTemplateName( "org/apache/maven/doxia/siterenderer/resources/default-site.vm" );
           siteContext.setLocale( locale );
               
           RenderingContext context = new RenderingContext( outputDirectory, filename );

           SiteRendererSink renderSink = new SiteRendererSink( context );

           // This method uses the sink instance passed for the environment to render the report page.
           executeConfigReport(locale, renderSink);

           renderSink.close();

           if ( !isExternalReport() ) { // MSHARED-204: only render Doxia sink if not an external report
                
             outputDirectory.mkdirs();
             writer = new OutputStreamWriter( new FileOutputStream( new File( outputDirectory, filename ) ), "UTF-8" );
             getSiteRenderer().generateDocument( writer, renderSink, siteContext );
           }
         }
         else {
           Sink renderSink = factory.createSink(outputDirectory, filename);

           // This method uses the sink instance passed for the environment to render the report page.
           executeConfigReport(locale, renderSink);

           renderSink.close();
         }
      } catch (Exception e) {
         getLog().error("Report, Failed to create server-config-env: " + e.getMessage(), e);
         throw new MavenReportException(getName( Locale.ENGLISH ) + "Report, Failed to create server-config-env: " 
                                                                  + e.getMessage(), e);
      } finally {
         IOUtil.close( writer );
      }
  }

  .......................

  /**
   * Renders the table header cell with the specified width and text using the specified sink instance.
   * @param sink
   *   {@link Sink} instance to render the table header cell.
   * @param width
   *   {@link String} of the table header cell.
   * @param text
   *   {@link String} in the table header cell.
   */
  protected void sinkHeaderCellText(Sink sink, String width, String text) {

        SinkEventAttributes attrs = new SinkEventAttributeSet();
        attrs.addAttribute(SinkEventAttributes.WIDTH, width);
        sink.tableHeaderCell(attrs);
        sink.text(text);
        sink.tableHeaderCell_();
  }
}