☰
C# DbHelper 适配 Oracle/SQL Server/SQLite
2026/10/30 14:12:53 网站建设 项目流程

简介:dataDemo.rar 是一份面向 C# 初学者与.NET 开发者的数据库操作示例包,重点演示 Oracle、SQL Server 与 SQLite 的连接、查询、结果集处理及事务管理等常见场景,适合用来对照学习不同数据库的 ADO.NET 访问方式。压缩包共 38 个文件,以 cs 源码、sln/csproj 工程配置、config 连接配置和 resx 资源文件为主,并包含 exe、dll、pdb 及 db 数据库文件,整体约 623KB,可打开工程梳理代码结构。目前已有 755 人学习,说明其对入门与查漏补缺有一定参考价值。包内按 OracleHelper、SqlServerHelper、SqliteHelper 等模块组织示例,便于读者理解连接字符串配置、命令执行、数据读取与异常处理思路,也能据此比较不同数据库在 C# 项目中的接入差异。

1. 三种库一套代码:dataDemo 里到底装了什么

上个月帮朋友看一个现场部署的老系统,总部机房跑的是 Oracle,分支节点装的是 SQL Server,单机版客户端又必须塞一个 SQLite 进去做离线缓存。同一句查询,三套方言、三个驱动、三份连接串,改一个字段要动三个地方,改漏一处就是现场报错。dataDemo.rar 就是为这种局面准备的最小可运行样例:用 C# 一套 DbHelper,把 Oracle、SQL Server、SQLite 三种库的连接创建、增删改查、分页、事务收进同一个入口,压缩包里是可直接编译的工程加三份建表脚本。

它适合两类人:一类是长期只写 SQL Server、第一次被要求适配 Oracle 的 C# 开发者;另一类是手里有个老系统,想加一层轻量数据访问层又不想引一堆 ORM 依赖的人。代码量不大,但多库适配里那些真正会绊人的地方——参数前缀、分页语法、驱动版本——它都摆出来了。

2. 选型与目录:Oracle、SQL Server、SQLite 怎么被一套 C# 代码收编

2.1 为什么不用 ORM 一把梭

多库项目第一个岔路口就是选数据访问方式。很多人第一反应是上 EF Core 或者某个轻量 ORM,觉得「换 Provider 就行」。真跑起来会发现,换 Provider 只解决了连接层,解决不了方言层:Oracle 的ROWNUM、NVL、TO_DATE,SQL Server 的TOP、ISNULL、OFFSET FETCH,SQLite 的LIMIT OFFSET、IFNULL,这些在 LINQ 表达式里绕不过去,最后还是要落到裸 SQL。

这个 Demo 走的是 ADO.NET 原生接口路线,核心是 .NET 里的System.Data.Common抽象:DbConnection、DbCommand、DbParameter、DbDataAdapter四个基类,加上DbProviderFactory这个工厂入口。三种数据库的驱动各自提供自己的 Factory 实现,业务代码只跟基类打交道。好处很直接——依赖少,压缩包解开装三个 NuGet 包就能编译;驱动版本可控,Oracle 那边托管驱动和客户端驱动的坑可以自己决定踩哪个;SQL 完全自己写,方言差异显式暴露,不用猜 ORM 翻译成了什么。

代价也有:没有实体映射,DataTable转对象的代码要自己写;没有变更跟踪,更新要自己拼UPDATE。对 Demo 来说是优点,对业务系统来说,我一般会在这一层上面再包一层仓储,把DataTable换成强类型对象,这个改造放到最后一章讲。

2.2 压缩包解开后的目录与分层

工程结构不复杂,但分层是清晰的,照着这个骨架加文件不会乱:

dataDemo/ ├── DataDemo.sln ├── src/ │ ├── DataDemo.Core/ # 数据访问核心,不依赖任何 UI │ │ ├── DbFactory.cs # DbProviderFactory 封装,按类型创建连接 │ │ ├── DbHelper.cs # 统一增删改查入口 │ │ ├── DbTypeKind.cs # 数据库类型枚举 │ │ ├── SqlDialect.cs # 分页、日期函数等方言拼装 │ │ └── ConnectionConfig.cs # 连接串读取 │ ├── DataDemo.Console/ # 控制台冒烟入口,先跑这个 │ │ └── Program.cs │ └── DataDemo.Models/ # 实体与 DataTable 转换 │ └── UserInfo.cs ├── sql/ │ ├── sqlserver_init.sql │ ├── oracle_init.sql │ └── sqlite_init.sql └── config/ └── connections.config # 三套连接串样例

这个划分的关键点在于DataDemo.Core不引用控制台、不引用 UI。多库项目最常见的腐化方式,就是连接串和 SQL 慢慢散落到各个窗体的按钮事件里,半年后没人说得清哪句 SQL 只在 Oracle 下能跑。把数据访问收进一个类库,控制台只负责调用和打印,换 WinForms、换 Web API 都不用动核心。

