由浅入深:自己动手开发模板引擎——置换型模板引擎(四)

受到群里兄弟们的竭力邀请,老陈终于决定来分享一下.NET下的模板引擎开发技术。本系列文章将会带您由浅入深的全面认识模板引擎的概念、设计、分析和实战应用,一步一步的带您开发出完全属于自己的模板引擎。关于模板引擎的概念,我去年在百度百科上录入了自己的解释(请参考:模板引擎)。老陈曾经自己开发了一套网鸟Asp.Net模板引擎,虽然我自己并不乐意去推广它,但这已经无法阻挡群友的喜爱了!

概述

置换型模板引擎系列是我们进入模板引擎开发领域的基础课程,这里讲述的一些原理、概念和实践方案都是后续模板引擎开发中所需要用到的,正所谓是由浅入深、循序渐进!在编写这些博文的时候,我遇到了很多阻力。为了能够让菜鸟朋友入门又不让高手们嗤之以鼻感觉到木有干货,这让老陈真的是煞费苦心!如果仅仅是开源一份代码出去,那么完成这样的项目本身可能不需要多少时间,然而要把这些组织成文字分享给大家,实在是很头疼的一件事情。

最初,我只是想将整个置换型模板引擎分为两节完成,但是发现不太可能,因此就不断的拆分。第一课我们简单了解了一些概念和原理,第二节我们深入探讨了字符流解析为Token流的过程,而第三节我们将这种过程简单的封装了一些,并融入测试驱动开发的概念进去,借此给大家分享更多的开发技巧。而本节,也是作为置换型模板引擎的最后一节,将会对第三节课中我们所做的简单封装执行重构。

我们今天重构的理念就是使用面向对象设计的理念来归纳整理模板引擎的业务流程、分析实体并创建代码模型以及建立单元测试等。我个人不是专业的写手,每篇博文的本意都是为大家分享一些开发经验和技巧,但我不保证我的词汇描述以及实践方案的绝对准确性。

需求分析

有了前面几节课,我们对模板引擎的原理已经有了非常清楚的认识,它本身的实现就是某种替换机制。为了追求高效、严谨,最后我们提到了按流替代式模板引擎并作出深入探讨。经历了三节课的认知和学习,我们知道按流替代式模板引擎的工作过程会经历如下阶段:

  1. 解析模板:
    1. 以字符为单位解析模板代码,并将代码整理为Token流。在没有复杂需求的前提下,每一个Token都是有着直接意义的。要么它表示普通的Text对象,会原原本本的输出;要么 表示一种Label对象,在输出的时候会被替换为真实的业务数据;
    2. 有了Token流,按照顺序就可以将Text对象和Label对象按照实际的业务需求进行输出。实际上我们之前的举例并没有真正的深入到流的概念,使用的都是集合。集合与流的最大区别就是流只能向前,其中的每个元素基本上就只有一次访问机会,而集合是任意的。
  2. 设定业务数据;
  3. 处置并得到输出结果。输出结果可以保存到临时变量,也可以直接输出展示,此后变脱离模板引擎的业务范围了。

在第三节课中,我们引入了一个Label中的Label的概念,即上篇文章中的“{CreationTime:yyyy年MM月dd日 HH:mm:ss}”标签。 这个标记使得我们不是死板的去替换Label,而是可以在模板中直接指定某些数据的输出格式。那么把这种标签还理解为Label的话是不是不太合适了呢?如果未来我们增加更加复杂的语法呢?

是的,为了使得流程更加清晰,我们再引入一个概念——Element。对!元素!就是模板元素!现在我们的解析流程变更为:

  1. 将字符流转换为Token流;
  2. 将Token流转换为Element流;
  3. 如果有可能,还需要把Element整理为Tag语句(这是解释型引擎内必备的东西)

在这里留下一个作业:请您结合这几节讲述的内容整理出一个完整的模板引擎工作流程图。

实体建模

在面向对象程序设计里,几乎每一件事物都可以使用结构等来描述,因为编程语言里面之所以支持命名空间、类、结构、接口等概念,就是为了描述面向对象编程。今天我试着从一个菜鸟的角度来分析和考虑如何实现实体建模,思路可能不太符合您的习惯,但我相信这样的过程菜鸟们一定会喜欢!

