一、前言 在非靜態頁面的項目開發中,必定會涉及到對於資料庫的訪問,最開始呢,我們使用 Ado.Net,通過編寫 SQL 幫助類幫我們實現對於資料庫的快速訪問,後來,ORM(Object Relational Mapping,對象關係映射)出現了,我們開始使用 EF、Dapper、NHibernate ...
一、前言
在非靜態頁面的項目開發中,必定會涉及到對於資料庫的訪問,最開始呢,我們使用 Ado.Net,通過編寫 SQL 幫助類幫我們實現對於資料庫的快速訪問,後來,ORM(Object Relational Mapping,對象關係映射)出現了,我們開始使用 EF、Dapper、NHibernate,亦或是國人的 SqlSugar 代替我們原來的 SqlHelper.cs。通過這些 ORM 工具,我們可以很快速的將資料庫中的表與代碼中的類進行映射,同時,通過編寫 SQL 或是 Lambda 表達式的方式,更加便捷的實現對於數據層的訪問。
就像文章標題中所說的這樣,在這個項目中我是使用的 Dapper 來進行的數據訪問,每個人都有自己的編程習慣,本篇文章只是介紹我在 Grapefruit.VuCore 這個項目中是如何基於 Dapper 創建自己使用的幫助方法的,不會涉及各種 ORM 工具的對比,請友善查看、討論。
系列目錄地址:ASP.NET Core 項目實戰
倉儲地址:https://github.com/Lanesra712/Grapefruit.VuCore
二、Step by Step
1、整體思路
在 Grapefruit.VuCore 這個項目中,我選擇將 SQL 語句存儲在 XML 文件中(XML 以嵌入的資源的方式嵌入到程式集中),通過編寫中間件的方式,在程式運行時將存儲有 SQL 語句的 XML 程式集寫入到 Redis 緩存中。當使用到 SQL 語句時,通過 Redis 中的 Key 值進行獲取到 Value,從而將 SQL 語句與我們的代碼進行拆分。
涉及到的類文件主要是在以下的類庫中,基於 Dapper 的數據訪問代碼則位於基礎構造層(02_Infrastructure)中,而使用到這些數據訪問代碼的,有且僅在位於領域層(03_Domain)中的代碼。同時,領域層的文件分佈結構和應用層(04_Applicatin)保持相同。
2、擴展數據訪問方法
在使用 Dapper 之前,我們首先需要在 Grapefruit.Infrastructure 這個類庫中添加對於 Dapper 的引用。同時,因為需要將 SQL 語句存儲到 Redis 緩存中,與之前使用 Redis 存儲 Token 時相同,這裡,也是使用的微軟的分散式緩存介面,因此,同樣需要添加對於此 DLL 的引用。
Install-Package Dapper
Install-Package Microsoft.Extensions.Caching.Abstractions
在 Grapefruit.Infrastructure 類庫中創建一個 Dapper 文件夾,我們基於 Dapper 的擴展代碼全部置於此處,整個的代碼結構如下圖所示。
在整個 Dapper 文件夾下類/介面/枚舉文件,主要可以按照功能分為三部分。
2.1、輔助功能文件
主要包含 DataBaseTypeEnum 這個枚舉類以及 SqlCommand 這個用來將存儲在 XML 中的 SQL 進行映射的幫助類。
DataBaseTypeEnum 這個資料庫類型枚舉類主要定義了可以使用的資料庫類型。我們知道,Dapper 這個 ORM 主要是通過擴展 IDbConnection 介面,從而給我們提供附加的數據操作功能,而我們在創建資料庫連接對象時,不管是 SqlConnection 還是 MySqlConnection 最終對於資料庫最基礎的操作,都是繼承於 IDbConnection 這個介面。因此,我們可以在後面創建資料庫連接對象時,通過不同的枚舉值,創建針對不同資料庫操作的資料庫連接對象。
public enum DataBaseTypeEnum { SqlServer = 1, MySql = 2, PostgreSql = 3, Oracle = 4 }
SqlCommand 這個類文件只是定義了一些屬性,因為我是將 SQL 語句寫到 XML 文件中,同時會將 XML 文件存儲到 Redis 緩存中,因此,SqlCommand 這個類主要用來將我們獲取到的 SQL 語句與類文件做一個映射關係。
public class SqlCommand { /// <summary> /// SQL語句名稱 /// </summary> public string Name { get; set; } /// <summary> /// SQL語句或存儲過程內容 /// </summary> public string Sql { get; set; } }
2.2、SQL 存儲讀取
對於 SQL 語句的存儲、讀取,我定義了一個 IDataRepository 介面,DataRepository 繼承於 IDataRepository 實現對於 SQL 語句的操作。
public interface IDataRepository { /// <summary> /// 獲取 SQL 語句 /// </summary> /// <param name="commandName"></param> /// <returns></returns> string GetCommandSQL(string commandName); /// <summary> /// 批量寫入 SQL 語句 /// </summary> void LoadDataXmlStore(); }
存儲 SQL 的 XML 我是以附加的資源存儲到 dll 中,因此,這裡我是通過載入 dll 的方式獲取到所有的 SQL 語句,之後,根據 Name 屬性判斷 Redis 中是否存在,當不存在時就寫入 Redis 緩存中。核心的代碼如下所示,如果你需要查看完整的代碼,可以去 Github 上查看。
/// <summary> /// 載入dll中包含的SQL語句 /// </summary> /// <param name="fullPath">命令名稱</param> private void LoadCommandXml(string fullPath) { SqlCommand command = null; Assembly dll = Assembly.LoadFile(fullPath); string[] xmlFiles = dll.GetManifestResourceNames(); for (int i = 0; i < xmlFiles.Length; i++) { Stream stream = dll.GetManifestResourceStream(xmlFiles[i]); XElement rootNode = XElement.Load(stream); var targetNodes = from n in rootNode.Descendants("Command") select n; foreach (var item in targetNodes) { command = new SqlCommand { Name = item.Attribute("Name").Value.ToString(), Sql = item.Value.ToString().Replace("<![CDATA[", "").Replace("]]>", "") }; command.Sql = command.Sql.Replace("\r\n", "").Replace("\n", "").Trim(); LoadSQL(command.Name, command.Sql); } } } /// <summary> /// 載入SQL語句 /// </summary> /// <param name="commandName">SQL語句名稱</param> /// <param name="commandSQL">SQL語句內容</param> private void LoadSQL(string commandName, string commandSQL) { if (string.IsNullOrEmpty(commandName)) { throw new ArgumentNullException("CommandName is null or empty!"); } string result = GetCommandSQL(commandName); if (string.IsNullOrEmpty(result)) { StoreToCache(commandName, commandSQL); } }
2.3、數據操作
對於數據的操作,這裡我定義了 IDataAccess 這個介面,提供了同步、非同步的方式,實現對於數據的訪問。在項目開發中,對於數據的操作,更多的還是根據欄位值獲取對象、獲取對象集合、執行 SQL 獲取受影響的行數,獲取欄位值,所以,這裡主要就定義了這幾類的方法。
public interface IDataAccess { /// 關閉資料庫連接 bool CloseConnection(IDbConnection connection); /// 資料庫連接 IDbConnection DbConnection(); /// 執行SQL語句或存儲過程返回對象 T Execute<T>(string sql, object param, bool hasTransaction = false, CommandType commandType = CommandType.Text); /// 執行SQL語句返回對象 T Execute<T>(string sql, object param, IDbTransaction transaction, IDbConnection connection, CommandType commandType = CommandType.Text); /// 執行SQL語句或存儲過程返回對象 Task<T> ExecuteAsync<T>(string sql, object param, bool hasTransaction = false, CommandType commandType = CommandType.Text); /// 執行SQL語句返回對象 Task<T> ExecuteAsync<T>(string sql, object param, IDbTransaction transaction, IDbConnection connection, CommandType commandType = CommandType.Text); /// 執行SQL語句或存儲過程,返回IList<T>對象 IList<T> ExecuteIList<T>(string sql, object param, bool hasTransaction = false, CommandType commandType = CommandType.Text); /// 執行SQL語句或存儲過程,返回IList<T>對象 IList<T> ExecuteIList<T>(string sql, object param, IDbTransaction transaction, IDbConnection connection, CommandType commandType = CommandType.Text); /// 執行SQL語句或存儲過程,返回IList<T>對象 Task<IList<T>> ExecuteIListAsync<T>(string sql, object param, bool hasTransaction = false, CommandType commandType = CommandType.Text); /// 執行SQL語句或存儲過程,返回IList<T>對象 Task<IList<T>> ExecuteIListAsync<T>(string sql, object param, IDbTransaction transaction, IDbConnection connection, CommandType commandType = CommandType.Text); /// 執行SQL語句或存儲過程返回受影響行數 int ExecuteNonQuery(string sql, object param, bool hasTransaction = false, CommandType commandType = CommandType.Text); /// 執行SQL語句或存儲過程返回受影響行數 int ExecuteNonQuery(string sql, object param, IDbTransaction transaction, IDbConnection connection, CommandType commandType = CommandType.Text); /// 執行SQL語句或存儲過程返回受影響行數 Task<int> ExecuteNonQueryAsync(string sql, object param, bool hasTransaction = false, CommandType commandType = CommandType.Text); /// 執行SQL語句或存儲過程返回受影響行數 Task<int> ExecuteNonQueryAsync(string sql, object param, IDbTransaction transaction, IDbConnection connection, CommandType commandType = CommandType.Text); /// 執行語句返回T對象 T ExecuteScalar<T>(string sql, object param, bool hasTransaction = false, CommandType commandType = CommandType.Text); /// 執行語句返回T對象 Task<T> ExecuteScalarAsync<T>(string sql, object param, bool hasTransaction = false, CommandType commandType = CommandType.Text); }
在 IDataAccess 介面的功能實現與調用上,我採用了代理模式的方式,會涉及到 DataAccess、DataAccessProxy、DataAccessProxyFactory、DBManager 這四個類文件,之間的調用過程如下。
DataAccess 是介面的實現類,通過下麵的幾個類進行隱藏,不直接暴露給外界方法。一些介面的實現如下所示。
/// <summary> /// 創建資料庫連接 /// </summary> /// <returns></returns> public IDbConnection DbConnection() { IDbConnection connection = null; switch (_dataBaseType) { case DataBaseTypeEnum.SqlServer: connection = new SqlConnection(_connectionString); break; case DataBaseTypeEnum.MySql: connection = new MySqlConnection(_connectionString); break; }; return connection; } /// <summary> /// 執行SQL語句或存儲過程,返回IList<T>對象 /// </summary> /// <typeparam name="T">類型</typeparam> /// <param name="sql">SQL語句 or 存儲過程名</param> /// <param name="param">參數</param> /// <param name="transaction">外部事務</param> /// <param name="connection">資料庫連接</param> /// <param name="commandType">命令類型</param> /// <returns></returns> public IList<T> ExecuteIList<T>(string sql, object param, IDbTransaction transaction, IDbConnection connection, CommandType commandType = CommandType.Text) { IList<T> list = null; if (connection.State == ConnectionState.Closed) { connection.Open(); } try { if (commandType == CommandType.Text) { list = connection.Query<T>(sql, param, transaction, true, null, CommandType.Text).ToList(); } else { list = connection.Query<T>(sql, param, transaction, true, null, CommandType.StoredProcedure).ToList(); } } catch (Exception ex) { _logger.LogError($"SQL語句:{sql},使用外部事務執行 ExecuteIList<T> 方法出錯,錯誤信息:{ex.Message}"); throw ex; } return list; }
DBManager 是外界方法訪問的類,通過 CreateDataAccess 方法會創建一個 IDataAccess 對象,從而達到訪問介面中方法的目的。
[ThreadStatic] private static IDataAccess _sMsSqlFactory; /// <summary> /// /// </summary> /// <param name="cp"></param> /// <returns></returns> private static IDataAccess CreateDataAccess(ConnectionParameter cp) { return new DataAccessProxy(DataAccessProxyFactory.Create(cp)); } /// <summary> /// MsSQL 資料庫連接字元串 /// </summary> public static IDataAccess MsSQL { get { ConnectionParameter cp; if (_sMsSqlFactory == null) { cp = new ConnectionParameter { ConnectionString = ConfigurationManager.GetConfig("ConnectionStrings:MsSQLConnection"), DataBaseType = DataBaseTypeEnum.SqlServer }; _sMsSqlFactory = CreateDataAccess(cp); } return _sMsSqlFactory; } }
DataAccessProxy 就是實際介面功能實現類的代理,通過有參構造函數的方式進行調用,同時,類中繼承於 IDataAccess 的方法都是不實現的,都是通過 _dataAccess 調用介面中的方法。
/// <summary> /// /// </summary> private readonly IDataAccess _dataAccess; /// <summary> /// ctor /// </summary> /// <param name="dataAccess"></param> public DataAccessProxy(IDataAccess dataAccess) { _dataAccess = dataAccess ?? throw new ArgumentNullException("dataAccess is null"); } /// <summary> /// 執行SQL語句或存儲過程,返回IList<T>對象 /// </summary> /// <typeparam name="T">類型</typeparam> /// <param name="sql">SQL語句 or 存儲過程名</param> /// <param name="param">參數</param> /// <param name="transaction">外部事務</param> /// <param name="connection">資料庫連接</param> /// <param name="commandType">命令類型</param> /// <returns></returns> public IList<T> ExecuteIList<T>(string sql, object param, IDbTransaction transaction, IDbConnection connection, CommandType commandType = CommandType.Text) { return _dataAccess.ExecuteIList<T>(sql, param, transaction, connection, commandType); }
DataAccessProxyFactory 這個類有一個 Create 靜態方法,通過實例化 DataAccess 類的方式返回 IDataAccess 介面,從而達到真正調用到介面實現類。
/// <summary> /// 創建資料庫連接字元串 /// </summary> /// <param name="cp"></param> /// <returns></returns> public static IDataAccess Create(ConnectionParameter cp) { if (string.IsNullOrEmpty(cp.ConnectionString)) { throw new ArgumentNullException("ConnectionString is null or empty!"); } return new DataAccess(cp.ConnectionString, cp.DataBaseType); }
3、使用方法
因為我們對於 SQL 語句的獲取全部是從緩存中獲取的,因此,我們需要在程式執行前將所有的 SQL 語句寫入 Redis 中。在 ASP.NET MVC 中,我們可以在 Application_Start 方法中進行調用,但是在 ASP.NET Core 中,我一直沒找到如何實現僅在程式開始運行時執行代碼,所以,這裡,我採用了中間件的形式將 SQL 語句存儲到 Redis 中,當然,你的每一次請求,都會調用到這個中間件。如果大家有好的方法,歡迎在評論區里指出。
public class DapperMiddleware { private readonly ILogger _logger; private readonly IDataRepository _repository; private readonly RequestDelegate _request; /// <summary> /// ctor /// </summary> /// <param name="repository"></param> /// <param name="logger"></param> /// <param name="request"></param> public DapperMiddleware(IDataRepository repository, ILogger<DapperMiddleware> logger, RequestDelegate request) { _repository = repository; _logger = logger; _request = request; } /// <summary> /// 註入中間件到HttpContext中 /// </summary> /// <param name="context"></param> /// <returns></returns> public async Task InvokeAsync(HttpContext context) { Stopwatch sw = new Stopwatch(); sw.Start(); //載入存儲xml的dll _repository.LoadDataXmlStore(); sw.Stop(); TimeSpan ts = sw.Elapsed; _logger.LogInformation($"載入存儲 XML 文件DLL,總共用時:{ts.TotalMinutes} 秒"); await _request(context); } }
中間件的實現,只是調用了之前定義的 IDataRepository 介面中的 LoadDataXmlStore 方法,同時記錄下了載入的時間。在 DapperMiddlewareExtensions 這個靜態類中,定義了中間件的使用方法,之後我們在 Startup 的 Configure 方法里調用即可。
public static class DapperMiddlewareExtensions { /// <summary> /// 調用中間件 /// </summary> /// <param name="builder"></param> /// <returns></returns> public static IApplicationBuilder UseDapper(this IApplicationBuilder builder) { return builder.UseMiddleware<DapperMiddleware>(); } }
中間件的調用代碼如下,同時,因為我們在中間件中通過依賴註入的方式使用到了 IDataRepository 介面,所以,我們也需要在 ConfigureServices 中註入該介面,這裡,採用單例的方式即可。
public class Startup { // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { //DI Sql Data services.AddTransient<IDataRepository, DataRepository>(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env, IApiVersionDescriptionProvider provider) { //Load Sql Data app.UseDapper(); } }
當所有的 SQL 語句寫入到緩存中後,我們就可以使用了,這裡的示例代碼實現的是上一篇(ASP.NET Core 實戰:基於 Jwt Token 的許可權控制全揭露)中,進行 Jwt Token 授權,驗證登錄用戶信息的功能。
整個的調用過程如下圖所示。
在 SecretDomain 中,我定義了一個 GetUserForLoginAsync 方法,通過帳戶名和密碼獲取用戶的信息,調用了之前定義的數據訪問方法。
public class SecretDomain : ISecretDomain { #region Initialize /// <summary> /// 倉儲介面 /// </summary> private readonly IDataRepository _repository; /// <summary> /// ctor /// </summary> /// <param name="repository"></param> public SecretDomain(IDataRepository repository) { _repository = repository; } #endregion #region API Implements /// <summary> /// 根據帳戶名、密碼獲取用戶實體信息 /// </summary> /// <param name="account">賬戶名</param> /// <param name="password">密碼</param> /// <returns></returns> public async Task<IdentityUser> GetUserForLoginAsync(string account, string password) { StringBuilder strSql = new StringBuilder(); strSql.Append(_repository.GetCommandSQL("Secret_GetUserByLoginAsync")); string sql = strSql.ToString(); return await DBManager.MsSQL.ExecuteAsync<IdentityUser>(sql, new { account, password }); } #endregion }
XML 的結構如下所示,註意,這裡需要修改 XML 的屬性,生成操作改為附加的資源。
<?xml version="1.0" encoding="utf-8" ?> <Commands> <Command Name="Secret_GetUserByLoginAsync"> <![CDATA[ SELECT Id ,Name ,Account ,Password ,Salt FROM IdentityUser WHERE Account=@account AND Password=@password; ]]> </Command> <Command Name="Secret_NewId"> <![CDATA[ select NEWID(); ]]> </Command> </Commands>
因為篇幅原因,這裡就不把所有的代碼都列出來,整個調用的過程演示如下,如果有不明白的,或是有什麼好的建議的,歡迎在評論區中提出。因為,資料庫表並沒有設計好,這裡只是建了一個實驗用的表,,這裡我使用的是 SQL Server 2012,創建表的 SQL 語句如下。
USE [GrapefruitVuCore] GO ALTER TABLE [dbo].[IdentityUser] DROP CONSTRAINT [DF_User_Id] GO /****** Object: Table [dbo].[IdentityUser] Script Date: 2019/2/24 9:41:15 ******/ DROP TABLE [dbo].[IdentityUser] GO /****** Object: Table [dbo].[IdentityUser] Script Date: 2019/2/24 9:41:15 ******/ SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE TABLE [dbo].[IdentityUser]( [Id] [uniqueidentifier] NOT NULL, [Name] [nvarchar](50) NOT NULL, [Account] [nvarchar](50) NOT NULL, [Password] [nvarchar](100) NOT NULL, [Salt] [uniqueidentifier] NOT NULL, CONSTRAINT [PK__User__3214EC07D257C709] PRIMARY KEY CLUSTERED ( [Id] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] GO ALTER TABLE [dbo].[IdentityUser] ADD CONSTRAINT [DF_User_Id] DEFAULT (newid()) FOR [Id] GO
三、總結
這一章主要是介紹下我是如何使用 Dapper 構建我的數據訪問幫助方法的,每個人都會有自己的編程習慣,這裡只是給大家提供一個思路,適不適合你就不一定啦。因為年後工作開始變得多起來了,現在主要都是周末才能寫博客了,所以更新的速度會變慢些,同時,這一系列的文章,按我的設想,其實還有一兩篇文章差不多就結束了(VUE 前後端交互、Docker 部署),嗯,因為 Vue 那塊我還在學習中(其實就是很長時間沒看了。。。),所以接下來的一段時間可能會側重於 Vue 系列(Vue.js 牛刀小試),ASP.NET Core 系列可能會不定期更新,希望大家同樣可以多多關註啊。最後,感謝之前贊賞的小伙伴。