今天來介紹一則私房小技巧,能讓 C# 的複雜型別屬性與資料庫欄位無縫接軌,抱著「花盆種出葱請朋友來家裡吃飯品嚐」的心情分享給大家。

舉個實例說明應用情境。假設我有個 C# 資料型別長這樣,其中包含一些非基礎型別(Primitive Type)屬性,像是自訂列舉、int[] 及自訂物件陣列(Medal[]):

    public class Runner
    {
        public string Id { get; set; }
        public string Name { get; set; }
        public Levels Level { get; set; }
        public int[] Topics { get; set; }
        public Medal[] Medals { get; set; }
    }

    public enum Levels
    {
        VIP, Regular, Trial
    }

    public class Medal
    {
        public string Name { get; set; }
        public DateTime Date { get; set; }
    }

我想將用這個型別產生的物件(如以下範例)寫入 Runner 資料表存成一筆資料(為求單純,Topics 與 Medals 就不另拆資料表了),大家會怎麼會設計?

var p = new Runner()
{
    Id = "A01",
    Name = "Jeffrey",
    Level = Levels.VIP,
    Topics = new int[] { 1, 3, 5, 7 },
    Medals = new Medal[]
    {
        new Medal() { Name="42K", Date=new DateTime(2012, 2, 19) },
        new Medal() { Name="SUB4", Date=new DateTime(2017, 2, 12) }
    }
};

依據直覺,資料表會差不多像這樣:

CREATE TABLE [dbo].Runner
(
	[Id] VARCHAR(8) NOT NULL PRIMARY KEY, 
    [Name] NVARCHAR(32) NOT NULL, 
    [Level] CHAR NOT NULL, -- V=VIP, R=Regular, T=Trial
    [Topics] VARCHAR(128) NOT NULL, 
    [Medals] VARCHAR(1024) NOT NULL
)

string、int、DateTime 等基礎型別可以直接對映成 NVARCHAR、INTEGER、DATE... 等資料庫型別,但遇到較複雜的型別便得自己想辦法轉換。 就本案例,Level 用單一字元 V/R/T 代表 VIP/Regular/Trial 等列舉值,Topics 用逗號分隔數字字串表示整數陣列,Medals 為求省事我直接用 JSON 轉換。

依此設計,在寫入資料及讀取資料時,便需要寫一段轉換邏輯將特定型別屬性轉成字串以寫入 VARCHAR,讀取則反過來要將字串轉回列舉、int[] 及 Medal[] 型別。 程式差不多會像這樣:(使用 Dapper 搭配 SQL LocalDB 示範)

class Program
{
    static void Main(string[] args)
    {
        var p = new Runner()
        {
            Id = "A01",
            Name = "Jeffrey",
            Level = Levels.VIP,
            Topics = new int[] { 1, 3, 5, 7 },
            Medals = new Medal[]
            {
                new Medal() { Name="42K", Date=new DateTime(2012, 2, 19) },
                new Medal() { Name="SUB4", Date=new DateTime(2017, 2, 12) }
            }
        };
        TestWrite(p);
        var q = TestRead(p.Id);
        Console.WriteLine("AreEqual Check=" +
            (JsonConvert.SerializeObject(p) == JsonConvert.SerializeObject(q)));
        Console.Read();
    }
    static string cs =
        "Data Source=(LocalDb)\\MSSQLLocalDB;AttachDBFilename=X:\\LocalDB\\RunnerDB.mdf";
    static void TestWrite(Runner r)
    {
        using (var cn = new SqlConnection(cs))
        {
            cn.Execute("DELETE FROM Runner WHERE Id = @id", new { id = r.Id });
            cn.Execute(
@"INSERT INTO Runner (Id,Name,Level,Topics,Medals)
VALUES (@id,@name,@level,@topics,@medals)", new
{
id = r.Id,
name = r.Name,
level = r.Level.ToString().Substring(0, 1),
topics = string.Join(",", r.Topics.ToArray()),
medals = JsonConvert.SerializeObject(r.Medals)
});
        }
    }
    static Runner TestRead(string id)
    {
        using (var cn = new SqlConnection(cs))
        {
            var d =
                cn.Query("SELECT * FROM Runner WHERE Id=@id", new { id })
                .Single(); //忽略查無資料的狀況
            return new Runner()
            {
                Id = d.Id,
                Name = d.Name,
                Level =
                    (Levels)
                    Enum.Parse(typeof(Levels),
                    Enum.GetNames(typeof(Levels))
                    .First(o => o.StartsWith(d.Level))),
                Topics = ((string)d.Topics).Split(',').Select(o => int.Parse(o)).ToArray(),
                Medals = JsonConvert.DeserializeObject<Medal[]>(d.Medals)
            };
        }
    }
}

