65.9K
CodeProject 正在变化。 阅读更多。
Home

Dapper - 极速映射器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (7投票s)

2023年1月3日

CPOL

8分钟阅读

viewsIcon

18902

downloadIcon

140

在ASP.NET 7应用程序中引入Dapper

引言

Dapper是一款流行的SQL数据库映射器。通常,它将表行中的列字段映射到C#类实例中的属性,反之亦然。在数据管理的“三层模型”中,Dapper位于中间层,它从上层的视图或表示层获取数据,并以数据库可以处理的格式将其呈现给下层的数据层。同样,它从数据库获取数据,并以视图可以消费的格式将其呈现给上层。

为什么使用Dapper?

它易于使用、轻量级(47K)且速度快——大约是Entity Framework速度的两倍。其作者是StackOverflow的团队,他们维护着Dapper并用它来访问他们庞大的数据库。Dapper并非旨在取代Entity Framework;Entity Framework是一个出色的对象关系映射器(ORM),拥有你可能想要的所有功能,但许多用户在控制它时遇到困难,导致走了不必要的路线,并携带了永远不会打开的行李。有了Dapper,你就在驾驶座上。你掌握着它的方向,并决定它到达目的地后做什么。

实现

Dapper作为开源项目可在GitHub上找到,也可以作为Nuget包使用。它并非独立存在,而是作为一组扩展方法实现的,这些方法实际上扩展了IDbConnection接口。这类似于system.Linq扩展IEnumerable<T>的方式。Dapper查询的典型格式是,将一个SQL语句作为string传递,并附带语句所需的任何参数,这些参数以匿名类型的成员形式表示。大致如下所示。

 int id = 7;
 string sql = @"Select *from dbo.Employees where EmployeeID=@Id;";
 using IDbConnection connection = _connectionCreator.CreateConnection();
 var employee = await connection.QueryFirstOrDefaultAsync<Employee>(sql,new{Id=id});

这里,SQL语句从Employees表中的行中选择所有列字段,其中EmployeeID列等于输入参数Id。“@”符号标识了语句所需的输入参数。QueryFirstOrDefaultAsync<T>泛型方法被使用,因为EmployeeID是主键,所以只有一个记录需要查找。匿名类型的成员名称与SQL语句引用的输入参数名称匹配,并设置为局部变量id的值。Dapper将查询的输出映射到type T的一个实例。在此示例中,type T被定义为Employee类。这就是基本设置,还有其他Query方法用于从数据库请求数据,以及Execute方法用于向数据库传递命令,如InsertDeleteUpdate。通常,同一方法的异步和同步版本都存在。 Learn Dapper网站和GitHub上的DapperReadme.md文件提供了许多Dapper的优秀示例。

为ASP.NET应用程序中的Dapper配置

管理数据库连接

数据库连接在使用前需要打开,并在查询完成后立即关闭和释放。这意味着每个Dapper扩展方法都伴随着一定量的连接管理工作。还需要引用一个数据库服务器特定的IDbConnection实例。因此,这里可能会出现大量代码重复,并且每次进行数据库调用的方法都会依赖于所选的IDbConnection类型。解决此问题的一种方法是将Dapper方法封装在IDatabaseContext类中,这样使用该类的任何东西都不需要了解Dapper或数据库连接。下面是一个调用DapperExecuteAsync方法的IDatabaseContext方法的示例。

   public async Task<T> QueryFirstOrDefaultAsync<T>(
       string sql,
       object? parameters = null,
       CommandType? commandType = null)
    {
        using IDbConnection connection = _connectionCreator.CreateConnection();
        var result = await connection.QueryFirstOrDefaultAsync<T>(sql, parameters,
         commandType: commandType);
        return result;
    }

ConnectionCreator.CreateConnection方法是一个工厂方法,它返回一个Connection的新实例。

     public class MsSqlConnectionCreator : IConnectionCreator
    {
        protected ServerOptions _serverOptions;
        public MsSqlConnectionCreator(IOptions<ServerOptions> serverOptionsSnapshot)
        {
            _serverOptions = serverOptionsSnapshot.Value;
        }
                                     
        public  IDbConnection CreateConnection()
        {
            var connectionString = _serverOptions.MsSql;
            return new SqlConnection(connectionString);
        }
    }

DatabaseContext通过注入到其构造函数中的IOptions<T>泛型类型的实例从数据库连接字符串中获取数据库连接。

public SqlServerContext(IOptions<ServerOptions> serverOptionsSnapshot)
    {
        _serverOptions = serverOptionsSnapshot.Value;
        _connectionString = _serverOptions.MsSql;
    }    

这种技术被认为优于选择传递ConfigurationManager并使用该管理器读取连接的替代方案。它的优点是将ConfigurationManager的作用域限制在Program.csStartup.cs中的其他应用程序构建器范围内。appsettings.jsonConnectionStrings节的成员值在运行时绑定到ServerOptions单例类。

"ConnectionStrings": {
  "MsSql": "Data Source=(localdb)\\ProjectModels;
   Initial Catalog=Northwind;Integrated Security=True",
  "MySql": "Server=127.0.0.1;user ID=root; Database=northwind; Password=Pa$$w0rd"
    
  },
....

ServerOptions类有两个属性,它们与ConnectionStrings节中的名称相匹配。

public class ServerOptions
{
    //ConnectionStrings is not mapped, 
    //it's used to avoid a magic string when referencing
    // the appsettings "ConnectionStrings" section
    public const string ConnectionStrings = "ConnectionStrings";

    public string MsSql { get; set; } = string.Empty;
    public string MySql { get; set; } = string.Empty;
}

绑定在Program.cs中设置。