整个模板引擎分为两个体系,一个是对外公开的业务引擎和实体,一个是对内的代码解析器和实体

模板引擎的定义

模板引擎自身不是现实中的一种实体,它是一种业务,也可以理解为帮助类——即某种封装。以下是思路:

  1. 模板引擎就是用来处置模板的,因此它需要有个模板的属性,而这个模板是在模板引擎初始化时就存在的,模板引擎无权修改它;
  2. 处置模板本身就是做事的过程,这个需要定义为方法,通过这个方法我们应该能捕获处置结果;
  3. 要处置模板标签,需要一个预定义变量的容器,要提供一套添加变量、删除变量等的方法;

整理后我们使用接口描述,如下:

 1 /// <summary>
2 /// 定义模板引擎的基本功能。
3 /// </summary>
4 public interface ITemplateEngine
5 {
6 /// <summary>
7 /// 获取模板。
8 /// </summary>
9 Template Template { get; }
10
11 /// <summary>
12 /// 设定变量标记的置换值。
13 /// </summary>
14 /// <param name="key">键名。</param>
15 /// <param name="value">值。</param>
16 void SetVariable(string key, object value);
17
18 /// <summary>
19 /// 删除变量标记的置换值。
20 /// </summary>
21 /// <param name="key">键名。</param>
22 void RemoveVariable(string key);
23
24 /// <summary>
25 /// 清空变量标记的置换值。
26 /// </summary>
27 void ClearVariables();
28
29 /// <summary>
30 /// 处理模板。将处理结果保存到字符编写器中。
31 /// </summary>
32 /// <param name="writer">指定一个字符编写器。</param>
33 void Process(TextWriter writer);
34
35 /// <summary>
36 /// 处理模板。并将结果作为字符串返回。
37 /// </summary>
38 /// <returns>返回 <see cref="System.String"/></returns>
39 string Process();
40 }

模板的定义

在上文中我们提到,今天增加了一个Element的概念,那么模板的直接构成者就是Element,就如HTML代码是由各种Element和Text组成的一样,Text是一种特殊的Element。那么,模板的描述就非常简单了,它就是Element的集合:

 1 /// <summary>
2 /// 定义一个模板。
3 /// </summary>
4 public interface ITemplate
5 {
6 /// <summary>
7 /// 获取模板的标签库。
8 /// </summary>
9 List<Element> Elements { get; }
10 }

Element的定义

Element是构成模板的基本单位,然而Element并不是只有一种,前面我们提到最起码会分为Label和Text两种。既然是面向对象的设计,我们就使用多态性来描述Element。多态是指同一(种)事物的多种形态,而不是指状态。先来看看我们的模板代码:

[<time>{CreationTime:yyyy年MM月dd日 HH:mm:ss}</time>]\r\n<a href=\"{url}\">{title}</a>

归纳一下:

  • 非{xxx}格式的都理解为普通Text,会原原本本的输出;
  • {xxx}是Label
  • {xxx:xxx}是带有格式化字符串的Label

OK,那么就可以形成如下关系图:

图中的VariableLabel和TextElement共同派生自Element,体现出了Element的多态性。FormattableVariableLabel派生自VariableLabel又提现了VariableLabel的多态性。这里,我们将Element定义为抽象类,就不需要定义接口了。如果要定义,那么这个接口就只需要两个属性:Line和Column。因为Element的共同特点就是有特定的位置,至于是否有数据在里面这个是说不定的事情!

仔细观察VariableLabel还独自声明了一个Process(Dictionary<string, object> variables)方法,这个将数据置换的过程移动到了Element自身。降低了整个代码架构的耦合性。

另外,我们这里的Element定义实际上还缺少了对“{”、“}”和“:”等特殊字符的描述,他们也是模板代码的基本元素之一。只不过,在解析过程中我们要忽略它们,这里即使定义了,也可能用不到。

代码解析器的定义

代码解析器就只有一个作用——将Token流转换为Element集合,它应该从词法分析器初始化,也仅需要一个公开方法:

 1 /// <summary>
