☰
C# Oracle 实体类自动生成:元数据驱动代码生成器实践
2026/11/2 19:08:42 网站建设 项目流程

简介:面向C#与Oracle开发者的实体类生成工具包,适合需要快速搭建数据访问层、减少手写模型类的中级开发者,也适用于旧项目数据层重构、从既有库反向建模及教学演示等场景。它围绕OracleCodeGenerator展开,可依据数据库表或视图自动生成C#实体类,并与Entity Framework的DbContext配合完成查询、增删改等数据操作,同时涉及Oracle连接配置、主键与表映射标注、属性类型匹配等建模要点。压缩包共62个文件,以18个cs源码、9个dll程序集以及xml、xaml界面与配置文件为主,另有项目解决方案和少量资源文件,整体约2.99MB,结构紧凑便于查阅。已有214人学习。包内提供生成器源码与可执行程序,便于按现有表结构批量产出实体类,并参考其中的连接参数、实体设计示例和Repository、Unit of Work等分层扩展思路,用于缩短数据访问层搭建时间、统一模型命名与映射规范。

1. 从一张三十多个字段的订单表说起:C# Oracle 实体类到底该怎么自动生成

接手一个 C# Oracle 的老系统,最折磨人的往往不是业务逻辑,而是实体类。一张订单表三十多个字段,手写完还要对着数据字典一个个核对类型,改个字段名全项目编译报错。这套东西说白了就是一个元数据驱动的代码生成器:连上库,读数据字典,把表结构翻译成 C# 类和配套的增删改查方法。它适合正在做老系统重构、.NET 侧对接 Oracle,或者被手写 Model 折磨过的开发者。下面按我拆解的顺序讲:结构、元数据、生成、踩坑、二次改造。

2. 整体结构:一个元数据驱动的生成器拆成四块

生成器本质是 ETL 的一个变种:从 Oracle 数据字典里 extract,把列类型、可空、注释 transform 成 C# 声明,最后 load 成 .cs 文件。整个链路看着简单,真正的复杂点在“元数据读得准不准”,模板反而是最不值钱的部分。所以我习惯把它切成四块:命令行选项、字典读取器、类型映射器、代码拼装器。四块之间靠TableMeta、ColumnMeta两个纯 POCO 传递数据,谁都不用知道谁的存在。

这样分的好处很直接:类型映射规则改动最频繁,字典读取几乎不动,拼装器偶尔调样式,三者互不牵连。上线前那几轮返工,九成问题都出在第一块和第三块,隔离好了改起来不用全量回归。

2.1 托管驱动与连接串:先解决“连得上、不装客户端”

.NET 侧连 Oracle 有两条历史路线:早期的System.Data.OracleClient已经不再维护,别碰;主流做法是Oracle.ManagedDataAccess,纯托管、不需要在机器上装客户端,部署到容器里也不用折腾本地库。常见做法是引 NuGet 包Oracle.ManagedDataAccess.Core,命名空间是Oracle.ManagedDataAccess.Client。

连接串我一般写成这种形态,参数名按驱动的要求来:

// 连接串建议从环境变量读,别硬编码进仓库 var cs = Environment.GetEnvironmentVariable("ORA_CONN") ?? "User Id=app;Password=***;Data Source=10.0.0.12:1521/orclpdb;Pooling=true;Connection Timeout=15;";

Data Source部分要分清是 SID 还是服务名,服务名那种带斜杠的写法在新库上更通用,写错了报的是ORA-12514,跟密码错误完全两回事。Connection Timeout单位是秒,生成器是短连接批处理,把它压到 15 秒能让脚本失败得更快,不用在 CI 里干等。Pooling=true保留默认即可,生成一张表和生成两百张表差别在往返次数,不在单次连接。

2.2 工程目录:四个模块各管一件事

拆完之后的目录大概长这样,Core 里全是可单元测试的逻辑,Cli 只负责解析参数和打印日志:

OraEntityGen/ ├─ OraEntityGen.Core/ │ ├─ GenOptions.cs // 参数模型:连接串、schema、表清单、输出目录、命名风格 │ ├─ OracleSchemaReader.cs // 查字典表,产出 TableMeta │ ├─ TypeMapper.cs // Oracle 类型 -> C# 类型 + 是否可空 │ └─ EntityGenerator.cs // TableMeta -> .cs 文本 ├─ OraEntityGen.Templates/ │ └─ Entity.sbn // 可选,走模板引擎时才需要 └─ OraEntityGen.Cli/ └─ Program.cs // 入口

