Dapper 作为一款轻量级 ORM,之所以被大量 .NET 开发者青睐,核心就在于它在保持手写 SQL 灵活性的同时,通过参数化查询极大降低了 SQL 注入的风险。但很多开发者在使用 Dapper 时,会陷入一个误区:认为只要使用了 Dapper 的 Query 方法并传递了参数,就天然免疫注入了。实际上,真正的安全边界在于参数是否被底层当作数据而非代码执行。当你在拼接 SQL 字符串时,哪怕只拼接了一个微不足道的过滤条件,防护体系就已经出现了裂缝。而使用匿名对象传递参数,正是在不引入额外 DTO 类的前提下,既维持代码简洁性,又守住安全底线的最佳平衡点。
为什么拼接 SQL 是万恶之源SQL 注入的本质是用户输入的数据被解析器当成了 SQL 指令的一部分。在 Dapper 中,如果你写出这样的代码:
var query = $"SELECT * FROM Users WHERE Name = '{userName}'";
var user = connection.Query<User>(query);
无论你外层做了多少过滤,只要 userName 中包含单引号或 SQL 关键字,数据库就会执行非预期的逻辑。Dapper 本身没有提供内置的字符串转义函数来防范这种情况,因为它设计的初衷就是让你使用参数化。将数据与代码分离,是唯一正确的解法。
匿名对象参数化的底层逻辑当你使用匿名对象传递参数时,Dapper 会在内部遍历对象的属性,将其转换为 DbParameter 集合。这个过程会强制让数据库引擎将参数值视为字面量数据,而不是可执行代码。例如:
var user = connection.Query<User>(
"SELECT * FROM Users WHERE Name = @Name AND Age > @Age",
new { Name = userName, Age = 18 }
);
此时,即使 userName 包含恶意片段,数据库也只会把它当作一个完整的字符串去匹配 Name 字段,而不会解析其中的 SQL 语法。这种机制是数据库驱动层面提供的安全保证,远比任何上层过滤可靠。
匿名对象相比显式参数对象的优势在 Dapper 中,你也可以使用 DynamicParameters 或者预定义的 DTO 类来传递参数。但匿名对象在特定场景下具有不可替代的优势:它无需预先定义类,减少了项目中 DTO 的数量膨胀。尤其在查询条件多变、组合复杂的搜索接口中,为每一种查询组合创建参数类会迅速拖垮代码的可维护性。匿名对象允许你在方法内部即时构建参数结构,代码自包含性更强,阅读时无需在多个文件间跳转。
动态构建匿名对象的技巧实际业务中,查询条件往往是可选的。你不能直接修改匿名对象的属性,但可以通过字典或 ExpandoObject 来动态构建参数,再传入 Dapper。更优雅的做法是利用 C# 的集合初始化器和 LINQ 来组装:
var conditions = new List<string>();
var parameters = new DynamicParameters();
if (!string.IsNullOrEmpty(name))
{
conditions.Add("Name = @Name");
parameters.Add("@Name", name);
}
if (minAge.HasValue)
{
conditions.Add("Age >= @MinAge");
parameters.Add("@MinAge", minAge.Value);
}
var sql = "SELECT * FROM Users";
if (conditions.Any())
{
sql += " WHERE " + string.Join(" AND ", conditions);
}
var users = connection.Query<User>(sql, parameters);
这种方式虽然使用了 DynamicParameters 而非纯匿名对象,但它保留了参数化的安全性,同时解决了动态查询的难题。如果你坚持使用匿名对象,可以通过反射或第三方库如 Dapper.SqlBuilder 来辅助构建,但 DynamicParameters 在动态场景下是更务实的官方推荐方案。
列表参数传递的常见陷阱很多开发者在处理 IN 查询时会不自觉地拼接字符串:
var ids = new[] { 1, 2, 3 };
var sql = $"SELECT * FROM Users WHERE Id IN ({string.Join(",", ids)})";
这直接打开了注入的大门,因为 ids 中的元素如果来自用户输入,就可能包含恶意内容。Dapper 对列表参数有原生支持:
var sql = "SELECT * FROM Users WHERE Id IN @Ids";
var users = connection.Query<User>(sql, new { Ids = ids });
Dapper 会自动将数组展开为多个参数,如 @Ids1, @Ids2, @Ids3,全程保持参数化。这一特性在大多数主流数据库驱动中都有效,但需要注意部分老旧数据库或驱动可能对参数数量有限制,此时需要分批查询。
Like 查询的安全写法模糊查询是另一个重灾区。开发者习惯这样写:
var sql = $"SELECT * FROM Products WHERE Name LIKE '%{keyword}%'";
这同样将用户输入直接嵌入了 SQL。正确的做法是将通配符放在参数值中,而非 SQL 字符串里:
var sql = "SELECT * FROM Products WHERE Name LIKE @Keyword";
var products = connection.Query<Product>(sql, new { Keyword = $"%{keyword}%" });
这样即使用户输入了单引号或百分号,也只会被当作普通字符处理。百分号在参数值中是安全的,因为它不会破坏 SQL 结构,只是作为字符串的一部分传递给 LIKE 运算符。
表名和列名的处理困境匿名对象参数化只能保护数据值,无法保护 SQL 结构部分,如表名、列名、排序字段等。当业务需要动态表名时,任何参数化手段都无能为力,因为数据库参数只能用于值位置。此时必须通过白名单机制来防范:
private static readonly HashSet<string> AllowedTables = new()
{
"Users", "Orders", "Products"
};
public IEnumerable<T> QueryTable<T>(string tableName)
{
if (!AllowedTables.Contains(tableName))
throw new ArgumentException("Invalid table name");
var sql = $"SELECT * FROM [{tableName}]";
return connection.Query<T>(sql);
}
使用方括号包裹标识符可以防止部分注入,但根本防线在于白名单。永远不要将用户输入直接作为 SQL 标识符,这是铁律。
存储过程调用中的参数化调用存储过程时,很多开发者会放松警惕,认为存储过程内部已经处理了安全。但如果存储过程内部使用了动态 SQL 拼接,风险依然存在。在 Dapper 中调用存储过程时,同样要坚持参数化:
var parameters = new { UserId = userId, Role = role };
var result = connection.Query<UserRole>(
"usp_GetUserRoles",
parameters,
commandType: CommandType.StoredProcedure
);
这样即使存储过程内部有动态 SQL,传入的参数值也不会被误解析。安全是纵深防御,不能依赖单一环节。
Dapper 与 EF Core 在防注入上的差异Entity Framework Core 通过 LINQ 表达式树自动生成参数化 SQL,几乎杜绝了拼接的可能。但 Dapper 给了你完全的自由,也意味着你需要完全负责。这不是 Dapper 的缺陷,而是设计哲学的不同。理解这一点后,你会明白:在 Dapper 中,每一条 SQL 字符串都必须是硬编码的常量,或者由白名单控制的结构部分与参数化值部分严格分离。任何将用户输入直接拼入 SQL 字符串的行为,都是对 Dapper 安全模型的破坏。
代码审查中的检查清单在实际项目中,可以建立一套简单的自查规则:SQL 字符串中是否出现了任何变量插值?如果有,立即改为参数化。是否使用了 string.Format 或 $ 字符串插值来构建包含用户输入的 SQL?如果是,立即重构。IN 查询和 LIKE 查询是否使用了参数化?如果没有,立即修复。动态排序或分组字段是否有白名单校验?如果没有,立即添加。这套规则简单但有效,能拦截绝大多数注入风险。
性能与安全的平衡参数化查询不仅安全,还能提升数据库性能。因为参数化 SQL 的查询计划可以被缓存复用,而拼接 SQL 每次都会产生新的查询计划,增加数据库负担。Dapper 的匿名对象参数化在性能开销上几乎可以忽略不计,属性反射在现代 .NET 版本中经过高度优化,且 Dapper 内部使用了轻量级的 IL 生成和缓存机制,不会成为瓶颈。
匿名对象的局限性及替代方案匿名对象并非万能。在需要跨方法传递参数、或者参数数量超过一定规模时,匿名对象的可读性会下降。此时可以引入 record 类型:
public record UserQueryParams(string Name, int? MinAge, string SortBy);
record 类型兼具匿名对象的简洁性和强类型的安全优势,且支持 with 表达式进行非破坏性修改,非常适合作为 Dapper 的参数载体。这是从匿名对象到强类型参数的自然演进路径。
第三方工具的安全风险一些开发者喜欢使用 Dapper.Contrib 或 SqlKata 等扩展库来动态构建 SQL。这些库本身可能提供了防注入机制,但你必须深入理解其内部实现。例如 SqlKata 默认会参数化值,但如果你使用了 Raw 方法强行插入字符串,安全性就取决于你的实现了。依赖第三方库不能替代对 SQL 注入原理的理解,工具只是辅助,安全意识才是根本。
总结最佳实践的核心原则在 Dapper 中防止 SQL 注入的最佳实践可以归结为一句话:所有来自外部的数据必须通过参数传递,所有 SQL 结构必须由代码控制。匿名对象是实现这一原则的最轻量级工具,它让你在保持代码简洁的同时,不牺牲安全性。当你习惯了这种模式,会发现它不仅没有增加开发负担,反而让代码逻辑更清晰、更易维护。安全不是束缚,而是高质量代码的自然属性。