sql/目录下三份初始化脚本建议逐字执行一遍,别跳过。三种库的建表语法差异比想象中大:自增主键,SQL Server 是IDENTITY(1,1),Oracle 12c 之后是GENERATED BY DEFAULT AS IDENTITY,SQLite 是INTEGER PRIMARY KEY AUTOINCREMENT;字符串类型,Oracle 里VARCHAR2和VARCHAR是两回事,长度语义也不一样;日期类型,Oracle 的DATE带时分秒,SQL Server 的DATE只有日期。这些差异在 Demo 阶段看着琐碎,上了生产就是数据错乱。

2.3 连接串集中管理:配置节怎么组织

三套连接串如果写在代码里,换个环境就要重新编译。Demo 用的是 .NET 的connectionStrings配置节,控制台工程里对应的App.config长这样:

<?xml version="1.0" encoding="utf-8"?> <configuration> <connectionStrings> <!-- SQL Server:本地实例名按实际环境改,TrustServerCertificate 仅用于内网自签证书 --> <add name="SqlServer" connectionString="Server=127.0.0.1,1433;Database=DataDemo;User Id=demouser;Password=***;TrustServerCertificate=True;Connect Timeout=15" providerName="Microsoft.Data.SqlClient" /> <!-- Oracle:Data Source 用 EZConnect 格式,host:port/service_name --> <add name="Oracle" connectionString="User Id=demouser;Password=***;Data Source=127.0.0.1:1521/ORCLPDB;Connection Timeout=15" providerName="Oracle.ManagedDataAccess.Client" /> <!-- SQLite:DataDirectory 指向程序运行目录,避免写死绝对路径 --> <add name="SQLite" connectionString="Data Source=|DataDirectory|\datademo.db;Version=3;Pooling=True;" providerName="System.Data.SQLite" /> </connectionStrings> </configuration>

读取这一段建议单独封装,不要在业务代码里到处ConfigurationManager.ConnectionStrings["xxx"]:

