在使用.NET进行开发时,与数据库进行交互几乎是不可避免的。常见的方法就是使用诸如Entity Framework这样的ORM工具来实现对象关系映射。
Dapper作为一种轻量级且高性能的ORM工具,拥有许多优点。而它真正出色的地方在于其速度和可控性。在本文中,我们将探讨Dapper对于那些对性能要求较高、数据库关系较为简单的项目来说,使用原始SQL查询时所具备的适用性。
这篇文章会指导您如何利用Dapper和Postgres数据库为社交媒体应用程序构建一个轻量级的.NET Web API。
先决条件
-
最新的.Net SDK及运行环境(在撰写本文时,建议使用.Net 10)。
-
需要掌握C#编程语言以及依赖注入机制的工作原理。
-
必须拥有正在运行的PostgreSQL实例。
-
还需要准备数据库查看工具,例如DBeaver、pgAdmin,或者VS Code中的PostgreSQL扩展插件。
目录
什么是Dapper?
Dapper是一种“微型ORM”工具,它以极低的抽象层次提供轻量级、高性能的数据访问功能。它完全依赖于SQL查询语句来与数据库进行交互,例如:
SELECT * FROM influencers WHERE ID = 1,并将查询结果直接映射到对象中。
Dapper的README文件中对它的功能做了如下描述:
<对象关系映射器是一种充当编程语言与数据库之间翻译工具的组件,它使软件能够使用熟悉的编程对象来与数据库进行交互(而非直接使用SQL查询语句),从而让在应用程序中管理和操作数据变得更加容易。
由于Dapper是一种“微型ORM”,它介于直接使用SQL和像Entity Framework这样的完整ORM之间。它具备许多基本功能,但不会包含那些冗余的部分,因此它是一款更安全、运行速度更快的数据库工具。
Dapper的挑战与优势
Dapper面临的挑战
| 挑战 | 描述 |
|---|---|
| 缺乏内置的LINQ支持 | Dapper没有集成LINQ功能,因此开发人员必须手动编写SQL语句来进行数据过滤和操作。对于那些习惯在像EF Core这样的ORM中使用LINQ的开发团队来说,这会显著增加开发难度。 |
| 约定规则较少 | 由于默认设置和自动化机制较少,Dapper要求开发者为数据库模式和参数进行显式的配置,因此相比那些依赖大量约定规则的ORM,Dapper需要编写更多的配置代码。 |
| 不适用于复杂的数据模型 | 对于那些包含复杂实体关系的数据模型来说,Dapper需要开发者手动编写SQL语句并进行映射操作,这会增加应用程序的复杂性。 |
| 不支持延迟加载 | 相关实体不会根据需求自动被加载,所有数据都必须通过多次查询或单独的SQL语句来获取,这可能会导致性能问题或额外的代码开销。 |
| 需要手动编写SQL语句 | 开发人员必须手动编写所有的SQL查询语句,这种方式容易出错,而且如果参数处理不当,还可能存在SQL注入的风险。此外,手动编写SQL代码也会增加维护工作的难度。 |
| 不支持对象变更跟踪 | Dapper无法监控对象的变更情况,因此更新和删除操作都需要通过手动编写SQL语句来完成,这会降低CRUD操作的效率。 |
| 缺乏数据库迁移功能 | Dapper没有内置的数据库模式演化或版本控制工具,因此开发人员必须依赖外部库或手动脚本来处理这些任务,这与EF Core集成化的迁移机制形成了鲜明对比。 |
| 连接管理存在问题 | 虽然Dapper使用了ADO.NET进行连接管理,但如果使用不当(例如没有正确释放连接),就可能会导致连接池出现问题或资源泄漏,尤其是在高并发的Web应用程序中。 |
Dapper的优势
不过,我们不必过分关注这些挑战。实际上,Dapper的优势远远超过了它的不足之处。
| 优势 | 描述 |
|---|---|
| 极低的抽象层次 | 作为一款微ORM框架,Dapper在ADO.NET之上构建了一层轻量级的封装层,使开发人员能够直接操作SQL语句,而无需经历复杂的抽象过程。这对于那些希望精确控制数据访问逻辑、并且喜欢贴近底层进行开发的开发者来说,无疑是理想的选择。 |
| 专为读操作密集型应用优化 | 在需要频繁进行数据读取的应用程序中,Dapper能够发挥出其优势。对于报告生成或数据看板等场景,它允许开发者对SQL查询语句进行自定义优化,从而显著提升数据检索效率——这一点相比那些功能较为复杂的ORM框架来说更为明显。 |
| 高性能 | Dapper以其轻量级的设计及低开销而著称,在处理大规模数据集或高吞吐量的操作时,其执行速度极为迅速。在很多性能测试中,它的表现往往优于Entity Framework。 |
| 对SQL查询语句的完全控制权 | 开发人员可以自行编写SQL语句,从而针对复杂的应用场景进行精确的控制。虽然这种做法需要手动管理代码,但它避免了其他ORM框架中自动生成的SQL语句所带来的“黑箱”问题。 |
| 灵活的结果映射机制 | Dapper允许开发者将查询结果轻松映射到自定义对象或视图模型中,并且支持根据具体需求添加自定义逻辑。对于涉及联接操作的场景,它也提供了多种灵活的映射方式,无需遵循固定的规则。 |
| 简单易用 | 由于其API接口设计简洁、配置流程快速,Dapper的学习曲线非常平缓。无论是用于快速原型开发,还是集成到现有的.NET项目中,它都能轻松满足需求,而不会带来任何额外的复杂性。 |
| 支持返回多个结果集 | 通过使用QueryMultiple等方法,Dapper能够高效地处理那些在一次查询中需要返回多个结果集的情况,从而减少与数据库之间的交互次数,进一步提升效率。 |
| 内置的SQL注入防护机制 | 自动参数化功能有效防止了SQL注入攻击,使得Dapper在保障灵活性的同时,也具备出色的安全性。 |
| 广泛的数据库兼容性 | Dapper基于ADO.NET开发,因此可以与任何类型的数据库提供商(如SQL Server、PostgreSQL等)无缝配合使用,这种灵活性使得用户不会受到特定供应商的限制。 |
入门 – 安装
您将使用CLI(命令行界面)在项目中安装Dapper。我选择这种方法,因为它不仅更快,还能帮助您熟悉CLI的使用。
克隆仓库:
在终端应用程序中,导航到希望存放仓库的文件夹,然后运行以下命令来克隆公共教程仓库:
git clone https://github.com/grant-dot-dev/dapper_tutorial.git
接下来,您需要将Dapper以及Postgres支持添加到项目中。可以使用以下命令来完成这一操作:
cd FCC.DapperTutorial
# 添加Dapper NuGet包
dotnet add package Dapper
# 添加Postgres驱动程序
dotnet add package Npgsql
在appsettings.json文件中,将Postgres实例的位置更新到defaultConnection连接字符串中。
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=social_media;Username=postgres;Password=yourpassword"
}
创建种子文件
在项目中创建一个名为Infrastructure的文件夹,然后在该文件夹中创建一个名为DbUtilities.cs的文件,并将以下代码添加到其中:
using Dapper;
using Npgsql;
using Microsoft.Extensions.Configuration;
namespace DapperTutorial Infrastructure;
public static class DBUtilities
{
private const string CreateUserSql =@
CREATE TABLE IF NOT EXISTS ""User"" (
UserId TEXT PRIMARY KEY,
Username TEXT,
FirstName TEXT,
LastName TEXT,
Avatar TEXT,
Email TEXT,
DOB DATE
);";
private const string CreatePostSql =@
CREATE TABLE IF NOT EXISTS Post (
PostId TEXT PRIMARY KEY,
Likes INTEGER,
Content TEXT,
Timestamp TIMESTAMP,
UserId TEXT,
FOREIGN KEY(UserId) REFERENCES ""User""(UserId)
);';
private const string InsertUsersSql =@
INSERT INTO ""User"" (UserId, Username, FirstName, LastName, Avatar, Email, DOB) VALUES
('1', 'iron_man', 'Tony', 'Stark', NULL, 'tony.stark@example.com', '1970-05-29'),
('2', 'batman', 'Bruce', 'Wayne', NULL, 'bruce.wayne@example.com', '1972-11-11'),
('3', 'spiderman', 'Peter', 'Parker', NULL, 'peter.parker@example.com', '1995-08-10'),
('4', 'wonderwoman', 'Diana', 'Prince', NULL, 'diana.prince@example.com', '1985-04-02'),
('5', 'superman', 'Clark', 'Kent', NULL, 'clark.kent@example.com', '1980-07-18'),
('6', 'black-widow', 'Natasha', 'Romanoff', NULL, 'natasha.romanoff@example.com', '1983-06-25'),
('7', 'deadpool', 'Wade', 'Wilson', NULL, 'wade.wilson@example.com', '1977-02-19'),
('8', 'green-lantern', 'Hal', 'Jordan', NULL, 'hal.jordan@example.com', '1988-09-05'),
('9', 'captain-america', 'Steve', 'Rogers', NULL, 'steve.rogers@example.com', '1920-07-04'),
('10', 'catwoman', 'Selina', 'Kyle', NULL, 'selina.kyle@example.com', '1982-12-08')
ON CONFLICT (UserId) DO NOTHING;";
private const string InsertPostsSql =@
INSERT INTO Post (PostId, Likes, Content, Timestamp, UserId) VALUES
('p1', 10, 'Hello, world!', '2025-10-12 10:00:00', '1'),
('p2', 5, 'My first post!', '2025-10-12 11:00:00', '2'),
('p3', 7, 'Excited to join!', '2025-10-12 12:00:00', '3'),
('p4', 3, 'What a great day!', '2025-10-12 13:00:00', '4'),
('p5', 15, 'Superhero meetup!', '2025-10-12 14:00:00', '5')
ON CONFLICT (PostId) DO NOTHING;";
public static async Task SeedDatabaseAsync IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("DefaultConnection");
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
try
{
await connection.ExecuteAsync(CreateUserSql, transaction: transaction);
await connection.ExecuteAsync>CreatePostSql, transaction: transaction);
var userCount = await connection.QuerySingleAsync<int>>(
@"SELECT COUNT(*) FROM ""User"";", transaction: transaction);
var postCount = await connection.QuerySingleAsync<int>>(
"SELECT COUNT(*) FROM Post;", transaction: transaction);
if (userCount > 0 && postCount > 0)
{
await transaction COMMITAsync();
return;
}
await connection.ExecuteAsync(InsertUsersSql, transaction: transaction);
await connection.ExecuteAsync(InsertPostsSql, transaction: transaction);
await transaction.commitAsync();
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}
}
}
在《Program.cs》文件中,像平常一样为你的API添加一个种子数据生成端点:
// 添加 using 语句
using DapperTutorialInfrastructure;
// 添加种子数据生成端点
app.MapPost("/seed", async (IConfiguration configuration) =>
{
try
{
await DBUtilities.SeedDatabaseAsync(configuration);
return Results.Ok("数据库种子数据已成功生成.");
}
catch (Exception ex)
{
return Results.Problem($"在生成数据库种子数据时发生了错误:{ex.Message}");
}
});
在终端中,使用dotnet run运行应用程序,然后调用/seed端点,这样就会开始生成数据库种子数据。你可以通过自己喜欢的数据库工具来检查这些种子数据是否已经正确生成。
使用 Dapper 查询数据
本教程的这一部分将重点介绍如何使用 Dapper 库来查询数据库。我们会探讨加载、保存以及保护 SQL 数据库的基本方法。
首先,我们将实现仓库模式——这是一种在开发中常用的设计模式。仓库充当了所有数据库相关操作的中心节点。如果使用实现了特定接口的仓库,就可以方便地对数据库功能进行测试和模拟。
首先,在“Models”文件夹中为你的数据创建相应的模型类。
// User.cs 模型类
public sealed class User
{
public string UserId { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string FirstName { get; init; } = string.Empty;
public string LastName { get; init; } = string.Empty;
public string? Avatar { get; init; }
public string Email { get; init; } = string.Empty;
public DateOnly? DOB { get; init; }
}
// Post.cs 模型类
public sealed class Post
{
public string PostId { get; init; } = string.Empty;
public int Likes { get; init; }
public string Content { get; init; } = string.Empty;
public DateTime Timestamp { get; init; }
public string UserId { get; init; } = string.Empty;
}
遵循最佳实践,在“Application”文件夹中创建一个IRepository.cs文件,并将以下代码粘贴到其中,这些代码定义了你的数据查询功能:
using DapperTutorial Models;
namespace DapperTutorial.Application;
public interface IRepository
{
Task> GetUsersAsync();
Task GetUserByIdAsync(string userId);
Task> GetPostsAsync();
Task GetPostByIdAsync(string postId);
Task> GetPostsByUser(string userId);
}
现在你已经得到了一个基础接口,可以在更复杂的应用程序中利用它来进行单元测试。这就是实现这种设计模式的诸多好处之一。
接下来,按照要求创建这个仓库的具体实现类吧:
using Dapper;
using DapperTutorial.Application;
using DapperTutorial Models;
using Npgsql;
namespace DapperTutorialInfrastructure;
public class Repository(IConfiguration configuration) : IRepository
{
private readonly string _connectionString = configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("连接字符串缺失:ConnectionStrings:DefaultConnection");
public async Task> GetUsersAsync()
{
const string sql = @"SELECT
UserId,
Username,
FirstName,
LastName,
Avatar,
Email,
DOB
FROM User
ORDER BY Username;";
(await using var connection = CreateConnection();
var users = await connection.QueryAsync(sql);
return users.AsList();
}
public async Task GetUserByIdAsync(string userId)
{
const string sql = @"SELECT
UserId,
Username,
FirstName,
LastName,
Avatar,
Email,
DOB
FROM User
WHERE UserId = @UserId;";
(await using var connection = CreateConnection();
return await connection.QuerySingleOrDefaultAsync(sql, new { UserId = userId });
}
public async Task> GetPostsAsync()
{
const string sql = @"SELECT
PostId,
Likes,
Content,
Timestamp,
UserId
FROM Post
ORDER BY Timestamp DESC;";
(await using var connection = CreateConnection();
var posts = await connection.QueryAsync(sql);
return posts.AsList();
}
public async Task> GetPostsByUser(string userId)
{
const string sql = @"SELECT
PostId,
Likes,
Content,
Timestamp,
UserId
FROM Post
WHERE UserId = @UserId
ORDER BY Timestamp DESC;";
(await using var connection = CreateConnection();
var posts = await connection.QueryAsync(sql, new { UserId = userId });
return posts.AsList();
}
public async Task GetPostByIdAsync(string postId)
{
var sql = @"SELECT * FROM Post
WHERE PostId = @PostId;";
(await using var connection = CreateConnection();
return await connection.QuerySingleOrDefaultAsync(sql, new { PostId =postId });
}
private NpgsqlConnection CreateConnection() => new(_connectionString);
}
解析Repository类:
该类的构造函数会接收一个IConfiguration对象,这个对象是由ASP.Net Core的内置依赖注入系统自动提供的。因此,你无需自己创建这个对象——框架会为你完成这一工作。这样,你就可以访问在appsettings.json中定义的连接字符串了,而Dapper正是需要这个连接字符串才能与数据库建立连接的。
你还会注意到这里使用了空值合并操作符??。这种编程模式的作用是在连接字符串缺失时立即抛出一个明确、易于理解的错误,而不是等到后来在尝试使用该连接字符串的值时才出现莫名其妙的NullReferenceException》异常。
每种方法都包含一个以@字符开头的SQL字符串,这使得该字符串成为字面值字符串。这样一来,字符串就可以跨多行显示,而无需使用\n转义字符或进行字符串连接操作,因此我们可以像在查询编辑器中一样来格式化SQL语句,这样代码的可读性也会大大提高。
User是PostgreSQL中的保留字,因此"User"需要用双引号括起来。
如果你熟悉SQL,那么使用Dapper的查询方法会非常方便。这正是Dapper的核心优势:它既提供了普通SQL的灵活性,又具备对象关系映射框架带来的诸多好处。
-
QueryAsync():当你执行的查询可能返回零个或多个结果时,可以使用这个方法。该方法总是会返回一个集合对象,而永远不会返回null值,因此当没有找到任何记录时,你就不需要担心进行空值检查的问题了。 -
QuerySingleOrDefaultAsync():当你预期最多只会得到一个结果时,就可以使用这个方法。例如GetByIdAsync方法或获取用户的个人资料信息时,这种用法非常适用。如果没有找到匹配的记录,该方法会返回该类型的默认值——对于像User?这样的引用类型来说,默认值就是null。
QuerySingle与QueryFirst的区别
当你需要返回一个结果时,QuerySingleOrDefaultAsync和QueryFirstOrDefaultAsync都可以使用。而且,当没有找到任何记录时,这两种方法都会为引用类型返回默认值null。
它们的区别在于:当查询结果中包含多条记录时,这两种方法的行为是不同的。
QuerySingleOrDefaultAsync会抛出异常,因此在使用主键进行查询时,这种方法是更安全的选择。因为如果真的返回了两个结果,那就说明肯定出了严重的问题,你需要立即得到反馈,而不是让系统默默地返回错误的记录(比如重复的记录)。
QueryFirstOrDefaultAsync则会忽略其他结果,只返回第一条记录。因此,在某些场景下,这种方法更为适用。例如,“给我显示这个用户最近发布的帖子”,虽然预期会得到多条结果,但最终只需要最新一条,而这样的需求通常是通过查询语句中的ORDER BY子句来实现的。
使用Dapper写入数据
你已经成功创建了用于从数据存储层中查询和获取数据的方法,但现在你也需要编写用于插入和更新现有记录的方法。
插入方法
在IRepository接口中添加一个CreateUserAsync()方法的定义,然后将下面的实现代码复制到你的Repository.cs文件中:
public async Task CreateUserAsync(User user)
{
const string sql =@
INSERT INTO ""User"" (
UserId,
Username,
FirstName,
LastName,
Avatar,
Email,
DOB
) VALUES (
@UserId,
@Username,
@FirstName,
@LastName,
@Avatar,
@Email,
@DOB
);";
await using var connection = CreateConnection();
var rowsAffected = await connection.ExecuteAsync(sql, user);
return rowsAffected > 0;
}
与之前一样,请写出用于将记录插入到“User”表中的SQL语句,并说明需要哪些参数来为这些字段赋值。
有几点需要注意:
-
Dapper会根据名称将
user对象的属性直接映射到@参数上,因此你可以直接使用user对象,而无需构建匿名对象。 -
ExecuteAsync()方法会返回受影响的行数,因此如果返回值满足rowsAffected > 0,就可以说明操作成功。 -
在模型中,
Avatar和DOB字段是可以设置为空的,Dapper能够妥善处理这种情况:当这些字段的值为null时,它会在数据库中插入NULL。
保护你的数据库——参数化与SQL注入防护
你可能已经注意到,在整个教程中,我们从未直接将值插入到SQL字符串中。相反,我们使用了@UserId、@Username这样的占位符,实际的值则是另外传递的。这种做法被称为参数化,它是开发者应该养成的最重要的习惯之一。
要理解为什么这样做很重要,就请考虑如果不采用参数化会发生什么。如果你使用字符串插值的方式来构建查询语句:
// 绝对不要这样做
var sql = $"SELECT * FROM \"User\" WHERE Username = '{username}'";
恶意用户可能会将以下内容作为用户名传递进来:
' OR '1'='1'
这样,你的查询语句就会变成:
SELECT * FROM "User" WHERE Username = '' OR '1'='1'
由于'1'='1'这个条件始终为真,因此这条查询会返回数据库中的所有用户记录。如果攻击者使用更具破坏性的代码,他们甚至可以删除表格、删除数据或窃取敏感信息。这种攻击方式被称为SQL注入攻击,它是Web应用中最常见且危害最大的安全漏洞之一。
Dapper能够自动保护你免受这类攻击。当你编写如下代码时:
const string sql = @"SELECT * FROM ""User"" WHERE Username = @Username";
var user = await connection.QuerySingleOrDefaultAsync<User>>(sql, new { Username = username });
Dapper会将SQL语句和参数值分别发送到数据库。数据库首先接收查询结构,然后对其进行编译,最后再将参数值作为普通数据进行处理——这些参数值永远不会被解释为SQL指令。无论用户传递什么值,它都只会被当作普通的数据来处理,而绝不会被视为命令。
正因如此,在整个教程中,你总会看到以下写法:
-
在SQL字符串中使用
@ParameterName占位符 -
参数值是通过匿名对象、模型或
DynamicParameters等方式传递的
而绝对不会使用字符串插值或在SQL字符串中进行字符串拼接操作。
使用Dapper更新数据
有时候,你并不想创建新的记录,而是希望更新现有的记录。以下是实现这一功能所需的代码。请将这段代码添加到你的仓库中,并确保在仓库接口中定义相应的方法:
public async Task UpdateUserAsync(User user)
{
const string sql = @"UPDATE ""User"" SET
Username = @Username,
FirstName = @FirstName,
LastName = @LastName,
Avatar = @Avatar,
Email = @Email,
DOB = @DOB
WHERE UserId = @UserId;";
await using var connection = CreateConnection();
var affectedRows = await connection.ExecuteAsync(sql, user);
return affectedRows > 0;
}
与Dapper中的其他查询操作一样,你需要编写SQL语句,并将其与需要更新的对象一起传递给ExecuteAsync()方法。Dapper会自动根据对象属性的名称来匹配相应的SQL字段。
使用Dapper删除记录
同样地,请将以下代码添加到你的仓库中。你需要编写删除操作的SQL语句,并将其传递给ExecuteAsync()方法。在这个例子中,你不是传递用户对象,而是手动将`UserId`参数映射到SQL语句中的`@UserId`字段。
public async Task DeleteUserAsync(string userId)
{
const string sql = @"DELETE FROM ""User""
WHERE UserId = @UserId;";
await using var connection = CreateConnection();
var affectedRows = await connection.ExecuteAsync(sql, new { UserId = userId });
return affectedRows > 0;
}
有时候,你可能不想一次删除一条记录,而是希望一次性删除多条记录。Dapper的内部机制允许你这样做——只需将要删除的记录列表传递给ExecuteAsync()方法即可:
public async Task DeleteUsersBatchAsync(IEnumerable userIds)
{
const string sql = @"DELETE FROM ""User""
WHERE UserId = @UserId;";
await using var connection = CreateConnection();
var affectedRows = await connection.ExecuteAsync(sql, userIds.Select(id => new { UserId = id }));
return affectedRows > 0;
}
Dapper会遍历这个列表,对列表中的每一条记录都执行一次删除操作。实际上,这并不是一个批量查询,而是多次单独的查询。因此,对于大型数据集来说,可能需要考虑其他处理方法;但对于常见的使用场景而言,这种做法确实简洁方便。
使用Dapper进行批量处理
如果想要真正实现一次性的批量处理操作,那么你可能需要依赖SQL语句本身,而不是Dapper。在这个过程中,Dapper只是负责传递数据而已,真正的批量处理逻辑是体现在SQL查询语句中的。
最常见的方法是使用ANY操作符(具体取决于所使用的SQL数据库类型):
public async Task DeleteUsersAsync(IEnumerable userIds)
{
const string sql = @"DELETE FROM ""User""
WHERE UserId = ANY(@UserIds);";
await using var connection = CreateConnection();
var affectedRows = await connection.ExecuteAsync(sql, new { UserIds = userIds.ToArray() });
return affectedRows > 0;
}
这种方法会向数据库发送一条包含所有用户ID的查询语句,而不是为每个ID分别发送一条查询。PostgreSQL支持直接将数组传递给ANY()操作符,这也是PostgreSQL中处理此类情况的常用方式。
注意: ANY(@UserIds)是PostgreSQL特有的语法。如果使用的是SQL Server,应该使用WHERE UserId IN @UserIds——Dapper在接收到一个IEnumerable类型的参数时,会自动将其转换为适合使用的形式。
总结如下:
-
多次执行:对于列表中的每个元素都需要进行一次数据库交互,这种方法简单但效率低下,尤其是在需要删除大量用户或他们的帖子时。
-
ANY/IN:无论列表的大小如何,都只需要进行一次数据库交互,因此对于处理大规模数据集来说,这种方法的效率要高得多。
在大多数日常使用场景中,这两种方法之间的差异并不明显。但当你知道自己正在处理一个集合对象时,选择使用能够一次性完成所有操作的查询方式是一个好习惯。
IRepository接口,添加在本教程中学到的任何新方法。使用Dapper进行事务处理
如果你还记得之前使用的数据初始化工具,可能会注意到其中已经使用了事务机制。现在,是时候了解什么是事务以及它们为什么如此重要了。
事务能够确保所有操作要么全部成功,要么全部失败。这就是原子性原则:数据库的状态永远不会处于半完成的状态。
想象这样一个场景:当有新用户注册到某个社交媒体应用时,系统会要求他们立即写第一篇帖子——这意味着用户的账户信息和他们的第一篇帖子是通过同一条SQL语句存储到数据库中的。
如果没有使用事务机制,如果用户信息的插入操作成功了,但帖子的插入操作失败了,那么数据库中就会留下一个“孤儿”记录:这个用户的账户存在,但没有任何帖子,而且我们也无法知道具体出了什么问题。
如果你熟悉SQL,可能曾经直接编写过如下形式的事务代码:
BEGIN TRANSACTION;
INSERT INTO "User" (UserId, Username ...) VALUES (...);
INSERT INTO Post (PostId, ...) VALUES (...);
COMMIT;
而使用Dapper时,你不需要在SQL语句中直接编写事务逻辑,而是可以在C#代码中对其进行管理。每个SQL语句仍然保持独立的地位,而你可以在应用程序层面将它们组合在一起形成一个事务。这样既能保证同样的原子性,还能利用C#的try/catch机制来优雅地处理错误并触发回滚操作。
public async Task
{
const string insertUserSql = @"INSERT INTO ""User"" (UserId, Username, FirstName, LastName, Avatar, Email, DOB)
VALUES (@UserId, @Username, @FirstName, @LastName, @Avatar, @Email, @DOB);";
const string insertPostSql = @"INSERT INTO Post (PostId, Likes, Content, Timestamp, UserId)
VALUES (@PostId, @Likes, @Content, @Timestamp, @UserId);";
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
try
{
await connection.ExecuteAsync(insertUserSql, user, transaction);
await connection.ExecuteAsync(insertPostSql, post, transaction);
await transaction COMMITAsync();
return true;
}
catch (Exception)
{
await transaction.RollbackAsync();
return false;
}
}
这意味着你可以编写一些有用的方法,这些方法能够根据特定条件来执行相应的SQL语句。举个例子:有3段不同的SQL代码,它们可以作为同一事务的一部分被执行,分别是insertUserSql、insertPostSql和logActivitySql。
在下面的示例中,假设你有一个名为ActivityLog的表格,用于记录用户在应用程序中的各种操作。这三段SQL代码都属于同一事务,但其中只有两段一定会被执行。如果设置了“不记录活动日志”的选项,那么整个事务就会只包含创建用户和发布帖子的操作,而不会记录用户的活动日志;如果有任何步骤失败,那么整个事务都会被回滚。
public async Task
{
const string insertUserSql = @"INSERT INTO ""User"" (UserId, Username, FirstName, LastName, Avatar, Email, DOB)
VALUES (@UserId, @Username, @FirstName, @LastName, @Avatar, @Email, @DOB);";
const string insertPostSql = @"INSERT INTO Post (PostId, Likes, Content, Timestamp, UserId)
VALUES (@PostId, @Likes, @Content, @Timestamp, @UserId);";
const string logActivitySql = @"INSERT INTO ActivityLog (UserId, Action, Timestamp)
VALUES (@UserId, 'SIGNUP', @Timestamp);";
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
try
{
await connection.ExecuteAsync(insertUserSql, user, transaction);
await connection ExecuteAsync(insertPostSql, post, transaction);
if (logActivity)
{
await connection.ExecuteAsync(logActivitySql, new { user.UserId, Timestamp = DateTime.UtcNow }, transaction);
}
await transaction COMMITAsync();
return true;
}
catch (Exception)
{
await transaction.RollbackAsync();
return false;
}
}
多映射/数据拆分
到目前为止,所有的查询都是从单个表中获取数据,并将这些数据映射到同一个对象中。但实际应用中很少会这样操作。数据通常是关联的,因此你经常需要使用JOIN操作来同时查询多个表。
Dapper通过多映射功能来解决这个问题,这种机制允许一个查询将结果同时映射到多个对象中。为了演示这一功能,我们将返回一份包含帖子内容以及相关作者信息的列表(而不仅仅是UserId这个外键信息)。
首先,在你的Models文件夹中创建用于存储这些组合数据的模型。
public sealed class PostWithAuthor
{
public Post Post { get; init; } = null!;
public User Author { get; init; } = null!;
}
现在,在你的仓库中添加以下方法(别忘了也要在相应的接口中定义这个方法)。
public async Task> GetPostsWithAuthorsAsync()
{
const string sql =@
SELECT
p.PostId,
p.Likes,
p.Content,
p.Timestamp,
p.UserId,
uUserId,
u.Username,
u.FirstName,
u.LastName,
u.Avatar,
u.Email,
u.DOB
FROM Post p
INNER JOIN User u ON p UserId = u.UserId
ORDER BY pTimestamp DESC;";
await using var connection = CreateConnection();
var results = await connection.QueryAsync(
sql,
(post, user) => new PostWithAuthor { Post = post, Author = user },
splitOn: "UserId"
);
return results.AsList();
}
与你之前编写过的查询相比,这里的代码结构要复杂一些,让我们来详细分析一下。
SELECT语句
与之前的查询不同,在这种情况下列的顺序非常重要。Dapper需要知道结果集中Post表的列在哪里结束,User表的列又从哪里开始。因此你必须明确列出所有的列,先列出所有Post表的列,然后再列出User表的列。
QueryAsync
你现在传递的是三种类型,而不是单一的通用类型:前两种类型是你希望将结果映射到的对象,第三种类型则是返回的结果类型。Dapper会将结果集拆分为Post和User>两部分,然后把它们交给你,让你自行组合这些数据。
映射函数
(post, user) => new PostWithAuthor { Post = post, Author = user }
在这里,你需要将两个对象合并在一起。Dapper会为每一行提供对应的`Post`和`User`对象,而你需要返回合并后的`PostWithAuthor`对象。在这个环节,你可以完全掌控整个处理过程:如果你想将结果合并成一个简单的对象而不是嵌套结构,也可以在这里进行相应的操作。
splitOn: "UserId"
这是最需要理解的部分。`splitOn`参数告诉Dapper哪个列标志着第一个对象的结束以及第二个对象的开始。在这种情况下,当Dapper在结果集中第二次遇到`UserId`这个字段时,它就会知道从这一刻起的所有数据都属于`User`对象。
默认情况下,`splitOn`的值为`Id`——因此如果你的分割字段就是`Id`,那么根本不需要特别指定它。但由于`Post`和`User`对象都使用`UserId`作为标识符,所以在这里必须明确指定这个字段。
一个常见的错误
由于`splitOn`是根据列的位置来划分数据的,因此你用来分割的数据列必须是SELECT语句中第二个对象的**第一列**。如果`uUserId`不是`User`对象列表中的第一列,Dapper就会在错误的位置进行分割,从而导致数据映射错误。这种错误很难被发现,因为程序不会抛出异常,只是返回错误的结果而已。