實測將物件寫入後再讀取,比對前後一致,而資料在資料表中會像這樣:

寫入讀取時要外加一坨轉換邏輯感覺有點 Low,如果能把轉換邏輯收進 Runner 物件,在寫入及讀取資料時自動執行,程式才會優雅,所以我們來改造一下。

自動轉換的核心原理是另外新增實際寫入資料庫的對映屬性,以 Medal[] Medals 為例,我選擇新增一個 string MedalsJson 用來儲存 Medals 序列化後的 JSON 字串。而 Medals 屬性 get 改成動態由 MedalsJson 解析,set 時則將 Medal[] 轉成 JSON 存入 MedalJson。同理,Level、Topics 也如泡製。 我習慣一併調整資料表欄位名稱為 LevelFlag、TopicsStr、MedalJson,對映這些新增的儲存屬性,力求命名清晰不易混淆,所以資料表欄位名稱將調整如下:

CREATE TABLE [dbo].Runner
(
	[Id] VARCHAR(8) NOT NULL PRIMARY KEY, 
    [Name] NVARCHAR(32) NOT NULL, 
    [LevelFlag] CHAR NOT NULL, -- V=VIP, R=Regular, T=Trial
    [TopicsStr] VARCHAR(128) NOT NULL, 
    [MedalsJson] VARCHAR(1024) NOT NULL
)

依據前述構想,將 Runner 型別改寫如下:

    public class Runner
    {
        [Key]
        public string Id { get; set; }
        public string Name { get; set; }

        [JsonConverter(typeof(StringEnumConverter))]
        [NotMapped]
        public Levels Level {
            get
            {
                return (Levels)Enum.Parse(typeof(Levels),
                            Enum.GetNames(typeof(Levels))
                            .First(o => o.StartsWith(LevelFlag)));
            }
            set
            {
                LevelFlag = value.ToString().Substring(0, 1);
            }
        }
        [JsonIgnore]
        public string LevelFlag { get; set; } = "T";

        [NotMapped]
        public int[] Topics {
            get {
                if (string.IsNullOrEmpty(TopicsStr))
                    return new int[] { };
                return TopicsStr.Split(',').Select(o => int.Parse(o)).ToArray();
            }
            set {
                if (value == null || !value.Any())
                    TopicsStr = null;
                else 
                    TopicsStr = string.Join(",", value);
            }
        }

        [JsonIgnore]
        public string TopicsStr { get; set; }

        private Medal[] _medals = null;
        [NotMapped]
        public Medal[] Medals {
            get
            {
                if (string.IsNullOrEmpty(MedalsJson)) return null;
                if (_medals == null)
                {
                    _medals = JsonConvert.DeserializeObject<Medal[]>(MedalsJson); 
                }
                return _medals;
            }
            set
            {
                MedalsJson = JsonConvert.SerializeObject(value);
                _medals = value;
            }
        }
        [JsonIgnore]
        public string MedalsJson { get; set; }
        
    }

