欢迎访问 生活随笔!

生活随笔

当前位置: 首页 > 编程资源 > 编程问答 >内容正文

编程问答

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

发布时间:2025/6/15 编程问答 66 豆豆
生活随笔 收集整理的这篇文章主要介绍了 由浅入深:自己动手开发模板引擎——置换型模板引擎(三) 小编觉得挺不错的,现在分享给大家,帮大家做个参考.

受到群里兄弟们的竭力邀请,老陈终于决定来分享一下.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。你还需要文档吗?文档在很多时候并不是必要的,但在某些时候又是非要不可的,要区别对待。

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

  • 标记格式和上一课一样,都是“{label}”
  • 今天加强的是允许对某些变量自定义格式化字符串,这里以日期类型为举例。聪明的你一定想到了,就是在输出Token流的时候需要有一段类似于dateTime.ToString("yyyy年MM月dd日 HH:mm:ss") 的代码。
  • 由于增加了一个格式化参数的语法,在“{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


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

    总结

    以上是生活随笔为你收集整理的由浅入深:自己动手开发模板引擎——置换型模板引擎(三)的全部内容,希望文章能够帮你解决所遇到的问题。

    如果觉得生活随笔网站内容还不错,欢迎将生活随笔推荐给好友。