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

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

在上一篇我们以简单明快的方式介绍了置换型模版引擎的关键技术——模板标记的流式解析。采用流式解析可以达到相当好的解析性能,因为它基本上只需要对字符串(模板)扫描一次就可以完成所有代码的解析。不像String.Split()和正则表达式那样会造成很多迭代效应。今天我们引入一个较为复杂的示例,然后封装一个实用级别的模板引擎。封装就意味着使用者无需了解内部如何实现,只需要知道如何引用即可(为了降低门槛,本文没有进行高级封装和重构,这些内容在下一篇文章中放出)

概述

题外话:在某公司入职之后,我曾经非常抱怨其CRM系统代码架构的糟糕程度,其中比较重要的一点是不伦不类的面向对象/过程的编码以及各种无法重用或无意重用的代码。一位同事便向我请教,如何编写面向对象的应用程序呢?实际上面向对象首先是一种深度思维的结果,方法就只有一个:把一切都当作对象!

回到我们今天的话题,想做好面向对象的设计,首先要明确一下我们要做什么——我们要做的是一个模板引擎。它应当能够解析一些模板代码,然后根据外部业务数据生成我们期望的结果。当不关心如何实现这些需求的时候,可以先定义一个接口(暂时不要关心这个接口定义是否合理,否则哪里来的重构?)

 1 /// <summary>
2 /// 定义模板引擎的基本功能。
3 /// </summary>
4 public interface ITemplateEngine
5 {
6 /// <summary>
7 /// 解析模板。
8 /// </summary>
9 /// <param name="templateString">包含模板内容的字符串。</param>
10 void Parser(string templateString);
11
12 /// <summary>
13 /// 设定变量标记的值。
14 /// </summary>
15 /// <param name="key">键名。</param>
16 /// <param name="value">值。</param>
17 void SetValue(string key, object value);
18
19 /// <summary>
20 /// 处理模板并输出结果。
21 /// </summary>
22 /// <returns>返回包含业务数据的字符串。</returns>
23 string Process();
24 }

定义了模板引擎的基本功能,我们就试着实现一下。为了让大家接触到更多的流式解析技巧,本例对上一篇文章中的标记语法做了更改,使其更为复杂。如果您仔细观察上面的接口定义,会发现SetValue()方法的value参数被定义为object。我们的目标是满足如下需求:

 1 [TestFixture]
2 public sealed class TemplateEngineUnitTests
3 {
4 private const string _templateString = "[<time>{CreationTime:yyyy年MM月dd日 HH:mm:ss}</time>] <a href=\"{url}\">{title}</a>";
5 private const string _html = "[<time>2012年04月03日 16:30:24</time>] <a href=\"http://www.ymind.net/\">陈彦铭的博客</a>";
6
7 [Test]
8 public void ProcessTest()
9 {
10 var templateEngine = new TemplateEngine();
11 templateEngine.Parser(_templateString);
12 templateEngine.SetValue("url", "http://www.ymind.net/");
13 templateEngine.SetValue("title", "陈彦铭的博客");
14 templateEngine.SetValue("CreationTime", new DateTime(2012, 4, 3, 16, 30, 24));
15
16 var html = templateEngine.Process();
17
18 Trace.WriteLine(html);
19
20 Assert.AreEqual(html, _html);
21 }
22 }

有经验的朋友可能已经发现了,这不是个单元测试么?是的,在这里老陈使用了测试驱动开发的思路(我会尽量的在我的博文中给大家分享各方面的经验技巧,这才是传说中的干货!)。测试驱动开发有什么好处?很显然,有了单元测试代码,我们就很明确的知道我们要做什么了,而且单元测试本身就是一个demo。你还需要文档吗?文档在很多时候并不是必要的,但在某些时候又是非要不可的,要区别对待。

