由淺入深:自己動手開發模板引擎——置換型模板引擎(四)
受到群裡兄弟們的竭力邀請,老陳終於決定來分享一下.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 >< strong >{CreationTime:< span
style="color: #888888;">yyyy年MM月dd日 HH:mm:ss</ span >}</ strong ></ time >]\r\n< a
href=\"<strong>{url}</ strong >\">< strong >{title}</ strong ></ 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 // 對輸出結果進行測試驗證,首先不能是null24 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月9日之後我們將開始討論解釋型模板引擎,敬請關注!