2013/10/27

打造動態 Enum

在 C# 中 Enum 是一個純粹靜態的結構, 當你宣告了一個 enum, 那麼它的值就固定在那裡了, 你非得去更改它的定義, 才能看到內容項目的變更。那麼, 如果我們能把它的內容項目 (包括它的值) 變成動態的呢? 

在接下去之前, 我必須先把它適用的情境清楚的描述一遍, 否則大家可能無法理解為什麼要這麼做

Why dynamic enum?

回想一下, 你會在什麼情形下定義 enum 這種資料結構? 若探究 Enum 的本質, 我想有很多人會回答「絕對不會改的值, 可以放在 enum」。例如, 假設你的公司長久以都只有五個部門, 那麼你可能會寫一個如下的 enum 結構:

public enum Department : byte
{
    人事,
    總務,
    會計,
    行銷,
    研發
}

如此, 你就可以很方便地使用「Department.人事」或「Department.研發」來代表對應的單位。

但是, 如果某一天董事長突發奇想, 他決定增加一個新的單位, 例如叫做「稽核」吧! 請問你該怎麼辦? 

當然, 最簡單直覺的方法, 就是回頭去修改程式, 在原有的 enum 結構中增加一個欄位, 叫做「稽核」, 那麼事情就解決了。

我想上述的情境大概就是絕大多數程式設計師會採用的做法。亦即, 只有幾年才有可能改一次的穩定結構, 才會宣告為 enum。或者, 不要說商業邏輯了, 整個 Windows 程式庫中, 所有的 enum 結構, 都是幾乎根本不會異動的。

但是換個角度來講, 是不是有可能稍為頻繁異動的結構 (例如每年可能會改變一次, 或者無法預測會不會改變), 就不能使用 enum 呢?

這時我們必須回頭想想看, 到底 enum 能夠給我們帶來什麼方便? 這種方便性, 到底值不值得我們動手去更改長久以來大家習慣的做法 (或者說是思想的框架) ?

從某個角度來看, 在 enum 所訂定的欄位, 可以說是「強型別」的 (意喻而已), 所以我們可以很方便地寫出如下的程式碼:

switch (department)
{
    case Department.人事:
        ...
        break;
    case Department.總務:
        ...
        break;
    ...
}

如果我們不使用 enum, 當然也做得出同樣的功能, 代價也不大, 最多就是做些型別的檢查和比較而已; 呃... 還有防呆。

但是如果我們一定要使用 enum 呢?

The benifits

以下, 我要提出一個整套的解決方案。等我解說完異之後, 你應該看得出來, 採用動態 Enum 只不過是整個解決方案中的一環而已。

首先, 我要先列出一個類別, 我在下面會另作解譯:

using System.Data.Entity;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace DynamicEnum
{
    [Table("EnumType")]
    public class EnumType
    {
        [Key]
        public int UniqId { get; set; }
        public string Category { get; set; }
        public byte TypeId { get; set; }
        public string Name { get; set; }
        public string Tips { get; set; }
        public bool IsActive { get; set; }
        public byte Order { get; set; }

        public override string ToString()
        {
            return string.Format("<{0}> ({1}) {2}", this.Category, this.TypeId, this.Name);
        }
    }

    public class EnumTypes : DbContext
    {
        public DbSet Types { get;set; }
    }
}

眼尖的朋友也許看得出來上述程式是一個 Entity Framework 專用的程式。如果你不了解 EF, 可以參考「在 VS2013 以 Code First 方式建立 EF 資料庫"」這篇文章。

當然, 你也可以無需藉助 EF 而以手動方式在資料庫中建立一個同樣的資料庫/資料表。基本上, 這個資料表包括以下幾個主要欄位:

  • Category NVARCHAR NOT NULL
  • TypeId TINYINT NOT NULL
  • Name NVARCHAR NOT NULL
  • Tips NVARCHAR NULL
  • IsActive BOOL NOT NULL
  • Order TINYINT NOT NULL

如果你對資料庫空間很遘究的話, 你也可以拿 Category + TypeId 當作 PK, 而把 UniqId 這個遞增欄位省略掉。

利用這個表, 我把整個方案中的所有「類型」都放進來。如果你體會不出這是什麼意思的話, 那麼你可以看看我在 Category 欄位中都放些什麼, 你就應該清楚了:

  • Department
  • EmployeeType
  • Region
  • ...

換句話說, 我將從這張表產生像 Department.人事、Department.研發、EmployeeType.正職、EmployeeType.退休、Region.台北、Region.新北... 等等 enum 結構。

