Tuesday, June 30, 2009

Writing a Custom NAnt Task (Part 2)

Over the past couple of months, I have been working on writing the SchemaSpy Task for NAnt located on CodePlex.  When I began to create a few custom tasks for NAnt in the past, I found very little documentation about how to create one.  Much of the documentation surrounding how to create a custom task is based solely on examples without any supporting information.  While this works very well for basic things, there were some experiences I had to work through while writing the task for SchemaSpy that I would like to talk about here.  In this, the second of four parts, we'll be extending what we created on Part 1 by adding a FileSet collection to add some flexibility to our application.  In addition, the accompanying source code will remain in both C# and VB.Net similar to the first section in this series.

 

Changing the Input Directory Attribute:

In Part 1 of this series, our Log Combining task contained an attribute to identify the directory that held all files that were to be combined.  What we want to do first is remove this attribute and provide an alternative solution using the NAnt FileSet object.  The reason to change this is to allow us to not be tied to a single directory for combining files, as well as provide a ways to be more selective of the files to combine. For more general information on the FileSet Type of NAnt, make sure to check out the official documentation for FileSet over at the NAnt website.

In order to use a FileSet object, we'll need to import the NAnt.Core.Types namespace.  In addition, we have to instantiate a private FileSet object in order to be exposed by the new property.  The reason for this is that the NAnt engine does not look to ensure that the collection-based objects are instantiated.  On collection-based elements that contains children elements, the NAnt engine ultimately just adds items into the collection.  If the collection is not instantiated, a null-reference error will be thrown.

After the property has been setup and the private object has been instantiated, the code for such should look similar to the following:

   1:  private FileSet _logFileSet = new FileSet();
   2:   
   3:  /// <summary>
   4:  /// The files that will be combined by this task.
   5:  /// </summary>
   6:  [BuildElement("fileset")]
   7:  [StringValidator(AllowEmpty = false)]
   8:  public FileSet LogFileSet 
   9:  {
  10:      get { return _logFileSet; } 
  11:      set { _logFileSet = value; }
  12:  }

 

Once this is done, we can update the project's build file to look something like the below to mimic the same functionality as we had with the previous attribute.

   1:  <logCombiner outputFile="combined.txt" >
   2:      <fileset basedir="..\logs\">
   3:          <include name="**/*" />
   4:      </fileset>
   5:  </logCombiner>

 

Updating the Combining Method to use the FileSet:

Now that we have the task attribute and build file setup, we need to update the code to use the collection. In order to keep things simple, I'm not going to touch the current CombineFiles() method.  Instead, I want to ensure the information I will be getting from the FileSet object will be able to be valid and fit into that method call.

To do this, we need to change the name of the ValidateInputDirectory() method to ValidateInputFile().  This will ensure the name of the method will not confuse other developers.  In addition, we want to change the logic of this method slightly by changing Directory.Exists() to File.Exists().  We will be working with files at this point instead of directories so testing we want to make sure we test for the proper item.

   1:  /// <summary>
   2:  /// Validates the Input Directory Value
   3:  /// </summary>
   4:  /// <param name="path">The directory location to validate</param>
   5:  private void ValidateInputFile(string path)
   6:  {
   7:      if (!File.Exists(path))
   8:      {
   9:          throw new BuildException("The input directory of " + path + " does not exist.");
  10:      }
  11:  }

Next, we need to update the GetInputFiles() method.  This method returns an array of strings representing the file paths.  These paths are then passed to the CombineFiles() method.  We want to keep this signature.  Inside of the method, we need to instantiate an item to hold our string array; like a List<string>.  Next, we need to loop through the FileSet.FileNames collection and add each entry into our string list.  Lastly, we want to make sure the files are validated through our ValidateInputFile() method before returning the string array.  Once all of this is done, the method should look like the following:

   1:  /// <summary>
   2:  /// Retrieves a listing of files from a provided directory
   3:  /// </summary>
   4:  /// <returns>A string array of all file paths.</returns>
   5:  private string[] GetInputFiles()
   6:  {
   7:      List<string> output = new List<string>();
   8:   
   9:      foreach (string path in _logFileSet.FileNames)
  10:      {
  11:          ValidateInputFile(path);
  12:          output.Add(path);
  13:      }
  14:   
  15:      return output.ToArray();
  16:  }

 

Looking Ahead:

At this point, we have updated our NAnt task from using a single input directory to combine all files in the directory to a more robust solution that allows us to specify which files to include and exclude.  We can compile the code and install the task as described in the previous segment for testing.  In the next post in this series, I'll be diving into writing a custom element collection that can be added to our NAnt task in order to provide more opportunities.

 

Source Code:


kick it on DotNetKicks.comShout it

Thursday, June 4, 2009

Writing a Custom NAnt Task (Part 1)

Over the past month, I have been working on writing the SchemaSpy Task for NAnt located on CodePlex.  When I have began to create a few custom tasks for NAnt in the past, I found very little documentation about how to create one.  Much of the documentation surrounding how to create a custom task is based solely on examples without any supporting information.  While this works very well for basic things, there were some experiences I had to work through while writing the task for SchemaSpy that I would talk about them here.  In this, the first of four parts, we'll be going over how to write your first task and add some simple attributes to it. 

To make the example a bit more practical than "Hello World", we'll be creating a log combiner task. In addition to attempting to make a more practical example, the accompanying source code contains both a C# and VB examples and has been refactored to assist in readability.

 

Getting Started Creating the Log Combining Task:

In order to get started we'll need to make sure we have downloaded NAnt.  Once that is downloaded, we can open up Visual Studio and create a new Class Library project.  In this example, we'll call the project NAntLogCombiner.

New Project Window

Once the project is created we need to do 4 things:

  1. Rename Class1.cs to LogCombinerTask.cs
  2. Add a reference to the NAnt.Core.dll
  3. Add the NAnt.Core and NAnt.Core.Attributes namespaces to our file
  4. Have our LogCombinerTask class inherit from the NAnt.Core.Task class and implement its abstract members.

After these 4 steps are completed, we'll be left with code that looks similar to the code below.

using System.IO;
using NAnt.Core;
using NAnt.Core.Attributes;


namespace NAntLogCombiner
{
public class LogCombinerTask : Task
{
protected override void ExecuteTask()
{
throw new System.NotImplementedException();
}
}
}

The NAnt.Core.Task abstract class only requires 1 method to be overridden.  The ExecuteTask() method is called by the NAnt build engine and is where we will need to place our code.  Since we are going to be combining text files, we need 2 things; a directory that holds the files we want to read, and the output file.  For the time being, we'll hard code these variables.  After writing the code that consumes the input files and writes their contents to the output file, we have the following in our LogCombinerTask:


protected override void ExecuteTask()
{
string inputDirectory = @"..\logs\";
string outputFile = "combined.txt";

string[] inputFiles = Directory.GetFiles(inputDirectory);

using (StreamWriter outputStream = File.CreateText(outputFile))
{
foreach (string file in inputFiles)
{
outputStream.Write(ReadFile(file));
}
}
}

private string ReadFile(string fileToRead)
{
StringBuilder returnString = new StringBuilder();

using (StreamReader inputStream = File.OpenText(fileToRead))
{
while (inputStream.Peek() > 0)
{
returnString.AppendLine(inputStream.ReadLine());
}
}

return returnString.ToString();
}

While we're missing a few things (like validating the directory exists, making the paths configurable, etc.), the task builds and will work under its current constraints.  Before we can test this inside of NAnt though, we have to configure the class and project a bit more.


Decorating the Class for NAnt:


While we have a working class for combining text files located in a single directory, it is not something that can be consumed by NAnt.  NAnt provides an assortment of attributes that can be used to decorate class files in order to inform NAnt how each class (and it's members as we'll see) maps to the markup located in a build file.

In our simple class that we have so far, we only have to add the TaskName attribute onto the class declaration. This attribute tasks just the string name of our task that will represent the element inside of a build file.  I'm going to name the task "logCombiner" to be consistent with the casing of other tasks as well as the name and purpose of our project.  With the attribute added, the class declaration part of our code now looks like the following:


[TaskName("logCombiner")]
public class LogCombinerTask : Task

Naming the Assembly:


With our class properly decorated, we're ready to build our task in preparation for testing.  The way that NAnt imports tasks is through dynamic loading of assemblies and then using reflection.  This is a fairly common practice; however, NAnt only imports assemblies that meet the following pattern; "NAnt.*.Tasks.dll".  Our project, by default, does not output it's assembly into this naming convention.  We can easily remedy this by opening the project properties and setting the Assembly Name to "NAnt.LogCombiner.Tasks.dll" as shown in the image below.

Project Properties



Testing the Assembly:


Now that the Assembly is named correctly for NAnt, we can build the project and test is in NAnt.  We can import our task into NAnt by dropping the DLL into NAnt's bin directory or using NAnt's loadtasks task.  For simplicity sake, I'm going to assume our NAnt.LogCombiner.Tasks.dll has been copied to NAnt's bin directory. 


Once the task has been imported, we can call our task in a build file.  Below is an example build file that only calls our new task.


<?xml version="1.0" encoding="utf-8"?>
<project name="Log Combiner" default="default" basedir=".">
<description>This is an example build file.</description>

<target name="default" description="Default Task">
<logCombiner />
</target>
</project>

Before we run this build file, we need to make sure that we have a directory above the current directory called "logs" that contain our text files.  Once our log files are in place, we can run our build file where it will sequentially read the files from the directory and merge them into the combined.txt file.


Congratulations on creating your first NAnt task; however, it's pretty generic and not very configurable.  Let's enhance our task now by moving where we declare our variables for the input directory and output file into task variables instead of being hard coded.



Adding Task Attributes:


In order to add attributes to our task so we can declare where the input folder or output file is located, we simply have to add a couple of properties to our class file and decorate them accordingly.  Let's update our LogCombiner class by adding an InputDirectory property as well as an OutputFile property and update the code to use them appropriately.  In addition to implementing the properties, we also need to decorate them with the appropriate attributes so NAnt will know how to map the XML-based attributes to our object. 


[TaskAttribute("inputDirectory", Required = true)]
[StringValidator(AllowEmpty = false)]
public string InputDirectory { get; set; }

[TaskAttribute("outputFile", Required = true)]
[StringValidator(AllowEmpty = false)]
public string OutputFile { get; set; }

Each of the two properties have 2 attributes associated with it.  The first, TaskName, defines what the task attribute is called inside of the build file and if it's required or not.  This name does not have to match the name of the property.  The second, is a StringValidator to help ensure the integrity of the data that is passed into it.  For the example, the validation on both properties is to just ensure that an empty string is not passed into each attribute.


With these updates made, the only things we have left to do is recompile and deploy the dll, update the build file, and test.  Below is the updated build file:


<?xml version="1.0" encoding="utf-8"?>
<project name="Log Combiner" default="default" basedir=".">
<description>This is an example build file.</description>

<target name="default" description="Default Task">
<logCombiner inputDirectory="..\logs\" outputFile="combined.txt" />
</target>
</project>

Looking Ahead:


In this part of this series, we created a simple task with a few attributes to enhance the customization.  In the next segments, we'll look into what it takes to add our own types for children nodes similar to the NAnt fileset tags to add file separators as well as allow for including and excluding different files.



Source Code:



kick it on DotNetKicks.comShout it