2014/4/28

[入門] T4 入門教學

T4 是從 Visual Studio 2008 之後加入的新功能, 目的是提供一個文字範本, 讓開發者以動態方式產生文字內容, 例如程式碼。或許你會覺得 "T4" 這個名字很奇怪, 但事實上它是 "Text Template Transformation Toolkit" 縮寫。因為它的開頭字母剛好是四個 T, 所以就稱為 T4。

Visual Studio 本身大量地使用 T4 作為動態程式碼的輸出工具。當然, 我們也可以使用 T4 來產生我們自己的程式碼。不過, 我們不僅可以產生程式碼, 基本上, 我們可以使用它來產生各種文字

Step 1

請使用 Visual Studio 建立一個 Windows Form 專案 (事實上你也可以使用 Console 或者 ASP.NET 專案, 視你自己喜好), 然後在專案中加入一個「執行階段文字範本」項目:

如上, 我加入了一個叫做 "GetWeather.tt" 的 T4 檔案。

Step 2

接著, 請將這個 T4 檔案的內容修改如下:

<# // 程式一 #>
<#@ template language="C#"#>
<#
for (int i = 0; i < 12; i++) 
{
   WriteLine(i.ToString());
} #>

在上述程式中, <# // XXX #> 是 T4 的註解語法,<#@ template language="C#"#> 是指定此範本要使用何種語言。如果你使用 C#, 那麼這一行可以省略, 因為預設值就是 C#。如果你使用 VB, 請改成 <#@ template language="VB"#>

接著, 在以 <# ... #> 包住的範圍內, 就是你的程式內容。你寫的程式就是以此方式組成。如上面的程式所示, 我在一個 for 迴圈內使用 WriteLine 方法輸出從 0 到 11 共 12 個數字。

對於 ASP 或 ASP.NET 的開發者, 這種語法應該是再熟悉不過了。的確, 如果我們提早下個結論, 那麼 T4 就是如此而已, 沒有什麼神秘之處。如果你之前對 T4 還有什麼疑慮的話, 那麼做到這裡, 你應該已經放心不少了吧!?

不過我要補充一下, 在這裡我使用的 WriteLine() 方法可不是 Console.WriteLine(), 也不是 Debug.WriteLine(); 它實際上是 TextTransformation 類別中提供的方法之一。Write() 和 WriteLine() 是 T4 中最方便的方法之一, 其功能很類似 StringBuilder 的 Append() 和 AppendLine() 方法。做過以下幾個步驟之後, 你就知道為什麼我會做這種比喻。不過, 你在使用 T4 產生文字時, 多半並不會用到 Write(), 也不會用到 WriteLine() (但是不是不行); 稍後你就知道為什麼。

不過, 如果你對 Write() 和 WriteLine() 之類的工具方法有興趣, 可以參考 MSDN 的「文字範本公用程式方法」一文。

Step 3

接著, 請在你的應用程式寫入如下程式:

// 程式二
GetWeather w = new GetWeather();
rt.Text = w.TransformText();

其中 rt 是我的 Windows Form 裡的一個 RichTextBox 控制項。

如上, 對 T4 類別 (即 GetWeather) 的 instance 下達 TransformText() 方法, 就會輸出這個 T4 的對應文字, 也就是從 0 到 11 這十二行數字。

如果你是性急的朋友, 看到這個步驟為止, 就足夠你另外自行發展了, 因為嚴格來說, T4 並沒有太多學問在裡面, 只有一些可以留意的細節。我會在下面的步驟中繼續說明。

Step 4

如果你的警覺性夠的話, 在上一個步驟中, 你可能會在心裡產生一個疑問: 如果我們只是想要產生這麼簡單的文字內容的話, 那麼以上那個 T4 範本的所有功能, 我們都可以很簡單地使用 string.Format 或者 StringBuilder 做出來; 我們繞一個大圈來做這種事情有意義嗎?