但是, 我同時會從這張表產生多組 RadioButtonList 或者 CheckBoxList 控制項 (我習慣使用 User Control), 其資料 bind 到這張資料表。這些 User Control 會被拿名為 ucDepartment、ucEmployeeType 等等。在這些 CheckBoxList 或 RadioButtonList 裡的各個 ListItem 會依照 EnumType.Order 排序, 並且加上 EnumType.Tips 作為其 ToolTip。

因此, 如果你修改了資料庫中這張表的資料, 你不但可以馬上在網頁中看到修改的結果, 也可以很快地在程式中看到對應的 enum 已被改變 (很可惜的, 你無法即時看到, 待會再做說明)。

唯一的限制, 就是你只能允許在資料庫中進行新增和部份修改的動作, 不能將任何項目刪除或者改名, 否則會影響到程式 (就像你也不能隨意把你程式的 enum 結構中的項目或改名刪除一樣)。要做到這一點, 你一定要確定你的使用者沒有任意刪改任何項目的權限。

那麼, 既然不能隨便刪改欄位, 如果你有什麼欄位真的不再需要了, 或者不想讓使用者看到, 該怎麼辦?

很簡單, 這就是為什麼我在資料表中加入了一個 IsActive 欄位的原因。你只需要判斷某一列是否為 IsActive, 就知道要不要把它 bind 進對應的 CheckBoxList 或 RadioButtonList 裡面。

不過, 要記得最好不要在動態產生 enum 的程式中篩選掉 IsActive == false 的欄位 (下一節說明)。使用者不能看到, 不代表你不能看到。

BuildProvider

要做到動態 Enum, 就必須藉助 .Net 所提供的動態產生程式碼的功能。這些功能都放置在 CodeDom 命名空間之下。這些功能, 說真的, 都不是什麼新玩意, 遠自 .Net Framework 1.1 時代就有了, 只不過也許實在太少人用了, 到現在為止, 都還是個冷門中的冷門; 範例也很少。

其實, 如果你寫過 ASP.NET 的自訂控制項的話 (請參考「開發自訂的 ASP.NET 伺服器控制項」技術文件), 你將發現這兩者的基本原理還真的差不多, 有點像是在 DOM 樹中把子控制項層層套疊進母控制項, 然後一次 populate 出來。

不過, 首先, 我們必須做幾件事情。

第一, 在你的 ASP.NET 專案中建立 App_Code 資料夾; 你的程式必須放在這個資料夾下面, 不能放在根目錄下的其它子目錄 (即使最近的 ASP.NET 已經不會強迫你把程式都放在 App_Code 之下了)。

第二, 請在你的 Web.config 檔案中的 compilation 區段中加入 buildProviders 區段。請參考我的 Web.config 寫法:

<?xml version="1.0"?>
<configuration>
  <system.web>
    <compilation debug="true" targetFramework="4.5">
      <buildProviders>
        <add extension=".enum" type="DynamicEnum.MyProvider"/>
      </buildProviders>
    </compilation>
    <httpRuntime targetFramework="4.5"/>
  </system.web>
</configuration>

請特別注意 <add extension... > 這個區段的寫法。透過 add extension=".enum" 這道指令, 我將要讓 Visual Studio 針對副檔名為 .enum 的檔案進行編譯。之後, 它每次在 App_Code 資料夾下面找到副檔名為 .enum 的檔案, 就是叫出 DynamicEnum.MyProvider 這個類別出來進行編譯, 並且立即產生程式碼。在我的程式中, DynamicEnum 是我的 ASP.NET 專案的命名空間, 而 MyProvider 則是類別名稱。

第三, 在 EumType 資料表中填入以下資料:

Category TypeId Name Tips IsActive Order
Family 1 Johnny Johnny Lee True 1
Family 2 Grace Grace Lee True 2
Family 3 Joad Joad Lee True 3

當然, 你不一定要像我一樣把資料寫在資料庫裡面; 寫在 XML 檔案或者任何資料來源都可以。

第四, 請在你的 App_Code 子目錄下新增一個類別, 其內容如下:

using System.CodeDom;
using System.Web.Compilation;

namespace DynamicEnum
{
    public class MyProvider : BuildProvider
    {
        public override void GenerateCode(AssemblyBuilder myAb)
        {
            string OwnNameSpace = "DynamicEnum";
            using (var db = new EnumTypes())
            {
                CodeCompileUnit ccu = new CodeCompileUnit();
                CodeNamespace cn = new CodeNamespace(OwnNameSpace);

                string typeName = "Family";
                CodeTypeDeclaration ctd = new CodeTypeDeclaration(typeName);
                ctd.Attributes = MemberAttributes.Public;
                ctd.IsClass = false;
                ctd.IsEnum = true;
                ctd.BaseTypes.Add(typeof(byte));

                foreach (var typ in db.Types)
                {
                    if (!string.IsNullOrEmpty(typ.Category)) // 防呆
                    {
                        CodeMemberField cmf = new CodeMemberField();
                        cmf.Name = typ.Name;
                        cmf.InitExpression = new CodeFieldReferenceExpression(null,typ.TypeId.ToString());
                        ctd.Members.Add(cmf);
                    }
                }
                cn.Types.Add(ctd);
                ccu.Namespaces.Add(cn);
                myAb.AddCodeCompileUnit(this, ccu);                
            }
        }
    }
}

