简介:一份面向 C# 与 Oracle 数据库开发者的源码工具项目,基于 OracleCodeGenerator 实现自动创建实体类,能够帮助开发者在 Entity Framework 等 ORM 场景中快速生成与数据库表对应的 C# 实体类,减少手动编写和维护数据模型的工作量。资源压缩包共 62 个文件,以 cs 源码、dll 运行库、xaml 界面、xml 类型映射等类型为主,体积仅 2.99MB,包含可直接运行的程序和完整解决方案,便于学习、运行与二次开发。内容覆盖 Oracle 连接字符串配置、实体类属性映射、主键标注、DbContext 用法等关键知识点,并提供了 Repository、Code First 迁移等代码生成的扩展思路。已有 211 人浏览/学习,整体轻量而实用,对希望提升 C# 访问 Oracle 数据层搭建效率的入门与中级开发者颇有参考价值。 这几年陆陆续续做了不少 C# 和 Oracle 一起用的项目,从最开始的 WinForm 上位机,到后来的 Web API,都会撞上同一件事:把数据库表的数据取出来,变成能直接操作的对象,也就是创建实体类。Oracle 在国内的老系统、制造业、工控后台里出现频率极高,C# 这边无论是 .NET Framework 还是新一点的 .NET 6/8,操作 Oracle 都要面对一堆细节:驱动选哪个、Oracle 的参数为什么是冒号不是 @、NUMBER 类型怎么映射、老版本数据库怎么分页。这篇文章我就从“创建实体类”这件事出发,把 C# 操作 Oracle 从环境准备、手写实体类,到用 Dapper 和 EF Core 落地,再到常见的坑,完整走一遍。适合正在搭新项目、或者被 oracle.ManagedDataAccess 折磨过的新手参考,也适合老手查漏补缺。
1. 动手之前,先想清楚实体类解决了什么
1.1 为什么每次都绕不开这一堆样板代码
很多初学者第一次接触 Oracle,写出来的代码是这样的:new 一个 Connection,再 new 一个 Command,循环 DataReader,按列读值塞给对象。写一个查询还好,如果一张表有 20 个字段,你就要写 20 行obj.Field = reader.GetXxx(reader.GetOrdinal("FIELD"))。更难受的是你还要处理 DBNull、类型转换、参数顺序,写完之后和数据库表结构耦合得死死的,字段一变,C# 代码跟着改,改漏了直接运行时报错。
实体类解决的就是这件事:把表结构固定成强类型对象,让数据库字段和 C# 属性一一对应。这样业务代码里操作的、传递的、绑定的都是这个对象,数据库访问被隔离在数据层,上层根本不用关心 SQL 还是存储过程。对 C# 这种强类型语言来说,实体类还能享受编译期检查、智能提示、重构不手抖,这些福利在裸写 DataReader 时是体会不到的。
1.2 三条路线:手写、Dapper、EF Core 怎么选
创建实体类一般有三条路,没有绝对的好坏,得看项目场景。
- 手写实体类 + Oracle.ManagedDataAccess.Core,适合小项目、工具类、上位机内部功能。控制力强,没有黑魔法,缺点是样板代码多。
- Dapper + 手写实体类,适合大部分业务系统。实体类可以手写,也可以用一个简单的 SQL 反向生成;Dapper 只负责把查询结果映射成实体,轻量、透明、性能高。
- EF Core + Oracle.EntityFrameworkCore,适合表结构复杂、团队规模大、希望由框架自动生成实体的场景。EF Core 提供反向工程,一条命令把整库表生成成实体类,还带上下文、配置映射、迁移,缺点是版本兼容坑多,学习曲线陡。
我自己的建议是:项目不大,数据访问就用原生态和 Dapper;项目大、表多,再上 EF Core 也不迟。毕竟实体类本身只是第一步,后面的并发、事务、缓存才是大头。
2. 环境准备和连接串,先把地基打牢
2.1 NuGet 包别装错
C# 连 Oracle,很多人还停留在 System.Data.OracleClient 时代,那个是旧 .NET Framework 自带的,微软早就标注过时了。现在主流选择是 Oracle.ManagedDataAccess.Core,这是 Oracle 官方出的托管驱动,跨平台,.NET Core / .NET 5+ 都能用,纯托管代码,不用装 Oracle 客户端,这一点比老 ODP.NET(Oracle.DataAccess)省心得多。
在项目里装包就一行命令:
dotnet add package Oracle.ManagedDataAccess.Core如果你是 Visual Studio 的 Package Manager Console,就执行:
Install-Package Oracle.ManagedDataAccess.Core装了它之后,命名空间是Oracle.ManagedDataAccess.Client,核心类三个:OracleConnection、OracleCommand、OracleDataReader。整体写法和 SqlClient 极像,语法十秒能上手。
如果用 EF Core,包名注意要装Oracle.EntityFrameworkCore,还有Microsoft.EntityFrameworkCore.Design和Microsoft.EntityFrameworkCore.Tools,这三个缺一个,反向工程命令可能起不来。另外千万记住,Oracle.EntityFrameworkCore 版本必须和当前项目用的 EF Core 版本对应,版本差了,运行时会直接给你一个 TypeLoadException,一脸懵。
2.2 连接字符串和 appsettings.json 的配置
Oracle 连接串比 SqlServer 多了个 Data Source 的讲究。最简单的写法是host:port/service_name:
Data Source=192.168.1.100:1521/ORCLPDB1;User Id=scott;Password=tiger;其中 ORCLPDB1 是服务名,不是 SID,这是很多初学者的第一个坑。Oracle 12c 之后默认有 CDB/PDB 结构,你连 PDB 要用服务名;如果你的库是 11g 那种老结构,也可以写 SID 形式:
Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=192.168.1.100)(PORT=1521))(CONNECT_DATA=(SID=ORCL)));实际开发中连接串一般放 appsettings.json,不要硬编码。.NET 6+ 的写法是:
{ "ConnectionStrings": { "OracleConn": "Data Source=192.168.1.100:1521/ORCLPDB1;User Id=scott;Password=tiger;" } }然后通过 IConfiguration 读取。上位机场景如果不想引入太多框架,也可以直接用 ConfigurationBuilder 读 JSON 文件,反正原则就一个:连接串和代码分离,避免每次换库重新编译。
3. 手写实体类:从 EMP 表讲清楚映射规则
3.1 表结构设计和实体类怎么对应
我这里用 Oracle 最经典的 EMP 表做例子,大家都熟。表结构大概是 EMPNO(数字主键)、ENAME(姓名)、JOB(职位)、MGR(上级编号)、HIREDATE(入职日期)、SAL(薪资)、COMM(奖金)、DEPTNO(部门编号)。对应到 C# 实体类,我一般这样设计:
public class Emp { public int EmpNo { get; set; } public string Ename { get; set; } public string Job { get; set; } public int? Mgr { get; set; } public DateTime HireDate { get; set; } public decimal Sal { get; set; } public decimal? Comm { get; set; } public int DeptNo { get; set; } }列名映射我习惯直接去掉下划线换成 PascalCase,比如DEPT_NO映射成DeptNo,查询时用SELECT DEPT_NO AS DeptNo FROM ...;或者实体属性叫DEPT_NO也行,但这就让 C# 代码变得很丑。可空字段我这里都加了?,比如Mgr和Comm在表里允许为 NULL,属性必须声明成可空类型,否则赋值 NULL 时直接炸。
3.2 Oracle 类型到 C# 类型的映射表
Oracle 的类型体系比较老派,NUMBER 一个类型就能表示整数和小数,所以映射成 C# 类型时要注意选择。这是我的经验表:
| Oracle 类型 | 推荐 C# 类型 | 备注 |
|---|---|---|
| NUMBER (整数,精度 <=9) | int | 注意精度,容易溢出 |
| NUMBER (整数,精度 10-18) | long | 大整数场景 |
| NUMBER (小数) | decimal | 钱相关必须用 decimal |
| NUMBER(1) | int / short | 别直接映射 bool,驱动不一定帮你转 |
| VARCHAR2 / NVARCHAR2 | string | |
| CHAR / NCHAR | string | 注意 CHAR 会补空格,记得 Trim |
| DATE | DateTime | Oracle 的 DATE 带时间 |
| TIMESTAMP | DateTime | |
| TIMESTAMP WITH TIME ZONE | DateTimeOffset | 有时区需求才用 |
| CLOB | string | 大文本 |
| BLOB | byte[] | 二进制 |
特别注意 NUMBER 映射成 int 的坑。如果表中某个 NUMBER 列精度很高,比如 NUMBER(12),C# 属性用 int 就装不下,Dapper 和 ADO.NET 在赋值时会抛异常。稳妥起见,不确定就全用 decimal 或 long,等确定范围再收窄。
3.3 DataReader / DataTable 转实体的代码
手写映射的标准姿势是读 DataReader 然后逐列取值。用 EMP 表做个查询示例:
using Oracle.ManagedDataAccess.Client; var connStr = "Data Source=192.168.1.100:1521/ORCLPDB1;User Id=scott;Password=tiger;"; var list = new List<Emp>(); using var conn = new OracleConnection(connStr); conn.Open(); using var cmd = new OracleCommand("SELECT EMPNO, ENAME, JOB, MGR, HIREDATE, SAL, COMM, DEPTNO FROM EMP WHERE DEPTNO = :deptNo", conn); cmd.Parameters.Add(new OracleParameter("deptNo", OracleDbType.Int32) { Value = 10 }); using var reader = cmd.ExecuteReader(); while (reader.Read()) { var emp = new Emp { EmpNo = reader.GetInt32(reader.GetOrdinal("EMPNO")), Ename = reader.GetString(reader.GetOrdinal("ENAME")), Job = reader.GetString(reader.GetOrdinal("JOB")), Mgr = reader.IsDBNull(reader.GetOrdinal("MGR")) ? null : reader.GetInt32(reader.GetOrdinal("MGR")), HireDate = reader.GetDateTime(reader.GetOrdinal("HIREDATE")), Sal = reader.GetDecimal(reader.GetOrdinal("SAL")), Comm = reader.IsDBNull(reader.GetOrdinal("COMM")) ? null : reader.GetDecimal(reader.GetOrdinal("COMM")), DeptNo = reader.GetInt32(reader.GetOrdinal("DEPTNO")) }; list.Add(emp); }这里有个容易被忽略的细节:NULL 列直接调用GetInt32/GetDecimal会抛InvalidCastException,所以可空列必须先用IsDBNull判断再取值。每次手写这么一遍确实烦,所以很多项目会再用一个泛型反射方法把 DataReader 转实体,但反射也有性能开销,小数据量无所谓,大数据量循环频繁调用时要注意。
3.4 参数别用错,Oracle 的参数是冒号
很多从 SqlServer 转过来的人会习惯写@deptNo,在 Oracle 里参数占位符要用冒号:deptNo。这是一个看起来小、实际极其常见的错误,网上几乎每天都能看到有人问为什么 ORA-00936、ORA-00911。OracleCommand 添加参数时,参数名可以带冒号也可以不带,但 SQL 里必须带:
cmd.Parameters.Add(new OracleParameter("deptNo", OracleDbType.Int32) { Value = 10 });SQL 写成... WHERE DEPTNO = :deptNo就对了。还有一点,Oracle 参数的顺序并不重要,重要的是名字必须和 SQL 里的占位符一致,这一点比 SqlClient 更友好。
4. 用 Dapper 让实体类“活”起来
4.1 为什么 Dapper 适合 Oracle + C#
手写了几个实体的增删改查之后,你会觉得大部分代码都在做机械重复。Dapper 是 Stack Overflow 团队出的轻量 ORM,不搞复杂的对象关系映射,只做一件事:把 SQL 查询结果按列名前缀自动映射到实体属性。它性能高、上手快、不会把你困在框架的抽象里,特别适合 C# + Oracle 这种需要写原生 SQL 的场景。
在 NuGet 里装 Dapper:
dotnet add package Dapper然后原来的 DataReader 循环就变成了:
using var conn = new OracleConnection(connStr); var list = conn.Query<Emp>( "SELECT EMPNO, ENAME, JOB, MGR, HIREDATE, SAL, COMM, DEPTNO FROM EMP WHERE DEPTNO = :DeptNo", new { DeptNo = 10 }).ToList();Dapper 会自动把查询结果的列名(默认大写 EMPNO)和实体属性 EmpNo 做不区分大小写的匹配,下划线不做自动转换,所以如果你的列名是 EMP_NO,要么 SQL 里AS EmpNo,要么实体属性就叫 EMP_NO。我用方案是 SQL 里加别名,保持实体类干净。
4.2 增删改查和分页怎么写
Dapper 的增删改直接用 Execute,SQL 自己写。插入时如果要拿自增主键,Oracle 12c 之后可以用 Identity 列配 RETURNING 子句,Dapper 用 DynamicParameters 接返回值:
var sql = @"INSERT INTO EMP (EMPNO, ENAME, SAL) VALUES (:EmpNo, :Ename, :Sal) RETURNING EMPNO INTO :EmpNoOut"; var p = new DynamicParameters(); p.Add("EmpNo", emp.EmpNo, DbType.Int32); p.Add("Ename", emp.Ename, DbType.String); p.Add("Sal", emp.Sal, DbType.Decimal); p.Add("EmpNoOut", dbType: DbType.Int32, direction: ParameterDirection.Output); conn.Execute(sql, p); var newId = p.Get<int>("EmpNoOut");如果你用老版本 Oracle(11g 及以下),没有 Identity,一般是用序列 + 触发器,那插入主键就靠序列取 NEXTVAL,RETURNING 同样可以帮你拿回主键,套路完全一样,只是 SQL 里把主键值换成SEQ_EMP.NEXTVAL。
分页这块也有坑。Oracle 12c 开始支持标准OFFSET ... FETCH,写起来很人性化:
var page = conn.Query<Emp>( "SELECT * FROM EMP ORDER BY EMPNO OFFSET :Offset ROWS FETCH NEXT :Limit ROWS ONLY", new { Offset = (pageIndex - 1) * pageSize, Limit = pageSize }).ToList();但老库(11g)不支持这个语法,只能用 ROWNUM 三层嵌套,SQL 又长又难维护:
SELECT * FROM ( SELECT e.*, ROWNUM rn FROM ( SELECT * FROM EMP ORDER BY EMPNO ) e WHERE ROWNUM <= :EndRow ) WHERE rn > :StartRow建议直接把后一段封装成一个分页工具方法,项目里统一调用。
4.3 查询结果的 NULL 和类型问题
Dapper 对 NULL 的处理比原生 DataReader 友好,可空属性(int?、decimal?)能自动映射为 null,不会抛异常。但有一个坑是 Oracle 的 CHAR 类型,如果字段定义为 CHAR(10),存的值实际只有“SMITH”五个字母,查询出来会带空格补齐到 10 位。Dapper 不会帮你 Trim,页面显示时会莫名多一堆空格,对比字符串也怎么都比不上。解决方法是实体的属性加一个 setter 处理:
private string _job; public string Job { get => _job?.Trim(); set => _job = value; }或者干脆在 SQL 里TRIM(JOB) AS JOB,最简单。这个细节在 Oracle 的旧系统里特别常见,碰到了不要怀疑是驱动 bug,就是 CHAR 的固定长度特性。
5. EF Core 反向工程,自动生成实体类
5.1 安装工具和包
如果你的表非常多,手写实体类明显不现实。EF Core 提供了一个反向工程命令,基于现有数据库生成实体类和 DbContext,一条命令能把几百张表变成类,省下的不是一点半点。先安装三个包:
dotnet add package Oracle.EntityFrameworkCore dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Microsoft.EntityFrameworkCore.Tools然后在项目目录执行:
dotnet ef dbcontext scaffold "Data Source=192.168.1.100:1521/ORCLPDB1;User Id=scott;Password=tiger;" Oracle.EntityFrameworkCore -o Models --table EMP --table DEPT --no-onconfiguring重点看几个参数:-o Models指定输出目录,--table只生成指定表,表名用大写,不然可能找不到;--no-onconfiguring是不生成带连接串的 OnConfiguring 方法,避免把密码硬编码进去。如果你不写--table,会把整个库的表全部生成,Oracle 里系统表一大堆,生成结果非常壮观,强烈建议用--table过滤。
5.2 生成后要检查的三个映射问题
EF Core 生成实体并不代表万事大吉。我实际用了之后发现三个地方必须再检查一遍。
第一,NUMBER 字段默认映射成 decimal,即使这个列实际是整数。比如 EMPNO 生成出来就是public decimal EmpNo { get; set; },如果后续代码想拿它做 int 运算,全是强制转换或 toInt(),很痛苦。我通常手动改成 int,同时注意精度范围。
第二,Column 特性带上了原始列名和类型。比如[Column("ENAME")] public string ENAME { get; set; },属性名也是大写下划线,看起来很丑。可以统一改成 PascalCase,只要把[Column("ENAME")]留着,EF 就能正确映射。
第三,实体里所有的约束、外键、索引会生成一堆 Navigation 属性和 Fluent API 配置。如果你的表有关联,生成的导航属性会很多,业务上用不到的就直接删掉,否则序列化接口时可能循环引用、性能下降。删导航属性不影响查询,EF 依然可以 Join。
5.3 没有主键的表怎么处理
反向工程时如果表没有主键,Oracle 这边常见的是视图或者历史表,EF Core 生成会直接报错“无法为主键实体生成类型”。这种情况有两个思路:给表加主键,这是最推荐的做法,实在不能加的话,就在 SQL 里选一个唯一列然后手动在实体类上标注[Key]。
还有一个 Oracle 历史遗留问题:如果表上有ROWID,可以把它加入实体类并标记为 RowVersion,这样在分页、去重、以及并发更新时比较方便。新版 EF Core 对 Oracle ROWID 的支持已经比较完善,但生成时不会自动加,需要你手动补,不是所有项目都要用,知道有这条路就行。
6. 实战踩坑记录,这些坑我全踩过
6.1 大小写和引号问题
Oracle 如果建表时没加双引号,表名和列名都会自动转成大写;如果加了双引号,就会强制使用你写的那个大小写。所以查询时你写小写列名,Oracle 会尝试转成大写,一般能查到;但如果你建表时用了"EmpNo"这种带引号的小写命名,那查询时必须也加双引号且大小写完全一致,否则就是 ORA-00904 无效标识符。解决方法是:新表统一用不含引号的大写命名,实体属性随便你写成 PascalCase,只要 SQL 给别名就行。老库如果已经混用了大小写,写 SQL 时老老实实加双引号。
6.2 NUMBER 类型精度溢出和映射值域
我遇到过最无语的一个 bug:某个统计字段在 Oracle 里是 NUMBER,C# 属性是 int,平时数据量小没事,某天数据涨了,数值超过 int 的最大值 2147483647,查询直接报OverflowException。后来我把所有可能变大的 NUMBER 都改成 long 或 decimal。这里给个经验值:金额类用 decimal,ID 类用 int 或 long,统计量/数量用 long 或 decimal,尽量不要用 float 和 double,Oracle 的 NUMBER 是十进制精度,double 是二进制浮点,金额计算会出现精度丢失。
6.3 DBNull 和空字符串是两回事
Oracle 里NULL和一个空字符串''在很多场景下是不同概念,但 Oracle 老版本里''会被当成 NULL 处理,这带来一堆隐蔽问题。C# 这边要注意,DataReader 读 NULL 会得到DBNull.Value,如果你直接赋给 string 属性,可能要判断一下再赋值。Dapper 会自动把 NULL 给 null,但 EF Core 反向工程生成的可空字符串属性也会有同样的问题,所以前端传空字符串时你要决定到底存 NULL 还是空串,统一规则,不然各种统计对不上。
6.4 TIMESTAMP 和 DATE 的读取差异
Oracle 中 DATE 本身就带时间,精度到秒;TIMESTAMP 更精细,带小数秒。C# 都映射成 DateTime 没问题,但要注意有些驱动在读取 TIMESTAMP 时如果配置不对,可能出现毫秒丢失的问题。如果你要做精确时间比较(比如比对某个操作的先后顺序),建议字段直接用 TIMESTAMP,并且 C# 侧不要用DateTime.Now这种精度低的进行对比。更麻烦的是 TIMESTAMP WITH TIME ZONE,它映射成 DateTimeOffset,如果你误用 DateTime 接收,赋值时会丢时区信息。我的建议是:没有跨时区需求的系统,别用带时区的类型,自找麻烦。
6.5 常见报错速查
最后整理一个排错表,都是我实际调试时高频遇到的:
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
| ORA-00936 缺少表达式 | SQL 里用了 @ 参数前缀 | 改成 :参数名 |
| ORA-00904 无效标识符 | 列名大小写或引号不对 | 查表结构,确认列名是否带引号 |
| ORA-01017 用户名/口令无效 | 账号密码错误,或连错库 | 检查连接串和用户权限 |
| ORA-12514 监听程序无法识别服务 | 服务名/SID 写错 | 确认连接串用的是服务名还是 SID |
| InvalidCastException | NUMBER 转 int 溢出,或 NULL 直接取值 | 改 long/decimal,加 IsDBNull 判断 |
| TypeLoadException: Method not found | Oracle.EntityFrameworkCore 与 EF Core 版本不匹配 | 统一升级到对应版本 |
| 序列化实体循环引用 | 导航属性互相引用 | 忽略或删除不需要的导航属性 |
| DEGREE 并行度报错/查询慢 | 老库统计信息过期 | 定期执行EXEC DBMS_STATS.GATHER_TABLE_STATS(...) |
6.6 一个容易忽略的部署依赖问题
如果你用的是 Oracle.ManagedDataAccess.Core 且部署在服务器上,注意它不需要安装 Oracle 客户端,这是一个巨大的优势。但要注意,如果服务器上没有装 Oracle Instant Client,某些依赖 Oracle 原生网络库功能(比如高级压缩、加密)可能会报错。大多数常规查询、Dapper、EF Core 操作都不受影响,但如果你用了高级特性,部署时先在测试服务器上做一轮完整回归,别等现场炸了再排查。
还有一个小细节:连接池默认是开启的,OracleCommand 用完一定要 Dispose 或用 using,连接池满了会导致连接泄漏,报“连接请求超时”。这一点在高并发的 Web API 项目里很容易踩,我见过一个线上服务频繁超时,最后排查到是某个方法忘记关连接,加上 using 之后立刻恢复正常。
做这套东西这么多次,我个人体验是:实体类本身并不难,难的是把 Oracle 的旧特性和 C# 的强类型映射处理好。如果你也在做上位机或工控项目,C# 连着 Oracle,建议在项目初期就把实体类的命名规范、NULL 处理规则、类型映射表定下来,后面所有数据访问代码都照着走。最后再分享一个小习惯:每建一张表就顺手写一个SELECT ... AS 属性名的查询做基准测试,额外查询一下 0 行和大数值行的情况,这样大部分运行期坑在开发阶段就能筛掉。
本文还有配套的精品资源,点击获取