public static class ConnectionConfig { // 按数据库类型取连接串,缺失时直接抛,别返回 null 让调用方去猜 public static string Get(DbTypeKind kind) { var name = kind.ToString(); var setting = ConfigurationManager.ConnectionStrings[name]; if (setting == null || string.IsNullOrWhiteSpace(setting.ConnectionString)) throw new ConfigurationErrorsException($"未配置名为 {name} 的连接串"); return setting.ConnectionString; } }

这里的逻辑很朴素,但有两个细节值得说。一是缺失时立刻抛异常,而不是返回空串——多库项目里「连接串配错了但程序照跑」是最难查的一类问题,通常会表现成查出来 0 行数据,而不是报错。二是|DataDirectory|这个占位符,SQLite 用它把数据库文件锚定到应用的AppDomain.CurrentDomain.DataDirectory,在控制台和 WinForms 下默认都是 exe 所在目录,但 IIS 托管时会被改到App_Data,如果部署形态会变,最好在启动时显式AppDomain.CurrentDomain.SetData("DataDirectory", path)覆盖掉。

另外提醒一句,配置里的Password=***是占位,真实密码不要跟着源码走。Demo 图省事写在配置里没问题,生产环境我会走环境变量或者一个加密的配置提供程序,尤其是 Oracle 那种一个库挂多个应用的场景,账号泄露的影响面比想象中大。

3. 核心链路拆解:从 DbProviderFactory 到 DataTable 的完整走法

3.1 数据库类型枚举与连接工厂

整个多库适配的支点是DbProviderFactory。它是 .NET 提供的抽象工厂,每种数据库驱动都提供一个静态的Instance,拿到之后就能用同一套代码创建连接、命令、参数、适配器。Demo 里的工厂封装如下:

using System.Data.Common; using Microsoft.Data.SqlClient; using Oracle.ManagedDataAccess.Client; using System.Data.SQLite; public enum DbTypeKind { SqlServer, Oracle, SQLite } public static class DbFactory { // 拿到对应驱动的 ProviderFactory,后续所有对象都由它创建 public static DbProviderFactory GetFactory(DbTypeKind kind) { switch (kind) { case DbTypeKind.SqlServer: return SqlClientFactory.Instance; case DbTypeKind.Oracle: return OracleClientFactory.Instance; case DbTypeKind.SQLite: return SQLiteFactory.Instance; default: throw new NotSupportedException($"未支持的数据库类型:{kind}"); } } // 创建并打开连接,连接串由 ConnectionConfig 提供 public static DbConnection OpenConnection(DbTypeKind kind) { var factory = GetFactory(kind); var conn = factory.CreateConnection(); conn.ConnectionString = ConnectionConfig.Get(kind); conn.Open(); return conn; } }

逻辑上分两步:先按枚举拿到 Factory,再由 Factory 创建连接。关键参数是DbTypeKind,它决定了后面所有方言分支走向,也决定读哪个连接串,所以命名必须和配置节的name严格一致——这里用kind.ToString()直接拼名字,省了一次映射表,代价是枚举值改名就要同步改配置,属于可以接受的小约束。

这里有一个必须说清楚的点:OracleClientFactory.Instance来自Oracle.ManagedDataAccess.Client命名空间,是纯托管驱动;另一个常见选择是System.Data.OracleClient,那是 .NET Framework 自带的老驱动,早已停止维护,还在一些老项目里被引用。压缩包里用的是托管驱动,这也是我现在的默认选择——托管驱动不需要在机器上装 Oracle 客户端,部署时少一个很大的不确定因素。SQLite 那边同理,System.Data.SQLite需要匹配 x86/x64 的原生互操作 DLL,Microsoft.Data.Sqlite则是另一套包,两者 API 相似但混用会出乱子,一个工程里只留一个。

3.2 DbHelper 的三个执行入口

业务代码真正调用的不是工厂,而是 DbHelper。它把「开连接、建命令、绑参数、执行、关连接」这套重复动作封掉,对外只留几个语义明确的方法:

public class DbHelper { private readonly DbTypeKind _kind; private readonly DbProviderFactory _factory; public DbHelper(DbTypeKind kind) { _kind = kind; _factory = DbFactory.GetFactory(kind); } // 增删改:返回受影响行数 public int ExecuteNonQuery(string sql, params DbParameter[] parameters) { using (var conn = DbFactory.OpenConnection(_kind)) using (var cmd = BuildCommand(conn, sql, parameters)) { return cmd.ExecuteNonQuery(); } } // 单值查询:取第一行第一列,常用于 COUNT / 序列 / 自增回填 public object ExecuteScalar(string sql, params DbParameter[] parameters) { using (var conn = DbFactory.OpenConnection(_kind)) using (var cmd = BuildCommand(conn, sql, parameters)) { return cmd.ExecuteScalar(); } } // 多行查询:返回 DataTable,后续再转实体 public DataTable ExecuteDataTable(string sql, params DbParameter[] parameters) { using (var conn = DbFactory.OpenConnection(_kind)) using (var cmd = BuildCommand(conn, sql, parameters)) using (var adapter = _factory.CreateDataAdapter()) { adapter.SelectCommand = cmd; var table = new DataTable(); adapter.Fill(table); return table; } } // 统一装配命令对象,参数绑定逻辑只在这里出现一次 private DbCommand BuildCommand(DbConnection conn, string sql, DbParameter[] parameters) { var cmd = _factory.CreateCommand(); cmd.Connection = conn; cmd.CommandText = sql; cmd.CommandTimeout = 30; if (parameters != null && parameters.Length > 0) cmd.Parameters.AddRange(parameters); return cmd; } }

三个入口的差别在于返回形态和底层方法:ExecuteNonQuery走DbCommand.ExecuteNonQuery,返回受影响行数,INSERT/UPDATE/DELETE用它;ExecuteScalar走ExecuteScalar,只取结果集第一行第一列,做COUNT(*)或者插入后取当前值最快;ExecuteDataTable走DbDataAdapter.Fill,一次性把结果集灌进DataTable,适合数据量可控的列表查询。

CommandTimeout = 30这行别小看。默认值是 30 秒没错,但不同驱动对它的解释有细微差别,而且 Oracle 那边如果遇到执行计划突变,30 秒够卡住一个线程很久。我一般在BuildCommand里把它做成可配置,报表类查询单独给 120 秒,普通增删改给 15 秒,超时早失败总比线程池被拖垮强。

另外注意using的嵌套写法:连接、命令、适配器全部包在using里。DbConnection实现了IDisposable,Close和Dispose的组合在不同驱动下行为不完全一致,显式using是最省心的做法,不要指望 GC 帮你收尾——连接池被占满的报错,八成都是某处忘了关连接。

3.3 参数前缀:冒号与 @ 的方言差异

多库适配里最容易翻车的地方不是 SQL 语法,而是参数前缀。SQL Server 和 SQLite 用@name,Oracle 用:name。如果业务代码里写死@id,在 Oracle 下会直接抛「未绑定变量」。

Demo 的处理办法是把前缀收敛到一个方法里,业务层只传参数名和值,前缀由 DbHelper 补:

public DbParameter CreateParameter(string name, object value) { // 去掉调用方可能带上的前缀,统一由这里补 var pureName = name.TrimStart('@', ':'); var p = _factory.CreateParameter(); // Oracle 必须用冒号,其他两种用 @ p.ParameterName = _kind == DbTypeKind.Oracle ? ":" + pureName : "@" + pureName; p.Value = value ?? DBNull.Value; // null 要转 DBNull,否则驱动报错 return p; }

逻辑上只有三步,但每一步都有坑。第一步TrimStart('@', ':')是为了容错,调用方写@id还是id都能用,避免同一份业务代码在不同库之间复制粘贴时前缀打架。第二步按类型补前缀,这是整段的核心。第三步的?? DBNull.Value尤其重要:C# 的null和数据库的NULL是两回事,直接把null塞进Parameter.Value,SQL Server 驱动会当成「未设置值」,Oracle 驱动会抛ORA-01084,SQLite 驱动则可能静默写入一个空串。这个坑我在三个库上各踩过一次,后来一律在参数层兜住。

配套的 SQL 文本也要注意写法。Demo 里的约定是 SQL 里只写不带前缀的名字,执行前由SqlDialect做一次替换:

// 把 SQL 里的 {p} 占位替换成对应方言的参数前缀 public string BindParam(string sql, params string[] names) { var prefix = _kind == DbTypeKind.Oracle ? ":" : "@"; foreach (var n in names) sql = sql.Replace("{" + n + "}", prefix + n); return sql; }

调用方写SELECT * FROM T_USER WHERE ID = {id},BindParam(sql, "id")之后在 Oracle 下变成:id,在另两种库下变成@id。多一层替换看着啰嗦,但它让同一份 SQL 常量能复用到三个库,改语句的时候不用三个文件同步改。names参数我一般要求显式列全,不做自动正则扫描——自动扫描容易把字符串字面量里的花括号误伤,显式列全虽然多敲几个字,但出错时一眼能看出是哪个参数没绑。

3.4 分页 SQL 的三套写法

分页是方言差异最集中的地方,也是最容易写出「在 A 库能跑、在 B 库报语法错」的语句。Demo 的SqlDialect里用了一个方法统一出口:

public string BuildPaging(string baseSql, string orderBy, int pageIndex, int pageSize) { // pageIndex 从 1 开始,内部换算成偏移量 int offset = (pageIndex - 1) * pageSize; switch (_kind) { case DbTypeKind.SQLite: // SQLite:LIMIT ... OFFSET ...,orderBy 不能为空 return $"{baseSql} ORDER BY {orderBy} LIMIT {pageSize} OFFSET {offset}"; case DbTypeKind.SqlServer: // SQL Server 2012+:OFFSET FETCH 必须配 ORDER BY return $"{baseSql} ORDER BY {orderBy} OFFSET {offset} ROWS FETCH NEXT {pageSize} ROWS ONLY"; case DbTypeKind.Oracle: // Oracle 12c+:语法与 SQL Server 接近,但不支持 @ 参数直接拼在 FETCH 后 return $"{baseSql} ORDER BY {orderBy} OFFSET {offset} ROWS FETCH NEXT {pageSize} ROWS ONLY"; default: throw new NotSupportedException(_kind.ToString()); } }

三个分支里,SQLite 和 SQL Server 是标准写法,Oracle 那支要看版本:12c 及以上支持OFFSET ... FETCH,11g 及以下必须用ROWNUM嵌套。如果目标环境里有 11g,得换成另一个分支:

-- Oracle 11g 分页:三层嵌套,内层排序编号,外层卡区间 SELECT * FROM ( SELECT t.*, ROWNUM rn FROM ( SELECT * FROM T_USER ORDER BY ID ) t WHERE ROWNUM <= :endRow ) WHERE rn > :startRow

这段的:endRow = offset + pageSize,:startRow = offset,两个都是开区间和闭区间的组合,写错一个边界就会少一行或者多一行。我在一个老项目里因为把>写成>=,导致每页第一条重复出现,查了两天才定位到,血泪经验就是分页边界一定要用「总数为 N、每页 M 条、逐页拉完去重后条数等于 N」这个断言去验。

另外,ORDER BY是分页的前置条件,三种库都要求它存在,但表现不同:SQL Server 缺ORDER BY直接语法报错,SQLite 和 Oracle 允许省略但结果顺序不确定。所以BuildPaging里我把orderBy设计成必填参数,从签名上就不给省略的机会。这个方法用字符串插值拼 SQL 是安全的,因为pageSize和offset都是int,不可能注入;但orderBy是字符串,调用方千万不能直接把前端传来的排序字段拼进去,必须走白名单映射,这一点在最后一章会展开。

3.5 事务封装与批量提交

单条 SQL 的事务好写,多库项目真正麻烦的是「同一个事务里混着三种库的操作」——这种场景通常出现在数据同步任务里,从 Oracle 读、往 SQLite 写。跨库分布式事务要上事务协调器,Demo 不碰这块,它只封单库事务,但把写法固定下来:

public int ExecuteInTransaction(Action<DbHelper, DbTransaction> action) { using (var conn = DbFactory.OpenConnection(_kind)) using (var tran = conn.BeginTransaction()) { try { action(this, tran); // 业务逻辑在外层传入,复用同一个事务对象 tran.Commit(); return 1; } catch (DbException ex) { tran.Rollback(); // 驱动层异常必须回滚 throw new DataAccessException("事务执行失败", ex); } } }

关键在于事务对象要显式挂到命令上。很多人写完BeginTransaction就忘了给cmd.Transaction赋值,结果 SQL Server 抛「当分配给命令的连接处于挂起的本地事务中时,该命令必须具有事务对象」,Oracle 抛ORA-01084,SQLite 则可能把语句放在事务外面直接执行,回滚的时候才发现根本没生效。Demo 在BuildCommand之外又加了一个重载,专门接收DbTransaction并赋值,就是为了堵这个口子。

批量提交方面,三种库的策略不一样。SQL Server 可以用SqlBulkCopy,性能最好但只有 SQL Server 专属;Oracle 用OracleBulkCopy或者ArrayBinding;SQLite 最朴素,包一个事务循环INSERT就是几个数量级的差距。Demo 走的是通用路线——ExecuteInTransaction里循环执行参数化INSERT,靠事务把每次提交的 fsync 开销摊薄。实测下来,SQLite 单条插入一千行要十几秒,包进一个事务后能到几百毫秒,这个差异在实际项目里足够决定用户是夸你还是骂你。

4. 跑起来:先 SQLite 后 Oracle 的最小可运行路径

4.1 环境与依赖清单

跑这个 Demo 不需要三套数据库全装齐,按依赖从轻到重依次上:先 SQLite(零安装),再 SQL Server(Express 版就够),最后 Oracle(最重,可以最后配)。依赖清单如下:

组件版本要求用途是否必装
.NET SDK6.0 或以上编译运行控制台工程必装
Microsoft.Data.SqlClient5.xSQL Server 驱动需接 SQL Server 时装
Oracle.ManagedDataAccess.Core3.xOracle 托管驱动需接 Oracle 时装
System.Data.SQLite.Core1.0.118 左右SQLite 驱动含原生库必装
SQL Server Express2019 或以上本地测试实例可选
Oracle Database12c 及以上本地测试实例可选

装包命令直接抄:

# 数据访问核心库需要这三个,按实际要接的库裁剪 dotnet add src/DataDemo.Core package System.Data.SQLite.Core dotnet add src/DataDemo.Core package Microsoft.Data.SqlClient dotnet add src/DataDemo.Core package Oracle.ManagedDataAccess.Core # 编译整个解决方案,先确认能过 dotnet build DataDemo.sln -c Debug

Oracle.ManagedDataAccess.Core是 .NET Core / .NET 5+ 用的托管驱动包,和 .NET Framework 时代的Oracle.ManagedDataAccess是两个包,名字容易看混。工程目标框架如果是net6.0却装了后者,编译会提示不兼容;反过来在net48工程里装 Core 版本也一样。这是新手最常见的一个卡点,装包前先看 csproj 里的TargetFramework。

System.Data.SQLite.Core会自动带上SQLite.Interop.dll,这个原生库分 x86 和 x64。控制台工程默认 AnyCPU 且勾了「优先 32 位」的话,运行时会找 x86 版本;如果只拷贝了 x64 目录,就会报「无法加载 DLL SQLite.Interop.dll」。稳妥做法是把平台显式设成 x64,或者干脆换成Microsoft.Data.Sqlite,后者把原生库打包进了运行时包,省掉这层麻烦。

4.2 第一步:SQLite 零依赖跑通建表与查询

SQLite 的好处是不用装任何服务,一个文件就是一个库。先用它把整条链路走通,能排除掉「代码问题」还是「数据库环境问题」的干扰。

# 在工程输出目录建库文件,路径和连接串里的 |DataDirectory| 对应 cd src/DataDemo.Console/bin/Debug/net6.0 sqlite3 datademo.db < ../../../../../sql/sqlite_init.sql # 确认表建出来了 sqlite3 datademo.db ".tables" sqlite3 datademo.db "SELECT COUNT(*) FROM T_USER;"

sqlite3命令行工具不一定在系统里,没有的话用 DB Browser for SQLite 打开文件执行脚本也一样。建表脚本的核心部分:

-- SQLite 建表:自增主键必须写 INTEGER PRIMARY KEY,AUTOINCREMENT 可选 CREATE TABLE IF NOT EXISTS T_USER ( ID INTEGER PRIMARY KEY AUTOINCREMENT, USER_NAME VARCHAR(50) NOT NULL, AGE INTEGER NULL, CREATE_TIME DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 建个索引,后面测分页时能看出差别 CREATE INDEX IF NOT EXISTS IX_T_USER_NAME ON T_USER(USER_NAME); -- 灌几条测试数据 INSERT INTO T_USER (USER_NAME, AGE) VALUES ('user_a', 28); INSERT INTO T_USER (USER_NAME, AGE) VALUES ('user_b', 34);

SQLite 的类型系统是「动态类型」,VARCHAR(50)里的 50 它根本不校验,写多长都收。所以别指望靠字段长度在 SQLite 层挡住脏数据,长度校验得放在应用层。另外DATETIME在 SQLite 里其实是文本,存的是 ISO8601 字符串,比较大小的前提是格式统一——这一点在跨库同步时会造成麻烦,Oracle 的DATE读出来是DateTime,写进 SQLite 变成字符串,再读回来如果格式带时区就会解析失败。我一般统一约定存 UTC 的yyyy-MM-dd HH:mm:ss,不用参数化日期以外的任何格式。

控制台入口改成先跑 SQLite:

static void Main(string[] args) { // 显式指定 DataDirectory,避免不同宿主环境下解析到别处 AppDomain.CurrentDomain.SetData("DataDirectory", AppContext.BaseDirectory); var db = new DbHelper(DbTypeKind.SQLite); var table = db.ExecuteDataTable("SELECT ID, USER_NAME, AGE FROM T_USER ORDER BY ID"); Console.WriteLine($"SQLite 查到 {table.Rows.Count} 行"); foreach (DataRow row in table.Rows) Console.WriteLine($"{row["ID"]}\t{row["USER_NAME"]}\t{row["AGE"]}"); }

这段跑通,说明工厂、连接串读取、命令装配、DataTable填充四个环节都没问题。剩下的库只需要换DbTypeKind和连接串,代码一行不用动——这正是整个 Demo 想证明的事。

4.3 第二步:接 SQL Server

SQL Server 这一侧最省事,本地装个 Express 版,或者用 Docker 起一个都行。建库、跑脚本、改连接串三步:

# 用 sqlcmd 跑初始化脚本,-S 指定实例,-U/-P 指定账号 sqlcmd -S 127.0.0.1,1433 -U sa -P "YourPassword" -C -i sql/sqlserver_init.sql

-C是信任服务器证书,本地自签证书不加这个会连不上。生产环境别这么干,正经做法是把证书导入信任链。脚本里的关键差异点:

-- SQL Server 建表:自增用 IDENTITY,日期默认值用 GETDATE() CREATE TABLE T_USER ( ID INT IDENTITY(1,1) PRIMARY KEY, USER_NAME NVARCHAR(50) NOT NULL, -- 中文名用 NVARCHAR,VARCHAR 会丢字符 AGE INT NULL, CREATE_TIME DATETIME2 DEFAULT GETDATE() );

NVARCHAR这一点必须强调。VARCHAR在 SQL Server 里是非 Unicode,存中文要看排序规则,排序规则不对就是一堆问号。跨库项目里,Oracle 那边用NVARCHAR2或者字符集是AL32UTF8的VARCHAR2,SQLite 本身就是 UTF-8,只有 SQL Server 有这层坑。

改配置里的SqlServer连接串,然后把控制台的DbTypeKind.SQLite换成DbTypeKind.SqlServer,重跑。如果这时候报「用户登录失败」,先确认是不是只启用了 Windows 身份验证——Express 版默认如此,需要手动切到混合模式再重启服务。这个报错信息很含糊,容易让人怀疑密码写错了,实际上是服务端压根没开 SQL 登录。

4.4 第三步:接 Oracle

Oracle 是三套里最重的,但如果用托管驱动,可以省掉装客户端的步骤。本地起一个实例之后,先建用户再跑脚本:

-- 用 sys 登录后先建业务用户,Oracle 里没有「数据库」这层概念,用户即 schema CREATE USER demouser IDENTIFIED BY "YourPassword"; GRANT CONNECT, RESOURCE TO demouser; GRANT UNLIMITED TABLESPACE TO demouser;

建表脚本里几个必须注意的差异:

-- Oracle 建表:12c 及以上用 IDENTITY,字符串优先 VARCHAR2 CREATE TABLE T_USER ( ID NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, USER_NAME VARCHAR2(50 CHAR) NOT NULL, -- 带 CHAR 语义才是按字符算长度 AGE NUMBER(3) NULL, CREATE_TIME DATE DEFAULT SYSDATE );

VARCHAR2(50 CHAR)里的CHAR是关键字,不加的话默认按字节算,一个中文字符在 UTF-8 下占 3 字节,50 字节只能存 16 个汉字,写入超长直接报ORA-12899。这是我从 SQL Server 迁过来时踩的第一个坑,报错信息里会写「实际值大于允许值」,但看着字段定义明明是 50,很容易懵。

连接串里的Data Source用 EZConnect 格式host:port/service_name,service_name不是 SID,两者搞混会报ORA-12505。如果实例是老式的 SID 配置,得写成host:port:SID。分辨方法很简单:连上去执行SELECT sys_context('USERENV','SERVICE_NAME') FROM dual;,能查到就说明是服务名。

4.5 冒烟验证:四条断言确认链路通

三套库都配好之后,别靠肉眼看输出,写一个冒烟方法逐条断言:

static void Smoke(DbTypeKind kind) { var db = new DbHelper(kind); // 1. 连通性:能取到 1 就说明连接和基本查询正常 var one = Convert.ToInt32(db.ExecuteScalar("SELECT 1 FROM DUAL".Replace("FROM DUAL", kind == DbTypeKind.Oracle ? "FROM DUAL" : ""))); if (one != 1) throw new Exception($"{kind} 连通性检查失败"); // 2. 写入:插入一条带参数的记录 var insertSql = db.Dialect.BindParam( "INSERT INTO T_USER (USER_NAME, AGE) VALUES ({name}, {age})", "name", "age"); var rows = db.ExecuteNonQuery(insertSql, db.CreateParameter("name", "smoke_" + kind), db.CreateParameter("age", 20)); if (rows != 1) throw new Exception($"{kind} 插入影响行数异常:{rows}"); // 3. 参数化查询:确认参数绑定没串味 var count = Convert.ToInt32(db.ExecuteScalar( db.Dialect.BindParam("SELECT COUNT(*) FROM T_USER WHERE USER_NAME = {name}", "name"), db.CreateParameter("name", "smoke_" + kind))); if (count != 1) throw new Exception($"{kind} 参数化查询失败"); // 4. 分页:取第二页,条数必须等于 pageSize var page = db.ExecuteDataTable(db.Dialect.BuildPaging( "SELECT ID, USER_NAME FROM T_USER", "ID", 2, 5)); if (page.Rows.Count > 5) throw new Exception($"{kind} 分页条数超限"); Console.WriteLine($"{kind} 冒烟通过"); }

四条断言分别覆盖连接、写入、参数绑定、方言分页,恰好是三个库差异最大的四个点。冒烟方法写成接收DbTypeKind参数的形式,三种库共用一份代码,这样任何一处适配退化都会立刻暴露。实际项目里我会把这个方法挂到 CI 上,每次改动数据访问层就跑一遍,比人肉点界面可靠得多。

注意第一行的SELECT 1 FROM DUAL处理方式有点糙,正式代码里应该在SqlDialect里加一个GetDummyTable()方法,Oracle 返回" FROM DUAL",其他两种返回空串。Demo 里这么写是为了让冒烟代码短一些,抄的时候建议改掉。

5. 排查与避坑:驱动、连接串、事务的五条血泪记录

5.1 驱动与依赖相关

现象:编译能过,运行时抛「Could not load file or assembly 'Oracle.ManagedDataAccess'」或版本冲突。原因通常是两个包引用了同一个驱动的不同大版本,NuGet 解析时选了低的那个,而代码里用了高版本才有的 API。也可能是Oracle.ManagedDataAccess和Oracle.ManagedDataAccess.Core同时被间接引用。解决方式是把 csproj 里的显式引用统一到一个包,用dotnet list package --include-transitive把传递依赖打出来看,发现有重复的直接在 csproj 里ExcludeAssets掉,或者统一升到同一个大版本。我一般还会在输出目录里放一个app.config的bindingRedirect,.NET Framework 工程尤其需要。

现象:SQLite 报「无法加载 DLL 'SQLite.Interop.dll':找不到指定的模块」。原因是原生互操作库的位数和进程位数不匹配。AnyCPU 的控制台在 64 位系统上默认跑 64 位,但发布时只拷贝了 x86 目录;或者在 IIS 里应用池开了 32 位,却只有 x64 的文件。解决办法是先把工程的PlatformTarget显式设成x64,然后确认输出目录下的x64/SQLite.Interop.dll存在且被打包。如果还是不行,换成Microsoft.Data.Sqlite,它的原生依赖通过运行时包带进来,不需要手动管目录。

5.2 连接串与网络相关

现象:本地能连,部署到另一台机器上连 Oracle 报ORA-12541: TNS: 无监听程序。原因一般是连接串里写的是host:port/SID但实例用的是服务名,或者目标机器的监听端口不是默认的 1521,又或者防火墙只放行了应用服务器到数据库的部分端口。排查顺序是:先在数据库服务器上用lsnrctl status看监听状态和实际端口,再从应用服务器用telnet host port确认网络可达,最后核对连接串里是服务名还是 SID。这三步能排掉九成的连接问题,剩下的才是账号密码和权限。

现象:SQL Server 报「连接超时时间已到,在从池中获取连接之前超时时间已过」。这句话的关键词是「从池中获取」,说明不是网络慢,是连接池被占满了。原因基本只有一个:某处代码打开了连接但没关,通常是DbConnection没有包在using里,或者异常路径上跳过了Close。定位办法是在连接串里临时加Max Pool Size=10把池压小,让问题更快复现,然后在DbFactory.OpenConnection里加一行计数日志,看打开和关闭是否配平。长期方案是统一走using,别在业务层手动管连接。

5.3 事务与类型相关

现象:事务里第一条INSERT成功,第二条报「该命令必须具有事务对象」,回滚后发现第一条也没了。原因是第二条命令创建时没有把DbTransaction赋给cmd.Transaction。SQL Server 和 Oracle 都会显式拒绝,SQLite 的表现更隐蔽——它可能把语句放到事务外执行,回滚时看起来「没生效」,实际数据已经落盘。解决办法是给DbHelper加一个必须传DbTransaction的BuildCommand重载,并且把ExecuteInTransaction设计成唯一的事务入口,业务代码没有别的路径可以绕过。

现象:Oracle 里存进去的中文变成问号,或者插入时报ORA-12899值太大。两个原因常常一起出现:一是字段定义没加CHAR语义,VARCHAR2(50)按字节算只够 16 个汉字;二是数据库字符集不是AL32UTF8,客户端和服务端字符集不匹配导致转换丢字符。排查先用SELECT * FROM nls_database_parameters WHERE parameter LIKE '%CHARACTERSET%';看服务端字符集,再用SELECT length('中文'), lengthb('中文') FROM dual;看字符长度和字节长度的差异。修法是把字段改成VARCHAR2(50 CHAR),字符集问题则要在建库阶段就定好,事后改代价很大。

6. 二次开发:把 Demo 改成能进生产的访问层

Demo 的定位是「证明这条路能走通」,离生产还有几步。改造的第一步是给DbHelper加接口约束,让上层不直接依赖具体类:抽出IDbHelper,方法签名保持ExecuteNonQuery / ExecuteScalar / ExecuteDataTable / ExecuteInTransaction四个,然后按库写三个实现只会增加重复代码,更好的做法是保留一个实现,把DbTypeKind做成构造参数,通过 DI 容器注册时按配置决定传哪个值。这样单元测试里可以塞一个 SQLite 内存库(连接串写Data Source=:memory:),跑得飞快还不用起服务。

第二个改造点是DataTable转实体。生产代码里到处row["USER_NAME"].ToString()是很糟的体验,字段改名编译器不报错,空值还要判DBNull。我的习惯是在DataDemo.Models里加一个轻量映射:

// 用反射做列名到属性的映射,字段名与属性名一致时零配置 public static List<T> ToList<T>(DataTable table) where T : new() { var props = typeof(T).GetProperties() .ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase); var list = new List<T>(table.Rows.Count); foreach (DataRow row in table.Rows) { var item = new T(); foreach (DataColumn col in table.Columns) { if (!props.TryGetValue(col.ColumnName, out var prop)) continue; var value = row[col]; // DBNull 不写进属性,保留默认值,避免 int 字段被赋成 0 和「真的 0」混淆 if (value == DBNull.Value) continue; prop.SetValue(item, Convert.ChangeType(value, Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType)); } list.Add(item); } return list; }

StringComparer.OrdinalIgnoreCase是为了兼容 Oracle 返回全大写列名、SQL Server 保留建表时的大小写这种差异——不加这一行,同一份代码在 Oracle 下会全部映射不上属性,而且不报错,只是拿到一堆默认值。DBNull那个continue也是刻意为之:int类型的属性如果被赋成 0,你没法区分「数据库里是 NULL」和「数据库里就是 0」,保留默认值至少能让int?的字段正确表达空值语义。

第三个改造点是防护。Demo 里BuildPaging的orderBy是直接拼进 SQL 的,示例代码里传的是字面量所以安全,但真实业务里排序字段往往来自前端。我会加一个白名单:

// 排序字段白名单,不在表里的直接拒绝,杜绝拼接注入 private static readonly Dictionary<string, string> SortWhitelist = new() { ["id"] = "ID", ["name"] = "USER_NAME", ["createTime"] = "CREATE_TIME" }; public string SafeOrderBy(string input) { return SortWhitelist.TryGetValue(input ?? "", out var col) ? col : "ID"; }

pageSize也一样,虽然它是int不会注入,但要卡上限,比如最大 500,防止有人传个 100000 把内存打爆。这类参数校验放在SqlDialect里比放在业务层更合适,因为它是方言层的一部分,改了分页实现也不会漏掉校验。

再往上一层,如果项目规模上来了,可以考虑把部分查询换成 Dapper——它跟 Demo 的思路其实是一路的,都是裸 SQL 加参数化,只是把DataTable到对象的映射做掉了,还能直接返回IEnumerable<T>。但要注意 Dapper 的参数前缀处理和DbTypeKind的耦合,跨库时还是要自己管方言。我的做法是保留DbHelper作为底层执行器,Dapper 只用在那些纯查询、不涉及方言差异的地方,两边共用同一个DbProviderFactory。

最后说个验证习惯。每改一次Query或BuildPaging,我会先把第 4 章那个Smoke方法在三个库上各跑一遍,再手动跑一条「总数 23 条、每页 5 条」的分页,把三页的 ID 拉出来去重,条数必须等于 23。这个动作看着笨,但它挡掉过我至少三次「边界差一」的低级错误。从那以后我每接一个多库项目,都先把连接串、参数前缀、分页语法这三件事写成一张对照表贴在显示器边上,改代码前先看一眼表,比事后翻日志省太多时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询