事實上, T4 所有能做的事, 我們的確都可以使用 string.Format 或者 StringBuilder 來產生。然而就像 ASP.NET 一樣, 我們可以有更大的彈性, 讓文字中的程式邏輯不要那麼緊密地和其它靜態文字本身結合; 這是一個是否要做 de-coupling 的抉擇。換句話說, 如果你想輸出的文字幾乎通通都是與邏輯相依的, 那麼使用 string.Format 或者 StringBuilder 就夠了。但是如果你要輸出的文字泰半以上都是可能需要修改的靜態文字, 或者必須使用大量迴圈才能組出來的話, 那麼使用 T4 則是比較方便的。

就像我很久以前做過的 SQL Command 的自動產生程式, 它可以讓使用者指定資料庫和資料表之後, 自動把 CRUD 的 Ado.Net 程式, 以及對應的 Stored Procedure 組出來。但是我那個時候能用的工具並不多, 連 StringBuilder 都還沒有, 所以我是使用 ASP.NET 的 Repeater 去組出來的。那個過程說真的, 並不會讓人覺得很愉快, 因為工作很繁雜。如果那時候有 T4 可以用, 你覺得我會選擇哪一種?

現在, 請把程式一修改如下:

<# // 程式三 #>
<#@ template language="C#"#>
<#
for (int i = 0; i < 12; i++) 
{ 
#>編號: <#= i.ToString() #>
<# } #>

按下 <F5> 執行之後, 你可以看到這段程式要做的事情和程式一幾乎是完全一模一樣的, 我只額外加入了「編號:」這幾個字而已。但是請再仔細看看, 我在這裡已經不使用 WriteLine() 方法, 而使用直接輸出的語法。換句話說, 在任何 <# .. #> 之外的區段中, 你輸入什麼文字, 就會出現什麼文字, 包括空白和斷行等等。

在 T4 語法中以 "<#@" 開頭的稱為「指示詞」(Directives)。除了程式一就已經出現過的 "template language" 指示詞是用來指定使用的語言外, 我們也常使用來匯入命名空間和外部參考等等。不過, 由於在程式一和程式二裡我們並用不到任何其它命名空間和外部參考, 所以我就沒有列出任何一項。

以 "<#" 開頭、以 "#>" 結尾的部份, 統稱為「控制區塊」(Control Blocks), 我們一般稱為程式碼區塊。介於控制區塊與另一個控制區塊之間的文字, 會被原封不動地輸出, 例如程式二中的「編號:」這幾個字, 以及夾雜其間的空白、TAB 字元和斷行字元等等。

此外, 以 "<#=" 開頭的區塊, 稱為「運算式控制區塊」(Expression Control Block), 代表那些無關流程, 會被 T4 引擎評估並輸出為字串的部份。就像程式二中的 "i.ToString()" 這道指令。這個運算式控制區塊和其它控制區塊不同之處在於, 以 "<#=" 開頭的區塊經評估之後會輸出為文字, 其它控制區塊不會。

最後一種是以 "<#+" 開頭的「類別功能控制區塊」(Class Control Block), 可以讓你把類別定義在 T4 檔案裡面, 有點像是 ASP.NET 中的 Inline 寫法。不過我在下面會介紹比較容易維護的做法 (姑且稱為 Code Behind), 也就是把類別和邏輯寫在其它程式中, 而不是全部寫在 T4 檔案中。如此可以保持 T4 檔案偏向以「呈現」(presentation) 為目的, 而不是「邏輯」(logic)。所以, 知道有這種區塊存在就可以了, 我不推薦採用這個寫法來定義類別, 除非這些類別確實有需要以動態方式在 T4 中組成 (不過, 我個人還沒有遇到過這種狀況, 但是仍然不排斥可能會有這種情形)。

以上各種指示詞的詳細說明可以參考 MSDN 上的「撰寫 T4 文字範本」一文。

Step 5

在此後幾個步驟中, 我們來玩大一點。

OpenWeatherMap 是一個提供免費全球氣象查詢查詢的網站, 透過它所提供的 API, 我們可以查詢全世界各重要城市的氣象資訊。它的查詢方法很簡單, 大致上就是使用 "http://api.openweathermap.org/data/2.5/weather?q={0}" 的網址取回一串 JSON 字串。上述連結中的 {0} 代表城市名稱, 例如 "Taipei,tw"、"Paris,fr"、"Tokyo,jp" 等等。他也可以傳回 XML 格式, 如果你不喜歡 JSON 的話。

OpenWeatherMap 可以提供的資訊很多, 你可以在這裡看到實際上它可以做到哪些功能。不過, 本文的重點是 T4, 不是氣象、也不是 JSON 或者 XML, 我只是拿它來做個示範而已, 所以我不會去取用它能提供的所有資訊。如果你真的對它有興趣的話, 你可以自行深入研究; 我在本文中不會對這個網站的功能著墨過深, 以免轉移了焦點。

我假設你已經知道如何從網站中取回 JSON 文字、如何解析、如何使用自訂類別呈現。所以我不會描述其細節。不過如果你不知道的話, 你可以參考我寫的「在 JSON.NET 中自動對應 JavaScript 時間」一文, 裡面有提到了一些技巧。上文中的時間轉換程式, 在本文中也剛好用得上。

不過, 如果你真的對 JSON 很陌生, 又不想浪費時間在解析 JSON 上面, 那麼其實你根本不需要採用此處範例所使用的方法。我只是從 OpenWeatherMap 取到資料而已, 而你可以到你自己的資料庫去取得你既有的資料, 文字檔也行; 不一定要去讀網路上取得的資料。

現在, 請連上 "http://api.openweathermap.org/data/2.5/weather?q=Taipei,tw&lang=zh-TW", 然後把 JSON 內容整個拷貝起來:

接著, 在 Visual Studio 中建立一個類別檔案 (例如 Weather.cs), 然後將上述 JSON 文字貼上去以產生類別 (「編輯」、「選擇性貼上」、「貼上 JSON 做為類別」)。如此, VS 會自動幫我們產生該 JSON 的對應類別。把根目錄類別改名為 OpenWeather, 其它地方暫時不要去動它。如果一切順利的話, 你會看到如下的類別:

// 程式四
public class OpenWeather
{
    public Coord coord { get; set; }
    public Sys sys { get; set; }
    public Weather[] weather { get; set; }
    public string _base { get; set; }
    public Main main { get; set; }
    public Wind wind { get; set; }
    public Clouds clouds { get; set; }
    public int dt { get; set; }
    public int id { get; set; }
    public string name { get; set; }
    public int cod { get; set; }
}

public class Coord
{
    public float lon { get; set; }
    public float lat { get; set; }
}

public class Sys
{
    public float message { get; set; }
    public string country { get; set; }
    public long sunrise { get; set; }
    public long sunset { get; set; }
}

public class Main
{
    public float temp { get; set; }
    public float pressure { get; set; }
    public int humidity { get; set; }
    public float temp_min { get; set; }
    public float temp_max { get; set; }
}

public class Wind
{
    public float speed { get; set; }
    public float deg { get; set; }
}

public class Clouds
{
    public int all { get; set; }
}

public class Weather
{
    public int id { get; set; }
    public string main { get; set; }
    public string description { get; set; }
    public string icon { get; set; }
}

請注意, VS 無法非常精準地幫我們判斷一些欄位的型別。有些看來型別為 int 的欄位, 未來可能會傳來 float 型別的數字。每當你遇到這種狀況, 就必須手動把那些型別改成 float; 例如 temp 和 pressure 等等欄位。 

我把從 OpenWeatherMap 取得資料的過程包在一個靜態的 GetWeather() 方法裡面; 它會把所有事情做好, 只傳回一個 OpenWeather 類別的 instance, 如果它不是 null, 就表示已取得資料。我在最後面會把所有程式列出, 至於細節我就不解釋了。

Step 6

回到 T4。和 ASP.NET 的 Web Form 一樣, T4 文件本身是一個類別, 而且它也是一個 partial 類別。運用這個特性, 我們可以把資料傳進去給它。現在, 我要在這個 T4 文件 (GetWeather.tt) 之外建立該類別 (public class GetWeather) 的建構函式:

// 程式五
public partial class GetWeather
{
    public OpenWeather w = null;

    public GetWeather(string city, ref string err)
    {
        w = OpenWeather.GetWeather(city, ref err);
        string s = w.name;
    }
}

為了方便起見, 我把這個建構式放在 Weather.cs 裡面。但是你不一定要放在這裡, 你也可以放在另一個獨立的檔案之下。不過, 由於 Weather 類別和 GetWeather.tt (以及 GetWeather 類別) 是緊密相依的, 放在同一個檔案裡應該並沒有不妥。如果你的專案結構更複雜的話, 那麼才需要另做打算。

或許有些比較眼尖的朋友發現, 在 GetWeather.tt 下面不是有個 GetWeather.cs 嗎? 它的內容看起來就是 GetWeather 的類別定義! 把 GetWeather 的建構式放在這裡, 豈不是更合理?

不, 一點都不合理。你會有這種想法, 一定是因為你沒看清楚 GetWeather.cs 檔案最上方的警告。它明白告訴你, 這個檔案是動態產生的。不管你在裡面寫了什麼, 一旦你在它的 T4 檔案中改動了什麼 (例如加上一個逗點), 這個 .cs 檔就會重新產生一遍, 把你寫的所有東西都抹掉 (而且不會發出任何警告)。所以把建構式寫在那裡剛好是最糟糕的做法。

Step 7

接著, 我們把 T4 樣板改成我們要的形式:

<# // 程式六 #>
<#@ template language="C#"#>
城市: <#= w.name #>
天氣: <#= w.weather[0].description #>
日出時間: <#= string.Format("{0:t}", w.sys.SunRise) #>
日落時間: <#= string.Format("{0:t}", w.sys.SunSet) #>
目前溫度: <#= string.Format("{0:0.0} ({1:0.0}~{2:0.0})〬C", 
    w.main.temp, w.main.temp_min - 273.15F, w.main.temp_max - 273.15F) #>
氣壓: <#= w.main.pressure #>百帕
濕度: <#= w.main.humidity #>%

若依循這種模式, 我們只要再稍為做一些架構, 程式是不是就有點 MVVM 的味道了呢? 只差沒有 XAML 或其它 framework 的強大功能支援而已。

到這裡為止, 重要的程式都已經寫好了。我們只需要將 T4 樣板產生的文字帶出來就可以了:

// 程式七
private void ShowWeatherUsingT4(string city)
{
    string err = string.Empty;
    GetWeather w = new GetWeather(city, ref err);
    if (null != w && string.IsNullOrEmpty(err))
        rtT4.Text = w.TransformText();
    else
        rtT4.Text = string.Format("發生錯誤! {0}", err);
}

執行結果如下:

結論

在本文中, 我們可以看出, 我們可以使用嵌鑲的語法, 像 ASP 或 ASP.NET 一樣, 把文字自動產生出來。在 T4 樣板中, 我們只需把注意力放在資料的呈現方式, 而不用管資料的來源。

在 T4 的 Code Behind (就是它的 Partial Class 程式) 中, 我們可以把它當作 T4 文字的 Model。雖然在本文中並沒有在這裡寫太多的程式邏輯 (而且為了精簡起見而把它寫在無關的檔案裡面), 但是如果有必要的話, 它的彈性可以很大。