2 /// 定义模板代码解析器。
3 /// </summary>
4 internal interface ITemplateParser
5 {
6 /// <summary>
7 /// 解析模板代码。
8 /// </summary>
9 /// <returns>返回 <see cref="Element"/> 对象的集合。</returns>
10 List<Element> Parse();
11 }

词法分析器的定义

词法分析器的作用是将字符流转换为Token流:

 1 /// <summary>
2 /// 定义模板词法分析器。
3 /// </summary>
4 internal interface ITemplateLexer
5 {
6 /// <summary>
7 /// 继续分析下一条词汇,并返回分析结果。
8 /// </summary>
9 /// <returns>Token</returns>
10 Token Next();
11 }

这里我们仅仅使用了一个唯一的Next()方法,它的返回值是Token。也就是说,词法分析是一个只能向前的过程,现在您是否能够领略到为什么我一直在强调Token流的概念么?作业:请认真思考流和集合的区别。

Token的定义

实际上,Token与Element一样,都有位置属性。然而为了便于后期处理,我们还需要保存Token代表的数据(这里的Text,实际上应该定义为Data更加合适,为了直观,这里就Text吧!),还要指明当前Token的类型(TokenKind):

 1 /// <summary>
2 /// 定义一个 Token。
3 /// </summary>
4 internal interface IToken
5 {
6 /// <summary>
7 /// 获取 Token 所在的列。
8 /// </summary>
9 int Column { get; }
10
11 /// <summary>
12 /// 获取 Token 所在的行。
13 /// </summary>
14 int Line { get; }
15
16 /// <summary>
17 /// 获取 Token 类型。
18 /// </summary>
19 TokenKind Kind { get; }
20
21 /// <summary>
22 /// 获取 Token 文本。
23 /// </summary>
24 string Text { get; }
25 }

其他定义

Token需要TokenKind来描述其类型,这是一个有限的状态集合,那么就定义为枚举值。词法分析器这个东东,实际上在第二课第三课已经见识过了,我们不断的在不同的状态中穿梭,那么就需要一个词法分析状态的枚举值,这两个枚举值的定义分别如下:

 1 /// <summary>
2 /// 表示 Token 类型的枚举值。
3 /// </summary>
4 internal enum TokenKind
5 {
6 /// <summary>
7 /// 未指定类型。
8 /// </summary>
9 None = 0,
10
11 /// <summary>
12 /// 左大括号。
13 /// </summary>
14 LeftBracket = 1,
15
16 /// <summary>
17 /// 右大括号。
18 /// </summary>
19 RightBracket = 2,
20
21 /// <summary>
22 /// 普通文本。
23 /// </summary>
24 Text = 3,
25
26 /// <summary>
27 /// 标签。
28 /// </summary>
29 Label = 4,
30
31 /// <summary>
32 /// 格式化字符串前导符号。
33 /// </summary>
34 FormatStringPreamble = 5,
35
36 /// <summary>
37 /// 格式化字符串。
38 /// </summary>
39 FormatString = 6,
40
41 /// <summary>
42 /// 表示字符流末尾。
43 /// </summary>
44 EOF = 7
45 }
46
47 /// <summary>
48 /// 表示词法分析模式的枚举值。
49 /// </summary>
50 /// <remarks>记得上次我们的命名是PaserMode么?今天我们换个更加专业的单词。</remarks>
51 internal enum LexerMode
52 {
53 /// <summary>
54 /// 未定义状态。
55 /// </summary>
56 Text = 0,
57
58 /// <summary>
59 /// 进入标签。
60 /// </summary>
61 Label = 1,
62
63 /// <summary>
64 /// 进入格式化字符串。
65 /// </summary>
66 FormatString = 2,
67 }

单元测试

完成了基本的实体接口定义,我们不着急编写功能实现的代码,而是先创建个单元测试,实现以测试为目的来驱动我们的开发过程。测试驱动开发的好处,是我们在开发之前就已经知道了我们的编码目标!而平常我们经常是需求驱动开发的,这个不算科学,当遇到多个团队配合的时候,就显得难以交流。较好的方案是:需求驱动测试、测试驱动开发、开发驱动猴子

实际上,我们的单元测试代码在上一课中就编写过了,稍加修改如下:

 1 [TestFixture]
