不知讀者們有沒有遇到過如下的狀況? 假設你需要從某個 CSV 檔案中匯入資料; 我們已經知道每個欄位是什麼。然後你為這份資料建立了類別, 也為每一個需要的欄位建立了屬性。當然, 你也一定知道每一個欄位是第幾欄, 但是 Visual Studio 並不知道。你必須每次都去查, 才能知道哪個欄位是哪一欄。假設 CSV 檔案內容如下
不知讀者們有沒有遇到過如下的狀況? 假設你需要從某個 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 就可能輕鬆辦到; 在這裡我就不列範例了。不過有幾個小小的心得, 可以跟讀者們分享:
- 如果你的原始來源是 Excel, 請注意 Excel 只能匯出逗號或 TAB 分隔的 CSV, 無法自由選擇其它符號。因此, 你必須特別注意每一欄文字裡不能有逗號或 TAB, 否則最後結果不可能正確。
- 在 CSV 檔案中, 每一列代表一筆資料; 因此, 每一欄文字中當然不能有斷行符號。我的做法是在匯出之前將斷行字元全部以全形逗號取代。
- 有些罕用字若以 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>