註1:在 LevelFlag、TopicsStr、MedalsJson 放上 [JsonIgnored] 的理由是 JSON 化前端可直接使用 Level、Topics、Medals 等屬性, 沒必要再由 LevleFlag、TopicsStr、MedalsJson 轉換,故不需要傳遞。延伸閱讀:Json.NET技巧兩則: 忽略屬性及列舉轉字串
註2: Level、Topics、Medals 放上 [NotMapped] (NotMappedAttribute) 是預留 Entity Framework 或其他自動機制識別之用,映對資料庫欄位時忽略這些屬性。

[2019-07-22 補充] 忘了說,如果你的轉換只需搭配 Dapper 使用,有個很厲害的武器 - Dapper TypeHandler,非常省事方便,屬性與資料庫欄位名稱還能維持一致。 (欄位與屬性型別不同但名稱相同這點倒是有好有壞,有時可能造成混淆) 但我後來多選擇在資料物件內自己處理轉換,理由是有些專案不一定是用 Dapper,會走 EF 或土砲 ORM 系統,此時可設計成 Entity 型別只包含要寫入資料庫欄位的屬性,利用 partial class 技巧 動態轉換出 int[]、Enum、自訂物件陣列等實際要應用型別的屬性,一樣可以順暢運作。(感謝軟體主廚提醒)

一旦資料型別具備自我轉換能力,INSERT、SELECT 執行程序直接使用原始物件即可,不需加工轉換,程式碼當場簡潔很多:

    static void TestWrite(Runner r)
    {
        using (var cn = new SqlConnection(cs))
        {
            cn.Execute("DELETE FROM Runner WHERE Id = @id", new { id = r.Id });
            cn.Execute(
@"INSERT INTO Runner (Id,Name,LevelFlag,TopicsStr,MedalsJson)
VALUES (@Id,@Name,@LevelFlag,@TopicsStr,@MedalsJson)", r);
        }
    }
    static Runner TestRead(string id)
    {
        using (var cn = new SqlConnection(cs))
        {
            return cn.Query<Runner>(
                "SELECT * FROM Runner WHERE Id=@id", new { id })
                .Single(); //忽略查無資料的狀況
        }
    }

【同場加映】

若嫌要自己產生 INSERT、SELECT 語法麻煩,我們還可寫個共用函式靠 System.Reflection 自動產生 INSERT 與 SELECT 的,這個前幾天才示範過

        //...省略...
        Insert<Runner>(p);
        var q = Read<Runner>(p.Id);
        Console.WriteLine("AreEqual Check=" +
            (JsonConvert.SerializeObject(p) == JsonConvert.SerializeObject(q)));
        Console.Read();
    }

    static void Insert<T>(T data)
    {
        var tableName = typeof(T).Name;

        var propNames = typeof(T)
            .GetProperties(BindingFlags.Public | BindingFlags.DeclaredOnly | BindingFlags.Instance)
            .Where(o => o.GetCustomAttribute(typeof(NotMappedAttribute)) == null) //略過標註[NotMapped]的屬性           
            .Select(o => o.Name).ToArray();
        string insertSql =
$@"INSERT INTO {tableName} ({string.Join(",", propNames)}) 
VALUES ({string.Join(",", propNames.Select(o => $"@{o}").ToArray())})";

        using (var cn = new SqlConnection(cs))
        {
            //方便重複測試,先刪光資料
            cn.Execute("DELETE FROM Runner");
            cn.Execute(insertSql, data);
        }
    }

    static T Read<T>(object pkValue)
    {
        using (var cn = new SqlConnection(cs))
        {
            var tableName = typeof(T).Name;
            //前題:資料物件有單一欄位Primary Key,並標註[Key] Attribute
            var pkProp =
                typeof(T).GetProperties().SingleOrDefault(o =>
                o.GetCustomAttribute(typeof(KeyAttribute)) != null);
            return cn.Query<T>($"SELECT * FROM {tableName} WHERE {pkProp.Name} = @id",
                new { id = pkValue }).Single();
        }
    }

以上就是我讓資料物件無縫穿梭於 C# 與資料庫間的小技巧,提供大家參考。

Tips of how to use C# property feature to save object to DB data table and restore thme from DB smoothly.


Comments

Be the first to post a comment

Post a comment