受到群里兄弟們的竭力邀請,老陳終於決定來分享一下.NET下的模板引擎開發技術。本系列文章將會帶您由淺入深的全面認識模板引擎的概念、設計、分析和實戰應用,一步一步的帶您開發出完全屬於自己的模板引擎。關於模板引擎的概念,我去年在百度百科上錄入了自己的解釋(請參考:模板引擎)。老陳曾經自己開發了一套網鳥Asp.Net模板引擎,雖然我自己並不樂意去推廣它,但這已經無法阻擋群友的喜愛了!
概述
置換型模板引擎系列是我們進入模板引擎開發領域的基礎課程,這里講述的一些原理、概念和實踐方案都是后續模板引擎開發中所需要用到的,正所謂是由淺入深、循序漸進!在編寫這些博文的時候,我遇到了很多阻力。為了能夠讓菜鳥朋友入門又不讓高手們嗤之以鼻感覺到木有干貨,這讓老陳真的是煞費苦心!如果僅僅是開源一份代碼出去,那么完成這樣的項目本身可能不需要多少時間,然而要把這些組織成文字分享給大家,實在是很頭疼的一件事情。
最初,我只是想將整個置換型模板引擎分為兩節完成,但是發現不太可能,因此就不斷的拆分。第一課我們簡單了解了一些概念和原理,第二節我們深入探討了字符流解析為Token流的過程,而第三節我們將這種過程簡單的封裝了一些,並融入測試驅動開發的概念進去,借此給大家分享更多的開發技巧。而本節,也是作為置換型模板引擎的最后一節,將會對第三節課中我們所做的簡單封裝執行重構。
我們今天重構的理念就是使用面向對象設計的理念來歸納整理模板引擎的業務流程、分析實體並創建代碼模型以及建立單元測試等。我個人不是專業的寫手,每篇博文的本意都是為大家分享一些開發經驗和技巧,但我不保證我的詞匯描述以及實踐方案的絕對准確性。
需求分析
有了前面幾節課,我們對模板引擎的原理已經有了非常清楚的認識,它本身的實現就是某種替換機制。為了追求高效、嚴謹,最后我們提到了按流替代式模板引擎並作出深入探討。經歷了三節課的認知和學習,我們知道按流替代式模板引擎的工作過程會經歷如下階段:
- 解析模板:
- 以字符為單位解析模板代碼,並將代碼整理為Token流。在沒有復雜需求的前提下,每一個Token都是有着直接意義的。要么它表示普通的Text對象,會原原本本的輸出;要么 表示一種Label對象,在輸出的時候會被替換為真實的業務數據;
- 有了Token流,按照順序就可以將Text對象和Label對象按照實際的業務需求進行輸出。實際上我們之前的舉例並沒有真正的深入到流的概念,使用的都是集合。集合與流的最大區別就是流只能向前,其中的每個元素基本上就只有一次訪問機會,而集合是任意的。
- 設定業務數據;
- 處置並得到輸出結果。輸出結果可以保存到臨時變量,也可以直接輸出展示,此后變脫離模板引擎的業務范圍了。
在第三節課中,我們引入了一個Label中的Label的概念,即上篇文章中的“{CreationTime:yyyy年MM月dd日 HH:mm:ss}”標簽。 這個標記使得我們不是死板的去替換Label,而是可以在模板中直接指定某些數據的輸出格式。那么把這種標簽還理解為Label的話是不是不太合適了呢?如果未來我們增加更加復雜的語法呢?
是的,為了使得流程更加清晰,我們再引入一個概念——Element。對!元素!就是模板元素!現在我們的解析流程變更為:
- 將字符流轉換為Token流;
- 將Token流轉換為Element流;
- 如果有可能,還需要把Element整理為Tag、語句等(這是解釋型引擎內必備的東西);
在這里留下一個作業:請您結合這幾節講述的內容整理出一個完整的模板引擎工作流程圖。
實體建模
在面向對象程序設計里,幾乎每一件事物都可以使用類、結構等來描述,因為編程語言里面之所以支持命名空間、類、結構、接口等概念,就是為了描述面向對象編程。今天我試着從一個菜鳥的角度來分析和考慮如何實現實體建模,思路可能不太符合您的習慣,但我相信這樣的過程菜鳥們一定會喜歡!
整個模板引擎分為兩個體系,一個是對外公開的業務引擎和實體,一個是對內的代碼解析器和實體。
模板引擎的定義
模板引擎自身不是現實中的一種實體,它是一種業務,也可以理解為幫助類——即某種封裝。以下是思路:
- 模板引擎就是用來處置模板的,因此它需要有個模板的屬性,而這個模板是在模板引擎初始化時就存在的,模板引擎無權修改它;
- 處置模板本身就是做事的過程,這個需要定義為方法,通過這個方法我們應該能捕獲處置結果;
- 要處置模板標簽,需要一個預定義變量的容器,要提供一套添加變量、刪除變量等的方法;
整理后我們使用接口描述,如下:
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日之后我們將開始討論解釋型模板引擎,敬請關注!