65.9K
CodeProject 正在变化。 阅读更多。
Home

精简高效的非意见模板引擎

starIconstarIconstarIconstarIconstarIcon

5.00/5 (17投票s)

2016年3月21日

CPOL

8分钟阅读

viewsIcon

27299

downloadIcon

149

基于Razor模板引擎语法,一个直接、可扩展、易于维护的模板引擎实现。

目录

介绍性 rant

我曾和模板引擎RazorEngine以及Microsoft的Razor解析器和代码生成器一起折腾过,每次折腾,我都要花费太多时间来正确处理NuGet依赖项,而且我从未成功地让ApplicationDomain正常工作,以至于我的bin文件夹里不会堆积成百上千个临时程序集文件。更糟糕的是,这不是一个开箱即用的解决方案。试试克隆RazorEngine仓库然后尝试编译它。成百上千个错误。剥离掉核心以外的所有东西,你仍然会遇到很多错误。在我看来,这是一个开源项目的重大失败。而且,我不想使用NuGet来加载所有依赖项,因为它似乎总会出错,我想要一个不依赖NuGet怪癖并且无论我是否使用Microsoft的其他Razor环境都能正常工作的解决方案。

所以我向你展示一个精益、高效的模板解析器,它可以简单地完成工作。

目标

  • 简单 - 让我们尝试用不到500行代码来写。
  • 简单(我之前说过吗?) - 让我们尝试在几个小时内写完。
  • 没有怪异的东西 - RazorEngine主页上关于ApplicationDomains和临时文件的那些东西,让我们试着避免它们。
  • 一些很酷的东西 - 对模板进行哈希,所以如果它改变了,就重新编译它,否则就使用内存中的版本。
  • 模型 - 哎呀,只有一个模型实例的混乱,为什么,为什么,为什么?为什么引入这种限制?

要求

我们应该能够将模板解析应用于任何文本文档,而不仅仅是HTML。因此,不允许对模板内容进行特殊处理——例如,处理HTML标签、自定义标签如<text>等。但是,我们会稍微遵守Razor语法,所以我们会支持以下功能。

代码块