var section = builder.Configuration.GetSection(“ConnectionStrings”);
builder.Services.Configure<ServerOptions>(section);

管理SQL语句

拥有一个实现IDataAccess接口的类可以解决很多问题,但表示层运行的服务仍然需要发出SQL语句。SQL语句本质上是数据库服务器的数据管理指令,理想情况下应该位于数据库内部。实现这一目标的方法是使用存储过程。存储过程是预编译的SQL语句,驻留在数据库中。在SQL Server对象资源管理器中,它们位于数据库的可编程性文件夹下。以下显示了如何从Northwind示例数据库中调用CustOrderHist存储过程。

string customerID = "ANTON";//input param
var results = await _databaseContext.QueryAsync<ProductSummary>
              (_storedProcedureId.CustomerOrderHistory,
               new { CustomerID = customerID }, 
               commandType: CommandType.StoredProcedure);

commandType参数是通过使用命名参数初始化的。命名参数避免了为排在所需参数之前的参数输入大量null值。过程名称作为自定义StoredProcedureId类的属性传递。

public  string CustomerOrderHistory { get; } = "dbo.CustOrderHist";

NorthwindOrders表中有超过800条记录,但Dapper可以在瞬间完成此过程。

示例应用程序

示例应用程序是一个ASP.NET 7控制台应用程序,它使用Northwind数据库。它需要在appsettings.json文件中更新连接字符串,以指向数据库服务器实例。示例将运行TSQL或MySQL语句,具体取决于所选的SQL Server。它们并非旨在成为定论,大多数都是不言自明的,但有几个可能需要一些澄清。

示例 1

此查询涉及两个类。Order类模拟Orders表,Employee类模拟Employees表。Order类还有一个引用Employees表主键的外键,此外,它还有一个引用Employee类实例的属性。

public class Order
{
    public int OrderID { get; set; }
    public string? CustomerID { get; set; }
    public int EmployeeID { get; set; }
    public DateTime OrderDate { get; set; }
    public int ShipperID { get; set; }
    public Employee? Employee { get; set; }
}

SQL语句在匹配的EmployeeIDs上连接这两个表,并按EmployeeLastName然后是FirstName对查询进行排序。

       string sql = @"select o.EmployeeID,o.OrderId,e.EmployeeID,e.FirstName,e.LastName
                     from dbo.Orders o
                     inner join dbo.Employees e
                     on o.EmployeeID  = e.EmployeeID
                     order by e.LastName, e.FirstName";

Dapper将此查询的结果映射到Order项的集合。

        var employeeOrders= await _databaseContext.QueryAsync<Order, Employee>(sql,
             (order, employee) =>
             {
                 order.Employee = employee;
                 return order;
             }, splitOn: "EmployeeID");

为了让Dapper能够高效地映射,需要提供一个函数,该函数将Order类的Employee属性设置为Employee实例,并返回Order类的实例。还需要告诉Dapper,在查询返回的所有列中,与Orders表相关的列在哪里结束,而与Employees表相关的列在哪里开始。假设是在遇到名称为“ID”的ToUpper()值的列名时。但是,在这种情况下,相关的标识列名是EmployeeID,因此splitOn参数需要设置为该名称。

示例 2

此示例包含一个通常会导致异常的SQL语句,因为它有一个集合作为输入参数,而集合参数通常是不允许的。Dapper通过将集合分解为一系列单个参数,以SQL解析器可以接受的格式来规避此限制。该语句将集合引用为@countries。局部变量countryArray通过匿名类型传递给QueryAsync方法,该匿名类型有一个成员,其名称必须与引用的参数名称相同,并且设置为局部数组实例。该查询获取数组中命名的四个国家/地区中每个国家/地区提供的产品总数。

string[] countryArray = new[] { "France", "Germany", "UK", "USA" };
string sql = @"Select Suppliers.Country As SuppliersCountry,COUNT(*) as ProductCount
             From Suppliers join Products on Suppliers.SupplierID=Products.SupplierID
             where Suppliers.Country in @countries
             Group by Suppliers.Country
             Order by ProductCount Desc;";
var results = await _dba.QueryAsync<(string SuppliersCountry, int ProductCount)>
              (sql, new { countries = countryArray });

如果您是SQL新手,上面的语句可能看起来有点令人生畏。一个有用的技巧是从后往前阅读它。因此,从最后一个子句开始,输出按ProductCount降序排序。它仅在Suppliers.Country存在于输入参数集合中时,才按Suppliers.Country进行分组。数据是通过将Suppliers表中的每一行与Products表中的匹配行进行连接而获得的,连接条件是Suppliers.SupplierID等于Products.SupplierID。选定的行和列的输出将Suppliers.Country作为SuppliersCountry列的成员,并将组中所有项目的计数作为ProductCount列的成员。在SQL中,分组表示为键/值对,其中值是从聚合函数派生的,该函数使用组的成员项集合作为输入参数。这将在几行代码中压缩大量功能。SQL是编程语言中的科伦坡;它比看起来更聪明。

结论

使用Dapper扩展方法作为数据库映射器,是使用**Entity Framework**提供的类型的有用且高效的替代方法。尤其是在不需要**Entity Framework**的高级功能的情况下。

学到的教训

务必await Dapper的所有async方法。简单地调用方法并返回Task以便另一个方法可以await它并不是一个好主意。尽管这种技术避免了使用async关键字的开销,但它也会导致`null`引用异常。方法是从`using`语句中调用的,并且一旦返回未完成的Task,`using`语句就会确保连接关闭,导致Dapper处于“脱节”状态。我是吃了亏才学到的。

历史

  • 2023年1月3日:初版
© . All rights reserved.