奔着这个单元测试代码,我们基本可以明确今天的学习内容:

  1. 标记格式和上一课一样,都是“{label}”
  2. 今天加强的是允许对某些变量自定义格式化字符串,这里以日期类型为举例。聪明的你一定想到了,就是在输出Token流的时候需要有一段类似于dateTime.ToString("yyyy年MM月dd日 HH:mm:ss") 的代码。
  3. 由于增加了一个格式化参数的语法,在“{label}”内部又需要将format字符串分离出来,因此解析的难度加大。

模板解析

根据上一节课的内容,我们首先来分析一下解析过程中所需要使用的状态:

 1 /// <summary>
2 /// 表示词法分析模式的枚举值。
3 /// </summary>
4 /// <remarks>记得上次我们的命名是PaserMode么?今天我们换个更加专业的单词。</remarks>
5 public enum LexerMode
6 {
7 /// <summary>
8 /// 未定义状态。
9 /// </summary>
10 None = 0,
11
12 /// <summary>
13 /// 进入标签。
14 /// </summary>
15 EnterLabel,
16
17 /// <summary>
18 /// 脱离标签。
19 /// </summary>
20 LeaveLabel,
21
22 /// <summary>
23 /// 进入格式化字符串。
24 /// </summary>
25 EnterFormatString,
26
27 /// <summary>
28 /// 脱离格式化字符串。
29 /// </summary>
30 LeaveFormatString,
31 }

请注意,每个模式都是成对出现的,因为流式解析总会是有始有终的!哪怕某些开始和结束在物理上是重合的。但是Enter和Leave这两个动作总是在描述同样一件事物,我们就可以缩减对象类型(这里是指词法分析模式),优化后定义如下:

 1 /// <summary>
2 /// 表示词法分析模式的枚举值。
3 /// </summary>
4 /// <remarks>记得上次我们的命名是PaserMode么?今天我们换个更加专业的单词。</remarks>
5 public enum LexerMode
6 {
7 /// <summary>
8 /// 未定义状态。
9 /// </summary>
10 Text = 0,
11
12 /// <summary>
13 /// 进入标签。
14 /// </summary>
15 Label = 1,
16
17 /// <summary>
18 /// 进入格式化字符串。
19 /// </summary>
20 FormatString = 2,
21 }

不过我们今天要强化的可不只是增加了一个格式化字符串这么简单,我们还要能够明确的了解到每个Token的位置信息和类型,这是我们下一节讲解解释型模版引擎时所需要用到的概念。Token在上一节中我们仅仅使用了一个string类型来表示,但这个满足不了我们的需要了,我们需要自定义一个Token类型,如下:

 1 /// <summary>
2 /// 表示一个 Token。
3 /// </summary>
4 public sealed class Token
5 {
6 /// <summary>
7 /// 初始化 <see cref="Token"/> 对象。
8 /// </summary>
9 /// <param name="kind"><see cref="TokenKind"/> 的枚举值之一。</param>
10 /// <param name="text">Token 文本。</param>
11 /// <param name="line">Token 所在的行。</param>
12 /// <param name="column">Token 所在的列。</param>
13 public Token(TokenKind kind, string text, int line, int column)
14 {
15 this.Text = text;
16 this.Kind = kind;
17 this.Column = column;
18 this.Line = line;
19 }
20
21 /// <summary>
22 /// 获取 Token 所在的列。
23 /// </summary>
24 public int Column { get; private set; }
25
26 /// <summary>
27 /// 获取 Token 所在的行。
28 /// </summary>
29 public int Line { get; private set; }
30
31 /// <summary>
32 /// 获取 Token 类型。
33 /// </summary>
34 public TokenKind Kind { get; private set; }
35
36 /// <summary>
37 /// 获取 Token 文本。
38 /// </summary>
39 public string Text { get; private set; }
40 }

我们使用行数、列数、类型和文本(内容)来共同描述一个Token,这下可丰富多彩了!TokenKind明显应该是个枚举值,根据本例,TokenKind的定义如下:

 1 /// <summary>
