C# Oracle 实体类自动生成:元数据驱动代码生成器实践 简介面向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 Idapp;Password***;Data Source10.0.0.12:1521/orclpdb;Poolingtrue;Connection Timeout15;;Data Source部分要分清是 SID 还是服务名服务名那种带斜杠的写法在新库上更通用写错了报的是ORA-12514跟密码错误完全两回事。Connection Timeout单位是秒生成器是短连接批处理把它压到 15 秒能让脚本失败得更快不用在 CI 里干等。Poolingtrue保留默认即可生成一张表和生成两百张表差别在往返次数不在单次连接。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); // IReadOnlyListTableMeta // 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/Ndata_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 IReadOnlyListTableMeta Read(string owner, IReadOnlyListstring 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 Dictionarystring, 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金额、比率一律 decimalINTEGER/SMALLINT—int本质是NUMBER(*,0)VARCHAR2/NVARCHAR2/CHAR—string长度不参与类型决策DATE—DateTime含时分秒TIMESTAMP(n)—DateTime时区类型用DateTimeOffsetCLOB/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、GROUPC# 不允许。 解决是维护关键字黑名单命中就加前缀或后缀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 的五步定位出问题别急着改代码我固定按这个顺序走一遍基本五分钟内能定位看生成器日志里“读到几张表、每张几列”列数和数据库对不上就是字典查询的问题。把拼好的 SQL 和绑定参数值原样打印出来粘到数据库客户端里跑能跑通就是代码问题。检查BindByName有没有设参数名和占位符是否一一对应。单独打开生成的.cs看可空类型和注释是否异常异常往往指向字符集或转义。跑一条SELECT * FROM 表 WHERE 10用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.QueryOrder(SELECT * FROM ORDERS WHERE ROWNUM 1).ToList(); Assert.True(rows.Count 1); Assert.All(rows, r Assert.True(r.OrderId 0));这套流程我用了几个项目之后最大的感受是生成器本身不难难的是把“表结构是脏的”这件事提前防住。从那以后我每次接新库都强制先跑一遍全表元数据体检——没有注释的列、无精度的NUMBER、命名不规范的字段先出一份报告再谈生成。希望帮到你。本文还有配套的精品资源点击获取