用最簡單的一段話來解譯上面這個程式, 就是先建立一個 CodeCompileUnit 物件, 它裡面包一個 CodeNameSpace (組成 "namespace DynamicEnum" 區段), 又在裡面包一個 CodeTypeDeclaration (組成 "public enum Family : byte" 區段), 然後填入各個 CodeMemberField (組成 "Johnny = 1, Grace = 2, Joad = 3" 等等)。最後它將組出一個如下的 enum 宣告:

namespace DynamicEnum
{
    public enum Family : byte
    {
        Johnny = 1,
        Grace = 2,
        Joad = 3
    }
}

第五, 在 App_Code 之下隨便建立一個副檔名為 .enum 的文字檔, 內容不拘。

如果一切都沒問題的話, 請重開專案, 並且在 App_Code 之下建立任何類別檔案, 然後你就可以從 intellisense 裡看到 DynamicEnum.Family 這個 enum 型別及其下各個欄位了:

DynamicEnum Intellisense

注意事項

CodeDom 並不是為了能夠動態產生 Enum 而存在的。或許你已經聯想到, 它既然能夠動態地產生 Enum 程式, 它一定也能動態地產生其它程式碼。是的, 透過 CodeDom, 我們確實可以產生各種程式碼, 包括 Class、Interface 等等。我們甚至可以將產生的程式碼寫進文字檔, 而不一定要再透過 System.Web.Compilation 之下的功能把它進行即時的編譯。事實上, MSDN 上所有我能找到的範例程式, 幾乎全部用來產生類別檔案; 我是刻意花了一點小功夫在做 trial and error, 才試出來 enum 要怎麼產生出來。只不過, 對我個人而言, 我並沒有需要去動態產生類別, 唯獨動態產生 enum 對我個人而言比較有用。

此外, 請特別注意 BuildProvider 和 AssemblyBuilder 這兩個類別在其它命名空間 (System.Web.Configuration) 也有; 如果你引用了錯誤的命名空間, 程式就無法正常運作。

除了 ASP.NET, 我個人猜想其它種類的專案應該也可以提供動態編譯。很可惜, 我在網路上搜尋很久, 仍然不得其門而入。主要的原因, 是因為只有 ASP.NET 才能在 Web.config 中指定 compilation 之下的 buildProviders 以對特定的副檔名進行編譯; 至於其它種類專案, 我還沒有找到類似的切入點。

在上述各個範例程式中, 我偷了很多懶 (或許比較挑剔的讀者們已經發現了); 其實我原本想要把它寫得更完整一點, 例如我應該讓程式變得更動態一點, 可以支援 Family 之外的不同類型。而且, 依我現在的設計, 丟進一個 .enum 檔案只是為了驅使 BuildProvider 去做編譯而已, 我並沒有充分利用這個檔案; 我應該可以想出讓它更有用的方法。不過, 意思已經到了; 或許等我更有時間時再來改善它。聰明的讀者們應該可以聞一知十, 麻煩自己去做修改吧!

還有, 我並不十分了解, 為什麼動態編出來的 enum, 只有在 App_Code 下面的程式才能引用? 我個人的解譯是因為在 App_Code 下面的所有程式會被編譯到同一支 assembly 裡面; 但是為何同樣是透過同一個 compiler, 靜態的程式可以被其它 assembly 引用而動態編譯的程式卻不行, 這就稍為超出我的理解範圍了。或許等我再有空一點, 再來研究這個問題。

最後一點, 當使用者修改資料庫內容以後, 請別期望你能在 VS2013 上面立即看到最新的結果。我甚至發現即使重新編譯專案, VS 也不會呼叫 BuildProvider 去重新產生程式碼。修改那個 .enum 檔案也是一樣。唯一的方法, 就是關閉專案再重新開啟一次。

所以, 不要把動態 enum 當作一個可以與客戶端即時溝通的方式。你應該把我介紹的這個方法單純地當作一個輔助性的工具, 讓你的程式更有彈性, 如此而已。還有, 你應該知道必須限制每個 item 的取名必須符合 .Net Framework 命名原則, 包括不能內含空白字元、不能使用某些符號 (例如加號); 你不會希望程式出錯在很難除錯的地方, 對吧?

1 則留言: