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





5.00/5 (17投票s)
基于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 GetHashCode
。GetHashCode
返回一个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
语句和程序集引用。