2 /// 表示 Token 类型的枚举值。
3 /// </summary>
4 public 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 }

也就是说本次我们将要面对5种Token(None纯粹是为了描述一个空类型)!

在往下看之前请您按照上一课中的方法自行实现一下本节课的需求,1小时之后再回来。

如果您自己推敲过了,可能会发现一个问题,即FormatString是嵌套在Label里面的,这个貌似很难区分啊!是的,本节之所以设计了这么一个需求,就是有了这么一个嵌套Token的解析过程,掌握这个技巧是至关重要的!因此,我希望您不要偷懒,自行先摸索摸索,先不要看后面的答案……

实际上,如果您曾经接触过编译原理的话,可能如上的难题根本就不是什么事,因为这是一个司空见惯的问题。这整个就是方法签名即形式参数的实现,比如:

  • Do()
  • Do("x")
  • Do("x", "y")
  • Do("x", y, "z")

很眼熟很常见不是?那么在解析这些代码的时候,由于模式会嵌套,也就意味着模式会后进先出。后进先出?!你想到了什么? 对!就是它,不要怀疑!Stack!只不过在泛型称霸天下的今天,我们当然要选用Stack<T>了!这里我就不再帖出自己的实现代码了,因为太长了。

变量赋值

变量赋值很简单,就是使用Dictionary<string, object>:

 1 private readonly Dictionary<string, object> _variables = new Dictionary<string, object>();
2
3 /// <summary>
4 /// 设定变量标记的值。
5 /// </summary>
6 /// <param name="key">键名。</param>
7 /// <param name="value">值。</param>
8 public void SetValue(string key, object value)
9 {
10 // 就这么简单
11 this._variables[key] = value;
12 }

这一小节没有任何难度,难道说简单一点不好么?

数据输出

在输出业务数据的时候,唯一的难点就是如何实现自定义格式化字符串,废话不多说,直接上代码:

 1 /// <summary>
2 /// 处理模板并输出结果。
3 /// </summary>
4 /// <returns>返回包含业务数据的字符串。</returns>
5 public string Process()
6 {
7 var result = new StringBuilder();
8
9 for (var index = 0; index < this._tokens.Count; index++)
10 {
11 var token = this._tokens[index];
12
13 switch (token.Kind)
14 {
15 case TokenKind.Label:
16 string value;
17
18 // 具体的Token流是:
19                 // Label = CreationTime
20                 // FormatStringPreamble = :
21                 // FormatString = yyyy年MM月dd日 HH:mm:ss
22                 // 因此这里减去2个索引值检查操作范围
23 if (index < this._tokens.Count - 2)
24 {
25 // 实现自定义格式化字符串
26 var nextToken = this._tokens[index + 2];
27
28 if (nextToken.Kind == TokenKind.FormatString)
29 {
30 // 注意这里使用 IFormattable 来验证目标类型是否实现了格式化功能
31 var obj = this._variables[token.Text] as IFormattable;
32
33 value = obj == null ? this._variables[token.Text].ToString() : obj.ToString(nextToken.Text, null);
34 }
35 else value = this._variables[token.Text].ToString();
36 }
37 else value = this._variables[token.Text].ToString();
38
39 result.Append(value);
40 break;
41
42 case TokenKind.Text:
43 result.Append(token.Text);
44 break;
45 }
46 }
47
48 return result.ToString();
49 }

总结及代码下载

与上一课相比,本课的内容跨度较大,但学习和理解的难度尚且不是很大。我们下一节课将会对本节代码进行重构封装,看看重构能给我们带来什么惊喜!

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


下集预报:本课的代码为了让新手容易理解所以没有做高度封装,下一篇博文将会对本次的代码执行一次高度封装,代码理解的难度较大,将会独立出一个词法分析器类、模板实体类等,充分的面向对象设计。
 

 

posted @ 2012-04-05 09:41  O.C  阅读(4005)  评论(9编辑  收藏  举报