Chitika

April 19, 2012

Localizable text template engine using RazorEngine

In some cases you need to use something like text template engine in your applications.
The best example is sending the email messages from an application.
Of course you can write a subject and a body for email message directly in your code:

    var subject = string.Format("Details about item ID - {0}", item.Id);
    var body = string.Format(@"Dear {0} {1},
This is a details about your item ID - {2}.
Regards.", item.FirstName, item.LastName, item.Id);

And everything is OK unless somebody ask you to change the text of  a subject or a body message.
In that case you have to change it in the code, release your application and deploy it again.
It often happens that when you already finished the deployment, you receive the new text message for a body, and based on my experience you will receive that kind of messages again and again.
Then somebody can ask you - what about localization? You need to send one message in English and another message in German. Later somebody ask about a Korean message, etc.

So, it's good example where a text template engine could help you.

In this post i will show you how to use RazorEngine for it.


Using the RazorEngine


The following command in the Package Manager console will install RazorEngine package into your ASP.NET MVC 4 WebAPI application.

PM > Install-Package RazorEngine

RazorEngine is a templating engine built upon Microsoft's Razor parsing technology. The RazorEngine allows you to use Razor syntax to build robust templates. Currently RazorEngine has integrated the vanilla Html + Code support, but it would support other markup languages in future.

So, let's start with TemplateEngine interface:

    public interface ITemplateEngine
    {
        string Parse(string template, dynamic model);
    }

This interface introduce just one method which receive a template text and a data model for a template in a parameters and send the generated text back.

Continue with realization:

    public class RazorTemplateEngine : ITemplateEngine
    {
        public string Parse(string template, dynamic model)
        {
            return Razor.Parse(template, model);
        }
    }

It's really easy, just don't forget include RazorEngine to your using block.


The localized templates service


Our template engine is ready. But we don't want to just convert one text to another using string variables.
We would like to specify the template name, the template data model and the current culture, and get the generated text back.

For that reason I will create an TemplatesService class, but let's start with an interface first:

    public interface ITemplatesService
    {
        string Parse(string templateName, dynamic model, CultureInfo cultureInfo = null);
    }

This simple interface has only one method which takes the template name, data model and current culture as a parameters and will return the generated text as well.

Realization of this simple interface is not so simple, but i will explain all the methods later.
But before I show you the realization I would like to tell you that our TemplatesService class needs to do some file system operations, e.g. read all contents of the files, check if a file exists.

I suggest to create a separate interface (and realization of course) to do all of this file system tasks. It allows you to avoid a lot of problems in unit testing, and make your services more clear to another developers.

The interface for the file system operations:

    public interface IFileSystemService
    {
        string ReadAllText(string fileName);
        bool FileExists(string fileName);
        string GetCurrentDirectory();
    }

And really simple realization:

    public class FileSystemService : IFileSystemService
    {
        public string ReadAllText(string fileName)
        {
            return File.ReadAllText(fileName);
        }

        public bool FileExists(string fileName)
        {
            return File.Exists(fileName);
        }

        public string GetCurrentDirectory()
        {
            return Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
        }
    }

I hope everything is clear to you in this class.

So, let's return to realization of TemplatesService. I will show you the realization from methods to methods. The constants, the properties and a constructor of the class, first:

    public class TemplatesService : ITemplatesService
    {
        private const string DefaultLanguage = "en";
        private const string TemplatesDirectoryName = "Templates";
        private const string TemplateFileNameWithCultureTemplate = "{0}.{1}.template";
        private const string TemplateFileNameWithoutCultureTemplate = "{0}.template";
        
        private readonly IFileSystemService _fileSystemService;
        private readonly ITemplateEngine _templateEngine;
        private readonly string _templatesDirectoryFullName;

        public TemplatesService(IFileSystemService fileSystemService, ITemplateEngine templateEngine)
        {
            _fileSystemService = fileSystemService;
            _templateEngine = templateEngine;
            _templatesDirectoryFullName = Path.Combine(_fileSystemService.GetCurrentDirectory(), TemplatesDirectoryName);
        }

        // rest of the code
    }

Nothing complex in this code: just declaring the four constants where I specified the default language name, the name of the directory where the templates are stored, and the string templates for the file name of template with and without a culture.
Also, I stored the full path to templates directory in the _templateDirectoryFullName property in the constructor of the class.

Then, the implementation of the one public method which declared in the interface:

        public string Parse(string templateName, dynamic model, CultureInfo cultureInfo = null)
        {
            var templateContent = GetContent(templateName, cultureInfo);

            return _templateEngine.Parse(templateContent, model);
        }

It takes the content of the template from GetContent method and call the template engine to get a string result.

        private string GetContent(string templateName, CultureInfo cultureInfo)
        {
            var templateFileName = TryGetFileName(templateName, cultureInfo);
            if (string.IsNullOrEmpty(templateFileName))
            {
                throw new FileNotFoundException(string.Format("Template file not found for template '{0}' in '{1}'", templateName, _templatesDirectoryFullName));
            }

            return _fileSystemService.ReadAllText(templateFileName);
        }

Te method GetContent tries to take the template full file name (a file name with a path) from the method TryGetFileName, and if this method return null or empty string throws an exception. Otherwise it reads all the template file content and return it.

        private string TryGetFileName(string templateName, CultureInfo cultureInfo)
        {
            var language = GetLanguageName(cultureInfo);

            // check file for current culture
            var fullFileName = GetFullFileName(templateName, language);
            if (_fileSystemService.FileExists(fullFileName))
            {
                return fullFileName;
            }

            // check file for default culture
            if (language != DefaultLanguage) 
            {
                fullFileName = GetFullFileName(templateName, DefaultLanguage);
                if (_fileSystemService.FileExists(fullFileName))
                {
                    return fullFileName;
                }
            }

            // check file without culture
            fullFileName = GetFullFileName(templateName, string.Empty);
            if (_fileSystemService.FileExists(fullFileName))
            {
                return fullFileName;
            }

            return string.Empty;
        }

This method gets the language name from CultureInfo parameters, and checks is the template file for that language exist, and if no, checks is the template file for default language exists, and if it is not found then checks is the template file without any culture exist.
For example, look at the templates files structure:

    subject.template
    subject.de.template

Let's imagine, that the current culture is German. The language is "de". The method should check the template file for this language and should found the second template 'subject.de.template'.
Then, let's imagine, that for now, the current culture is Korean. The language is "ko". The method should check the template file for this language, and will not find it, because we don't have a template file for Korean culture. Then the method should check the template file for default language which is 'en', and will not find it as well. The latest check will be for the template file without any culture and the first one template 'subject.template' should be found.

The implementation of the GetLanguage method is really simple:

        private static string GetLanguageName(CultureInfo cultureInfo)
        {
            return cultureInfo != null ? cultureInfo.TwoLetterISOLanguageName.ToLower() : DefaultLanguage;
        }

It returns the two letter ISO language name or the default language name if culture is not specified.

And the last method:

        
        private string GetFullFileName(string templateName, string language)
        {
            var fileNameTemplate = string.IsNullOrEmpty(language) ? TemplateFileNameWithoutCultureTemplate : TemplateFileNameWithCultureTemplate;

            var templateFileName = string.Format(fileNameTemplate, templateName, language);

            return Path.Combine(_templatesDirectoryFullName, templateFileName);
        }

That's all with the TemplatesService.


Using the localizable template service


To demonstrate how to use this service let's create the template file first.
Create a directory 'Templates' in your project.
Then click the right mouse button on this directory in the 'Solution Explorer' and select 'Add' -> 'New Item...' (or just press Ctrl+Shift+A).
In the new window click on 'Visual C# Items' and select the 'Text File' in the list. Enter the file name - 'first.template' and click 'Add' button.

Then insert the next text into template file:

ID: @Model.Id
Name: @Model.Name
Items:
@for(int i = 0; i < @Model.Items.Count; i++) {
    @:item #@i - @Model.Items[i]
}

Then write some simple code to parse the template:

            var model = new {
                Id = 10,
                Name = "Name1",
                Items = new List<string> {"Item1", "Item2", "Item3", "Item4", "Item5"}
            };

            var templateService = new TemplatesService(new FileSystemService(), new RazorTemplateEngine());

            var result = templateService.Parse("first", model);

After that, put the break point after the last line (var result = ...) and run your application in Debug mode.
When the debugger stops on that break point just check the value of the result variable in the Text Visualizer.

If should contains the next text:

ID: 10
Name: Name1
Items:
    item #0 - Item1
    item #1 - Item2
    item #2 - Item3
    item #3 - Item4
    item #4 - Item5

In my next post you can find how to create a localizable text template engine using StringTemplate.

That's all.
Good luck.

4 comments:

  1. I have learn several just right stuff here. Definitely
    worth bookmarking for revisiting. I wonder how much attempt you place
    to make this kind of fantastic informative website.



    My website - lotto 649 23 march 2013

    ReplyDelete
  2. it doesn't work for me ((

    Template file not found for template 'first' in 'C:\Users\UserName\AppData\Local\Temp\Temporary ASP.NET Files\root\1a3e82a9\9c2ce23\assembly\dl3\d7a75f04\34344e0f_ed70ce01\Templates'

    ReplyDelete
    Replies
    1. Try using this method instead:

      public string GetCurrentDirectory()
      {
      //return Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);

      return
      new System.Uri(
      System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().CodeBase)).
      LocalPath;
      }

      Delete
  3. Hi! This is a great post, really helped me a lot.

    Few things:

    - How about master layout, section and partial support? I've seen few examples on the net, but couldn't really make it work. It would be nice if you can add your thoughts here about those issues.

    - You are resolving the mapping Template Name -> Physical File really deep in the code. Problem with this is that I have a requirement to generate two templates for one email - html and text version. Maybe for future readers you should place this resolving up in the hierarchy, in the Parse method.

    Thanks!

    ReplyDelete