简介:这是一份面向C#开发者的Oracle数据库实体类自动生成工具项目,核心解决在C#中快速构建与Oracle表结构对应的实体类、减少手写数据访问代码的问题。资源基于OracleCodeGenerator实现,包含完整的Visual Studio解决方案,涵盖主窗口程序、实体类生成逻辑、数据库连接帮助类、界面样式配置及项目依赖库等。压缩包共62个文件,主要以cs源码、dll运行库、xaml界面文件和xml配置为主,另含少量exe、pdb与缓存文件,整体大小2.99MB,项目结构紧凑,便于直接编译运行或二次定制。已有212人学习下载。通过该资源可以了解Entity Framework与Oracle配合使用时的连接字符串配置方法、Code First模型设计原则,以及如何利用工具批量生成带主键标识和表映射注释的实体类。包内还提供了数据访问层的基础实现示例,能够帮助开发者掌握从数据库表到C#类的自动转换流程,适合初窥ORM用法、希望提升Oracle数据层开发效率的.NET工程师学习参考。
1. C#操作Oracle实体类:不靠ORM也能把查询结果映射成对象
做 C# 操作 Oracle 实体类这块的数据层,很多人第一反应是上 EF Core 或 Dapper。但真实项目里 Oracle 库经常是历史遗留的命名风格,字段全是 USER_NAME、DEPT_CODE 这种下划线大写,实体类想用驼峰属性,重 ORM 反而要写一堆映射配置。这个资源给的是另一条路:用 Oracle.ManagedDataAccess.Core 原生驱动,把 DataReader 的结果按约定反射成实体类,代码量不大,可控性却高很多。它解决两类具体诉求:一是让查询结果脱离 DataTable 变成强类型对象,二是把 SQL 参数化的坑在源头堵住。适合不想为一张表引一套 ORM、又希望数据访问层能统一维护的 C# 开发者。
2. Oracle连接与参数化:驱动选型、连接串与冒号前缀的坑
在 C# 里连 Oracle,驱动选择是第一步,选错后面全是坑。早期项目里见过几种方案,折腾一圈后我现在默认只用托管驱动,原因下面细说。
2.1 驱动选型:为什么最后选 Oracle.ManagedDataAccess.Core
常见的 C# 连 Oracle 方案有三种,各有各的适用场景,放在一起对比更清楚:
| 驱动 | 部署要求 | 是否跨平台 | 维护状态 |
|---|---|---|---|
| System.Data.OracleClient | 无需装客户端 | 仅 .NET Framework | 早已停止维护 |
| Oracle.DataAccess.Client | 服务器必须装 Instant Client | 仅 Windows | 可用但部署麻烦 |
| Oracle.ManagedDataAccess.Core | 无需装任何 Oracle 客户端 | .NET Core / .NET 5+ 全支持 | 持续更新 |
第一种是微软早期自带的驱动,接口停留在老一代,很多新特性不支持,也不开源,基本淘汰。第二种是非托管驱动,开发环境装一次客户端还好,到了测试服务器或容器环境,DLL 版本跟机器上已有的 Instant Client 对不上就起不来,出现“开发环境好好的,测试环境一跑就报错”的经典翻车现场。第三种纯托管实现,不依赖本机任何 Oracle 组件,项目里引个 NuGet 包就能跑,这也是我长期选它的原因。
安装方式很简单,在项目根目录执行:
dotnet add package Oracle.ManagedDataAccess.Core也可以在 Visual Studio 的 NuGet 包管理界面搜索后点击安装。命名空间是Oracle.ManagedDataAccess.Client,和老驱动的Oracle.DataAccess.Client只差一个单词,迁移时把 using 换掉,连接串保持不变,大部分代码可以平移。
注意:如果项目还在 .NET Framework 4.x,也能用这个包,选一个兼容的 NuGet 版本即可;核心用法和 .NET Core 完全一样。
2.2 连接字符串的三种写法与参数化规则
Oracle 连接字符串不像 SQL Server 那么单一,同样是连一个 1521 端口的库,至少三种写法。我一般按下面这样组织,需要换环境时只改 Data Source 这一段:
// 写法A:host:port/service_name,最常用 var connStrA = "Data Source=192.168.1.10:1521/ORCL;User Id=app_user;Password=your_pass;Pooling=true;Connection Timeout=15;"; // 写法B:直接指定SID,老库有时只注册了SID var connStrB = "Data Source=192.168.1.10:1521/ORCL;User Id=app_user;Password=your_pass;"; // 写法C:完整DESCRIPTION块,RAC或多地址时更稳 var connStrC = @"Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=192.168.1.10)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCL)));User Id=app_user;Password=your_pass;"; using var conn = new OracleConnection(connStrA); conn.Open();三种写法的差异点:写法 A 是 host:port/service_name,Oracle 12c 之后最通用;写法 B 用 SID,适合对方 DBA 只给 SID 的库;写法 C 是完整描述串,适合 RAC 节点切换或者从 TNSNAMES.ORA 里搬过来的配置。关键字统一用User Id和Password,中间有空格是标准写法,别改成UserId,驱动不识别。Pooling 默认本来就是 true,显式写上主要是让后来维护的人一眼能看清。Connection Timeout 控制的是建立连接阶段,单位秒。
连接打开之后,写查询的第一个规则就是参数化。Oracle 的参数前缀是半角冒号,不是 SQL Server 的 @:
using var cmd = new OracleCommand( "SELECT id, user_name, dept_code FROM t_app_user WHERE dept_code = :deptCode AND status = :status", conn); cmd.Parameters.Add("deptCode", OracleDbType.Int32).Value = 101; cmd.Parameters.Add("status", OracleDbType.Int32).Value = 1; using var reader = cmd.ExecuteReader();参数名在 Add 时不要带冒号,驱动会自动按名称绑定;如果带了冒号,绝大多数版本也能兼容,但代码里统一不带更干净。Oracle 的参数绑定按名称匹配,不按位置,所以参数在 SQL 里出现的顺序和 Add 的顺序不一致也没关系。OracleDbType 不能写成 SqlDbType.Int32,两个枚举的底层类型完全不同,写错会在执行时报类型转换错误。
2.3 常用 OracleDbType 到 C# 类型的映射表
做实体映射时,类型对应关系是绕不开的。下面这个表是我一直在用的基准,遇到特殊字段再单独处理:
| OracleDbType | 建议 C# 类型 | 说明 |
|---|---|---|
| NUMBER | int / long / decimal | 精度超过 18 位用 decimal |
| NVarchar2 / Varchar2 | string | 长度不定时用 string |
| Date / TimeStamp | DateTime | TimeStamp 带小数秒 |
| CLOB / NCLOB | string | 大文本直接映射成 string |
| Raw | byte[] | 二进制场景使用 |
| BINARY_FLOAT / BINARY_DOUBLE | float / double | 对应 IEEE754 浮点 |
| 无原生 bool | bool | 老表用 NUMBER(1) 存 0/1,需手动转 |
需要特别留意两点。第一,Oracle 的 NUMBER 是无精度定义的十进制类型,映射到 C# 的 decimal 最稳,映射成 int 或 long 时注意范围会不会溢出。第二,Oracle 没有原生布尔类型,历史表里常见的做法是 NUMBER(1) 存 0 和 1,DataReader 取出来是 short 或 int,直接赋值给 bool 属性会抛转换异常,实体层得先转成 bool 再暴露给上层。
3. DataReader转实体:反射映射、可空处理与字段归一化
DataReader 读到结果之后,最直接的方式当然是逐字段赋值,但这种方式在字段多起来之后,维护成本会快速失控。
3.1 逐字段赋值:代码少但维护成本高
如果只有一个查询,手写赋值也不难看:
var user = new User(); user.Id = reader.GetInt32(0); user.UserName = reader.GetString(1); user.DeptCode = reader.GetString(2); user.CreateTime = reader.GetDateTime(3);下标从 0 开始,看起来直观,问题在于:SQL 里加了一列之后,后面所有 GetXXX 的下标全部要跟着改;一旦 GetString(1) 和 GetDateTime(3) 的顺序写错,编译期不报错,运行期才炸。另一种写法是每列都用GetOrdinal("USER_NAME")取下标,但每列都做一次名称解析,列一多性能差不说,代码照样冗余。一张表二十几个字段时,这种逐字段代码就要写二十多行,还不包含判空逻辑。我一般只在一次性脚本里这么写,正式项目里绝不直接用。
3.2 反射映射器:一条通用方法处理所有实体
于是就有了通用反射映射器。核心思路是:遍历 OracleDataReader 的列,按列名找实体属性,类型转换和 DBNull 判空集中处理,一次写好所有实体复用:
// 需要 using System.Linq; public static class EntityMapper { public static T MapTo<T>(OracleDataReader reader) where T : class, new() { var entity = new T(); var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); for (var col = 0; col < reader.FieldCount; col++) { var colName = reader.GetName(col); var prop = props.FirstOrDefault(p => p.Name.Equals(colName, StringComparison.OrdinalIgnoreCase)); if (prop == null || !prop.CanWrite) continue; var raw = reader.GetValue(col); if (raw is DBNull) { // 可空值类型和引用类型置 null,非可空值类型跳过 if (Nullable.GetUnderlyingType(prop.PropertyType) != null || !prop.PropertyType.IsValueType) { prop.SetValue(entity, null); } continue; } var targetType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; prop.SetValue(entity, Convert.ChangeType(raw, targetType)); } return entity; } }逐段说明逻辑:
BindingFlags.Public | BindingFlags.Instance过滤出公共实例属性;CanWrite跳过只读属性,避免 SetValue 时抛异常。StringComparison.OrdinalIgnoreCase让列名 USER_NAME 能匹配属性 UserName,这是“忽略大小写匹配”的第一步。- DBNull 必须提前判断,否则 Convert.ChangeType 面对 DBNull 会直接抛 InvalidCastException。
Nullable.GetUnderlyingType处理 int?、DateTime? 这类可空值类型,让它能接受 null;非可空值类型遇到 null 会跳过,留给数据库默认值兜底。
这个映射器的边界要清楚:它依赖“列名和属性名能对得上”。如果库里字段是 USER_NAME,实体是 UserName,OrdinalIgnoreCase 匹配不上,必须走下一节的归一化。还有一点,为什么不用DataTable.Load(reader)再转实体?因为 DataTable 会把全部结果缓存到内存,而 DataReader 是流式读取,实体映射到哪行就释放哪行,大结果集下内存占用量差距明显。
3.3 字段归一化:USER_NAME 到 UserName 的转换规则
Oracle 历史库最喜欢下划线大写命名,C# 这边是 PascalCase。要做的事很简单:把列名转成全小写,按下划线拆分,每段首字母大写,再拼起来:
private static string ToPascal(string rawName) { var segments = rawName.ToLowerInvariant().Split('_', StringSplitOptions.RemoveEmptyEntries); var sb = new StringBuilder(); foreach (var seg in segments) { sb.Append(char.ToUpperInvariant(seg[0])); sb.Append(seg.Substring(1)); } return sb.ToString(); }USER_NAME 经过这个函数会变成 UserName,DEPT_CODE 变成 DeptCode。在映射器里使用时,要先按归一化后的名字匹配一次,匹配不到再用原始列名匹配一次,兼容库里已经用驼峰命名的列:
var prop = props.FirstOrDefault(p => p.Name.Equals(ToPascal(colName), StringComparison.OrdinalIgnoreCase)) ?? props.FirstOrDefault(p => p.Name.Equals(colName, StringComparison.OrdinalIgnoreCase));注意这个函数的局限:列名里如果有连续下划线,比如 USER__NAME,转换后会变成 UserName 而不是 UserName,和实体属性对不上。遇到这种极个别列,我一般直接在实体上做个标注,或者在映射器里加一个列名到属性名的字典映射,不让命名规则的例外散落到业务代码里。
4. 批量写入与事务边界:数组绑定、回滚纪律与超时配置
实体操作不只是查询,写入场景更考验细节。批量插入是最容易写慢的部分,事务边界最容易写错,超时配置则直接影响线上稳定性。
4.1 批量插入的三种姿势:逐条、数组绑定与 DataAdapter
最常见的写法是 foreach 循环里逐条 ExecuteNonQuery,数据量到几千条时耗时线性上涨,而且每条 SQL 都单独隐式事务,出问题连回滚都麻烦。Oracle 驱动支持数组绑定,可以一次调用把整批数据发到数据库端:
using var tx = conn.BeginTransaction(); using var cmd = new OracleCommand( "INSERT INTO t_app_user(id, user_name, dept_code) VALUES(:id, :userName, :deptCode)", conn); cmd.Transaction = tx; cmd.ArrayBindCount = 3; cmd.Parameters.Add("id", OracleDbType.Int32).Value = new[] { 1, 2, 3 }; cmd.Parameters.Add("userName", OracleDbType.NVarchar2).Value = new[] { "alice", "bob", "carol" }; cmd.Parameters.Add("deptCode", OracleDbType.Int32).Value = new[] { 101, 101, 102 }; int affected = cmd.ExecuteNonQuery(); tx.Commit();参数说明:ArrayBindCount必须和数组长度一致,驱动靠它知道这批要发多少行;三个参数数组长度也必须一致,不一致时有的版本取最短,有的直接报错。数组里的空值要用DBNull.Value占位,例如姓名这一列某行是 NULL,数组就写new object[] { "alice", DBNull.Value, "carol" },但注意这样数组类型会变成 object[],OracleDbType 推断会受影响,更稳妥的做法是业务层约定空值用空串,入库前再统一转换。
另一种场景是批量更新已有数据,用 OracleDataAdapter 配合 DataTable 的Update方法也能做批量提交,适合从 DataSet 直接同步变更的旧代码。但它内部是按行的状态逐条生成 SQL,不是严格意义上的批量执行,数据量特别大时性能不如数组绑定。
4.2 事务边界:读写分开,回滚必须有
事务的目的是让一组写操作要么全部提交要么全部回滚。实体操作里容易犯的错误是:开了事务,却忘了给 Command 挂 Transaction 属性。OracleCommand 在事务开启后如果没有绑定事务,执行写操作会直接在运行时抛错,排查起来不如 SQL Server 直观。
正确写法就是 4.1 里那段:Transaction 必须是同一个连接上开启的,Command 创建后马上cmd.Transaction = tx。一个事务里有多条 Command 时,每条都要挂同一个事务对象。读操作不需要开事务,纯查询直接 Connection + Command 就行,开了事务反而增加锁的持有时间。
回滚的纪律我一般这样定:事务代码放进 try/catch,catch 里先 Rollback 再 throw,绝不让异常带着未提交的事务往上抛。Commit 放在 try 的最后一行,Commit 成功后事务就结束了,后面再用同一连接做别的事不会受影响。
4.3 CommandTimeout 与连接回收:两个超时别弄混
CommandTimeout 和 Connection Timeout 是两个不同层面的参数,很多人混着调。Connection Timeout 在连接字符串里配,管的是建立连接的过程;CommandTimeout 是 Command 对象的属性,管的是单条 SQL 从执行到返回结果的最长等待秒数,默认值是 30 秒。一个复杂报表 JOIN 查了 40 秒,等来的就是超时异常,正确做法是:
cmd.CommandTimeout = 120;调之前最好先看执行计划,如果是全表扫描导致慢,调大超时只是拖延问题。连接回收方面,using var conn = new OracleConnection(...)这种写法在方法结束后会把连接放回连接池,不是物理断开;连接池默认开启,并发高峰不够用时可以加Max Pool Size=200这类参数,但根本问题是排查有没有连接没被释放。
5. Oracle实体操作避坑:五个高频报错的排查记录
做 C# + Oracle 最容易翻车的不是业务逻辑,而是驱动和数据库之间的沟通层。下面这些报错我都在真实环境里遇到过,按“现象 → 原因 → 解决”整理成清单,遇到同款可以直接照着查。
5.1 连接阶段:ORA-00911、ORA-01017 与监听错配
第一条,SQL 末尾写分号,执行报 ORA-00911: invalid character。
现象:SQL 从 PL/SQL Developer 或 Navicat 里复制出来,末尾带着“;”,放进 C# 执行直接报 invalid character。 原因:Oracle 的 SQL 引擎在驱动下不允许语句尾部有多余的分号,驱动不做清理,原样发给数据库端就炸了。 解决:在数据库访问层加一道公共过滤,SQL 统一不写分号,或者执行前加一句sql = sql.TrimEnd(';');。注意只能用 TrimEnd,不能用 Replace,防止把字符串字面量里的分号也干掉。
第二条,用户名密码明明对,连接报 ORA-01017: invalid username/password。
现象:用数据库客户端能连,程序里连不上,报用户名或口令无效。 原因:最常见的是连接字符串关键字写错,比如把“User Id”写成“UserId”,驱动不识别这个关键字,用户名没传进去;另一种是密码里有特殊字符如 @、: 时没有转义,被连接串解析器错误切分。 解决:关键字统一写成User Id=...;Password=...;,密码放在配置文件里时用连接字符串构建器生成,而不是手工拼字符串。手工拼串遇到密码带 @ 极容易翻车。
第三条,ORA-12541 或 ORA-12514,监听连不上。
现象:数据库能 ping 通,1521 端口也通,但连接报 no listener 或 listener does not currently know of service。 原因:listener 正常运行,但连接串里的服务名和 listener 注册的服务名对不上。有的库提供的是 SID,连接串里却用了 service_name;有的反过来。 解决:问 DBA 要准确的 service_name 或 SID,连接串里对应着写。快速验证办法是在数据库服务器上执行lsnrctl status看注册的服务名,再和连接串里的 Data Source 对比,一次就能定位。
5.2 数据阶段:DBNull、@前缀与参数名重复
第四条,GetInt32 在字段为 NULL 时抛 InvalidCastException。
现象:查询结果里有数据行,某个字段在数据库里是 NULL,程序在reader.GetInt32(reader.GetOrdinal("age"))这行抛异常。 原因:OracleDataReader 的强类型 Get 方法对 NULL 值不做转换,直接拒绝。 解决:手写读取时先reader.IsDBNull(ordinal)判断再取;用第 3 章的反射映射器则自动处理 DBNull。这是 DataReader 最容易踩的运行时异常,没有之一。
第五条,参数前缀写成 @,执行报 ORA-01036: illegal variable name/number。
现象:从 SQL Server 转过来,习惯了cmd.Parameters.Add("@id", ...),在 Oracle 上执行立刻报非法变量名。 原因:Oracle 参数前缀是冒号“:”,@ 会被当成 SQL 文本的一部分,驱动找不到对应的绑定变量。 解决:统一不带前缀写cmd.Parameters.Add("id", ...),SQL 文本里写“:id”。还有一个隐藏坑:同一个 Command 里参数名不要重复 Add,比如循环里反复执行时先cmd.Parameters.Clear()再重新 Add 就没问题;如果不 Clear 直接再 Add 同名参数,参数集合里出现两个同名列,绑定序号会错乱,报错往往会变成 ORA-00904 无效标识符,排查方向会跑偏。
6. 扩展方法加缓存:把实体映射收敛成一条查询管线
第 3 章的映射器每次查询都会反射实体类型,几十个查询跑下来没感觉,几百个接口一起跑,反射成本就能在性能报告里看见了。优化思路很简单:把 PropertyInfo 数组按 Type 缓存起来,一个实体类型只反射一次,后续查询直接复用。配合扩展方法,让调用点变成一行:
// 需要 using System.Collections.Concurrent; using System.Reflection; public static class OracleEntityReader { private static readonly ConcurrentDictionary<Type, PropertyInfo[]> Cache = new(); public static List<T> ReadList<T>(this OracleDataReader reader) where T : class, new() { var props = Cache.GetOrAdd(typeof(T), t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanWrite) .ToArray()); var list = new List<T>(); while (reader.Read()) { var item = new T(); foreach (var prop in props) { var raw = reader[ToDbName(prop.Name)]; var target = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; prop.SetValue(item, raw is DBNull ? null : Convert.ChangeType(raw, target)); } list.Add(item); } return list; } private static string ToDbName(string propName) => string.Concat(propName.Select((c, i) => i > 0 && char.IsUpper(c) ? "_" + c.ToString() : c.ToString())) .ToUpperInvariant(); }调用点就干净了:
using var reader = cmd.ExecuteReader(); var users = reader.ReadList<User>();逻辑说明:Cache 是线程安全的 ConcurrentDictionary,键是实体类型,值是属性数组;ToDbName 把 UserName 反变换成 USER_NAME,这是第 3 章归一化规则的逆过程。要注意reader[列名]在列不存在时抛 IndexOutOfRangeException,这个异常不要吞,让它暴露出来提醒你 SQL 列和实体属性没对齐。
三种实现方式的取舍可以参考这个表:
| 方式 | 反射次数 | 维护成本 | 适用场景 |
|---|---|---|---|
| 逐字段赋值 | 0 | 高,改列就要改代码 | 2-3 个字段的一次性脚本 |
| 每次查询反射 | 每次查询都反射 | 中 | 原型验证 |
| 类型缓存 + 扩展方法 | 每个类型一次 | 低 | 正式项目 |
以前我在模拟项目X里贪图快,每个查询方法各自手写 DataTable 搬运,后来一张用户表把 user_name 改成 login_name,连带二十多处硬编码全部编译报错,才下决心把映射收敛到一个公共方法里。从那以后接手的每个 Oracle 数据层,第一件事都是先定实体类映射规则和参数化写法的纪律,再往上叠业务代码。这套路子虽然不够炫,但好在稳定、好查、好改。希望帮到你。
本文还有配套的精品资源,点击获取