GenOptions之所以独立出来,是因为生成器最终要接进构建脚本。参数从命令行来还是从 JSON 配置文件来,对后面三块没有区别,抽出来以后换配置源不用改核心代码。命名风格(表名转类名是保留下划线还是转 Pascal)也挂在它身上,属于典型的“先定策略、后干活”。

2.3 生成流程:从选表到落盘的六步

我把主流程固定成六步,每一步失败都能单独定位:解析参数、建连接、查字典、组装TableMeta、类型映射、拼串落盘。入口代码不长,但顺序不能乱,尤其是落盘要在所有表都读完之后再做,否则中途某张表权限不足会留下半套残缺文件。

internal static class Program { private static int Main(string[] args) { // 1. 解析参数:-c 连接串 -s schema -t 表清单 -o 输出目录 var opt = GenOptions.Parse(args); if (opt.Tables.Count == 0) { Console.Error.WriteLine("至少指定一张表,例如 -t ORDERS,ORDER_ITEM"); return 1; } // 2~4. 读元数据:只读查询,不开事务,一次连接搞定 var reader = new OracleSchemaReader(opt.ConnectionString); var tables = reader.Read(opt.Schema, opt.Tables); // IReadOnlyList<TableMeta> // 5~6. 生成并落盘:这一步完全不碰数据库,纯字符串加工 var gen = new EntityGenerator(opt); Directory.CreateDirectory(opt.OutDir); foreach (var t in tables) { var code = gen.Build(t); var path = Path.Combine(opt.OutDir, t.ClassName + ".cs"); File.WriteAllText(path, code, new UTF8Encoding(true)); // 带 BOM,老项目更保险 Console.WriteLine($"生成 {path}({t.Columns.Count} 列)"); } return 0; } }

几个参数值得说清楚:-s是 schema 名,Oracle 里大写敏感,传小写要自己ToUpperInvariant();-t用逗号分隔,内部转成列表后拼绑定变量;-o指向项目的Models/Generated目录,方便.gitignore里排除或整体提交。UTF8Encoding(true)这个true是“要 BOM”,老版本编译器读无 BOM 文件时按系统 ANSI 解,中文注释就会变乱码,这是我在第一个项目里翻车的起点。

3. 元数据读取:把表结构查成 TableMeta

这一步决定生成质量。Oracle 的数据字典视图分三档:USER_只看当前用户,ALL_看有权限的对象,DBA_全库。生成器要能处理别人 schema 下的表,就必须用ALL_并在WHERE里显式限定OWNER,否则一换账号就查空了。

3.1 一条 SQL 把字段、类型、长度、可空、注释捞全

列信息在ALL_TAB_COLUMNS,注释在ALL_COL_COMMENTS,两张表按表名 + 列名关联。下面这条是生成器的主查询,把排序也做掉,保证每次生成的字段顺序稳定:

SELECT c.owner, c.table_name, c.column_id, c.column_name, c.data_type, c.data_length, c.char_length, c.data_precision, c.data_scale, c.nullable, c.data_default, cc.comments FROM all_tab_columns c LEFT JOIN all_col_comments cc ON cc.owner = c.owner AND cc.table_name = c.table_name AND cc.column_name = c.column_name WHERE c.owner = :owner AND c.table_name IN (:t0, :t1) -- 绑定量按表数动态拼 ORDER BY c.table_name, c.column_id;

字段含义逐个数:data_type给出大类(VARCHAR2、NUMBER、DATE、TIMESTAMP(6)这种带精度的也在里面),data_precision和data_scale才是决定NUMBER映射的关键,char_length对NVARCHAR2才准(data_length是字节数),nullable是'Y'/'N',data_default用来识别带默认值的列。comments直接进 XML 文档注释,省得以后再去数据库里翻。

一个细节:Oracle 的IN列表不能用数组绑定,:list传数组只认第一个元素。常见做法是按表数量拼出:t0, :t1, ...再逐个Add参数,表多的时候别忘了 Oracle 单个IN列表上限是 1000 项,超过就分批查。

3.2 主键、序列与自增列的识别

主键靠ALL_CONSTRAINTS和ALL_CONS_COLUMNS关联,constraint_type = 'P'就是主键。生成器要用它决定两件事:类上要不要标主键、插入语句里要不要跳过这一列。

SELECT cc.table_name, cc.column_name, cc.position FROM all_constraints c JOIN all_cons_columns cc ON cc.owner = c.owner AND cc.constraint_name = c.constraint_name WHERE c.constraint_type = 'P' AND c.owner = :owner AND cc.table_name IN (:t0, :t1);

自增这块要想清楚。较新的库有 identity 列,字典里能直接查到;老库通常是“序列 + 触发器”或者干脆在程序里SELECT seq.NEXTVAL FROM DUAL。我的做法是把它做成配置:GenOptions里给一个IdentityStrategy枚举,读到 identity 就自动标记,读到序列名(可通过命名约定SEQ_表名猜测)就生成RETURNING INTO的插入方法,两者都没有就生成普通的全字段插入。别在生成器里硬编码某一种策略,老库新库混着用的时候,血泪经验。

3.3 结果集映射成强类型:别用 DataTable

用DataTable接字典查询看着省事,后面每一列都得Convert.ToInt32(row["data_precision"]),DBNull判断写满屏。我一般直接OracleDataReader顺序读,边读边攒ColumnMeta,逻辑集中在一个方法里:

public IReadOnlyList<TableMeta> Read(string owner, IReadOnlyList<string> tables) { var names = tables.Select(t => t.ToUpperInvariant()).ToList(); var inClause = string.Join(",", names.Select((_, i) => $":t{i}")); using var conn = new OracleConnection(_cs); using var cmd = conn.CreateCommand(); cmd.CommandText = ColumnSql.Replace(":t0, :t1", inClause); // 模板里留占位 cmd.BindByName = true; // 关键:按名绑定 cmd.Parameters.Add(":owner", OracleDbType.Varchar2).Value = owner.ToUpperInvariant(); for (var i = 0; i < names.Count; i++) cmd.Parameters.Add($":t{i}", OracleDbType.Varchar2).Value = names[i]; conn.Open(); var map = new Dictionary<string, TableMeta>(StringComparer.OrdinalIgnoreCase); using var rd = cmd.ExecuteReader(); while (rd.Read()) { var tbl = rd.GetString(1); if (!map.TryGetValue(tbl, out var meta)) map[tbl] = meta = new TableMeta(tbl, owner); meta.Columns.Add(new ColumnMeta { Name = rd.GetString(3), Ordinal = rd.GetInt32(2), DataType = rd.GetString(4), // VARCHAR2 / NUMBER ... Precision = rd.IsDBNull(7) ? (int?)null : rd.GetInt32(7), Scale = rd.IsDBNull(8) ? (int?)null : rd.GetInt32(8), IsNullable = rd.GetString(9) == "Y", Comment = rd.IsDBNull(11) ? null : rd.GetString(11) }); } return map.Values.ToList(); }

BindByName = true是必须显式设的,驱动默认按位置绑定,参数顺序和 SQL 里的占位顺序不一致就会静默错位——这种错最难查,值没报错、结果就是不对。data_default里其实是VARCHAR2的表达式文本,带引号和换行,想用的话得清洗,我一般先不用。

4. 代码生成核心:类型映射、模板与命名规整

到这一步元数据已经是干净的对象了,剩下就是拼文本。很多人生怕自己拼得不优雅,上来就引一套模板引擎,其实对一个类模板来说,StringBuilder加一个AppendLine就够,改起来还快。

4.1 NUMBER(p,s) 到底该映射成什么

类型映射表是生成器的灵魂,写错了后期改一个字段就得重生成一遍全项目。下面是我一直在用的规则,NUMBER一定要看精度:

Oracle 类型条件C# 类型说明
NUMBERscale = 0且precision <= 9int小整数列,够用且省内存
NUMBERscale = 0且precision <= 18long主键常见区间
NUMBER其余情况(含无精度)decimal金额、比率一律 decimal
INTEGER/SMALLINT—int本质是NUMBER(*,0)
VARCHAR2/NVARCHAR2/CHAR—string长度不参与类型决策
DATE—DateTime含时分秒
TIMESTAMP(n)—DateTime时区类型用DateTimeOffset
CLOB/NCLOB—string大文本
BLOB/RAW—byte[]二进制
FLOAT/BINARY_DOUBLE—double浮点
BINARY_FLOAT—float—

两个坑提前说:NUMBER不带精度时用data_precision判断会得到null,此时无脑走decimal,别赌它是整数;DATE里有时间,映射成DateTime之后ToString()记得带格式化,不然前端拿到的是2024/1/1 0:00:00这种。可空列要生成int?、DateTime?,值类型不加?会在读取DBNull时直接抛异常——这是新手期最典型的一课。

4.2 用 StringBuilder 拼类:模板引擎不是必需品

拼装器的结构我是这么写的:先拼文件头(using、命名空间),再拼类声明和 XML 注释,最后逐列拼属性。列循环里只做三件事:算类型、算可空、拼注释。

public string Build(TableMeta t) { var sb = new StringBuilder(4096); sb.AppendLine("// <auto-generated> 由生成器产出,手改会被覆盖 </auto-generated>"); sb.AppendLine("using System;"); sb.AppendLine(); sb.AppendLine($"namespace {_opt.Namespace};"); sb.AppendLine(); sb.AppendLine($"/// <summary>{t.Comment ?? t.TableName}</summary>"); sb.AppendLine($"public class {t.ClassName}"); sb.AppendLine("{"); foreach (var c in t.Columns) { var (clrType, needNullable) = _mapper.Map(c); // 见 4.1 的规则表 var type = needNullable && IsValueType(clrType) ? clrType + "?" : clrType; if (!string.IsNullOrWhiteSpace(c.Comment)) sb.AppendLine($" /// <summary>{Escape(c.Comment)}</summary>"); if (c.Name.Equals(t.PrimaryKey, StringComparison.OrdinalIgnoreCase)) sb.AppendLine(" [Key]"); sb.AppendLine($" public {type} {ToPascal(c.Name)} {{ get; set; }}"); sb.AppendLine(); } sb.AppendLine("}"); return sb.ToString(); }

ToPascal把ORDER_ID转成OrderId,规则是按下划线切段、每段首字母大写,同时维护一张关键字黑名单(Event、Group、Value、Class这类),撞上了就加后缀ValueCol,或者直接@Value。Escape处理注释里的&、<、>,不转义的话 XML 文档注释在编译时会报CS1570,一堆警告看着很烦。

4.3 生成结果长什么样,以及怎么和 Dapper 对齐

生成出来的类大概是这样,注释、主键、可空都齐了:

/// <summary>订单主表</summary> public class Order { /// <summary>订单ID</summary> [Key] public long OrderId { get; set; } /// <summary>下单时间</summary> public DateTime? CreatedAt { get; set; } /// <summary>应付金额</summary> public decimal PayAmount { get; set; } /// <summary>备注</summary> public string Remark { get; set; } }

如果数据访问层用 Dapper,属性名和列名对不上(OrderIdvsORDER_ID)是最常见的报错来源。两种解法:一是在生成时给每个属性打[Column("ORDER_ID")],二是全局打开Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;,让下划线自动匹配。我倾向前者,显式标注不依赖全局开关,别人接手时不用猜。属性顺序按column_id排,读代码时和数据库里看到的一致,这算个小习惯但很省事。

5. 避坑与排查:生成器真正上线前踩过的五个坑

生成器跑通和生成器能用是两件事。下面五条都是我在真实表结构上撞过的,按“现象—原因—解决”写,遇到同款可以直接对照。

5.1 生成阶段的坑

坑一:中文注释全变乱码。现象是生成的.cs打开后注释是????或者一串方块。 原因是文件写成了无 BOM 的 UTF-8,而老版本编译器按系统 ANSI 代码页解析源文件。 解决是File.WriteAllText(path, code, new UTF8Encoding(true))带上 BOM,或者在项目里统一.editorconfig加charset = utf-8-bom,两条都做最稳。数据库字符集是ZHS16GBK的时候还要额外确认驱动回来的字符串没被二次转换。

坑二:表名或列名撞 C# 关键字,生成出来编译不过。现象是public string Class { get; set; }这种行一出现,整包编译失败。 原因是 Oracle 允许列名叫CLASS、EVENT、GROUP,C# 不允许。 解决是维护关键字黑名单,命中就加@前缀或后缀Value;类名还要额外避开System、Object、String这些和命名空间、类型重名的词。

5.2 运行阶段的坑

坑三:时间字段查出来差了几个小时。现象是数据库里2024-05-01 09:00:00,程序里拿到01:00。 原因是 OracleDATE不带时区,驱动按本地时区换算,DateTime.Kind是Unspecified,序列化时又按 UTC 处理一次。 解决是统一约定:要么全存 UTC 并在入口DateTime.SpecifyKind(dt, DateTimeKind.Utc),要么全用DateTime不做任何转换,序列化格式固定成yyyy-MM-dd HH:mm:ss。别两种混着用。

坑四:NUMBER精度丢失或溢出。现象是金额 0.1 存进去读出来是 0.10000000000000001,或者一个大整数直接抛溢出。 原因是无精度的NUMBER被映射成了浮点,或者precision判成了int但实际值超了。 解决是建表时就写死NUMBER(p,s),生成器映射成decimal,插入参数显式OracleDbType.Decimal并设Precision/Scale,别让驱动自己猜。

坑五:换 schema 之后一张表都读不到。现象是同样的表名,本地能生成,到了测试环境返回空列表。 原因是查询用了USER_TAB_COLUMNS,它只返回当前登录用户自己的对象。 解决是换ALL_TAB_COLUMNS并显式带上OWNER = :owner,同时在生成前先跑一次SELECT COUNT(*)校验,查不到就直接报错退出,不要生成一个空文件当成功。

5.3 排查顺序:从日志到 SQL 的五步定位

出问题别急着改代码,我固定按这个顺序走一遍,基本五分钟内能定位:

  1. 看生成器日志里“读到几张表、每张几列”,列数和数据库对不上就是字典查询的问题。
  2. 把拼好的 SQL 和绑定参数值原样打印出来,粘到数据库客户端里跑,能跑通就是代码问题。
  3. 检查BindByName有没有设,参数名和占位符是否一一对应。
  4. 单独打开生成的.cs,看可空类型和注释是否异常,异常往往指向字符集或转义。
  5. 跑一条SELECT * FROM 表 WHERE 1=0,用GetFieldType逐列打印驱动认为的类型,和映射表对一遍。

6. 二次开发:从“能生成”到“生成得能用”

拿到生成器之后,真正的工作才开始。我建议按扩展点改,不要动主流程。类型映射放到外部 JSON 里,键是data_type + precision/scale 区间,值是 C# 类型名,这样一个项目特有的NUMBER(1,0) -> bool之类的规则不用改代码;命名策略抽成一个INamePolicy接口,默认 Pascal,要保留原始列名再实现一个PreservePolicy;输出方式也留个缝,除了写文件还可以返回字符串,方便在 CI 里做“生成结果与仓库中文件是否一致”的校验,不一致就让流水线失败,防止有人手改了生成文件又忘了重新生成。

增量生成是我最推荐先做的一步:给每个TableMeta算一个哈希(表名 + 列名 + 类型 + 可空 + 注释拼起来),和输出目录里记录的上次哈希比对,没变就跳过。表多的时候生成时间能从几十秒降到几秒,更重要的是不会因为重新生成把本地未提交的改动冲掉。

验证方法也别省。生成完立刻写一个冒烟测试:对每张表跑一次SELECT * FROM 表 WHERE ROWNUM = 1,把结果映射成本次生成的实体类,只要不抛异常、字段值能对上,至少说明类型映射和可空标注是对的。这比人肉点开文件看几十遍靠得住。

// 冒烟测试的核心断言:能映射、能取值、主键不为 null var rows = conn.Query<Order>("SELECT * FROM ORDERS WHERE ROWNUM = 1").ToList(); Assert.True(rows.Count <= 1); Assert.All(rows, r => Assert.True(r.OrderId > 0));

这套流程我用了几个项目之后,最大的感受是:生成器本身不难,难的是把“表结构是脏的”这件事提前防住。从那以后我每次接新库,都强制先跑一遍全表元数据体检——没有注释的列、无精度的NUMBER、命名不规范的字段,先出一份报告,再谈生成。希望帮到你。

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

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

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

立即咨询