前言
上一篇文章收獲了 140 多條評論,這是我們始料未及的。
向來有爭議的話題都是公說公的理,婆說婆的理,Entity Framework的愛好者對此可以說是嗤之以鼻,不屑一顧,而Dapper愛好者則是舉雙手贊成,閱之大快人心。
每個人不同的閱歷,社會經驗,甚至對簡繁的偏見都會影響對此事的看法,凡事都有優劣,取其精華而棄之糟泊,方為上策。
這篇文章則將目光聚焦到Dapper。
Dapper是如此的簡單,她只提供了 3 個幫助函數:
- 執行一個查詢,將結果映射到一個強類型列表
- 執行一個查詢,將結果映射到一個動態對象列表
- 執行一個命令,不返回結果
而在實際的項目中,我們可能只會用到強類型列表,所以上面列出的 3 個幫助函數只會用到 2 個。
有人說了,簡單其實就意味着復雜,的確如此。
過少的封裝意味着每次可能要書寫過多的重復代碼,因此每個Dapper開發者可能都會自行擴展一些用着順手的方法,也就不足為奇了,俗話說一千個人眼里有一千個哈姆雷特。
下面我會分享在將 AppBoxPro 從 EntityFramework 遷移到 Dapper 中遇到的問題,以及解決方法,其中也包含我的小小封裝,希望你能喜歡。
下面是 AppBoxPro.Dapper 的項目開發截圖:
正文
模型的約定
我們對模型有兩個約定:
1. IKeyID接口
2. NotMapped特性
來看一下 User 模型的聲明:
public class User : IKeyID { [Key] public int ID { get; set; } [Required, StringLength(50)] public string Name { get; set; } [Required, StringLength(100)] public string Email { get; set; } public int? DeptID { get; set; } [NotMapped] public string UserDeptName { get; set; } }
其中 IKeyID 是一個接口,定義了模型類必須包含名為 ID 的屬性,這個接口是為了計算 FineUIPro 控件中模擬樹的下拉列表和表格的數據源。
NotMapped特性表明這個屬性沒有數據庫映射,僅僅作為一個內存中使用的屬性,一般有兩個用途:
1. 表關聯屬性,比如 User 模型中的 UserDeptName 屬性,在數據庫檢索時可以通過 inner join 將 Dept 表的 Name 屬性映射於此。
2. 內存中計算的值,比如在 Dept 模型中的 TreeLevel, Enabled, IsTreeLeaf,用於在模擬樹的表格中確定節點的層次結構和節點屬性。
一個請求一個數據庫連接
如果你查閱 Dapper 的文檔,你會發現一個常見的操作代碼段:
using (var conn = new MySqlConnection(connectionString)) { connection.Open(); var users = conn.Query<User>("select * from users"); // ... }
雖然看起來簡單,但是如果每一個地方都有加個 using 代碼段,勢必也會影響觀感和書寫體驗。
另一方面,一個縮進的代碼段會創建一個變量作用域,有時我們可能會希望在 using 外部獲取某個變量,這就變成了:
IEnumerable<User> users; using (var conn = new MySqlConnection(connectionString)) { connection.Open(); users = conn.Query<User>("select * from users"); // ... }
這樣寫起來就會感覺磕磕絆絆,一點都不美好了。
為了簡化代碼,我們遵循之前的邏輯,一個請求一個數據庫連接,將 IDbConnection 保存到 HttpContext 上下文中:
public static IDbConnection DB { get { if (!HttpContext.Current.Items.Contains("__AppBoxProContext")) { HttpContext.Current.Items["__AppBoxProContext"] = GetDbConnection(); } return HttpContext.Current.Items["__AppBoxProContext"] as IDbConnection; } }
public static IDbConnection GetDbConnection() { IDbConnection connection = new SqlConnection(ConfigurationManager.ConnectionStrings["MySQL"].ToString()); connection.Open(); return connection; }
然后在請求結束時銷毀這個連接,在 Global.asax 中:
protected virtual void Application_EndRequest() { var context = HttpContext.Current.Items["__AppBoxProContext"] as IDbConnection; if (context != null) { context.Dispose(); } }
經過這個簡單的封裝,上面的獲取用戶列表的代碼可以直接寫了:
var users = conn.Query<User>("select * from users");
通過ID檢索對象
在項目中,我們可能經常需要通過 ID 來檢索對象,在 Dapper 中實現很簡單:
User current = DB.QuerySingleOrDefault<User>("select * from users where ID = @UserID", new { UserID = id });
但是由於這個操作經常用到,我們可能需要多次的拷貝粘貼,而僅僅修改其中的幾個字符串。
當事情變得不再美好時,我們就要重構了,這次的提取公共方法沒有任何難度:
protected T FindByID<T>(int paramID) { return FindByID<T>(DB, paramID); } protected T FindByID<T>(IDbConnection conn, int paramID) { // 約定:類型 User 對應的數據庫表名 users var tableName = typeof(T).Name.ToLower() + "s"; return conn.QuerySingleOrDefault<T>("select * from "+ tableName +" where ID = @ParamID", new { ParamID = paramID }); }
可以看到其中的注釋,一個模型類到數據庫表的約定:User 模型對應於數據庫表名 users,這個約定有助於我們使用泛型,將參數強類型化(User)而無需傳遞字符串(users)。
經過這次的改造,通過ID檢索對象就簡單多了:
User current = FindByID<User>(id);
相關頁面展示(用戶編輯):
插入和更新
插入和更新是常見的數據庫操作,比如對菜單項的操作涉及對 menus 表的插入和更新:
Menu item = new Menu(); item.Name = tbxName.Text.Trim(); item.NavigateUrl = tbxUrl.Text.Trim(); item.SortIndex = Convert.ToInt32(tbxSortIndex.Text.Trim()); item.Remark = tbxRemark.Text.Trim(); DB.Execute("insert menus(Name, NavigateUrl, SortIndex, ImageUrl, Remark, ParentID, ViewPowerID) values (@Name, @NavigateUrl, @SortIndex, @ImageUrl, @Remark, @ParentID, @ViewPowerID);", item);
首先初始化一個 Menu 模型對象,然后從頁面上獲取屬性值並賦值到模型對象,最后通過 Dapper 提供的 Execute 方法執行插入操作。
相應的,更新操作需要首先通過菜單ID獲取菜單模型對象,然后更新數據庫:
Menu item = FindByID<Menu>(menuID); item.Name = tbxName.Text.Trim(); item.NavigateUrl = tbxUrl.Text.Trim(); item.SortIndex = Convert.ToInt32(tbxSortIndex.Text.Trim()); item.ImageUrl = tbxIcon.Text; item.Remark = tbxRemark.Text.Trim(); DB.Execute("update menus set Name = @Name, NavigateUrl = @NavigateUrl, SortIndex = @SortIndex, ImageUrl = @ImageUrl, Remark = @Remark, ParentID = @ParentID, ViewPowerID = @ViewPowerID where ID = @ID;", item);
上面的插入和更新操作存在兩個不方便的地方:
1. SQL語句中要包含多個要更新的屬性,容易遺漏和寫錯
2. 插入和更新的屬性列表相同時,寫法卻完全不同,不方便拷貝粘貼
為了克服上面兩個弱點,我們對插入更新進行了簡單的封裝,為了不手工填寫屬性列表,我們需要一個從模型類讀取屬性列表的方法:
private string[] GetReflectionProperties(object instance) { var result = new List<string>(); foreach (PropertyInfo property in instance.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) { var propertyName = property.Name; // NotMapped特性 var notMappedAttr = property.GetCustomAttribute<NotMappedAttribute>(false); if (notMappedAttr == null && propertyName != "ID") { result.Add(propertyName); } } return result.ToArray(); }
上面函數通過反射獲取實例對應模型類(instance.GetType())的屬性列表(GetProperties()),然后過濾掉 ID 屬性和擁有 NotMapped 標注的屬性,最后返回屬性數組。
對插入操作的封裝:
protected void ExecuteInsert<T>(object instance, params string[] fields) { return ExecuteInsert<T>(DB, instance, fields); } protected void ExecuteInsert<T>(IDbConnection conn, object instance, params string[] fields) { // 約定:類型 User 對應的數據庫表名 users string tableName = typeof(T).Name.ToLower() + "s"; if (fields.Length == 0) { fields = GetReflectionProperties(instance); } var fieldsSql1 = String.Join(",", fields); var fieldsSql2 = String.Join(",", fields.Select(field => "@" + field)); var sql = String.Format("insert {0} ({1}) values ({2});", tableName, fieldsSql1, fieldsSql2); return conn.Execute(sql, instance); }
ExecuteInsert 方法接受如下參數:
1. 類型 T:通過模型類名稱獲取數據庫表名,這是一個命名約定
2. instance:模型實例,需要插入到數據對應表中
3. fields:這是一個可變參數,如果未傳入 fields 參數,則通過前面定義的 GetReflectionProperties 函數獲取模型類的全部屬性列表
最后,經過簡單的字符串拼接,就能方便的生成需要的SQL語句,並執行 Dapper 的 Execute 來插入數據了。
使用 ExecuteInsert 方法,我們可以將上面的插入操作簡化為:
ExecuteInsert<Menu>(item, "Name", "NavigateUrl", "SortIndex", "ImageUrl", "Remark", "ParentID", "ViewPowerID");
或者,直接這樣寫:
ExecuteInsert<Menu>(item);
是不是很方便。
同樣,對更新的操作也是類似的,只不過在封裝時拼接SQL字符串的邏輯稍微不同:
protected void ExecuteUpdate<T>(object instance, params string[] fields) { return ExecuteUpdate<T>(DB, instance, fields); } protected void ExecuteUpdate<T>(IDbConnection conn, object instance, params string[] fields) { // 約定:類型 User 對應的數據庫表名 users string tableName = typeof(T).Name.ToLower() + "s"; if (fields.Length == 0) { fields = GetReflectionProperties(instance); } var fieldsSql = String.Join(",", fields.Select(field => field + " = @" + field)); var sql = String.Format("update {0} set {1} where ID = @ID", tableName, fieldsSql); return conn.Execute(sql, instance); }
使用封裝后的 ExecuteUpdate 方法,上面的更新操作可以簡化為:
ExecuteUpdate<Menu>(item);
相關頁面展示(用戶角色頁面):
插入后返回自增ID
有時,插入新的數據之后,我們需要立即獲取新插入數據的ID屬性,方便后續的數據庫操作,這就要對上面的 ExecuteInsert 進行改造,在 insert 語句之后加上如下SQL語句:
select last_insert_id();
上面的SQL語句僅適用於 MySQL 數據庫,當然對於其他數據庫也不難支持,后面會講解。更新后的 ExecuteInsert 方法如下:
protected int ExecuteInsert<T>(object instance, params string[] fields) { return ExecuteInsert<T>(DB, instance, fields); } protected int ExecuteInsert<T>(IDbConnection conn, object instance, params string[] fields) { // 約定:類型 User 對應的數據庫表名 users string tableName = typeof(T).Name.ToLower() + "s"; if (fields.Length == 0) { fields = GetReflectionProperties(instance); } var fieldsSql1 = String.Join(",", fields); var fieldsSql2 = String.Join(",", fields.Select(field => "@" + field)); var sql = String.Format("insert {0} ({1}) values ({2});", tableName, fieldsSql1, fieldsSql2); sql += "select last_insert_id();"; return conn.QuerySingle<int>(sql, instance); }
調用時,可以直接拿到新增行的ID,然后執行其他數據庫操作:
// 插入用戶 var userID = ExecuteInsert<User>(item); // 更新用戶所屬角色 DB.Execute("insert roleusers (UserID, RoleID) values (@UserID, @RoleID)", new { UserID = userID, RoleID = 101 });
過濾,分頁和排序
分頁和排序是使用 Dapper 的一個攔路虎,因為很多初學者一看到 Dapper 居然沒有內置的分頁功能就放棄了,至少對於 5 年前的我也遭遇了同樣的經歷。
這是完全沒有必要的!
因為分頁和排序完全是標准的SQL語句是事情,Dapper沒有義務為此負責。
我們可以通過簡單的封裝化腐朽為神奇,來看看過濾,分頁和排序也能如此簡單和優雅,這個過程一般可以分解為 3 個步驟:
1. 添加過濾條件(比如匹配名稱中的關鍵詞,只列出啟用的行....)
2. 獲取總記錄行數(數據庫分頁需要在頁面顯示總記錄數,已經當前頁的記錄其實序號)
3. 獲取當前分頁的數據
下面是 AppBoxPro 中角色列表頁面的過濾,分頁和排序代碼,我們可以一目了然:
// 查詢條件 var builder = new WhereBuilder(); string searchText = ttbSearchMessage.Text.Trim(); if (!String.IsNullOrEmpty(searchText)) { builder.AddWhere("Name like @SearchText"); builder.AddParameter("SearchText", "%" + searchText + "%"); } // 獲取總記錄數(在添加條件之后,排序和分頁之前) Grid1.RecordCount = Count<Role>(builder); // 排列和數據庫分頁 Grid1.DataSource = SortAndPage<Role>(builder, Grid1); Grid1.DataBind();
上面的涉及三個重要的自定義類和函數:
1. WhereBuilder:我們封裝的一個簡單的類,主要目的是將查詢條件,條件參數以及SQL語句 3 則封裝在一起。
2. Count:用來返回總記錄數。
3. SortAndPage:用來執行分頁和排序。
首先來看下WhereBuilder:
public class WhereBuilder { private DynamicParameters _parameters = new DynamicParameters(); public DynamicParameters Parameters { get { return _parameters; } set { _parameters = value; } } private List<string> _wheres = new List<string>(); public List<string> Wheres { get { return _wheres; } set { _wheres = value; } } private string _fromSql = String.Empty; public string FromSql { get { return _fromSql; } set { _fromSql = value; } } public void AddWhere(string item) { _wheres.Add(item); } public void AddParameter(string name, object value) { _parameters.Add(name, value); } }
其中:
1. _wheres: 對應於SQL的 where 子語句。
2. _parameters: 對應於 where 子語句用到的實際參數。
3. _fromSql: 如果省略此屬性,則從模型類名推導出需要操作的數據庫表名,對於需要進行表關聯的復雜查詢,則需要設置此參數,后面會進行詳細講解。
Count 的函數定義:
protected int Count<T>(WhereBuilder builder) { return Count<T>(DB, builder); } protected int Count<T>(IDbConnection conn, WhereBuilder builder) { var sql = builder.FromSql; if (String.IsNullOrEmpty(sql)) { // 約定:類型 User 對應的數據庫表名 users sql = typeof(T).Name.ToLower() + "s"; } sql = "select count(*) from " + sql; if (builder.Wheres.Count > 0) { sql += " where " + String.Join(" and ", builder.Wheres); } return conn.QuerySingleOrDefault<int>(sql, builder.Parameters); }
SortAndPage的函數定義:
protected IEnumerable<T> SortAndPage<T>(WhereBuilder builder, FineUIPro.Grid grid) { return SortAndPage<T>(DB, builder, grid); } protected IEnumerable<T> SortAndPage<T>(IDbConnection conn, WhereBuilder builder, FineUIPro.Grid grid) { // sql: users // sql: select * from users // sql: select onlines.*, users.Name UserName from onlines inner join users on users.ID = onlines.UserID var sql = builder.FromSql; if (String.IsNullOrEmpty(sql)) { // 約定:類型 User 對應的數據庫表名 users sql = typeof(T).Name.ToLower() + "s"; } if (!sql.StartsWith("select")) { sql = "select * from " + sql; } if (builder.Wheres.Count > 0) { sql += " where " + String.Join(" and ", builder.Wheres); } sql += " order by " + grid.SortField + " " + grid.SortDirection; sql += " limit @PageStartIndex, @PageSize"; builder.Parameters.Add("PageSize", grid.PageSize); builder.Parameters.Add("PageStartIndex", grid.PageSize * grid.PageIndex); return conn.Query<T>(sql, builder.Parameters); }
上面的封裝很簡單,對分頁的處理只有這三行代碼:
sql += " limit @PageStartIndex, @PageSize"; builder.Parameters.Add("PageSize", grid.PageSize); builder.Parameters.Add("PageStartIndex", grid.PageSize * grid.PageIndex);
當然這里的 limit 子句只適用於 MySQL,其他數據庫的用法后面會有介紹。
對於 builder.FromSql 屬性,如果留空,則檢索當前數據表的全部數據。而對於表關聯查詢,可以設置完整的 select 子句,下面會進行介紹。
表關聯
在線用戶列表頁面,對於某個用戶,我們不僅要列出用戶的登錄時間,最后操作時間,IP地址,還要列出用戶名和用戶中文名稱。
這里就需要用到表關聯,因為 onlines 只記錄用戶ID,而用戶名稱需要從 users 表獲取,下面就是此頁面的過濾,分頁和排序邏輯:
// 查詢條件 var builder = new WhereBuilder(); string searchText = ttbSearchMessage.Text.Trim(); if (!String.IsNullOrEmpty(searchText)) { builder.AddWhere("users.Name like @SearchText"); builder.AddParameter("SearchText", "%" + searchText + "%"); } DateTime twoHoursBefore = DateTime.Now.AddHours(-2); builder.AddWhere("onlines.UpdateTime > @TwoHoursBefore"); builder.AddParameter("TwoHoursBefore", twoHoursBefore); // 獲取總記錄數(在添加條件之后,排序和分頁之前) Grid1.RecordCount = Count<Online>(builder); // 排列和數據庫分頁 builder.FromSql = "select onlines.*, users.Name UserName, users.ChineseName UserChineseName from onlines inner join users on users.ID = onlines.UserID"; Grid1.DataSource = SortAndPage<Online>(builder, Grid1); Grid1.DataBind();
相關頁面展示(用戶列表):
事務(Transaction)
Dapper對事務有兩種支持,一種是直接在 Query 或者 Execute 中傳遞 transaction 對象,而另外一種則更加簡單。
在更新用戶信息時,首先是更新 users 表,然后還要操作用戶角色表和用戶部門表,對於多個數據表的多次操作,可以放到一個事務中:
using (var transactionScope = new TransactionScope()) { // 更新用戶 ExecuteUpdate<User>(DB, item); // 更新用戶所屬角色 int[] roleIDs = StringUtil.GetIntArrayFromString(hfSelectedRole.Text); DB.Execute("delete from roleusers where UserID = @UserID", new { UserID = userID }); DB.Execute("insert roleusers (UserID, RoleID) values (@UserID, @RoleID)", roleIDs.Select(u => new { UserID = userID, RoleID = u }).ToList()); // 更新用戶所屬職務 int[] titleIDs = StringUtil.GetIntArrayFromString(hfSelectedTitle.Text); DB.Execute("delete from titleusers where UserID = @UserID", new { UserID = userID }); DB.Execute("insert titleusers (UserID, TitleID) values (@UserID, @TitleID)", titleIDs.Select(u => new { UserID = userID, TitleID = u }).ToList()); transactionScope.Complete(); }
相關頁面展示(角色權限):
匿名參數(對象和數組)
Dapper支持方便的傳入匿名參數,前面已經多次看到,比如下面這個更新用戶角色的代碼:
DB.Execute("insert roleusers (UserID, RoleID) values (@UserID, @RoleID)", new { UserID = userID, RoleID = 101 });
不僅如此,Dapper還支持多次執行一個命令,只需要傳入一個匿名數組即可。
在 AppBoxPro 中,有多處應用場景,比如前面的更新用戶角色的代碼:
DB.Execute("insert roleusers (UserID, RoleID) values (@UserID, @RoleID)", roleIDs.Select(u => new { UserID = userID, RoleID = u }).ToList());
這里通過 Select 表達式獲取一個動態對象數組。
在 ConfigHelper 中,我們還有手工創建匿名數組的場景,用來更新 configs 表中的多個行數據:
DB.Execute("update configs set ConfigValue = @ConfigValue where ConfigKey = @ConfigKey", new[] { new { ConfigKey = "Title", ConfigValue = Title }, new { ConfigKey = "PageSize", ConfigValue = PageSize.ToString() }, new { ConfigKey = "Theme", ConfigValue = Theme }, new { ConfigKey = "HelpList", ConfigValue = HelpList }, new { ConfigKey = "MenuType", ConfigValue = MenuType } });
多數據庫支持
多數據庫支持真的不難,在我們支持的 MySQL 和 SQLServer 兩個數據庫中,只有少數幾處需要特殊處理。
1. 數據庫連接,我們可以根據 ProviderName 來生成不同的 IDbConnection 實例。
首先來看下 Web.config 中數據庫相關的配置節:
<appSettings> <!-- 需要連接的數據庫,對應於 connectionStrings 節的 name 屬性 --> <add key="Database" value="MySQL" /> </appSettings> <connectionStrings> <clear /> <add name="SQLServer" connectionString="Password=pass;Persist Security Info=True;User ID=sa;Initial Catalog=appbox;Data Source=." providerName="System.Data.SqlClient" /> <add name="MySQL" connectionString="Server=localhost;Database=appbox;Uid=root;Pwd=pass;Charset=utf8" providerName="MySql.Data.MySqlClient" /> </connectionStrings>
然后是對 GetDbConnection 的擴展:
public static IDbConnection GetDbConnection()
{
var database = ConfigurationManager.AppSettings["Database"];
var connectionStringSection = ConfigurationManager.ConnectionStrings[database];
var connectionString = connectionStringSection.ToString();
IDbConnection connection;
if (connectionStringSection.ProviderName.StartsWith("MySql"))
{
connection = new MySqlConnection(connectionString);
}
else
{
connection = new SqlConnection(connectionString);
}
// 打開數據庫連接
connection.Open();
return connection;
}
2. 插入后獲取新增的行ID
protected int ExecuteInsert<T>(IDbConnection conn, object instance, params string[] fields) { // 約定:類型 User 對應的數據庫表名 users string tableName = typeof(T).Name.ToLower() + "s"; if (fields.Length == 0) { fields = GetReflectionProperties(instance); } var fieldsSql1 = String.Join(",", fields); var fieldsSql2 = String.Join(",", fields.Select(field => "@" + field)); var sql = String.Format("insert {0} ({1}) values ({2});", tableName, fieldsSql1, fieldsSql2); if (conn is MySqlConnection) { sql += "select last_insert_id();"; } else { sql += "SELECT @@IDENTITY;"; } return conn.QuerySingle<int>(sql, instance); }
3. 數據庫分頁處理,更新后的 SortAndPage 函數:
protected IEnumerable<T> SortAndPage<T>(IDbConnection conn, WhereBuilder builder, FineUIPro.Grid grid) { var sql = builder.FromSql; if (String.IsNullOrEmpty(sql)) { // 約定:類型 User 對應的數據庫表名 users sql = typeof(T).Name.ToLower() + "s"; } if (!sql.StartsWith("select")) { sql = "select * from " + sql; } if (builder.Wheres.Count > 0) { sql += " where " + String.Join(" and ", builder.Wheres); } sql += " order by " + grid.SortField + " " + grid.SortDirection; // 分頁 if (conn is MySqlConnection) { sql += " limit @PageStartIndex, @PageSize"; } else { sql += " OFFSET @PageStartIndex ROWS FETCH NEXT @PageSize ROWS ONLY"; } builder.Parameters.Add("PageSize", grid.PageSize); builder.Parameters.Add("PageStartIndex", grid.PageSize * grid.PageIndex); return conn.Query<T>(sql, builder.Parameters); }
好了,上面就是全部的多數據庫處理代碼了。相比 jQuery 對不同瀏覽器的封裝,這里的多數據庫支持真是的小巫見大巫了。
小結
這篇文章主要描述了從 Entity Framework 遷移到 Dapper 時遇到的問題,以及我們給出的簡單封裝,希望你能喜歡。
后記
注:AppBox非免費軟件,如果你希望獲取如下版本和后續更新,請加入【三石和他的朋友們】付費知識星球下載源代碼:http://fineui.com/fans/
- AppBoxPro(Entity Framework版)
- AppBoxPro(Dapper版)
- AppBoxMvc(Entity Framework版)
- AppBoxMvc(Dapper版)