2015/7/30

[C#] 屬性中的屬性: 自訂 Attributes

不知讀者們有沒有遇到過如下的狀況? 假設你需要從某個 CSV 檔案中匯入資料; 我們已經知道每個欄位是什麼。然後你為這份資料建立了類別, 也為每一個需要的欄位建立了屬性。當然, 你也一定知道每一個欄位是第幾欄, 但是 Visual Studio 並不知道。你必須每次都去查, 才能知道哪個欄位是哪一欄。假設 CSV 檔案內容如下:

1, "Financial", "Johnny", "M", 20, "1995/1/1", "台北市", "中正區", "羅斯福路四段X巷X號", "0919-x9x2xx", ""
2, "IT", "Grace", "F", 20, "1995/1/2", "雲林縣", "斗六市", "明德路X號", "0937-x2x9xxx", "05-x5x3x3x"
3, "Secret Agency", "Frank", "M", 18, "1997/1/3", "宜蘭縣", "蘇澳鎮", "福德路x號", "0932-x9x05xxx", "09-x2x0x5x5"
4, "President", "Ohbevey", "M", 25, "2000/1/4", "屏東縣", "內埔鄉", "光明路x號", "0955-x8x1xxx", "08-x3x5x6x"

寫好的類別如下:

public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string WiredPhone { get; set; }
    public string MobilePhone { get; set; }
}

題外話。要將 CSV 檔案匯入, 只要使用 StreamReader 就可能輕鬆辦到; 在這裡我就不列範例了。不過有幾個小小的心得, 可以跟讀者們分享:

  1. 如果你的原始來源是 Excel, 請注意 Excel 只能匯出逗號或 TAB 分隔的 CSV, 無法自由選擇其它符號。因此, 你必須特別注意每一欄文字裡不能有逗號或 TAB, 否則最後結果不可能正確。
  2. 在 CSV 檔案中, 每一列代表一筆資料; 因此, 每一欄文字中當然不能有斷行符號。我的做法是在匯出之前將斷行字元全部以全形逗號取代。
  3. 有些罕用字若以 UTF-8 編碼, 看起來都很正常, 但是若以 ANSI 編碼, 則會變成小方塊或是問號。有時候我們不容易一眼看出這種字。我建議在匯入 CSV 檔案之前, 再次檢查它是否已使用 UTF-8 編碼。我的最終做法是將 Excel 檔案匯出成為「Unicode 文字」; 它是使用 TAB 分隔的 CSV 檔案。

如果你有興趣的話, 我把我寫的 Macro 寫在下面供讀者們參考。如果你不知道怎麼寫 VBA, 或者不知道如何在 Excel 中啟動巨集, 可以參考小歐的文章「[Office2010]在 Excel 寫 VBA」。

Sub ReplaceAll()

   ' 將所有分行字元改成全形逗點
   Range("A1").CurrentRegion.Replace _
       What:=Chr(10), Replacement:=",", _
       SearchOrder:=xlByColumns, MatchCase:=False

   ' 將所有半形逗點改成全形
   Range("A1").CurrentRegion.Replace _
       What:=", ", Replacement:=",", _
       SearchOrder:=xlByColumns, MatchCase:=False

   ' 將所有半形逗點改成全形
   Range("A1").CurrentRegion.Replace _
       What:=",", Replacement:=",", _
       SearchOrder:=xlByColumns, MatchCase:=False

   ' 在標題中將所有 * 字元拿掉 (在 Excel 中逸出字元為 ~, 可避開萬用字元 *)
   Range("A1:BD1").Replace _
       What:="~*", Replacement:="", _
       SearchOrder:=xlByColumns, MatchCase:=False

   ' 在標題中將所有 / 字元拿掉
   Range("A1:BD1").Replace _
       What:="/", Replacement:="", _
       SearchOrder:=xlByColumns, MatchCase:=False
       
End Sub