代码块始终以@{开头,并以匹配的}结束。嵌套的{ }当然是允许的,嵌套的@{不允许。

带代码块的文字复制

在代码块内部,一行文本可以使用@:语法逐字复制,例如

@: Do not interpret me as code.

变量替换

任何字面行都可以像这样进行变量替换

My name is @name

关于我称之为“单元测试”的说明

我的单元测试实际上并非真正的单元测试——它们更像是集成测试,因为我通常不测试单个方法,而是验证整个系统的行为。我发现这样的测试更有信息量,而且它们还有记录真实世界用例的好处。

运行时代码编译

我们将从最难的部分开始,代码编译——先只讲基本骨架。我正在从HOPE项目借用这段代码。

using System;
using System.Collections.Generic;
using System.Reflection;

using System.CodeDom.Compiler;
using Microsoft.CSharp;

namespace Clifton.Core.TemplateEngine
{
  public static class Compiler
  {
    public static Assembly Compile(string code, out List<string> errors)
    {
      Assembly assy = null;
      errors = null;
      CodeDomProvider provider = null;
      provider = CodeDomProvider.CreateProvider("CSharp");
      CompilerParameters cp = new CompilerParameters();

      // Generate a class library in memory.
      cp.GenerateExecutable = false;
      cp.GenerateInMemory = true;
      cp.TreatWarningsAsErrors = false;
      cp.ReferencedAssemblies.Add("System.dll");
      cp.ReferencedAssemblies.Add("Clifton.Core.TemplateEngine.dll");

      // Invoke compilation of the source file.
      CompilerResults cr = provider.CompileAssemblyFromSource(cp, code);

      if (cr.Errors.Count > 0)
      {
        errors = new List<string>();

        foreach (var err in cr.Errors)
        {
          errors.Add(err.ToString());
        }
      }
      else
      {
        assy = cr.CompiledAssembly;
      }

    return assy;
    }
  }
}

基本编译的单元测试

using System;
using System.Collections.Generic;
using System.Reflection;

using Microsoft.VisualStudio.TestTools.UnitTesting;

using Clifton.Core.TemplateEngine;

namespace Tests
{
  [TestClass]
  public class CompilerTests
  {
    [TestMethod]
    public void BasicCompilation()
    {
      List<string> errors;
      Assembly assy = Compiler.Compile(@"
using System;
using Clifton.Core.TemplateEngine;

public class RuntimeCompiled : ITestRuntimeAssembly
{
  public string HelloWorld()
  {
    return ""Hello World"";
  }
  public string Print(string something)
  {
    return ""This is something: "" + something;
  }
}
      ", out errors);
 
      if (assy == null)
      {
        errors.ForEach(err => Console.WriteLine(err));
      }
      else
      {
        ITestRuntimeAssembly t = (ITestRuntimeAssembly)assy.CreateInstance("RuntimeCompiled");
        string ret = t.HelloWorld();
        Assert.AreEqual("Hello World", ret);
      }
    }
  }
}

请注意,在要编译的代码中,我们正在实现一个接口。

public interface ITestRuntimeAssembly
{
  string HelloWorld();
  string Print(string something);
}

最终我们将实现正确的接口,并且模板包装类也将随之消失。接口还需要一个接受参数的方法,以便我们可以测试解析器中的变量替换。

另外请注意,因为代码是在内存中生成的,所以没有创建临时程序集文件。这样就解决了那个问题!稍后,我们将添加在代码未更改时重用现有程序集的功能。

解析模板

测试用例验证了代码、文字和参数是否被正确注入。给定一个输入字符串模板。

@{
string str = ""Hello World"";
int i = 10;
@:Literal
}
A line with @str and @i with @@ignore me

从解析器中获得的结果适用于运行时代码编译(不包括方法包装器)。

StringBuilder sb = new StringBuilder();
string str = "Hello World";
int i = 10;
sb.Append(" Literal")
sb.Append("A line with " + str.ToString() + " and " + i.ToString() + " with @ignore me")

请注意,结果字符串实际上是可执行代码。解析器实现足够简单。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using Clifton.Core.ExtensionMethods;

namespace Clifton.Core.TemplateEngine
{
  public static class Parser
  {
    public static string Parse(string text)
    {
      StringBuilder sb = new StringBuilder();
      sb.AppendLine("StringBuilder sb = new StringBuilder();");
      List<string> lines = GetLines(text);
      bool inCode = false;

      // Here we assume that the START_CODE_BLOCK and END_CODE_BLOCK are always at the beginning of a line.
      // Embedded code with { } (or other tokens) are always indented!

      lines.Where(l=>!String.IsNullOrEmpty(l)).ForEachWithIndex((line, idx) =>
      {
        switch (inCode)
        {
          case false:
          AppendNonCodeLine(sb, line, ref inCode);
          break;

          case true:
          AppendCodeOrLiteralLine(sb, line, ref inCode);
          break;
        }
      });

      return sb.ToString();
    }

    /// <summary>
    /// Returns the text split into lines with any trailing whitespace trimmed.
    /// </summary>
    private static List<string> GetLines(string text)
    {
      return text.Split(new char[] { '\r', '\n' }).Select(s => s.TrimEnd()).ToList();
    }

    private static void AppendNonCodeLine(StringBuilder sb, string line, ref bool inCode)
    {
      if (line.BeginsWith(Constants.START_CODE_BLOCK))
      {
        inCode = true;
      }
      else
      {
        // Append a non-code line.
        string parsedLine = VariableReplacement(line);
        parsedLine = parsedLine.Replace("\"", "\\\"");
        sb.AppendLine("sb.Append" + parsedLine.Quote().Parens() +";");
      }
    }

    private static void AppendCodeOrLiteralLine(StringBuilder sb, string line, ref bool inCode)
    {
      if (line.BeginsWith(Constants.END_CODE_BLOCK))
      {
        inCode = false;
      }
      else if (line.Trim().BeginsWith(Constants.LITERAL))
      {
        // Preserve leading whitespace.
        string literal = line.LeftOf(Constants.LITERAL) + line.RightOf(Constants.LITERAL);
        string parsedLiteral = VariableReplacement(literal);
        parsedLiteral = parsedLiteral.Replace("\"", "\\\"");
        sb.AppendLine("sb.Append" + parsedLiteral.Quote().Parens() +";");
      }
      else
      {
        // Append a code line.
        sb.AppendLine(line);
      }
    }

    private static string VariableReplacement(string line)
    {
      string parsedLine = String.Empty;
      string remainder = line;

      while (remainder.Contains("@"))
      {
        string left = remainder.LeftOf('@');
        string right = remainder.RightOf('@');

        // TODO: @@ translates to an inline @, so ignore.
        if ((right.Length > 0) && (right[0] == '@'))
        {
          parsedLine += left + "@";
          remainder = right.Substring(1); // move past second @
        }
        else
        {
          // Force close quote, inject variable name, append with + "
          parsedLine += left + "\" + " + right.LeftOf(' ') + ".ToString() + \"";
          remainder = " " + right.RightOf(' '); // everything after the token.
        }
      }

      parsedLine += remainder;
  
      return parsedLine;
    }
  }
}

编译已解析的模板

现在我们可以将这两个部分组合在一起,并包装在必要的样板代码中。

[TestClass]
public class CompileTemplateTests
{
  [TestMethod]
  public void CompileBasicTemplate()
  {
    string code = ParseTemplate();
    string assyCode = @"
using System;
using System.Text;
using Clifton.Core.TemplateEngine;

public class RuntimeCompiled : IRuntimeAssembly
{
  public string GetTemplate()
  {
";

    assyCode += code;
    assyCode += @"
    return sb.ToString();
  }
}";

    Assembly assy = CreateAssembly(assyCode);
    IRuntimeAssembly t = (IRuntimeAssembly)assy.CreateInstance("RuntimeCompiled");
    string ret = t.GetTemplate();
    Assert.AreEqual(" Literal\r\nA line with Hello World and 10 with @ignore me\r\n", ret);
  }

  /// <summary>
  /// Parse a template.
  /// </summary>
  private string ParseTemplate()
  {
    string template = @"
@{
  string str = ""Hello World"";
  int i = 10;
  @:Literal
}
A line with @str and @i with @@ignore me
";

    string parsed = Parser.Parse(template);
    Assert.AreEqual("StringBuilder sb = new StringBuilder();\r\n string str = \"Hello World\";\r\n int i = 10;\r\nsb.Append(\" Literal\\r\\n\");\r\nsb.Append(\"A line with \" + str.ToString() + \" and \" + i.ToString() + \" with @ignore me\\r\\n\");\r\n", parsed);

    return parsed;
  }

  /// <summary>
  /// Create an in-memory assembly.
  /// </summary>
  private Assembly CreateAssembly(string code)
  {
    List<string> errors;
    Assembly assy = Compiler.Compile(code, out errors);
    Assert.IsNotNull(assy);

    return assy;
  }
}

我在这里使用的接口是用于“真正的”引擎。

public interface IRuntimeAssembly
{
  string GetTemplate();
}

结果是一个已解析并执行的模板,可以交付给任何请求者。

 Literal
A line with Hello World and 10 with @ignore me

此时,你应该很清楚,你也可以传入任意数量的你可能想在代码中引用的参数、模型实例等。接下来我们将看看如何做到这一点。

让过程对程序员更友好

我们不希望程序员处理样板代码,所以我们将为他们处理。我们还将提供指定额外“using”语句以及需要引用的程序集的功能。

using System;
using System.Collections.Generic;
using System.Reflection;

namespace Clifton.Core.TemplateEngine
{
  public class TemplateEngine
  {
    public List<string> Usings { get; protected set; }
    public List<string> References { get; protected set; }

    public TemplateEngine()
    {
      Usings = new List<string>();
      References = new List<string>();
      Usings.Add("using System;");
      Usings.Add("using System.Text;");
      Usings.Add("using Clifton.Core.TemplateEngine;");
    }

    public string Parse(string template)
    {
      string code = Parser.Parse(template);

      string assyCode = String.Join("\r\n", Usings) + @"
public class RuntimeCompiled : IRuntimeAssembly
{
public string GetTemplate()
{
";

      assyCode += code;

      assyCode += @"
return sb.ToString();
}
}";

      List<string> errors;
      Assembly assy = Compiler.Compile(assyCode, out errors, References);
      string ret = null;

      if (assy == null)
      {
        throw new TemplateEngineException(errors);
      }
      else
      {
        IRuntimeAssembly t = (IRuntimeAssembly)assy.CreateInstance("RuntimeCompiled");
        ret = t.GetTemplate();
      }

    return ret;
    }
  }
}

这是单元测试,它演示了一种更友好的用法。

[TestMethod]
public void FriendlyCompileTemplate()
{
  string template = @"
@{
string str = ""Hello World"";
int i = 10;
@:Literal
}
A line with @str and @i with @@ignore me
";

  TemplateEngine eng = new TemplateEngine();
  string ret = eng.Parse(template);
  Assert.AreEqual(" Literal\r\nA line with Hello World and 10 with @ignore me\r\n", ret);
}

如何传递原生参数?

传递模型或可变数量参数的问题在于,编译的代码实现的接口必须在运行时指定。为了解决这个问题,我们可以传递一个对象数组。这是修改后的接口。

public interface IRuntimeAssembly
{
  string GetTemplate(object[] paramList = null);
}

这是测试方法。

[TestMethod]
public void ParameterPassing()
{
  string template = "A line with @str and @i with @@ignore me";

  TemplateEngine eng = new TemplateEngine();
  string ret = eng.Parse(template, new List<ParamTypeInfo>()
  {
    new ParamTypeInfo() {ParamName="str", ParamType="string", ParamValue = "Hello World"},
    new ParamTypeInfo() {ParamName="i", ParamType="int", ParamValue = 10},
  });

  Assert.AreEqual("A line with Hello World and 10 with @ignore me\r\n", ret);
}

创建ParamTypeInfo集合有点丑陋,但在实际世界情况中,这个过程可能可以重用。

重构后的Parse如下所示。

public string Parse(string template, List<ParamTypeInfo> parms)
{
  string code = Parser.Parse(template);
  StringBuilder sb = new StringBuilder(String.Join("\r\n", Usings));
  sb.Append(GetClassBoilerplate());
  InitializeParameters(sb, parms);
  sb.Append(code);
  sb.Append(GetFinisherBoilerplate());
  IRuntimeAssembly t = GetAssembly(sb.ToString());
  object[] objParms = parms.Select(p => p.ParamValue).ToArray();
  string ret = t.GetTemplate(objParms);

  return ret;
}

其中关键部分是InitializeParameters

private void InitializeParameters(StringBuilder sb, List<ParamTypeInfo> parms)
{
  parms.ForEachWithIndex((pti, idx) =>
  {
    sb.Append(pti.ParamType + " " + pti.ParamName + " = (" + pti.ParamType+")paramList[" + idx + "];\r\n");
  });
}

这生成的代码是这样的。

using System;
using System.Text;
using Clifton.Core.TemplateEngine;

public class RuntimeCompiled : IRuntimeAssembly
{
  public string GetTemplate(object[] paramList)
  {
    string str = (string)paramList[0];
    int i = (int)paramList[1];
    StringBuilder sb = new StringBuilder();
    sb.Append("A line with " + str.ToString() + " and " + i.ToString() + " with @ignore me\r\n");

    return sb.ToString();
  }
}

如何传递模型和其他非原生类型?

要做到这一点,我们需要处理“usings”和程序集引用,以便我们传递的内容实现模板已知的接口。这避免了dynamic对象幕后进行的所有反射。一个简单的方法是创建一个单独的程序集中的接口。我喜欢接口就是这个原因——你可以将接口放在一个单独的程序集中,这样你就不需要引用具体的程序集(至少是直接引用,具体的程序集确实需要能被程序集加载器发现)。这是一个在ModelInterface程序集中实现的接口的例子。

using System;

namespace ModelInterface
{
  public interface IModel
  {
    string Str { get; set; }
    int I { get; set; }
  }
}

实现如下:

public class Model : ModelInterface.IModel
{
  public string Str { get; set; }
  public int I { get; set; }
}

这个单元测试演示了我们如何使用它。

[TestMethod]
public void NonNativePassing()
{
  string template = "A line with @model.Str and @model.I with @@ignore me";
  Model model = new Model() { Str = "Howdy", I = 15 };

  TemplateEngine eng = new TemplateEngine();
  eng.Usings.Add("using ModelInterface;");
  eng.References.Add("ModelInterface.dll");
  string ret = eng.Parse(template, new List<ParamTypeInfo>()
  {
    new ParamTypeInfo() {ParamName="model", ParamType="IModel", ParamValue = model},
  });

  Assert.AreEqual("A line with Howdy and 15 with @ignore me\r\n", ret);
}

使用dynamic代替接口或引用程序集

好的,所以你真的想使用dynamic。

注意解析器参数和编译器设置之间的细微差别。

[TestMethod]
public void DynamicParameterTypePassing()
{
  string template = "A line with @model.Str and @model.I with @@ignore me";
  Model model = new Model() { Str = "Howdy", I = 15 };

  TemplateEngine eng = new TemplateEngine();
  // Removed: eng.Usings.Add("using ModelInterface;");
  // Removed: eng.References.Add("ModelInterface.dll");

  // References needed to support the "dynamic" keyword:
  eng.References.Add("Microsoft.CSharp.dll");
  eng.References.Add(typeof(System.Runtime.CompilerServices.DynamicAttribute).Assembly.Location);

  string ret = eng.Parse(template, new List<ParamTypeInfo>()
  {
    // new ParamTypeInfo() {ParamName="model", ParamType="IModel", ParamValue = model},
    // changed to:
    new ParamTypeInfo() {ParamName="model", ParamType="dynamic", ParamValue = model},
  });

  Assert.AreEqual("A line with Howdy and 15 with @ignore me\r\n", ret);
}

请注意,我们删除了“using…”行和引用,但我们添加了几个.NET程序集,以支持dynamic关键字。

当然,缺点是使用dynamic关键字时性能会下降,但极有可能,这种性能损失是可以接受的。关键在于你有选择

更多程序员友好性

如果能直接传递参数而不是创建ParamTypeInfo集合就好了。

基于参数类型进行假设

除非我们满足于非常默认的行为(我将在下文演示),否则我们仍然需要提供变量名。

这是测试用例。

[TestMethod]
public void SimplerParameterPassing()
{
  string template = "A line with @model.Str and @i with @@ignore me";
  Model model = new Model() { Str = "Howdy" };

  TemplateEngine eng = new TemplateEngine();
  eng.Usings.Add("using ModelInterface;");
  eng.References.Add("ModelInterface.dll");

  // An example of non-native and native type passing.
  string ret = eng.Parse(template, new string[] {"model", "i"}, model, 15);

  Assert.AreEqual("A line with Howdy and 15 with @ignore me\r\n", ret);
}

这是参数初始化代码。

private void InitializeParameters(StringBuilder sb, string[] names, object[] parms)
{
  parms.ForEachWithIndex((parm, idx) =>
  {
    Type t = parm.GetType();
    string typeName = t.IsClass ? "I" + t.Name : t.Name;
    sb.Append(typeName + " " + names[idx] + " = (" + typeName + ")paramList[" + idx + "];\r\n");
  });
}

请注意,类类型将使用以“I”开头的接口的隐式假设。为了说明这一点,这是生成的代码。

using System;
using System.Text;
using Clifton.Core.TemplateEngine;
using ModelInterface;
public class RuntimeCompiled : IRuntimeAssembly
{
  public string GetTemplate(object[] paramList)
  {
    IModel model = (IModel)paramList[0];
    Int32 i = (Int32)paramList[1];
    StringBuilder sb = new StringBuilder();
    sb.Append("A line with " + model.Str.ToString() + " and " + i.ToString() + " with @ignore me\r\n");

    return sb.ToString();
  }
}

假设动态的非原生类型

而最简单的就是传递类实例(即模型),其中每个模型类型的名称都是唯一的,这使得我们可以用很少的设置来解析、编译和执行模板生成器。

[TestMethod]
public void DynamicNonNativeTypeOnlyParameterPassing()
{
  string template = "A line with @model.Str and @model2.I with @@ignore me";
  Model model = new Model() { Str = "I'm Dynamic!", I=20 };
  Model2 model2 = new Model2() { I = 20 };

  TemplateEngine eng = new TemplateEngine();
  eng.UsesDynamic();
  string ret = eng.Parse(template, model, model2);

  Assert.AreEqual("A line with I'm Dynamic! and 20 with @ignore me\r\n", ret);
}

这里我们传递了两个模型,其中第二个模型定义为:

public class Model2
{
  public int I { get; set; }
}

我们还告诉引擎我们正在使用dynamic关键字语法。

后台实现会初始化变量并测试该用法的要求没有被违反。

/// <summary>
/// Only dynamic is supported. Non-class types are not supported because we can't determine their names.
/// Class types must be distinct.
/// </summary>
private void InitializeParameters(StringBuilder sb, object[] parms)
{
  List<string> typeNames = new List<string>();

  parms.ForEachWithIndex((parm, idx) =>
  {
    Type t = parm.GetType();
    string typeName = t.Name.CamelCase();

    if (!t.IsClass)
    {
      throw new TemplateEngineException("Automatic parameter passing does not support native types. Wrap the type in a class.");
    }

    if (typeNames.Contains(typeName))
    {
      throw new TemplateEngineException("Type names must be distinct.");
    }

    typeNames.Add(typeName);
    sb.Append("dynamic " + typeName + " = paramList[" + idx + "];\r\n");
  });
}

编译后的代码如下所示:

using System;
using System.Text;
using Clifton.Core.TemplateEngine;
public class RuntimeCompiled : IRuntimeAssembly
{
  public string GetTemplate(object[] paramList)
  {
    dynamic model = paramList[0];
    dynamic model2 = paramList[1];
    StringBuilder sb = new StringBuilder();
    sb.Append("A line with " + model.Str.ToString() + " and " + model2.I.ToString() + " with @ignore me\r\n");

    return sb.ToString();
  }
}

就是这样——轻松传递一个或多个模型的强大功能!

附加功能 - 缓存程序集

只要模板不变,我们就可以重用生成的程序集。

这是单元测试。

[TestMethod]
public void CacheTest()
{
  string template = "A line with @model.Str and @model2.I with @@ignore me";
  Model model = new Model() { Str = "I'm Dynamic!", I = 20 };
  Model2 model2 = new Model2() { I = 20 };

  TemplateEngine eng = new TemplateEngine();
  eng.UsesDynamic();
  string ret = eng.Parse(template, model, model2);

  Assert.AreEqual("A line with I'm Dynamic! and 20 with @ignore me\r\n", ret);
  Assert.IsTrue(eng.IsCached(template));

  model.Str = "Cached!";
  model2.I = 25;
  ret = eng.Parse(template, model, model2);
  Assert.AreEqual("A line with Cached! and 25 with @ignore me\r\n", ret);
}

后台,我们使用MD5算法计算哈希,而不是使用内置的.NET GetHashCodeGetHashCode返回一个int,碰撞的几率更大。如果你想使用不同的算法,可以重写GetHash方法。

public bool IsCached(string template)
{
  return cachedAssemblies.ContainsKey(GetHash(template));
}

public bool IsCached(string template, out IRuntimeAssembly t)
{
  return cachedAssemblies.TryGetValue(GetHash(template), out t);
}

public virtual Guid GetHash(string template)
{
  using (MD5 md5 = MD5.Create())
  {
    byte[] hash = md5.ComputeHash(Encoding.Default.GetBytes(template));

    return new Guid(hash);
  }
}

结论 - 我们完成了什么?

  • 编写代码、文章和单元测试所需时间:约6小时
  • 代码行数:492(不包括单元测试和我的扩展库)
  • 没有怪异的东西——没有临时文件等。
  • 程序集缓存。
  • 支持多种模型、原生类型。
  • 支持更高性能的接口以及动态对象。

而且,在我看来,一个非常简单、不带偏见的引擎,并且具有高度的可扩展性,可以满足现实世界的应用程序需求。

更新

3/21/2016

发布文章后,我修复了解析带引号行的一个bug。

另外,这个方法

/// <summary>
/// Making assumptions about class types and native types, still requiring variable names.
/// </summary>
private void InitializeParameters(StringBuilder sb, string[] names, object[] parms)
{
parms.ForEachWithIndex((parm, idx) =>
{
if (useDynamic)
{
sb.Append("dynamic " + names[idx] + " = paramList[" + idx + "];\r\n");
}
else
{
Type t = parm.GetType();
string typeName = t.IsClass ? "I" + t.Name : t.Name;
sb.Append(typeName + " " + names[idx] + " = (" + typeName + ")paramList[" + idx + "];\r\n");
}
});
}

已更新为在模板引擎被指示使用UsesDynamic调用来使用dynamic关键字时使用dynamic类型。这可以防止使用类型名称,否则需要using语句和程序集引用。

© . All rights reserved.