我在程式中寫了一個 OpenWeather 類別, 在這個範例中資料是來自網路上的一個 JSON 來源; 但是我們可以很輕易地另外寫個類別, 以取得其它資料來源。

在實際上, 我們可以把 T4 當作一個較弱的 XAML 引擎, 和較強的 StringBuilder (或 string.Format) 應用; 它可以用來產生各式各樣的自由格式的檔案, 包括網頁和程式碼, 甚至你可以想像的各種可以套用版型的文字檔。

以下是各原始程式。

Weather.cs :

using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace T4Demo
{
    public class OpenWeather
    {
        private static string pattUrl = "http://api.openweathermap.org/data/2.5/weather?q={0}&lang=zh_TW";
        private static string pattIconPath = "http://openweathermap.org/img/w/{0}.png";

        public Coord coord { get; set; }
        public Sys sys { get; set; }
        public Weather[] weather { get; set; }
        public string _base { get; set; }
        public Main main { get; set; }
        public Wind wind { get; set; }
        public Clouds clouds { get; set; }
        public int dt { get; set; }
        public int id { get; set; }
        public string name { get; set; }
        public int cod { get; set; }

        /// <summary>
        /// 取得天氣狀況; 若無法取得或發生錯誤, 則傳回 null
        /// </summary>
        /// <param name="city">要擷取的城市名稱, 格式為 "city,country", 例如 "Taipei,tw", "Tokyo,jp"</param>
        /// <param name="err">若有錯誤則記錄在此變數後傳出</param>
        public static OpenWeather GetWeather(string city, ref string err)
        {
            string url = string.Format(pattUrl, city);
            OpenWeather weather = new OpenWeather();
            try
            {
                using (WebClient webClient = new WebClient())
                {
                    webClient.Encoding = Encoding.UTF8;
                    string json = webClient.DownloadString(url);
                    weather = JsonConvert.DeserializeObject<OpenWeather>(json);
                }
            }
            catch (Exception ex)
            {
                err = ex.Message;
            }
            return weather;
        }

        public string GetIconPath()
        {
            return string.Format(pattIconPath, this.weather[0].icon);
        }

        internal static DateTime ConvertJsTimeToNormalTime(long ticks, bool isTaiwanTime = true)
        {
            return new DateTime(1970, 1, 1).AddMilliseconds(ticks).AddHours(isTaiwanTime ? 8 : 0);
        }
    } // class OpenWeather

    #region Sub classes of OpenWeather

    public class Coord
    {
        public float lon { get; set; }
        public float lat { get; set; }
    }

    public class Sys
    {
        public float message { get; set; }
        public string country { get; set; }
        public long sunrise { get; set; }
        public long sunset { get; set; }

        public DateTime SunRise
        {
            get
            {
                return OpenWeather.ConvertJsTimeToNormalTime((long)sunrise * 1000L);
            }
        }

        public DateTime SunSet
        {
            get
            {
                return OpenWeather.ConvertJsTimeToNormalTime((long)sunset * 1000L);
            }
        }
    }

    public class Main
    {
        public float temp { get; set; }
        public float pressure { get; set; }
        public int humidity { get; set; }
        public float temp_min { get; set; }
        public float temp_max { get; set; }
    }

    public class Wind
    {
        public float speed { get; set; }
        public float deg { get; set; }
    }

    public class Clouds
    {
        public int all { get; set; }
    }

    public class Weather
    {
        public int id { get; set; }
        public string main { get; set; }
        public string description { get; set; }
        public string icon { get; set; }
    }

    #endregion

    public partial class GetWeather
    {
        public OpenWeather w = null;

        public GetWeather(string city, ref string err)
        {
            w = OpenWeather.GetWeather(city, ref err);
            string s = w.name;
        }
    }

}