匯入之後, 使用 String.Split(new char[] { '\r', '\n' } 方法將每一筆資料讀入。這個部份也很簡單, 所以我也不列範例了。

接著, 我要把匯入的文字陣列轉成 List<Employee> 集合。若使用一般的做法, 應該會寫成這樣子:

List<employee> employees = new List<employee>();
foreach (string row in rows)
{
   string[] cols = row.Split(',');
   Employee emp = new Employee();
   emp.Id = int.Parse(cols[0]);
   emp.Name = col[2];
   emp.WiredPhone = col[10];
   emp.MobilePhone = col[9];
   employees.Add(emp);
}

讀者們可以看到, 在上面程式中, Id 是第 0 欄, Name 是第 2 欄, WiredPhone 是第 10 欄, MobilePhone 是第 9 欄。

其實, 如果我們偷個懶, 這個程式這樣子寫, 也沒什麼不對。問題是, 如果我們可以用很小的代價, 讓程式中盡量減少寫死 (hard-coded) 的部份, 我們就應該去做。在這個範例中, 如果來源資料 (即 CSV, 或其來源 Excel) 變動了, 例如加上或刪除了一個欄位, 那麼, 我們就必須回頭來改這個程式。

但是, 如果我們有辦法在定義屬性 (即 Id, Name, WiredPhone 和 MobilePhone 這類 Properties) 時同時定義它們的欄位號碼 (即 0, 2, 10, 9) 呢? 在此案例中, 欄位號碼顯然是屬性的附屬屬性; 如果要改, 我們應該在定義處改, 還是在其下游的方法中改? 換成另一種情境, 假設有很多方法都需要用到這些欄位的附屬屬性的話, 上面的答案應該是非常清楚的。

在 C# 中, 我們可以自訂 Attributes。自訂的 Attributes 其實也是類別, 而且必須繼承自 Attribute 類別。在本例中, 可以這樣做:

[AttributeUsage(AttributeTargets.Property, Inherited = false)]
public class ColumnNumber : Attribute
{
    private int _Num = -1;
    public int Num
    {
        get { return _Num; }
    }

    public ColumnNumber(int num)
    {
        this._Num = num;
    }
}

關於自訂屬性的詳細說明, 可以參考 MSDN 網站

AttributeTargets 列舉型號中, 讀者們可以看到, Attribute 其實可以使用在包括類別、結構、屬性、Enum 等等不同的語言元件之上。但是本文中只介紹套用至屬性 (Property) 而已。不過其基本原理都是差不多的。

特別聲明一下, 我向來是不翻譯 Attribute 這個字的 (本文標題除外)。因為如果翻譯成「屬性」, 勢必與 Property 這個字衝突。所以我的文章裡通常都使用 "attribute"。

如上範例, 定義好 ColumnNumber 這個 attribute 之後, 我們就可以把上面定義過的 Employee 類別改寫如下:

public class Employee
{
    [ColumnNumber(0)]
    public int Id { get; set; }
    [ColumnNumber(2)]
    public string Name { get; set; }
    [ColumnNumber(10)]
    public string WiredPhone { get; set; }
    [ColumnNumber(9)]
    public string MobilePhone { get; set; }
}

如此一來, Id 這個屬性就有了 ColumnNumber = 0 的附屬屬性, Name 這個屬性就有了 ColumnNumber = 2 的附屬屬性... 依此類推。

接著, 我們如何在程式中取出屬性的 attributes 呢? 我們可以使用 Attribute.GetCustomAttributes() 方法。不過其使用方式有點麻煩, 我另外在 Employee 類別裡寫了一個方法來叫用它:

public static int GetColumnNumber(string propertyName)
{
    var type = typeof(Employee);
    var property = type.GetProperty(propertyName);
    var attr = (ColumnNumber[])property.GetCustomAttributes(typeof(ColumnNumber), false);
    if (0 < attr.Length)
        return attr[0].Num;
    else
        throw new NullReferenceException("Employee.GetColumnNumber() error: Attempt to retrieve the non-existent \"ColumnNumber\" attribute out of property \"" + propertyName + "\" in class Employee! You must define it before using it.");
}

然後, 我們就可以把原來的程式改寫成如下的樣子了:

List<employee> employees = new List<employee>();
foreach (string row in rows)
{
   string[] cols = row.Split(',');
   Employee emp = new Employee();
   emp.Id = int.Parse(cols[Employee.GetColumnNumber("Id")]);
   emp.Name = col[Employee.GetColumnNumber("Name")];
   emp.WiredPhone = col[Employee.GetColumnNumber("WiredPhone")];
   emp.MobilePhone = col[Employee.GetColumnNumber("MobilePhone")];
   employees.Add(emp);
}

或許你會想, 在最後的程式裡, 屬性的名稱 (Id, Name, WiredPhone 和 MobilePhone) 依舊是寫死的。繞了這一大圈, 我們到底省了什麼?

我的答案是: 來源資料會不會變動, 並不是操之在我; 但是屬性名稱會不會變動, 卻是操之在我。如果你會一天到晚更改屬性名稱的話, 那麼請不要使用本文所介紹的做法。反之, 就可以考慮採用這種做法。

此外, 自訂的 Attribute 可以有很大的彈性; 你可以自訂許多個屬性, 而不像本例中只自訂了一個。Attribute 可以接受多個 (或者沒有) 參數, 也可以出現許多次。不要被本文中的情境所約束, 讀者們可以視情況自行發揮想像力, 找出最適當的應用。

以下再補一個實例。在 MVC 應用程式中, 我們可以在 View 裡使用 Html.Label() 方法以取出某一資料欄位的文字說明:

<span>
    @Html.Label("Id"):
    <span>@Model.Id</span>
</span>

當然, 我們在 Model 裡必須先以 DisplayName 預先定義欄位的 DisplayName:

using System.ComponentModel;
...
[DisplayName("編號")]
public int Id { get; set; }

如此, View 中呈現的將會是像「編號: 1234」這樣的結果。

如同本章對 Attributes 的介紹, 未來, 只要你在 Model 中把欄位的 DisplayName 做了修改(例如從「編號」改成「Identity」), 整個專案所有引用它的文字都會隨之更改, 而不必一個一個去改。

不過有個問題。假設你傳入 View 中的 Model不是一個 instance (例如 Phone), 而是 instance 列表 (例如 List<Phone>), 你就不能使用 @Html.Label("Id") 這個簡單的方法了!

幸好, 我們可以把上面的範例拿出來改一改, 取個變通的做法。

首先, 我們先借用上面的 GetColumnNumber() 方法, 把它改成一個泛型的版本:

/// <summary>
/// Retrieve the DisplayName attribute out of a property
/// </summary>
/// <typeparam name="T">The class</typeparam>
/// <param name="propertyName">The property name</param>
public static string GetDisplayName<T>(string propertyName)
{
    var type = typeof(T);
    var property = type.GetProperty(propertyName);
    var attr = (DisplayNameAttribute[])property.GetCustomAttributes(typeof(DisplayNameAttribute), false);
    if (0 < attr.Length)
        return attr[0].DisplayName;
    else
        throw new NullReferenceException("Johnny.GetDisplayName() error: Attempt to retrieve the non-existent \"DisplayNameAttribute\" attribute out of property \"" + propertyName + "\" in class " + typeof(T) + "! You must define it before using it.");
}

接著, 在類別裡新增一個同名的方法:

public static string GetDisplayName(string propertyName)
{
    return Johnny.GetDisplayName(propertyName);
}

在這裡 Johnny 只是我自己的命名空間而已, 沒有任何特別的意義。

現在你可以在 View 裡自由取出欄位的 DisplayName 了:

<span>
    @Phone.GetDisplayName("Id"):
    <span>@Model.Id</span>
</span>

 

沒有留言:

張貼留言