
文章開頭先給大家出一道面試題:
在設計某小型專案的資料庫(假設用的是 MySQL)時,如果給使用者表(User)新增一個欄位(Roles)用來儲存使用者的角色,你會給這個欄位設定什麼型別?提示:要考慮到角色在後端開發時需要用列舉表示,且一個使用者可能會擁有多個角色。
映入你腦海的第一個答案可能是:varchar 型別,用分隔符的方式來儲存多個角色,比如用 1|2|3 或 1,2,3 來表示使用者擁有多個角色。當然如果角色數量可能超過個位數,考慮到資料庫的查詢方便(比如用 INSTR 或 POSITION 來判斷使用者是否包含某個角色),角色的值至少要從數字 10 開始。方案是可行的,可是是不是太簡單了,有沒有更好的方案?
更好的回答應是整數型別(int、bigint 等),優點是寫 SQL 查詢條件更方便,效能、空間上都優於 varchar。但整數畢竟只是一個數字,怎麼表示多個角色呢?此時想到二進位位元運算的你,心中應該早有了答案。
且保留你心中的答案,接著看完本文,或許你會有意外的收穫,因為實際應用中可能還會遇到一連串的問題。為了更好的說明後面的問題,我們先來回顧一下列舉的基礎知識。
列舉基礎
列舉型別的作用是限制其變數只能從有限的選項中取值,這些選項(列舉型別的成員)各自對應於一個數字,數字預設從 0 開始,並以此遞增。例如:
public enum Days
{
Sunday, Monday, Tuesday, // ...
}
其中 Sunday 的值是 0,Monday 是 1,以此類推。為了一眼能看出每個成員代表的值,一般推薦顯示地將成員值寫出來,不要省略:
public enum Days
{
Sunday = 0, Monday = 1, Tuesday = 2, // ...
}
C# 列舉成員的型別預設是 int 型別,透過繼承可以宣告列舉成員為其他型別,比如:
public enum Days : byte
{
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
Sunday = 7
}
列舉型別一定是繼承自 byte、sbyte、short、ushort、int、uint、long 和 ulong 中的一種,不能是其他型別。
下面是幾個列舉的常見用法(以上面的 Days 列舉為例):
// 列舉轉字串
string foo = Days.Saturday.ToString(); // "Saturday"
string foo = Enum.GetName(typeof(Days), 6); // "Saturday"
// 字串轉列舉
Enum.TryParse("Tuesday", out Days bar); // true, bar = Days.Tuesday
(Days)Enum.Parse(typeof(Days), "Tuesday"); // Days.Tuesday
// 列舉轉數字
byte foo = (byte)Days.Monday; // 1
// 數字轉列舉
Days foo = (Days)2; // Days.Tuesday
// 取得列舉所屬的數字型別
Type foo = Enum.GetUnderlyingType(typeof(Days))); // System.Byte
// 取得所有的列舉成員
Array foo = Enum.GetValues(typeof(MyEnum);
// 取得所有列舉成員的欄位名稱
string[] foo = Enum.GetNames(typeof(Days));
另外,值得注意的是,列舉可能會得到非預期的值(值沒有對應的成員)。比如:
Days d = (Days)21; // 不會報錯
Enum.IsDefined(typeof(Days), d); // false
即使列舉沒有值為 0 的成員,它的預設值永遠都是 0。
var z = default(Days); // 0
列舉可以透過 Description、Display 等屬性(Attribute)來為成員新增有用的輔助資訊,比如:
public enum ApiStatus
{
[Description("成功")]
OK = 0,
[Description("資源未找到")]
NotFound = 2,
[Description("拒絕存取")]
AccessDenied = 3
}
static class EnumExtensions
{
public static string GetDescription(this Enum val)
{
var field = val.GetType().GetField(val.ToString());
var customAttribute = Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute));
if (customAttribute == null) { return val.ToString(); }
else { return ((DescriptionAttribute)customAttribute).Description; }
}
}
static void Main(string[] args)
{
Console.WriteLine(ApiStatus.Ok.GetDescription()); // "成功"
}
上面這些我認為已經包含了大部分我們日常用到的列舉知識了。下面我們繼續回到文章開頭說的使用者角色儲存問題。
使用者角色儲存問題
我們先定義一個列舉型別來表示兩種使用者角色:
public enum Roles
{
Admin = 1,
Member = 2
}
這樣,如果某個使用者同時擁有 Admin 和 Member 兩種角色,那麼 User 表的 Roles 欄位就該存 3。那問題來了,此時若查詢所有擁有 Admin 角色的使用者的 SQL 該怎麼寫呢?
對於有基礎的程式設計師來說,這個問題很簡單,只要用位元運算子邏輯 AND(‘&’)來查詢即可。
SELECT * FROM `User` WHERE `Roles` & 1 = 1;
同理,查詢同時擁有這兩種角色的使用者,SQL 語句應該這麼寫:
SELECT * FROM `User` WHERE `Roles` & 3 = 3;
對這條 SQL 語句用 C# 來實作查詢是這樣的(為了簡單,這裡使用了 Dapper):
public class User
{
public int Id { get; set; }
public Roles Roles { get; set; }
}
connection.Query<User>(
"SELECT * FROM `User` WHERE `Roles` & @roles = @roles;",
new { roles = Roles.Admin | Roles.Member });
對應的,在 C# 中要判斷使用者是否擁有某個角色,可以這麼判斷:
// 方式一
if (user.Roles & Roles.Admin == Roles.Admin)
{
// 做管理員可以做的事情
}
// 方式二
if (user.Roles.HasFlag(Roles.Admin))
{
// 做管理員可以做的事情
}
同理,在 C# 中你可以對列舉進行任意位元邏輯運算,比如要把角色從某個列舉變數中移除:
var foo = Roles.Admin | Roles.Member;
var bar = foo & ~foo;
這就解決了文章前面提到的用整數來儲存多角色的問題,不論資料庫還是 C# 語言,操作上都是可行的,而且也很方便靈活。
列舉的 Flags 屬性
下面我們提供一個透過角色來查詢使用者的方法,並示範如何呼叫,如下:
public IEnumerable<User> GetUsersInRoles(Roles roles)
{
_logger.LogDebug(roles.ToString());
_connection.Query<User>(
"SELECT * FROM `User` WHERE `Roles` & @roles = @roles;",
new { roles });
}
// 呼叫
_repository.GetUsersInRoles(Roles.Admin | Roles.Member);
Roles.Admin | Roles.Member 的值是 3,由於 Roles 列舉型別中並沒有定義一個值為 3 的欄位,所以在方法內 roles 參數顯示的是 3。3 這個資訊對於我們偵錯或輸出日誌很不友善。在方法內,我們並不知道這個 3 代表的是什麼。為了解決這個問題,C# 列舉有個很有用的屬性:FlagsAtrribute。
[Flags]
public enum Roles
{
Admin = 1,
Member = 2
}
加上這個 Flags 屬性後,我們再來偵錯 GetUsersInRoles(Roles roles) 方法時,roles 參數的值就會顯示為 Admin|Member 了。簡單來說,加不加 Flags 的差別是:
var roles = Roles.Admin | Roles.Member;
Console.WriteLing(roles.ToString()); // "3",沒有 Flags 屬性
Console.WriteLing(roles.ToString()); // "Admin, Member",有 Flags 屬性
給列舉加上 Flags 屬性,我覺得應當視為 C# 程式設計的一種最佳實踐,在定義列舉時盡量加上 Flags 屬性。
解決列舉值衝突:2 的冪
到這,列舉型別 Roles 一切看上去沒什麼問題,但如果現在要新增一個角色:Mananger,會發生什麼情況?按照數字值遞增的規則,Manager 的值應當設為 3。
[Flags]
public enum Roles
{
Admin = 1,
Member = 2,
Manager = 3
}
能不能把 Manager 的值設為 3?顯然不能,因為 Admin 和 Member 進行位的或邏輯運算(即:Admin | Member) 的值也是 3,表示同時擁有這兩種角色,這和 Manager 衝突了。那怎樣設值才能避免衝突呢?
既然是二進位邏輯運算「或」會和成員值產生衝突,那就利用邏輯運算或的規律來解決。我們知道「或」運算的邏輯是兩邊只要出現一個 1 結果就是 1,比如 1|1、1|0 結果都是 1,只有 0|0 的情況結果才是 0。那麼我們就要避免任意兩個值在相同的位置上出現 1。根據二進位滿 2 進 1 的特點,只要保證列舉的各項值都是 2 的冪即可。比如:
1: 00000001
2: 00000010
4: 00000100
8: 00001000
再往後增加的話就是 16、32、64...,其中各值不論怎麼相加都不會和成員的任一值衝突。這樣問題就解決了,所以我們要這樣定義 Roles 列舉的值:
[Flags]
public enum Roles
{
Admin = 1,
Member = 2,
Manager = 4,
Operator = 8
}
不過在定義值的時候要在心中小小計算一下,如果你想懶一點,可以用下面這種「移位」的方式來定義:
[Flags]
public enum Roles
{
Admin = 1 << 0,
Member = 1 << 1,
Manager = 1 << 2,
Operator = 1 << 3
}
一直往下遞增編值即可,閱讀體驗好,也不容易編錯。兩種方式是等效的,常數位移的計算是在編譯的時候進行的,所以相比不會有額外的負擔。
總結
本文透過一道小小的面試題引發一連串對列舉的思考。在小型系統中,把使用者角色直接儲存在使用者表是很常見的做法,此時把角色欄位設為整數型別(比如 int)是比較好的設計方案。但與此同時,也要考慮到一些最佳實踐,比如使用 Flags 屬性來幫助更好的偵錯和日誌輸出。也要考慮到實際開發中的各種潛在問題,比如多個列舉值進行或(‘|’)運算與成員值發生衝突的問題。
