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

3 comments:

  1. content is great....I am waiting for the next article

    ReplyDelete
  2. Recomended tutorial!
    Very very clear and effective

    ReplyDelete
  3. "NAnt only imports assemblies that meet the following pattern; "NAnt.*.Tasks.dll"

    I don't think it's completely correct: AFAIK the pattern is "*.Tasks.dll". No need for "NAnt." at the beginning

    ReplyDelete