2 public sealed class TemplateEngineUnitTests
3 {
4 private const string _templateString = "[<time>{CreationTime:yyyy年MM月dd日 HH:mm:ss}</time>]\r\n<a href=\"{url}\">{title}</a>";
5 private const string _html = "[<time>2012年04月03日 16:30:24</time>]\r\n<a href=\"http://www.ymind.net/\">陈彦铭的博客</a>";
6
7 [Test]
8 public void ProcessTest()
9 {
10 var templateEngine = TemplateEngine.FromString(_templateString);
11
12 templateEngine.SetVariable("url", "http://www.ymind.net/");
13 templateEngine.SetVariable("title", "陈彦铭的博客");
14 templateEngine.SetVariable("CreationTime", new DateTime(2012, 4, 3, 16, 30, 24));
15
16 var html = templateEngine.Process();
17
18 Trace.WriteLine(html);
19
20 // 还记得第一节课我就说,我为了简化代码架构使用了单元测试的方法来做的demo代码,那个不是真正的单元测试
21 // 因为在那个时候,我们的代码中没有包含结果验证的过程
22
23 // 对输出结果进行测试验证,首先不能是null
24 Assert.NotNull(html);
25
26 // 输出结果必须与预期结果完全一致
27 Assert.AreEqual(_html, html);
28
29 // 如果以上两个验证无法通过,那么执行的时候必定会报错!
30 }
31 }

做单元测试的方法有很多,我自己喜欢使用NUnit.Framework + ReSharper,效果如下图:

编码实现

编码实现这一步主要讲一下几个难点,剩下的请仔细琢磨代码。

难点一:如何实现FormattableVariableLabel的Process()方法

在.NET中,凡是支持自定义格式化字符串的对象必定都会实现IFormattable接口,利用这一点我们可以通过以下代码实现这个需求,说难也不难:

 1 /// <summary>
2 /// 处置当前元素。
3 /// </summary>
4 /// <param name="variables">与当前元素关联的对象。</param>
5 /// <returns>返回 <see cref="System.String"/></returns>
6 public override string Process(Dictionary<string, object> variables)
7 {
8 if (variables == null || variables.Count == 0) return String.Empty;
9 if (variables.ContainsKey(this.Name) == false) return String.Empty;
10
11 var obj = variables[this.Name] as IFormattable;
12
13 return obj == null ? variables[this.Name].ToString() : obj.ToString(this.Format, null);
14 }

难点二:如何将Token流转换为Element集合

我们为每个Token标记了位置和类型信息,依照这些信息进行归纳整理即可。在处理的时候只需要理会Text和Label两种类型即可,当遇到Label类型时,还有可能要读取FormatString,而在FormatString之前则必定是FormatStringPreamble!

详情请参考TemplateParser.Parse()方法的实现。

难点三:词法解析的过程只能向前会不会有问题?

实际上,流是一种很普通的概念,水管里面的水只能是一个方向;电流只会从一端到另外一端;网络数据流的发送和接受都是一次性的(如果您涉足过),如此等等。只能向前,这意味着更好的性能,因为这注定了某些事情我们只能做一次!

词法解析过程中可能要判断前后依赖的字符和字符串(这里要理解为字符数组),这里就需要定位了,记得FileStream里面有个Position属性么?呵呵,为什么我们就不能有呢?但是不要滥用它!

限于篇幅,通过文字已经无法准确去描述这个过程了,希望大家能够认真的研究TemplateLexer类!如果您搞不懂它,那么在制作解释型模板引擎的时候将会遇到很大的阻力!加油!不懂的地方跟帖发问!!!

总结及代码下载

置换型模板引擎系列分了4课才讲述完毕,实际上还不够完美,但时间仓促,也不想把篇幅拉的太长,希望大家能够多多研究代码。如果有问题请跟帖提出即可。

本系列教程并没有将话题集中在模板引擎自身,期间提到了状态机、有限状态机、编译原理、词法分析、单元测试、测试驱动开发、面向对象设计等等概念,希望大家能够有所收获!诚然,如果您发现了错误之处还请指出,欢迎挑刺!

代码下载:置换型模板引擎(4).zip


4月9日之后我们将开始讨论解释型模板引擎,敬请关注!

posted @ 2012-04-06 09:21  O.C  阅读(3675)  评论(6编辑  收藏  举报