1. 项目概述:从零到一掌握C#与SQL Server的交互核心
如果你刚开始用C#做项目,十有八九绕不开要和数据库打交道。而SQL Server作为微软技术栈里的“老搭档”,与C#的配合可以说是天衣无缝。但很多新手朋友一上来就被“增删改查”这四个字给难住了,感觉每个操作背后都有一堆SqlConnection、SqlCommand、SqlDataReader,还有令人头疼的异常处理和资源释放。别担心,这篇文章就是来帮你把这些看似零散的操作,串成一条清晰、可复现的实战路径。
我干了十多年后端开发,用C#操作SQL Server的代码写过不计其数。今天我们不谈那些高大上的ORM框架,就扎扎实实地回到最根本的ADO.NET,把“增、删、改、查”这四项基本功掰开揉碎了讲清楚。你会发现,掌握了这些底层操作,无论后面是用Dapper、Entity Framework,还是自己封装数据访问层,心里都会特别有底。这篇文章的目标,就是让你看完之后,能独立、安全、高效地写出与SQL Server交互的C#代码,并且清楚每一个步骤背后的“为什么”。
2. 环境准备与核心对象解析
在动手写代码之前,我们必须把“舞台”搭好,并且认识台上的每一位“演员”。很多问题其实都出在准备工作没做到位。
2.1 项目环境搭建与NuGet包管理
首先,创建一个新的C#控制台应用项目。打开Visual Studio或者你喜欢的IDE(比如Rider、VS Code),选择“控制台应用”模板即可。项目创建好后,我们需要引入操作SQL Server的核心程序集。
在.NET Framework项目中,你通常可以在“引用”里直接添加System.Data.SqlClient。但在现代的.NET Core或.NET 5/6/7/8中,我们通过NuGet包管理器来添加。打开“工具”->“NuGet包管理器”->“管理解决方案的NuGet程序包”,搜索并安装Microsoft.Data.SqlClient。这是微软官方维护的、支持新版本.NET的SQL Server数据提供程序,比旧的System.Data.SqlClient功能更新,性能也更好。
注意:如果你的项目是.NET Framework 4.7.2或更高版本,也可以使用
Microsoft.Data.SqlClient,它提供了更好的兼容性和新功能。对于全新的项目,我强烈建议直接使用它。
安装好后,别忘了在代码文件的顶部引入命名空间:
using Microsoft.Data.SqlClient; // 或者 using System.Data.SqlClient; (针对旧版.NET Framework) using System;2.2 ADO.NET核心四大件详解
ADO.NET是与数据库交互的底层库,它的核心是几个对象,理解它们各自的生命周期和职责至关重要。
SqlConnection(连接对象)这是与数据库服务器的物理通道。创建它需要连接字符串(Connection String),就像你要打电话,必须知道对方的电话号码。它的主要职责是打开和关闭连接。一个非常重要的原则是:连接是稀缺资源,用完后必须立即关闭。通常我们会使用
using语句块来确保即使发生异常,连接也能被正确释放。SqlCommand(命令对象)它代表要对数据库执行的一条SQL语句或存储过程。你需要为它指定要执行的命令文本(CommandText)和关联的连接(Connection)。它才是真正执行“增删改查”动作的执行者。你可以通过它执行不返回结果集的操作(如INSERT, UPDATE, DELETE),也可以执行返回结果集的操作(如SELECT)。
SqlDataReader(读取器对象)这是一个只进、只读的数据流,专门用于高效地从SELECT查询中读取数据。你可以把它想象成一个从头走到尾的扫描仪,一次只能读取当前行的一列数据。它非常高效,因为数据不是一次性全部加载到内存,而是边读边处理。它必须在一个打开的连接上工作,并且在读取数据时,该连接被独占,不能用于其他操作。
SqlDataAdapter与DataSet(适配器与数据集)这是一对“重量级”组合。
SqlDataAdapter像一个搬运工,它利用SqlCommand执行查询,然后将结果填充到DataSet这个内存中的离线数据库容器里。DataSet可以包含多个DataTable,并且维护数据之间的关系和状态(如新增、修改、删除)。这种方式适合处理复杂的数据关系或需要离线操作的场景,但内存开销较大。对于简单的“增删改查”,我们更多使用前三个对象。
理解这几个对象的关系是关键:SqlCommand挂在SqlConnection上执行,产生的结果可以由SqlDataReader流式读取,或者由SqlDataAdapter搬运到DataSet。
2.3 连接字符串的安全配置与管理
连接字符串是安全重灾区,绝对不能硬编码在代码里。一个典型的连接字符串长这样:
Server=localhost\SQLEXPRESS; Database=YourDatabase; Trusted_Connection=True; TrustServerCertificate=True;或者使用用户名密码:
Server=myServerAddress; Database=myDataBase; User Id=myUsername; Password=myPassword; TrustServerCertificate=True;Trusted_Connection=True:使用Windows身份验证,更安全,无需在连接串中暴露密码。TrustServerCertificate=True:在开发环境(尤其是使用本地SQL Express时)跳过证书验证,避免连接错误。生产环境应配置有效的证书。Encrypt=True:在生产环境中,强烈建议启用加密以保护数据传输安全。
安全实践:
- 绝对不要将带密码的连接字符串提交到源代码版本控制系统(如Git)。
- 在开发中,可以使用
appsettings.json文件存储,并通过IConfiguration读取。 - 在.NET Core中,典型做法是在
appsettings.Development.json中配置,并通过UserSecrets管理敏感信息。 - 在生产环境,使用环境变量或Azure Key Vault等安全存储服务。
3. 查(Read)操作:高效读取数据的多种姿势
查询是数据库操作中最频繁的动作。如何根据场景选择正确的方法,是写出高效代码的第一步。
3.1 使用SqlDataReader进行流式高效查询
当你需要快速、顺序地处理大量数据,并且不需要在内存中保留所有数据时,SqlDataReader是最佳选择。它的模式是“连接式”的,即在整个读取过程中,数据库连接必须保持打开状态。
string connectionString = "Your_Connection_String_Here"; string queryString = "SELECT CustomerID, CompanyName FROM Customers WHERE Country = @Country"; using (SqlConnection connection = new SqlConnection(connectionString)) { SqlCommand command = new SqlCommand(queryString, connection); // 使用参数化查询,防止SQL注入! command.Parameters.AddWithValue("@Country", "Germany"); try { connection.Open(); // 执行命令,获取DataReader using (SqlDataReader reader = command.ExecuteReader()) { // 判断是否有数据返回 if (reader.HasRows) { // 循环读取每一行 while (reader.Read()) { // 通过列名或序号获取数据。推荐使用列名,代码更清晰。 Console.WriteLine($"ID: {reader["CustomerID"]}, Name: {reader["CompanyName"]}"); // 也可以使用强类型方法,提高性能并避免装箱拆箱 // string id = reader.GetString(reader.GetOrdinal("CustomerID")); } } else { Console.WriteLine("No rows found."); } // reader在using块结束时自动关闭 } } catch (SqlException ex) { // 处理数据库特定异常,如连接失败、语法错误等 Console.WriteLine($"Database error: {ex.Message}"); } // connection在using块结束时自动关闭 }关键点与避坑指南:
using语句:确保SqlConnection和SqlDataReader被及时释放。这是防止连接泄漏(Connection Leak)的关键,连接泄漏会导致数据库连接池耗尽,应用崩溃。- 参数化查询(
@Country):这是抵御SQL注入攻击的唯一正确方式。永远不要用字符串拼接的方式来构造SQL语句,比如$“SELECT ... WHERE Country = ‘{country}’”。 reader.Read():将读取器前进到下一行。它返回一个布尔值,指示是否还有行。必须在读取数据前调用。- 读取数据的方法:
reader[“列名”]返回object,方便但涉及装箱。对于性能敏感的场景,使用GetString、GetInt32等强类型方法,并配合GetOrdinal获取列索引。 - 连接打开时机:在即将执行命令前(
ExecuteReader)才打开连接,操作完成后立即关闭,以最大化连接池的利用率。
3.2 使用SqlDataAdapter与DataSet进行离线灵活操作
如果你的业务逻辑需要复杂的数据遍历、前后滚动,或者需要将数据绑定到UI控件(如WinForms的DataGridView),DataSet是更合适的选择。它将数据一次性加载到内存中,断开与数据库的连接,允许你自由操作。
string connectionString = "Your_Connection_String_Here"; string queryString = "SELECT * FROM Orders WHERE OrderDate > @StartDate"; using (SqlConnection connection = new SqlConnection(connectionString)) { SqlDataAdapter adapter = new SqlDataAdapter(queryString, connection); // 同样,参数需要配置给SqlDataAdapter内部的SelectCommand adapter.SelectCommand.Parameters.AddWithValue("@StartDate", new DateTime(2023, 1, 1)); DataSet orderDataSet = new DataSet(); try { // Fill方法会自动打开和关闭连接(如果连接未打开) adapter.Fill(orderDataSet, "Orders"); // “Orders”是为这个DataTable起的名字 DataTable ordersTable = orderDataSet.Tables["Orders"]; if (ordersTable.Rows.Count > 0) { // 遍历所有行 foreach (DataRow row in ordersTable.Rows) { Console.WriteLine($"OrderID: {row["OrderID"]}, Date: {row["OrderDate"]}"); // 可以随意访问任何行任何列 DataRow firstRow = ordersTable.Rows[0]; // 可以修改数据 row["ShipCity"] = "Berlin"; } // DataSet可以包含多个表,并建立关系 // DataRelation relation = new DataRelation("CustOrderRel", // orderDataSet.Tables["Customers"].Columns["CustomerID"], // orderDataSet.Tables["Orders"].Columns["CustomerID"]); // orderDataSet.Relations.Add(relation); } } catch (SqlException ex) { Console.WriteLine($"Database error: {ex.Message}"); } // 无需手动关闭连接,Fill方法已处理 }核心区别与选择建议:
SqlDataReader:快、省内存,但只进只读,且占用连接。适合后台任务、数据导出、流式处理。DataSet:功能强大、灵活,可离线操作,但内存开销大。适合复杂业务逻辑、小型数据全集操作、需要数据绑定的桌面应用场景。
一个常见的误区是无论数据量大小都用DataSet。对于一次读取几百条、几千条记录并在内存中频繁查找、计算的情况,DataSet确实方便。但对于一次读取十万、百万条记录,SqlDataReader是唯一的选择,否则内存会瞬间爆掉。
3.3 执行标量查询与单值获取
有时候我们只需要查询一个值,比如记录总数(COUNT(*))、最大值(MAX)或某个聚合结果。这时使用ExecuteScalar方法最高效。
string sql = “SELECT COUNT(*) FROM Products WHERE UnitsInStock < @ReorderLevel”; using (SqlConnection conn = new SqlConnection(connString)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(“@ReorderLevel”, 10); conn.Open(); // ExecuteScalar返回结果集中第一行的第一列,类型是object int lowStockCount = (int)cmd.ExecuteScalar(); Console.WriteLine($“有{lowStockCount}种产品库存低于警戒线。”); }ExecuteScalar的返回值是object,需要根据查询结果进行类型转换。如果查询没有返回任何行,它会返回null,在转换前需要进行判断。
4. 增(Create)、改(Update)、删(Delete)操作与事务控制
非查询操作(INSERT, UPDATE, DELETE)相对简单,但它们涉及到数据的一致性,因此事务(Transaction)的使用至关重要。
4.1 基本的增删改实现
这三种操作都使用SqlCommand的ExecuteNonQuery方法,它返回受影响的行数。
插入(Insert)示例:
string insertSql = @” INSERT INTO Employees (FirstName, LastName, BirthDate, HireDate) VALUES (@FirstName, @LastName, @BirthDate, @HireDate); SELECT SCOPE_IDENTITY();”; // 获取刚插入记录的自增ID using (SqlConnection conn = new SqlConnection(connString)) using (SqlCommand cmd = new SqlCommand(insertSql, conn)) { cmd.Parameters.AddWithValue(“@FirstName”, “张”); cmd.Parameters.AddWithValue(“@LastName”, “三”); cmd.Parameters.AddWithValue(“@BirthDate”, new DateTime(1990, 5, 15)); cmd.Parameters.AddWithValue(“@HireDate”, DateTime.Today); conn.Open(); // ExecuteScalar用于获取SCOPE_IDENTITY()返回的新ID int newEmployeeId = Convert.ToInt32(cmd.ExecuteScalar()); Console.WriteLine($“新员工ID: {newEmployeeId}”); }这里用了一个技巧:在INSERT语句后加上SELECT SCOPE_IDENTITY(),然后使用ExecuteScalar执行,可以一次性完成插入并获取数据库自动生成的主键ID(比如自增列)。SCOPE_IDENTITY()函数返回当前作用域内最后生成的标识值,比@@IDENTITY更安全。
更新(Update)与删除(Delete)示例:
// 更新 string updateSql = “UPDATE Products SET UnitPrice = @NewPrice WHERE ProductID = @ProductID”; using (SqlCommand cmd = new SqlCommand(updateSql, conn)) { cmd.Parameters.AddWithValue(“@NewPrice”, 25.99m); cmd.Parameters.AddWithValue(“@ProductID”, 1); conn.Open(); int rowsAffected = cmd.ExecuteNonQuery(); Console.WriteLine($“更新了{rowsAffected}条记录。”); } // 删除 string deleteSql = “DELETE FROM Customers WHERE CustomerID = @CustomerID AND LastOrderDate < @CutoffDate”; using (SqlCommand cmd = new SqlCommand(deleteSql, conn)) { cmd.Parameters.AddWithValue(“@CustomerID”, “OLDCO”); cmd.Parameters.AddWithValue(“@CutoffDate”, DateTime.Today.AddYears(-5)); int rowsAffected = cmd.ExecuteNonQuery(); // 可以根据rowsAffected判断是否真的删除了记录 }ExecuteNonQuery返回受影响的行数。这个返回值非常有用,你可以用它来验证操作是否如预期执行(例如,更新时判断是否只更新了1条目标记录)。
4.2 事务(Transaction)的必须性与正确使用
事务是保证一组数据库操作要么全部成功,要么全部失败回滚的机制。想象一下银行转账:从A账户扣钱和向B账户加钱,必须作为一个整体。任何一步失败,另一步也必须撤销。
在C#中使用ADO.NET事务的基本模式如下:
string connString = “Your_Connection_String”; using (SqlConnection conn = new SqlConnection(connString)) { conn.Open(); // 1. 开启事务 SqlTransaction transaction = conn.BeginTransaction(); // 可以指定隔离级别,如 IsolationLevel.ReadCommitted try { string sql1 = “UPDATE Account SET Balance = Balance - 100 WHERE AccountId = @A”; string sql2 = “UPDATE Account SET Balance = Balance + 100 WHERE AccountId = @B”; // 操作1 using (SqlCommand cmd1 = new SqlCommand(sql1, conn, transaction)) { cmd1.Parameters.AddWithValue(“@A”, “Account001”); int r1 = cmd1.ExecuteNonQuery(); if (r1 != 1) throw new Exception(“扣款账户更新失败”); } // 模拟一个可能失败的操作 // bool somethingWrong = true; // if (somethingWrong) throw new ApplicationException(“模拟的异常”); // 操作2 using (SqlCommand cmd2 = new SqlCommand(sql2, conn, transaction)) { cmd2.Parameters.AddWithValue(“@B”, “Account002”); int r2 = cmd2.ExecuteNonQuery(); if (r2 != 1) throw new Exception(“收款账户更新失败”); } // 2. 一切顺利,提交事务 transaction.Commit(); Console.WriteLine(“转账成功!”); } catch (Exception ex) { // 3. 发生异常,回滚事务 Console.WriteLine($“操作失败,正在回滚。错误: {ex.Message}”); try { transaction.Rollback(); } catch (Exception rollbackEx) { // 处理回滚本身可能发生的异常(例如连接已断开) Console.WriteLine($“回滚时发生错误: {rollbackEx.Message}”); } // 向上抛出或处理业务异常 throw; } // using块会确保连接关闭 }事务使用核心要点:
- 同一个连接:事务内的所有命令必须使用同一个
SqlConnection对象。 - 关联事务:每个
SqlCommand对象必须通过构造函数或.Transaction属性与开启的SqlTransaction对象关联。 BeginTransaction:在连接打开后调用,开始一个事务。Commit:在所有操作成功后调用,将更改永久保存到数据库。Rollback:在catch块中调用,撤销事务内所有未提交的更改。- 异常处理:必须用
try-catch包裹事务操作,并在异常时回滚。回滚操作本身也可能失败,需要单独处理。 - 隔离级别:
BeginTransaction(IsolationLevel level)可以设置隔离级别,如ReadCommitted(默认,避免脏读)、Serializable(最高隔离,避免幻读)等。级别越高,一致性越强,但并发性能越差。大多数业务场景ReadCommitted已足够。
一个常见的坑是忘记将命令对象与事务关联。如果命令没有指定Transaction属性,它会在自己的隐式事务中执行,不受你控制的显式事务影响,这就破坏了原子性。
5. 高级技巧与性能优化实战
掌握了基础操作后,一些高级技巧能让你代码的健壮性和性能提升一个档次。
5.1 使用Using语句管理资源与连接池机制
前面反复强调了using,这里深入一下。SqlConnection和SqlDataReader都实现了IDisposable接口。using语句会在代码块结束时自动调用它们的Dispose方法。
- 对于
SqlConnection,Dispose()会先检查连接状态,如果打开则关闭它,然后将连接释放回连接池。 - 对于
SqlDataReader,Dispose()会关闭读取器并释放相关资源。
连接池(Connection Pooling)是ADO.NET一个至关重要的性能优化特性。默认是开启的。当你“关闭”一个连接时,物理TCP连接并没有真正断开,而是被标记为空闲,放回池子里。下次请求相同连接字符串的连接时,直接从池子里取出一个空闲的连接复用,避免了建立TCP连接和数据库登录验证的巨大开销(通常是几十到几百毫秒)。
using语句是确保连接能被正确放回池子的最佳实践。千万不要手动调用.Close()后就觉得万事大吉,Dispose()包含了Close()并做了更多清理工作。
5.2 参数化查询深入:类型、精度与性能
参数化查询不仅是防注入的盾牌,也对性能有帮助。数据库服务器可以对参数化查询的查询计划进行缓存和复用。
// 不推荐的字符串拼接(危险!) string badSql = $“SELECT * FROM Users WHERE Name = ‘{userInput}’”; // 推荐的参数化查询 string goodSql = “SELECT * FROM Users WHERE Name = @UserName”; cmd.Parameters.AddWithValue(“@UserName”, userInput);关于AddWithValue的注意事项:AddWithValue非常方便,但它有时会推断出不准确的数据类型,可能导致索引失效或隐式类型转换,影响性能。对于有严格类型要求的列(如DECIMAL的精度和小数位,NVARCHAR的长度),更推荐使用Add方法显式指定。
// 使用 Add 方法明确指定参数类型,更优 SqlParameter param = new SqlParameter(“@Price”, SqlDbType.Decimal); param.Value = 19.99m; param.Precision = 10; // 总位数 param.Scale = 2; // 小数位 cmd.Parameters.Add(param); // 或者使用简化的Add方法重载 cmd.Parameters.Add(“@Price”, SqlDbType.Decimal).Value = 19.99m; // 对于字符串,可以指定长度,有助于生成更优的执行计划 cmd.Parameters.Add(“@Description”, SqlDbType.NVarChar, 500).Value = description;5.3 存储过程(Stored Procedure)的调用
对于复杂的业务逻辑,将其封装在数据库的存储过程中,然后在C#中调用,是一种常见的架构模式。这样做可以利用数据库的计算能力,减少网络传输,也便于逻辑的集中管理和复用。
假设数据库中有一个存储过程sp_GetEmployeeOrders:
CREATE PROCEDURE sp_GetEmployeeOrders @EmployeeID INT, @Year INT = NULL -- 默认参数 AS BEGIN SELECT * FROM Orders WHERE EmployeeID = @EmployeeID AND (@Year IS NULL OR YEAR(OrderDate) = @Year) ORDER BY OrderDate DESC; END在C#中调用它:
string connString = “...”; using (SqlConnection conn = new SqlConnection(connString)) using (SqlCommand cmd = new SqlCommand(“sp_GetEmployeeOrders”, conn)) { // 1. 指定命令类型为存储过程 cmd.CommandType = CommandType.StoredProcedure; // 2. 添加参数 cmd.Parameters.AddWithValue(“@EmployeeID”, 5); // 为可选参数赋值,如果不赋值,存储过程会使用其默认值(NULL) cmd.Parameters.AddWithValue(“@Year”, 2023); conn.Open(); using (SqlDataReader reader = cmd.ExecuteReader()) { while (reader.Read()) { // 处理结果... } } }调用存储过程的关键是将SqlCommand的CommandType属性设置为CommandType.StoredProcedure。参数添加方式与普通SQL语句完全一致。如果存储过程有输出参数(OUTPUT),则需要设置参数的Direction属性为ParameterDirection.Output,并在执行命令后从参数中取值。
6. 异常处理、日志记录与连接管理最佳实践
健壮的程序必须能妥善处理错误,并留下可供排查的线索。
6.1 结构化异常处理与重试策略
数据库操作可能抛出多种异常,最常见的是SqlException。你应该捕获特定的异常,而不是通用的Exception。
try { // 数据库操作 conn.Open(); // ... } catch (SqlException sqlEx) { // 处理数据库特定错误 foreach (SqlError error in sqlEx.Errors) // SqlException可能包含多个错误 { Console.WriteLine($“SQL Error Number: {error.Number}, Message: {error.Message}”); // 错误号(Number)可以用来判断具体错误类型,如超时( -2 )、死锁(1205)等 if (error.Number == -2) // 超时 { // 可以考虑实现重试逻辑 Console.WriteLine(“查询超时,建议优化查询或增加超时时间。”); } else if (error.Number == 1205) // 死锁 { Console.WriteLine(“发生死锁,事务已被选为牺牲品。应重试事务。”); // 实现带退避策略的重试 } } } catch (InvalidOperationException invOpEx) { // 例如,在连接未打开时尝试执行命令 Console.WriteLine($“无效操作: {invOpEx.Message}”); } catch (Exception ex) // 最后捕获其他所有异常 { // 记录未预期的异常 Console.WriteLine($“未预期的错误: {ex.Message}”); } finally { // 确保资源清理的代码,using语句通常已涵盖 }对于网络波动或死锁等暂时性错误,实现一个简单的重试机制能大大提高系统的韧性。
public static void ExecuteWithRetry(Action action, int maxRetries = 3, int baseDelayMs = 100) { int retries = 0; while (true) { try { action(); break; // 成功则跳出循环 } catch (SqlException ex) when (IsTransientError(ex) && retries < maxRetries) { retries++; int delay = baseDelayMs * (int)Math.Pow(2, retries - 1); // 指数退避 Console.WriteLine($“暂时性错误,{delay}ms后重试第{retries}次...错误: {ex.Message}”); Thread.Sleep(delay); } } } private static bool IsTransientError(SqlException ex) { // 判断是否为暂时性错误:超时(-2)、死锁(1205)、连接中断等 foreach (SqlError err in ex.Errors) { if (err.Number == -2 || err.Number == 1205 || err.Number == 4060 /*数据库不可用*/) { return true; } } return false; } // 使用方式 ExecuteWithRetry(() => { using (var conn = new SqlConnection(connString)) using (var cmd = new SqlCommand(“...”, conn)) { conn.Open(); cmd.ExecuteNonQuery(); } });6.2 连接字符串管理与安全实践进阶
在真实项目中,连接字符串的管理需要更严谨。
1. 使用配置源:在.NET Core/5+中,标准做法是使用appsettings.json。
// appsettings.json { “ConnectionStrings”: { “DefaultConnection”: “Server=localhost;Database=MyAppDb;Trusted_Connection=True;TrustServerCertificate=True;” } }在Program.cs或启动类中:
var builder = WebApplication.CreateBuilder(args); var connectionString = builder.Configuration.GetConnectionString(“DefaultConnection”); // 然后通过依赖注入等方式使用2. 开发与生产环境分离:创建appsettings.Development.json用于开发环境,其中可以包含本地数据库的连接字符串。这个文件应该被加入.gitignore,避免敏感信息泄露。生产环境的连接字符串则通过环境变量或部署平台的配置界面设置。
3. 使用用户机密(User Secrets)进行本地开发:对于ASP.NET Core项目,可以使用用户机密管理工具来存储开发环境的敏感数据。
# 在项目目录下初始化 dotnet user-secrets init # 设置连接字符串 dotnet user-secrets set “ConnectionStrings:DefaultConnection” “你的敏感连接字符串”代码中通过Configuration可以像读取普通配置一样读取它,但在源代码中看不到明文。
4. 生产环境使用托管标识或密钥保管库:在Azure等云平台上,最佳实践是使用托管标识(Managed Identity)进行身份验证,完全无需在代码或配置中存储密码。或者,将连接字符串存储在Azure Key Vault等安全服务中,应用程序通过配置的标识去访问。
6.3 性能监控与简易日志记录
即使是一个简单的控制台应用,加入一些基本的日志也能在出问题时救命。
public static class DbLogger { public static void LogOperation(string operation, string commandText, long elapsedMilliseconds, bool success, string? error = null) { string logEntry = $“[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Operation: {operation}, “ + $“Command: {commandText.Substring(0, Math.Min(commandText.Length, 100))}..., “ + $“Duration: {elapsedMilliseconds}ms, Success: {success}”; if (!string.IsNullOrEmpty(error)) { logEntry += $“, Error: {error}”; } // 简单输出到控制台,实际项目中可写入文件、数据库或日志框架(如Serilog, NLog) Console.WriteLine(logEntry); // System.IO.File.AppendAllText(@“C:\logs\dbops.log”, logEntry + Environment.NewLine); } } // 在数据库操作中使用 var stopwatch = System.Diagnostics.Stopwatch.StartNew(); try { // ... 执行数据库命令 stopwatch.Stop(); DbLogger.LogOperation(“SELECT”, queryString, stopwatch.ElapsedMilliseconds, true); } catch (SqlException ex) { stopwatch.Stop(); DbLogger.LogOperation(“SELECT”, queryString, stopwatch.ElapsedMilliseconds, false, ex.Message); throw; }记录操作类型、简化的SQL(注意不要记录参数值,以防泄露敏感数据)、耗时和成功状态。这对于发现慢查询(耗时过长)和诊断错误非常有用。在正式项目中,应集成成熟的日志框架。
7. 从ADO.NET到现代ORM的平滑过渡思考
虽然本文聚焦于ADO.NET基础,但了解其与现代ORM(对象关系映射)框架的关系,能帮助你做出更好的技术选型。
1. DapperDapper是一个“微ORM”,它本质上是对ADO.NET的轻量级封装。它扩展了IDbConnection接口,让你用一行代码就能执行查询并将结果映射到强类型对象上,同时保留了手写SQL的灵活性和对性能的极致控制。如果你觉得纯ADO.NET代码太繁琐,但又不想被Entity Framework这样的全功能ORM约束,Dapper是完美的中间选择。
using Dapper; var products = connection.Query<Product>(“SELECT * FROM Products WHERE CategoryID = @CatID”, new { CatID = 1 });2. Entity Framework (EF) CoreEF Core是一个全功能的ORM,它让你用操作C#对象(实体)的方式来操作数据库,自动生成SQL语句。它提供了迁移(Migration)、变更跟踪、LINQ查询等高级功能,极大地提升了开发效率,尤其适合领域驱动设计(DDD)和快速迭代的项目。它的学习曲线更陡,并且需要你放弃对最终生成SQL的精细控制。
为什么还要学ADO.NET?因为ORM框架不是银弹。当你遇到复杂的报表查询、需要调用特定数据库函数、进行大批量数据操作或调试性能瓶颈时,最终往往还是要回到SQL和数据库连接的本源上来。理解ADO.NET,就是理解了所有.NET数据库访问技术的基石。当EF Core生成的SQL效率低下时,你可以通过FromSqlRaw执行自己优化的SQL;当Dapper不能满足需求时,你知道如何回退到更底层的方法。
掌握本文所述的“增删改查”及事务控制,意味着你拥有了直接与数据库对话的能力。这份能力能让你在使用任何高级框架时都充满自信,因为你知道它们背后发生了什么,也知道当它们不够用时,该如何自己动手解决问题。从今天起,试着在你的下一个项目中,有意识地运用参数化查询、妥善管理连接和事务,并加入适当的日志记录,你会发现代码的稳定性和可维护性会有立竿见影的提升。