GetWeather.tt :

<# // 程式六 #>
<#@ template language="C#"#>
城市: <#= w.name #>
天氣: <#= w.weather[0].description #>
日出時間: <#= string.Format("{0:t}", w.sys.SunRise) #>
日落時間: <#= string.Format("{0:t}", w.sys.SunSet) #>
目前溫度: <#= string.Format("{0:0.0} ({1:0.0}~{2:0.0})〬C", 
    w.main.temp, w.main.temp_min - 273.15F, w.main.temp_max - 273.15F) #>
氣壓: <#= w.main.pressure #>百帕
濕度: <#= w.main.humidity #>%

Form1.cs :

using System;
using System.Text;
using System.Windows.Forms;

namespace T4Demo
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
            ShowWeatherUsingT4("Taipei,tw");
        }

        private void ShowWeatherUsingT4(string city)
        {
            string err = string.Empty;
            GetWeather w = new GetWeather(city, ref err);
            if (null != w && string.IsNullOrEmpty(err))
                rtT4.Text = w.TransformText();
            else
                rtT4.Text = string.Format("發生錯誤! {0}", err);
        }
    }
}

這裡有完整的專案檔案可以下載。執行環境如下:

  • Windows 7 X64
  • Visual Studio Professional 2013 Update 1

註: 程式中忘了把溫度減去 273.15; 麻煩讀者自行修改。

沒有留言:

張貼留言