从Oracle数据字典到C#实体类:类型映射与批量代码生成实践
简介C# 与 Oracle 实体类生成工具面向需要简化数据访问层开发的 .NET 开发者尤其适合正在使用 Entity Framework、希望减少手写映射代码的中级学习者。压缩包共 62 个文件约 2.99MB以 18 个 cs 源码、9 个 dll 依赖、3 个 xml 配置及 xaml 界面文件、exe 程序等为主包含可运行的 OracleCodeGenerator 解决方案与配套资源。已有 212 人学习资源围绕 Oracle 连接字符串配置、表与视图选择生成实体类、字段类型映射、主键与表名注解以及 DbContext 和 Linq 用法展开其中的 WPF 界面、辅助类与 XML 配置也有助于理解生成流程。对于需要快速搭建 C# 操作 Oracle 模型层、参考 EF 映射规则和工具实现思路的开发者这份包具有较直接的借鉴价值。1. 从一张 200 列的 Oracle 表说起手敲实体类这件事我不想再干第二次接手一套跑了很多年的 c# oracle 系统第一件想干的事往往不是改业务而是把两百多列的表映射成实体类。我第一次干这事是硬敲的一天写了三张表还把 NUMBER(10,2) 的金额写成了 double上线第二天对账差了三分钱。后来我把这件事交给自己写的小工具连库、读数据字典、按一张固定映射表吐出 C# 实体类。它解决的是“表结构和代码迟早分叉”这个问题并非“会不会写 POCO”。适合两类人手上有存量 Oracle 库、还在用 ADO.NET 或 Dapper 的中后台项目以及需要长期跟进表结构变更的维护型团队。下面把这套生成器的每一层拆开。2. 先定类型映射表NUMBER 的三副面孔和 decimal 的 28 位天花板写生成器之前必须先有一张“Oracle 类型 → C# 类型”的对照表而且要固定下来不能边写边改。原因是代码生成的输出是可编译的源码一旦映射规则变动生成的几百个文件就是一次大面积 diff在没有单元测试的项目里很容易变成事故。这一章先把规则敲定第 3 章才能安心去读元数据。2.1 照搬 SQL Server 那套映射会翻在哪里很多人是从 SQL Server 转过来的脑子里有一套现成映射int → int、nvarchar → string、datetime → DateTime、bit → bool。这套东西直接搬到 Oracle 上会出错因为 Oracle 的类型系统在三个地方跟它不一样。第一Oracle 里几乎所有的整数都是 NUMBER。没有 tinyint、smallint、bigint 的分层只有NUMBER(p, s)一个类型加精度声明。你看到NUMBER就直接映射成int遇到NUMBER(20)就溢出映射成decimal主键、外键、状态位这些字段又全都变成了decimalDapper 查询出来的对象跟人脑里的模型对不上。第二Oracle 没有 bit/boolean 的列类型PL/SQL 里的 BOOLEAN 不能建列。标志位一般用NUMBER(1)或CHAR(1)存语义由业务代码约定数据字典里根本看不出来。生成器不能自作聪明地把NUMBER(1)翻译成bool——我见过不止一个库里这个字段存 0/1/2 三态一转 bool 就直接丢数据。第三DATE 带时分秒。这是从 SQL Server 过来的人栽得最多的一脚Oracle 的DATE精确到秒TIMESTAMP才到纳秒。你按“DATE 等于日期”去理解插入的时候时间部分会被静默截断而且不报错。所以映射表的第一条原则是宁可宽不可窄宁可让人后面手改也不要生成一个会悄悄丢精度的类型。2.2 NUMBER精度、小数位和“无精度”的三种情况NUMBER是生成器里唯一需要分情况讨论的类型判断依据是USER_TAB_COLUMNS里的DATA_PRECISION和DATA_SCALE两列此外没别的数据可用。分三种情况声明形式DATA_PRECISIONDATA_SCALE十进制上限建议 C# 类型NUMBER(4)409999shortNUMBER(9)90999999999intNUMBER(18)18018 位longNUMBER(28)28028 位decimalNUMBER(38)38038 位decimal 会溢出见 5.1NUMBER(10,2)1028 位整数 2 位小数decimalNUMBER无声明NULLNULL 或 0最大 38 位有效数字decimal并标注风险有个坑要提前说DATA_LENGTH对 NUMBER 来说不是精度它是内部存储字节数最大 22拿它当精度用所有 NUMBER 都会被判成同一个宽度。判断精度只能看DATA_PRECISION。还有一类是FLOAT和BINARY_FLOAT/BINARY_DOUBLE。FLOAT(n)在 Oracle 里其实是个二进制精度声明映射到 C# 最接近的是doubleBINARY_FLOAT对应floatBINARY_DOUBLE对应double。这类字段在业务表里不常见但一旦出现映射错了会出现“存进去和读出来不是一个数”的典型现象。2.3 日期与大对象DATE、TIMESTAMP、CLOB、BLOB、RAW日期家族有五个成员映射各不相同Oracle 类型C# 类型说明DATEDateTime精确到秒带时分秒TIMESTAMP(n)DateTime.NET 的 DateTime 是 100ns 精度n7 会丢精度TIMESTAMP WITH TIME ZONEDateTimeOffset带时区偏移用 DateTime 会丢信息TIMESTAMP WITH LOCAL TIME ZONEDateTimeOffset按会话时区归一化INTERVAL DAY TO SECONDTimeSpan少见但映射错会直接抛转换异常这里有个细节值得单独拎出来USER_TAB_COLUMNS.DATA_TYPE对带时区的 TIMESTAMP在不同版本里返回的字符串可能是TIMESTAMP WITH TIME ZONE也可能是带精度声明的形式。我的做法是不做精确匹配先StartsWith(TIMESTAMP)再看字符串里有没有TIME ZONE这样跨版本不会翻车。大对象和二进制就四个CLOB/NCLOB/XMLTYPE映射stringBLOB/RAW/LONG RAW映射byte[]。RAW有 2000 字节上限超过必须用 BLOB这个在生成阶段判断不了只能靠DATA_LENGTH提示。另外LONG和LONG RAW是 Oracle 的历史遗留类型已经不建议使用但老库里一定有映射上当成string和byte[]处理即可读取时要额外注意不能和其它列一起排序。2.4 映射规则表与可配置覆盖规则固定下来之后一定要留一个外部覆盖入口。原因很现实数据字典只能告诉你类型告诉不了业务语义。一个NUMBER(1)到底是布尔标志、状态枚举还是金额的整数位只有写业务的人知道。我一般用一个Dictionarystring, string承载覆盖key 是表名.列名都大写value 是目标 C# 类型。生成器先查覆盖表查不到再走默认规则。这个字典从哪来都行——appsettings.json、一个 YAML、甚至写在代码里的一组常量只要它是显式的、可 code review 的。// 覆盖表优先于默认规则业务语义只有写过这条业务线的人知道 private static readonly Dictionarystring, string Overrides new(StringComparer.OrdinalIgnoreCase) { [T_ORDER.IS_DELETED] bool, // 明确只存 0/1 [T_ORDER.AMOUNT] decimal, // 金额不允许用 double [T_ACCOUNT.BALANCE] string, // NUMBER(38)decimal 装不下按字符串进出 };这三行看着不起眼但它把“人的判断”和“机器的推断”分开了。后面任何一次生成结果和预期不符第一件事就是去这张表里看有没有对应条目。3. 读数据字典一条 SQL 把整库表结构捞进内存有了映射规则下一步是把表结构取出来。Oracle 把元数据全部放在数据字典视图里这条路比自己解析 DDL 靠谱得多——DDL 文本里可能带着存储参数、分区子句、约束定义解析起来是无底洞而数据字典是结构化的。3.1 USER_、ALL_、DBA_ 三个前缀选哪个数据字典有USER_、ALL_、DBA_三个前缀对应不同可见范围前缀可见范围需要的权限适用场景USER_当前登录用户自己的对象无应用账号只访问自己的 schemaALL_当前用户有权限访问的对象无跨 schema 读表需要按 OWNER 过滤DBA_全库所有对象DBA 角色运维/平台侧工具选错的后果很具体用ALL_TAB_COLUMNS但没加OWNER条件当库里存在同名表比如两个业务 schema 都有T_ORDER时你会把两张表的列合并成一张生成出来的实体类字段数变成两倍第 5 章会讲这个现象怎么排查。一般的应用账号拿不到 DBA 权限DBA_用不上如果生成器和被生成的表在同一个 schema 下直接用USER_最省事因为USER_视图本身就是过滤过的不会串表。跨 schema 场景才需要ALL_加OWNER :owner。3.2 列信息、主键、注释三段查询列信息这一条 SQL 我基本没改过它的好处是一次把列类型、长度、精度、可空性、默认值、注释全部带回来SELECT 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 USER_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS cc ON cc.TABLE_NAME c.TABLE_NAME AND cc.COLUMN_NAME c.COLUMN_NAME WHERE c.TABLE_NAME IN ( SELECT TABLE_NAME FROM USER_TABLES WHERE NESTED NO AND TABLE_NAME NOT LIKE BIN$% ) ORDER BY c.TABLE_NAME, c.COLUMN_ID这段 SQL 有四个地方值得说明。ORDER BY COLUMN_ID保证了属性顺序和表里的物理列序一致生成的代码读起来跟DESC输出对得上code review 时省很多事。NESTED NO排掉嵌套表NOT LIKE BIN$%排掉回收站里的临时表。少这两个条件生成器会产出几十个名叫BIN$xxxx的畸形类。DATA_DEFAULT是 LONG 类型这是个老坑LONG 列不能参与WHERE、不能用字符串函数、不能和其它列做比较。它只能被原样读出来在 C# 侧处理。所以默认值不会过滤全靠读回来再判断。COMMENTS是USER_COL_COMMENTS里的用LEFT JOIN而不是INNER JOIN——绝大多数表的绝大多数列是没有注释的用内连接会直接把整张表过滤没。主键单独查一次因为它跨两张视图SELECT cc.COLUMN_NAME, cc.POSITION FROM USER_CONSTRAINTS c JOIN USER_CONS_COLUMNS cc ON cc.CONSTRAINT_NAME c.CONSTRAINT_NAME AND cc.TABLE_NAME c.TABLE_NAME WHERE c.CONSTRAINT_TYPE P AND c.TABLE_NAME :tableName ORDER BY cc.POSITION主键信息的用处是给实体类打一个标记比如生成[Key]特性或者在注释里标出主键列以及在做增量更新时定位行。即使不用 ORM知道哪几列是主键对UPDATE ... WHERE的生成也有直接帮助。表注释则来自USER_TAB_COMMENTS一张表一行比查列注释便宜得多。3.3 用 ODP.NET 把元数据读成对象读元数据不需要任何 ORMOracle.ManagedDataAccess就够。项目里装Oracle.ManagedDataAccess.Core.NET 6/8或Oracle.ManagedDataAccess.NET Framework两者 API 基本一致。public sealed class OracleColumnMeta { public string TableName { get; init; } ; public int ColumnId { get; init; } public string ColumnName { get; init; } ; public string DataType { get; init; } ; public int? DataLength { get; init; } public int? Precision { get; init; } public int? Scale { get; init; } public bool IsNullable { get; init; } public string? Comment { get; init; } } public static ListOracleColumnMeta ReadAllColumns(OracleConnection conn, string sqlColumns) { var list new ListOracleColumnMeta(); using var cmd new OracleCommand(sqlColumns, conn); using var reader cmd.ExecuteReader(); // 一次读全库不要按表循环 while (reader.Read()) { list.Add(new OracleColumnMeta { TableName reader.GetString(0), ColumnId reader.GetInt32(1), ColumnName reader.GetString(2), DataType reader.IsDBNull(3) ? : reader.GetString(3), DataLength reader.IsDBNull(4) ? null : reader.GetInt32(4), // DATA_PRECISION / DATA_SCALE 对非 NUMBER 列是 NULL必须判空 Precision reader.IsDBNull(6) ? null : Convert.ToInt32(reader.GetValue(6)), Scale reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)), IsNullable reader.GetString(8) Y, Comment reader.IsDBNull(10) ? null : reader.GetString(10), }); } return list; }参数和写法上有几个点。连接用OracleConnectionStringBuilder拼别手写字符串Pooling true默认已开但MinPoolSize设成 1 能让第一次查询不额外付建连成本。生成器一般是一次性任务池子开太大没意义。GetValue(6)加Convert.ToInt32而不是GetInt32(6)是因为 Oracle 的 NUMBER 经 ODP.NET 读出来默认是decimalGetInt32在部分版本上会抛InvalidCastException。这种转换写法虽然啰嗦但跨版本稳。最关键的一条一次性把全库列读进内存别按表循环查。一个中等规模的库几百张表按表查就是几百次往返加上每张表还要单独查主键整体能跑出几分钟一次读完再在内存里GroupBy(TableName)通常几百毫秒搞定。这个差别在第 5 章的性能问题里还会回到。4. 生成 .cs 文件属性模板、命名转换和批量落盘到了这一步数据已经在内存里了剩下的就是把OracleColumnMeta变成能编译的 C# 源码。这一段是整个生成器里最容易出细节问题的地方因为 C# 的标识符规则比 Oracle 的列名规则严格得多而 Oracle 里什么奇怪的列名都可能存在。4.1 命名转换USER_NAME 到 UserName 的边界Oracle 的列名默认全大写用下划线分词而 C# 的属性名要求 PascalCase。中间的转换有几个边界必须处理。第一个是全大写词的正确切分。USER_NAME拆成USER和NAME各转成User、Name拼起来是UserName。但如果列名是全大写的连续字母比如USERNAME简单粗暴的“首字母大写其余小写”会得到Username而业务上可能期望UserName。这个没有通用解法我的处理是只做首字母大写、其余字母小写遇到需要精细化的走覆盖表。别在生成器里塞一本英文词典那是维护灾难。第二个是带引号建出来的列。Oracle 允许create table t (userId number)这种列名大小写敏感且保留原样。数据字典里读出来就是userId转换时不能把它压成Userid否则属性名和列名永久错位。判断依据是“是否全大写”——全大写的走首字母大写 其余小写混合大小写的只做首字母大写。第三个是非法起始字符和 C# 关键字。列名以数字开头1ST_FLAG加下划线前缀列名撞上class、event、params、decimal这些关键字加前缀class是合法标识符元数据里名字仍然是classDapper 能正常映射。private static readonly HashSetstring CsKeywords new(StringComparer.Ordinal) { class,event,params,decimal,object,string,base,this,null, public,private,internal,protected,fixed,checked,unchecked,operator }; public static string ToPascal(string columnName) { var parts columnName.Split(new[] { _, $, # }, StringSplitOptions.RemoveEmptyEntries); var sb new StringBuilder(); foreach (var p in parts) { if (p.Length 0) continue; // 全大写片段首字母大写其余转小写混合大小写只改首字母 var head char.ToUpperInvariant(p[0]); var tail p.All(char.IsUpper) ? p[1..].ToLowerInvariant() : p[1..]; sb.Append(head).Append(tail); } var name sb.Length 0 ? Col : sb.ToString(); if (char.IsDigit(name[0])) name _ name; // 数字开头 if (CsKeywords.Contains(name)) name name; // 关键字 return name; }这段代码里p.All(char.IsUpper)那一行是核心判断它同时解决了全大写和混合大小写两种情况不需要额外的配置。4.2 属性模板可空类型、注释、默认值属性模板要考虑三件事可空性、XML 注释的转义、以及类名与属性名冲突。可空性上值类型遇到NULLABLE Y一律加?这样 Dapper 和手写 ADO.NET 都不会因为 DBNull 抛异常。引用类型string、byte[]本身可空但配合#nullable enable时string和string?的语义不同——我的做法是文件头加#nullable enable可空列生成string?非空列生成string并给一个 string.Empty;的初始值避免编译告警。XML 注释必须转义。列注释里出现、、太正常了一句“金额 100 时”就能把/// summary结构破坏掉然后整个项目编译报一堆 CS1570 警告。用SecurityElement.Escape一行解决。类名与属性名冲突是 C# 特有的坑表USER生成的类叫User列USER生成的属性也叫User编译直接报 CS0542成员名不能与封闭类型相同。处理方式是在生成属性名之后和类名做一次比较相同就加后缀。const string Tmpl // auto-generated / // 由 OracleEntityGen 生成请勿手改需要改字段语义请改 Overrides 配置 #nullable enable namespace {0}; /// summary{1}/summary public sealed class {2} {{ {3} }} ; private static string BuildProperty(OracleColumnMeta c, string className) { var csType TypeMapper.Map(c); // 第 2 章的映射规则 if (c.IsNullable IsValueType(csType)) csType ?; var prop ToPascal(c.ColumnName); if (prop.TrimStart() className) prop Value; // 避开 CS0542 var sb new StringBuilder(); sb.AppendLine( /// summary); if (string.IsNullOrWhiteSpace(c.Comment)) sb.AppendLine($ /// {c.ColumnName} ({c.DataType})); else foreach (var line in c.Comment.Split(\n)) sb.AppendLine($ /// {SecurityElement.Escape(line.Trim())}); sb.AppendLine( /// /summary); var init (!c.IsNullable csType string) ? string.Empty; : ; sb.AppendLine($ public {csType} {prop} {{ get; set; }}{init}); return sb.ToString(); }Tmpl用的是 C# 11 的原始字符串字面量里面的{0}{2}是占位符所以花括号要写成{{ }}。如果项目还在老版本框架上把这行换成普通的string.Format 转义花括号即可。init那一行看起来只是消警告其实有实际意义非空的字符串列如果默认是null插入时很容易把一个null直接塞进NOT NULL列报 ORA-01400。给个空串虽然也可能不符合业务约束但至少错误会发生在约束层面而不是绑定层面栈信息更好读。4.3 批量生成与表过滤批量生成的主体是个两层循环外层是表内层是列。但真正决定好不好用的是过滤能力。实际项目里 90% 的表你并不想生成实体类。日志表、临时表、历史归档表、以TMP_/BAK_/LOG_开头的表生成出来只是污染代码目录。我的做法是配置两个名单IncludePrefixes和ExcludePrefixes默认排除BIN$、TMP_、BAK_剩下全部包含。这个比白名单更实用因为新加的表会自动被纳入而临时表一般有命名规律。public static void GenerateAll( IReadOnlyListOracleColumnMeta allColumns, IReadOnlyDictionarystring, string tableComments, string outputDir, string ns) { var byTable allColumns .GroupBy(c c.TableName, StringComparer.Ordinal) .ToDictionary(g g.Key, g g.OrderBy(c c.ColumnId).ToList()); foreach (var (table, cols) in byTable) { if (TableFilter.IsExcluded(table)) continue; // 前缀过滤 var className ToPascal(table) Entity; // 统一加后缀避开与系统类型重名 var props string.Join(Environment.NewLine, cols.Select(c BuildProperty(c, className))); var comment tableComments.TryGetValue(table, out var tc) ? tc : table; var code string.Format(Tmpl, ns, SecurityElement.Escape(comment), className, props); var path Path.Combine(outputDir, className .cs); File.WriteAllText(path, code, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); Console.WriteLine($generated {className} - {table} ({cols.Count} cols)); } }类名统一加Entity后缀是有意为之。Oracle 里叫ORDER、USER、SESSION、GROUP的表太多了这些名字在 C# 里要么是关键字要么会跟已有的类冲突加个后缀一次性绕开。new UTF8Encoding(true)是写 BOM。这个习惯来自一次事故生成的文件里有中文注释某些编辑器在无 BOM 的 UTF-8 下按 GBK 解码注释全变乱码然后有人“顺手修了一下”把编码问题带进了版本库。写 BOM 之后这类问题基本没了。4.4 生成后的自检用反射对一遍列数生成器最容易出的问题不是报错而是静默少生成——某张表因为过滤规则被跳过了或者某个列因为异常被吞掉了代码照样编译通过直到运行时查询才报“列不存在”。所以生成结束后要跑一遍自检把生成出来的源文件对应的类加载进来用反射取属性数量和数据字典里的列数对比再抽查几个关键类型看decimal有没有被误写成double。这个自检不一定要放进生产代码但至少要能手动触发。// 自检以 T_ORDER 为例比对属性数与列数 var expected byTable[T_ORDER].Count; var actual typeof(OrderEntity).GetProperties(BindingFlags.Public | BindingFlags.Instance).Length; if (expected ! actual) throw new InvalidOperationException($T_ORDER 生成不完整: 列 {expected}, 属性 {actual});自检的粒度可以粗一点能发现“整列漏掉”就够了。真正精细的类型校验放在第 6 章的构建流程里做更合适。5. 避坑与排查类型、标识符、性能上的五个翻车现场前面四章是理想路径实际跑起来会撞上下面这五类问题。每一条都是我自己或者身边的人实际踩过的顺序按踩到的频率排。5.1 类型与精度类的两个坑坑一NUMBER(38) 映射成 decimal 之后写入抛异常或精度被悄悄截断。现象某张表有一个NUMBER(38)的流水号或更常见的长整数 ID生成的实体类里是decimal大部分数据能正常读写偶尔一批数据在插入时抛ArithmeticException或者读出来的值末几位变成了 0。原因.NET 的decimal有效数字是 2829 位而 Oracle 的NUMBER(38)最大 38 位十进制数字。这两个范围没有交集的部分就是数据丢失发生的地方。更麻烦的是ODP.NET 在绑定参数时不会主动报“超范围”它会按你给的decimal值绑定而decimal本身已经丢过一轮精度了。解决精度超过 28 位的列统一覆盖成string进出都用字符串值的解析交给业务层。如果确定是整数且需要运算用System.Numerics.BigInteger。在第 2 章的覆盖表里加一行就行。同时把生成器的默认规则改成“p 28时输出string并在注释里打警告”让后来的人一眼看到。坑二DATE 列的时间部分在写入后消失。现象实体类里是DateTime赋值DateTime.Now写进库再读出来时分秒全是 0。或者反过来读出来的时间比预期少了 8 小时。原因有两个层面。第一如果参数用的是OracleDbType.DateODP.NET 只传日期部分时间被截断要用OracleDbType.TimeStamp才能带上时分秒。第二TIMESTAMP WITH TIME ZONE映射成DateTime会丢掉时区偏移而会话时区SESSIONTIMEZONE和数据库时区不一致时读出来的时间就整体偏移。解决生成器生成实体类只是第一步写入侧的参数绑定要配套。我一般在实体类旁边生成一个ToOracleParameters()方法用OracleDbType显式指定每个参数的类型DateTime一律绑TimeStampdecimal绑Decimalstring绑Varchar2别让驱动去猜。带时区的列则保持DateTimeOffset不在中间转DateTime。5.2 标识符与命名类的两个坑坑三生成的属性名和查询结果对不上Dapper 查出来一片 null。现象conn.QueryOrderEntity(select * from T_ORDER)实体类对象不是 null但所有属性都是默认值。原因Oracle 返回的列名是全大写的ORDER_NO而属性名是OrderNo。Dapper 的默认映射是不区分大小写但不忽略下划线ORDER_NO对不上OrderNo于是所有列都匹配失败——而且它不会报错只是静静地不填。解决三选一。第一全局开Dapper.DefaultTypeMap.MatchNamesWithUnderscores true;这是最省事的但它是全局静态设置会影响整个进程的映射行为接入老代码要谨慎。第二在生成的实体类上给每个属性加一个自建的[Column(ORDER_NO)]特性配合自定义ITypeMap。第三查询时手写别名select ORDER_NO as OrderNo ...最直白但最啰嗦。我一般选第一个然后在新项目里统一约定“列名一律下划线分词”。还有一个更隐蔽的变种如果列名是带引号建的小写或混合大小写orderNoMatchNamesWithUnderscores也救不了属性名必须和列名逐字符一致或者走[Column]特性。坑四生成的代码编译不过报 CS0102 或 CS0542。现象单个表生成没问题全库生成之后编译报“类型已包含 X 的定义”或“成员名不能与封闭类型相同”。原因有三种。第一同一张表里存在USER_ID和USERID两列下划线分词之后都变成UserId属性名重复。第二某列名和表名相同表STATUS列STATUS属性名与类名相同。第三不同 schema 的同名表被一起读进来类名重复这一条根因在第 3 章的视图选择上。解决生成属性之前做一次HashSetstring去重冲突时按出现顺序加数字后缀并在注释里标注原列名类名冲突时用表名 Entity并在最后做一次全量类名去重遇到重复直接中止并打印冲突的两张表不要静默改名——静默改名会让下次生成的 diff 变成一团乱麻。5.3 性能类的坑坑五生成器跑一次要几分钟改一行配置就得等。现象几百张表的库生成一次要 35 分钟其中绝大部分时间花在数据库往返上。原因最常见的写法是“外层循环表内层new OracleConnection 查列 查主键 查注释”四次开关连接乘以表数量。开连接虽然走了连接池但每次多一次网络往返几百张表就是上千次往返累计起来就是分钟级。另一种是查询里用了TABLE_NAME LIKE %或者干脆没加WHERE把整个字典视图拉了一遍再做过滤。解决连接开一次三段查询各执行一次列、主键、表注释全部读进内存之后再做分组和过滤。这一改通常能把几分钟压到一秒以内。另外OracleCommand.InitialLONGFetchSize对DATA_DEFAULT这种 LONG 列有影响设成 0 表示不预取能少传不少无用数据——反正默认值大部分时候用不上。提示排查这类问题时先看生成日志里“每张表的耗时”如果耗时随表数线性增长且单表耗时稳定在几十毫秒基本可以断定是连接/查询次数问题而不是数据量问题。6. 进阶把生成器接进构建流程多一道自动比对生成器写成控制台工具之后最大的风险变成“忘了跑”。表结构改了实体类还是旧的编译照样过运行时才报 ORA-00904。我后来的做法是把它接进构建流程并且在启动时加一道轻量比对。第一步把生成器做成一个独立的控制台项目Tools.EntityGen接受--check参数。带这个参数时不写文件只做比对读数据字典读现有的.cs文件重新生成一遍内容逐文件做字符串比较。有任何差异就以非零退出码结束。CI 里跑dotnet run --project Tools.EntityGen -- --check差异会让构建直接失败。# 本地先看一眼差异再决定是否落盘 dotnet run --project Tools.EntityGen -- --check --schema APP # 确认无误后重新生成 dotnet run --project Tools.EntityGen -- --write --out ./src/Domain/Entities--check和--write走的是同一套生成逻辑区别只在最后一步是“比较”还是“落盘”。这一点很重要如果两条路径各自实现迟早会出现“check 通过但 write 出来的东西不一样”的诡异情况。第二步加一个启动期的一致性检查。应用启动时连一次库把关键表的列名集合和实体类的属性集合通过[Column]特性或命名约定还原做对比不匹配就记一条 Error 级别日志或者干脆按配置决定是否启动失败。这个检查只覆盖核心表就行全量比对会拖慢启动。我一般只查 1020 张写入最频繁的表。检查项触发时机失败处理覆盖范围全量生成差异CI 构建构建失败全部纳入生成的表列数一致性应用启动Error 日志核心写表 1020 张关键列类型应用启动启动失败金额、主键、时间列第三步把--check的差异输出做得可读。只打印“有差异”没有意义要打印到列级别哪张表多了哪一列哪一列的类型从decimal变成了string。有了这个表结构变更的评审就能落地到具体字段上而不是一句“同步一下实体类”。这套东西跑顺之后实体类基本从日常工作中消失了——它变成了构建产物的一部分跟编译出来的 dll 一样不需要人去关心。有个教训值得记一笔早期我把生成器写成了“生成即覆盖”结果有一次某位同事手工在实体类里加了个计算属性下一次生成直接被冲掉找回来花了两小时。从那以后我每次改生成模板之前都强制走一遍--check看差异面再决定是改模板还是改覆盖表生成的文件头也永远带着那句“请勿手改”。希望帮到你。本文还有配套的精品资源点击获取