Thursday, May 27, 2010

Improved, Easy Email Template “Engine” with Smarter ToString Extension

This blog often focuses on Telerik and Microsoft news, and rarely do I bring you much “original thought” code (especially lately). But this class is just too useful not to share.

Let me just lead with the scenario:

  1. You have a program that is going to send auto-generated emails
  2. You want templates for your emails that support value merging
  3. You want to maintain your templates as HTML files for easier editing
  4. You want something as easy to use as String.Format

While there are tons of templating engines for C#, I just wanted something simple and to the point. The solution Scott Hanselman provided a couple of years ago was very close to what I wanted, but it doesn’t support collections (very important for Master-MasterDetail type messages). So I added the tweak to support these scenarios.

Here’s how you use it:

//Create a simple template
string template = "Hello {FirstName}! You've ordered: <ul>{Items:<li>{Name}</li>}</ul>";

//Create your object with the template values (can by anonymous type)
//(Let's use some pseduo LINQ to get a customer with a specific ID)
var myTemplateValues = myObjects.Where(o => o.Id == id).Select(o => new
   {
       FirstName = o.FirstName,
       Items = o.Items //Assume each item in ICollection has Name property
   });

//Merge values with template 
string mergedTemplate = myTemplateValues.ToStringWithFormat(template);

The result of the above code should be a string that looks something like:

Hello Todd! You’ve ordered: <ul><li>Item Name 1</li><li>Item Name 2</li></ul> You can, of course, add formatting to your objects in your template, too, such as: {Birthdate:dd MMM yyyy} But when you’ve got a collection, you can use the object format string to include nested property values, like this: {Customers:<li>{Birthdate:dd MM yyy}</li>} Pretty cool! It’s a nice balance between the templating engines that are super complex and a plain-jane String.Format. It doesn’t do everything you could ever want, but it does enough for many auto-email scenarios. In my own usage, I have a simple method that let’s me quickly merge my context values with my template when I’m ready to build an email’s body:
public static string GetMergedTemplate<T>(string template, T contextInstance)
{
   if (String.IsNullOrWhiteSpace(template))
       return null;

   return contextInstance.ToStringWithFormat(template);
} 
Which brings us to the implementation of ToStringWithFormat. This is a modified version of the extension method from Hanselman that gives us the extra power we desire:
/// <summary>
/// Returns a string and replaces named place holders with object values
/// </summary>
/// <remarks>Originally from Scott Hanselman's Blog: http://www.hanselman.com/blog/ASmarterOrPureEvilToStringWithExtensionMethods.aspx </remarks>
/// <param name="anObject"></param>
/// <param name="aFormat"></param>
/// <returns></returns>
public static string ToStringWithFormat(this object anObject, string aFormat)
{
   return StringExtensions.ToStringWithFormat(anObject, aFormat, null);
}

public static string ToStringWithFormat(this object anObject, string aFormat, IFormatProvider formatProvider)
{
   if (anObject == null)//Can't merge null object. Be nice and return original format string.
       return aFormat;

   StringBuilder sb = new StringBuilder();
   Type type = anObject.GetType();
   //Old pattern: @"({)([^}]+)(})" - Doesn't handle nested brackets
   //New pattern:"({)((?:[^{}]|{[^{}]*})*)(})" - Handles ONE LEVEL of nested brackets
   Regex reg = new Regex(@"({)((?:[^{}]|{[^{}]*})*)(})", RegexOptions.IgnoreCase);
   MatchCollection mc = reg.Matches(aFormat);
   int startIndex = 0;
   foreach (Match m in mc)
   {
       Group g = m.Groups[2]; //it's second in the match between { and }
       int length = g.Index - startIndex - 1;
       sb.Append(aFormat.Substring(startIndex, length));

       string toGet = String.Empty;
       string toFormat = String.Empty;
       int formatIndex = g.Value.IndexOf(":"); //formatting would be to the right of a :
       if (formatIndex == -1) //no formatting, no worries
       {
           toGet = g.Value;
       }
       else //pickup the formatting
       {
           toGet = g.Value.Substring(0, formatIndex);
           toFormat = g.Value.Substring(formatIndex + 1);
       }

       //first try properties
       PropertyInfo retrievedProperty = type.GetProperty(toGet);
       Type retrievedType = null;
       object retrievedObject = null;
       if (retrievedProperty != null)
       {
           retrievedType = retrievedProperty.PropertyType;
           retrievedObject = retrievedProperty.GetValue(anObject, null);
       }
       else //try fields
       {
           FieldInfo retrievedField = type.GetField(toGet);
           if (retrievedField != null)
           {
               retrievedType = retrievedField.FieldType;
               retrievedObject = retrievedField.GetValue(anObject);
           }
       }

       if (retrievedType != null) //Cool, we found something
       {
           string result = String.Empty;
           if (toFormat == String.Empty) //no format info
           {
               if (retrievedObject is ICollection)
               {
                   foreach (var item in (retrievedObject as ICollection))
                   {
                       //In this branch toFormat is blank, so just call toString on
                       //each object in collection (ex:{Items})
                       result += item.ToString();
                   }
               }
               else
                   result = retrievedObject.ToString();
           }
           else //format info
           {
               if (retrievedObject is ICollection) //Process first level collection
               {
                   foreach (var item in (retrievedObject as ICollection))
                   {
                       //In this branch toFormat contains nested property name, so
                       //make recursive call to ToStringWithFormat to process property value
                       //(ex: {Items: {PropertyName}})
                       result += item.ToStringWithFormat(toFormat);
                   }
               }
               else
                   result = String.Format(formatProvider, toFormat, retrievedObject);
           }
           sb.Append(result);
       }
       else //didn't find a property with that name, so be gracious and put it back
       {
           sb.Append("{");
           sb.Append(g.Value);
           sb.Append("}");
       }
       startIndex = g.Index + g.Length + 1;
   }
   if (startIndex < aFormat.Length) //include the rest (end) of the string
   {
       sb.Append(aFormat.Substring(startIndex));
   }
   return sb.ToString();
}
A key limit in this approach is the RegEx, which technically is not adept at finding nested bracket patterns. RegEx works fine if you accept a fixed number of supported nested levels, but it cannot dynamically handle unknown levels of nesting. A single level was fine for my needs and thus I left it at that, but this could be extend to provide even richer support for collections if RegEx were replaced.

I hope you enjoy this simple update on a classic. As commenters pointed out in Scott’s original post, this approach does create opportunity for “evil” when it comes to refactoring and performance, so there is still plenty of room to improve. But for a pragmatic solution, I’ve found this very useful.

0 comments: