☰
C# Oracle 实体类生成:基于数据字典的 POCO 代码生成实践
2026/11/2 20:08:13 网站建设 项目流程

简介:面向需要在 C# 项目中对接 Oracle 数据库的 .NET 开发者,这套资料围绕自动生成实体类展开,借助 OracleCodeGenerator 把数据库表、视图快速映射为可用的 C# 模型,减少手写重复代码与字段对应出错的情况。压缩包共 62 个文件,约 2.99MB,包含 18 个 .cs 源码文件、9 个 dll 依赖库,以及 xaml 界面文件、xml 配置、sln 与 csproj 工程文件等,基本保留了工具的源码结构与可编译工程。已有 214 人学习,适合具备一定 C# 基础、正在处理 Oracle 数据访问层的开发者参考。内容涉及 Entity Framework 与 Oracle 提供程序的配合、连接字符串配置、实体类命名与类型映射原则、主键与表名注解,以及 DbContext 的使用方式,并延伸到 Code First 迁移、Repository 与 Unit of Work 等优化思路。读者可据此理解生成器的实现逻辑,也能直接运行工具生成贴合自身库表的实体类,作为数据访问层搭建的起点。

1. 从一张 87 列的老表说起:c# oracle 实体类生成到底在解决什么

上周接手一个跑了七年的老系统,数据全在 Oracle 里,光一张订单明细表就 87 列。产品那边催着做新接口,我打开表结构的那一刻只想找一套现成的 c# oracle 实体类生成方案:手敲一下午写出来的 POCO,属性名拼错两个、NUMBER 全写成 double,第二天调用方对账发现金额差了几分钱才回头查。这个项目就是从这种场景里长出来的——一个独立的 C# 控制台工具,连上库读数据字典,把指定表的列名、类型、精度、可空、注释、主键一次性读出来,批量生成可以直接编译的实体类文件。它不依赖 Visual Studio 的 T4,也不要求机器上装 Oracle 客户端,适合手上有一堆 Oracle 老表要对接 ORM、又不想每次改表都手工同步字段的人。往下我会按「骨架怎么拆、元数据怎么读、代码怎么生成、坑在哪、怎么改成团队长期能用的工具」这条线,把这个项目拆开给你看。

2. 项目骨架与技术选型:Core 与 Cli 两层怎么拆

2.1 目录结构与职责边界

先看目录。这个项目的原则只有一条:所有跟「读库」和「拼代码」相关的逻辑都放在 Core 里,Cli 只负责解析参数、读配置、把结果打到控制台。这么拆的好处很直接——以后要加个 WinForms 小界面、或者塞进 CI 流水线里跑,Core 一行不用动。

OracleEntityGen/ ├── src/ │ ├── EntityGen.Core/ │ │ ├── Metadata/ │ │ │ ├── ColumnMetadata.cs │ │ │ ├── TableMetadata.cs │ │ │ └── OracleSchemaReader.cs │ │ ├── Mapping/ │ │ │ ├── TypeMapper.cs │ │ │ └── NamingConverter.cs │ │ ├── Templating/ │ │ │ ├── ClassTemplate.cs │ │ │ └── GenerateOptions.cs │ │ ├── EntityGenerator.cs │ │ └── EntityGen.Core.csproj │ └── EntityGen.Cli/ │ ├── Program.cs │ ├── CommandLineOptions.cs │ └── appsettings.json ├── tests/ │ └── EntityGen.Core.Tests/ ├── output/ └── OracleEntityGen.sln
文件职责对外暴露
OracleSchemaReader.cs连库、执行数据字典查询、组装内存模型ReadTable(owner, tableName)
TypeMapper.csOracle 类型 → C# 类型字符串MapToCSharp(column, options)
NamingConverter.cs列名/表名转换、保留字规避ToPascalCase(raw)
ClassTemplate.cs拼装完整类文件文本Render(table, options)
EntityGenerator.cs串起读表 → 映射 → 渲染 → 落盘GenerateAsync(request)
Program.cs参数解析、配置加载、错误输出进程入口

注意EntityGen.Core里不要引用Microsoft.Extensions.Configuration。配置对象应该由 Cli 解析完,以GenerateOptions这个纯 POCO 传进来。我第一次写的时候就图省事在 Core 里直接读了appsettings.json,后来想写单元测试,每次都得在测试目录里复制一份配置文件,血泪经验,别学。

NamingConverter单独抽出来也是同样的道理:命名规则是这个项目里最容易变的东西(有的团队要求表名去掉T_前缀,有的要求保留),把它做成无状态静态类,改规则只改一个文件,测试也好写。

2.2 为什么选 ODP.NET 托管驱动,而不是 T4 一把梭

跑 Oracle 的 C# 项目,驱动这一关绕不开。常见的三条路,差别挺大:

方案是否需要装 Oracle 客户端现状部署成本
System.Data.OracleClient需要早已停止演进,不建议新项目用高
Oracle.DataAccess.Client(非托管)需要,且位数必须对齐功能全,但部署容易翻车高
Oracle.ManagedDataAccess(托管)不需要,纯托管程序集目前主流选择低

生成器这类工具是给整个团队跑的,不是只在你本机跑。选非托管驱动意味着每个新同事入职第一件事是装客户端、配tnsnames.ora、再排查 32 位/64 位进程不匹配报的那个玄学错误。托管驱动一个 NuGet 包搞定,dotnet tool install或直接双击 exe 都能跑,这是选它的核心理由,不是因为它性能更好——生成器一天跑不了几次,性能根本不敏感。

那为什么不用 T4?T4 在「表结构实时读库」这个场景下确实能用,EdmGen那一套也有人这么干。问题出在三点:一是模板调试体验差,报错行号对不上生成出来的文本;二是 T4 模板的宿主是 Visual Studio,想放到 CI 里做「表结构变更后自动生成并提交」很别扭;三是 T4 生成的.tt文件跟业务代码混在一个项目里,新人看到一堆.tt和.Designer.cs容易懵。独立控制台 + 字符串模板,虽然土,但每个环节都能打断点,输出就是普通.cs文件,改起来没有任何魔法。

提示:托管驱动并不是完全不需要 Oracle 网络配置,Data Source仍然要写对,只是省掉了本地客户端这一层。连接串里用HOST:PORT/SERVICE_NAME的 EZConnect 写法最省事。

2.3 配置项与运行入口

配置我倾向于两层:appsettings.json存放连接串、命名空间、输出目录这类稳定的东西;命令行参数覆盖单次运行才变的表名、是否覆盖等。后者优先,方便临时只生成一张表。

{ "ConnectionString": "User Id=app_user;Password=***;Data Source=10.0.0.12:1521/ORCLPDB;Pooling=false", "Generation": { "Namespace": "Company.OrderService.Entities", "OutputDirectory": "./output", "BaseClass": "", "UseColumnAttribute": true, "UseNullableValueType": true, "TableNamePrefixes": ["T_", "TB_"] } }

Pooling=false是给一次性工具用的,避免进程退出时连接池还在后台挂着。UseColumnAttribute决定生成的属性上要不要带[Column("COL_NAME")],接了 Dapper 或者 EF Core 的团队一般会打开,纯手写 ADO.NET 的可以关掉,少一层依赖。

入口这段代码不长,但参数校验必须做足,否则报错信息会非常难读:

// Program.cs 核心片段 var builder = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false); var config = builder.Build(); var cli = CommandLineOptions.Parse(args); // 表名支持逗号分隔,也支持 * 通配 var tableNames = cli.Tables.Length > 0 ? cli.Tables : throw new ArgumentException("必须通过 --tables 指定至少一张表"); var options = new GenerateOptions { Namespace = config["Generation:Namespace"]!, OutputDirectory = Path.GetFullPath(config["Generation:OutputDirectory"]!), UseColumnAttribute = bool.Parse(config["Generation:UseColumnAttribute"]!), UseNullableValueType = bool.Parse(config["Generation:UseNullableValueType"]!), TableNamePrefixes = config.GetSection("Generation:TableNamePrefixes").Get<string[]>() ?? Array.Empty<string>() }; using var reader = new OracleSchemaReader(config["ConnectionString"]!); var generator = new EntityGenerator(reader, options); foreach (var name in tableNames) { var result = await generator.GenerateAsync(cli.Owner, name); Console.WriteLine($"{name} -> {result.FilePath} ({result.PropertyCount} 个属性)"); }

cli.Owner默认取连接串里的User Id,多 schema 环境才需要显式传--owner。GenerateAsync返回一个包含文件路径和属性数量的小结果对象,控制台打出来是为了让人扫一眼就能确认「87 列的表生成出来是不是 87 个属性」,这个数字对不上基本就是元数据查询漏了东西。

3. 元数据读取层:从 USER_TAB_COLUMNS 到内存模型

3.1 用两条 SQL 把列、类型、注释、主键全捞出来

Oracle 的数据字典视图命名是有规律的:USER_开头只看当前用户,ALL_看当前用户有权限的所有对象,DBA_需要额外授权。生成器要支持多 schema,所以统一用ALL_系列,代价是必须显式带上OWNER条件,否则一个大库能把内存撑爆。

列信息这条查询是核心,四个视图的关联关系要理清楚:ALL_TAB_COLUMNS提供列名、类型、长度、精度、是否可空、列序号;ALL_COL_COMMENTS提供字段注释;表注释在ALL_TAB_COMMENTS里,另外单独取。注意COLUMN_ID是有序的,ORDER BY要用它,不要用列名字母序,生成的属性顺序跟表结构一致,review 的时候才顺眼。

-- 列元数据:注意 LEFT JOIN,没有注释的列也要出来 SELECT c.COLUMN_NAME, c.DATA_TYPE, c.DATA_LENGTH, c.DATA_PRECISION, c.DATA_SCALE, c.NULLABLE, c.COLUMN_ID, 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 = :tableName ORDER BY c.COLUMN_ID

主键单独查一次。这里必须走ALL_CONSTRAINTS和ALL_CONS_COLUMNS的关联,CONSTRAINT_TYPE = 'P'筛主键,再用POSITION排序——联合主键的场景下顺序是有意义的,不能随便排。

-- 主键列:联合主键按 POSITION 排,复合主键的列顺序要保留 SELECT col.COLUMN_NAME FROM ALL_CONSTRAINTS con JOIN ALL_CONS_COLUMNS col ON col.OWNER = con.OWNER AND col.CONSTRAINT_NAME = con.CONSTRAINT_NAME WHERE con.OWNER = :owner AND con.TABLE_NAME = :tableName AND con.CONSTRAINT_TYPE = 'P' ORDER BY col.POSITION

参数说明::owner和:tableName是 Oracle 的绑定变量写法,前缀是冒号不是@。用OracleParameter时ParameterName写不写冒号都能识别,但为了可读性建议跟 SQL 保持一致。OWNER和TABLE_NAME在数据字典里默认是大写,如果调用方传进来的是小写,一定要在代码里ToUpperInvariant(),否则查询结果为空,而且不报错——这是这个项目最典型的「看起来跑通了其实什么都没生成」的故障。

3.2 内存模型:TableMetadata 与 ColumnMetadata

模型层不做任何逻辑,只做数据承载。唯一要留心的是可空类型:DATA_PRECISION和DATA_SCALE在列类型不是 NUMBER 时是NULL,映射到 C# 里必须是int?,不能是int,否则读出来就是 0,后面类型判断会误判成NUMBER(0,0)。

public sealed class ColumnMetadata { public string Name { get; init; } = string.Empty; public string DataType { get; init; } = string.Empty; public int DataLength { get; init; } // NUMBER 类型才有精度和小数位,其它类型为 null public int? Precision { get; init; } public int? Scale { get; init; } public bool IsNullable { get; init; } public int Ordinal { get; init; } public string? Comment { get; init; } public bool IsPrimaryKey { get; set; } } public sealed class TableMetadata { public string Owner { get; init; } = string.Empty; public string TableName { get; init; } = string.Empty; public string? TableComment { get; init; } public IReadOnlyList<ColumnMetadata> Columns { get; init; } = Array.Empty<ColumnMetadata>(); public bool HasCompositeKey => Columns.Count(c => c.IsPrimaryKey) > 1; }

HasCompositeKey这个属性是给模板用的,单主键和联合主键生成出来的特性不一样:单主键加[Key],联合主键要么用[Key, Column(Order = n)],要么干脆不加让 ORM 自己配。提前算好放在模型上,模板里就不用再写 LINQ。

3.3 OracleSchemaReader:连接、执行、组装

读取器的职责边界就到这里为止,它不知道「C# 类型」这回事,也不碰文件。整个类只依赖Oracle.ManagedDataAccess.Client。

public sealed class OracleSchemaReader : IDisposable { private readonly OracleConnection _connection; public OracleSchemaReader(string connectionString) { _connection = new OracleConnection(connectionString); _connection.Open(); // 构造即连接,失败尽早暴露 } public async Task<TableMetadata> ReadTableAsync(string owner, string tableName) { var columns = new List<ColumnMetadata>(); await using var cmd = _connection.CreateCommand(); cmd.CommandText = ColumnSql; // 上面那条列的 SQL // 数据字典里对象名大写,这里统一转换,避免查不到还不报错 cmd.BindByName = true; cmd.Parameters.Add(new OracleParameter("owner", owner.ToUpperInvariant())); cmd.Parameters.Add(new OracleParameter("tableName", tableName.ToUpperInvariant())); await using (var reader = await cmd.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { columns.Add(new ColumnMetadata { Name = reader.GetString(0), DataType = reader.GetString(1), DataLength = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)), Precision = reader.IsDBNull(3) ? null : Convert.ToInt32(reader.GetValue(3)), Scale = reader.IsDBNull(4) ? null : Convert.ToInt32(reader.GetValue(4)), IsNullable = reader.GetString(5) == "Y", Ordinal = Convert.ToInt32(reader.GetValue(6)), Comment = reader.IsDBNull(7) ? null : reader.GetString(7) }); } } if (columns.Count == 0) throw new InvalidOperationException($"表 {owner}.{tableName} 没有读到任何列,检查 OWNER 大小写和查询权限"); var pkNames = await ReadPrimaryKeyAsync(owner, tableName); foreach (var col in columns) col.IsPrimaryKey = pkNames.Contains(col.Name, StringComparer.OrdinalIgnoreCase); return new TableMetadata { Owner = owner.ToUpperInvariant(), TableName = tableName.ToUpperInvariant(), TableComment = await ReadTableCommentAsync(owner, tableName), Columns = columns }; } public void Dispose() => _connection.Dispose(); }

几个参数和细节值得单独说。cmd.BindByName = true必须开,ODP.NET 默认是按位置绑定参数的,SQL 里参数顺序一换就错位,这个默认值坑过不少人。reader.GetValue加Convert.ToInt32而不是直接GetInt32,是因为DATA_LENGTH在 Oracle 里是NUMBER类型,驱动返回的可能是decimal,强转会抛InvalidCastException。

columns.Count == 0那个异常是故意加的。前面说过,大小写不对的时候 Oracle 不报错,只是返回空结果集,如果不做这个检查,后面会生成一个只有类声明、没有任何属性的空类,编译能过,运行时才发现字段全丢——这种问题排查起来比重写一遍还费时间。

4. 生成层拆解:类型映射、命名转换与模板渲染

4.1 Oracle 类型到 C# 类型的映射表

这张表是这个项目里最需要「按业务场景调」的部分,没有一个放之四海皆准的答案。下面这份是默认策略,覆盖了绝大多数业务表:

Oracle 类型条件C# 类型说明
NUMBERScale > 0decimal金额、比率必须用 decimal
NUMBERScale = 0且Precision <= 9int常规主键、计数
NUMBERScale = 0且Precision <= 18long雪花 ID、大流水号
NUMBERScale = 0且Precision > 18decimal超长整数,long 装不下
NUMBER无精度decimal不指定精度的 NUMBER 最安全是 decimal
VARCHAR2/NVARCHAR2/CHAR/NCHAR/LONG—string—
CLOB/NCLOB—string大文本,映射 string 够用
DATE/TIMESTAMP—DateTimeOracle 的 DATE 带时分秒,别当 date 用
TIMESTAMP WITH TIME ZONE—DateTimeOffset带时区信息,用 DateTime 会丢
BLOB/RAW/LONG RAW—byte[]—
FLOAT/BINARY_DOUBLE—double—
BINARY_FLOAT—float—

NUMBER(1)是最容易吵起来的一格。有人坚持映射bool(因为很多系统拿它存 0/1 标志位),有人坚持int。我的做法是默认int,然后提供一个BooleanNumberColumns配置项,让调用方按「表名.列名」显式声明哪些列要当 bool 处理。理由很实在:Oracle 里的NUMBER(1)未必只存 0 和 1,可能是状态枚举的 2、3、4,映射成 bool 之后2读出来是true,数据就悄悄错了。

public static string MapToCSharp(ColumnMetadata col, GenerateOptions options) { var type = col.DataType.ToUpperInvariant(); string csharp; switch (type) { case "NUMBER": if (col.Scale is > 0) csharp = "decimal"; else if (col.Precision is null) csharp = "decimal"; else if (col.Precision <= 9) csharp = "int"; else if (col.Precision <= 18) csharp = "long"; else csharp = "decimal"; break; case "VARCHAR2": case "NVARCHAR2": case "CHAR": case "NCHAR": case "LONG": case "CLOB": case "NCLOB": csharp = "string"; break; case "DATE": case "TIMESTAMP": csharp = "DateTime"; break; case "TIMESTAMP WITH TIME ZONE": csharp = "DateTimeOffset"; break; case "BLOB": case "RAW": case "LONG RAW": csharp = "byte[]"; break; case "FLOAT": case "BINARY_DOUBLE": csharp = "double"; break; case "BINARY_FLOAT": csharp = "float"; break; default: // 不认识的类型退化成 string 加注释,避免生成器直接崩 csharp = "string"; break; } // 值类型且列可空时才加 ?,string 和 byte[] 本身是引用类型不需要 if (options.UseNullableValueType && col.IsNullable && IsValueType(csharp)) csharp += "?"; return csharp; }

注意default分支的处理。遇到不认识的类型(比如空间类型SDO_GEOMETRY)直接抛异常会让整批生成中断,退化成string并在属性注释里标一句「未识别的 Oracle 类型,请人工确认」,比中断更有用。IsValueType就是个小函数,判断int/long/decimal/DateTime/DateTimeOffset/double/float/bool这些值类型,别用反射,写死一个HashSet<string>更快也更直观。

4.2 命名转换:从 SCREAMING_SNAKE 到 PascalCase

Oracle 老表的命名风格高度统一:全大写、下划线分隔、经常带T_/TB_前缀。转换规则不复杂,但边界情况不少:

private static readonly HashSet<string> ReservedWords = new(StringComparer.OrdinalIgnoreCase) { "class", "namespace", "public", "private", "event", "object", "string", "base", "this", "params", "operator", "using", "lock", "fixed", "default" }; public static string ToPascalCase(string raw, IEnumerable<string> prefixes) { var name = raw.Trim(); // 去掉表名前缀,T_ORDER_ITEM -> ORDER_ITEM foreach (var prefix in prefixes) { if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { name = name[prefix.Length..]; break; } } var parts = name.Split('_', StringSplitOptions.RemoveEmptyEntries) .Select(p => char.ToUpperInvariant(p[0]) + p[1..].ToLowerInvariant()); var result = string.Concat(parts); // 数字开头非法,补下划线 if (result.Length > 0 && char.IsDigit(result[0])) result = "_" + result; // 与 C# 关键字冲突,加 @ 前缀 return ReservedWords.Contains(result) ? "@" + result : result; }

ToLowerInvariant那段是关键。Oracle 的列名是全大写,如果只做首字母大写,ORDER_NO会变成ORDERNO而不是OrderNo,可读性差一截。切分之后每段统一「首字母大写 + 其余小写」才是正确姿势。@前缀只对属性和变量有效,类名不能加,所以类名的保留字处理要换个策略——我一般是在类名后面追加Entity后缀,比如ORDER表生成OrderEntity,这个 suffix 放在GenerateOptions里可配。

还有一种情况是列名本身不带下划线,比如CREATEDATE。这时候上面这套拆不出来,只能得到Createdate。我的处理是提供一个ColumnNameOverrides字典,配置里显式写{"CREATEDATE": "CreateDate"},覆盖自动转换结果。全自动的驼峰拆词算法(比如识别CREATE这种词根)准确率不高,不如让人手工兜底几个例外。

4.3 模板渲染:一个类文件的生成全过程

到这一步,数据齐了、类型定了、名字也转好了,剩下的就是把文本拼出来。模板用StringBuilder手写,不用 Razor、不用 Scriban,因为文件结构足够简单,引一个模板引擎反而要处理转义、缩进、空白行这些破事。

public string Render(TableMetadata table, GenerateOptions options) { var className = NamingConverter.ToPascalCase(table.TableName, options.TableNamePrefixes) + options.ClassSuffix; var sb = new StringBuilder(); sb.AppendLine("// <auto-generated> 由实体生成器自动生成,请勿手工修改"); sb.AppendLine("// 需要扩展请使用 partial class 写在另一个文件里"); sb.AppendLine("// </auto-generated>"); sb.AppendLine("using System;"); sb.AppendLine("using System.ComponentModel.DataAnnotations;"); sb.AppendLine("using System.ComponentModel.DataAnnotations.Schema;"); sb.AppendLine(); sb.AppendLine($"namespace {options.Namespace};"); sb.AppendLine(); if (!string.IsNullOrWhiteSpace(table.TableComment)) sb.AppendLine($"/// <summary>{Clean(table.TableComment)}</summary>"); sb.AppendLine("[Table(\"" + table.TableName + "\")]"); sb.AppendLine($"public partial class {className}"); sb.AppendLine("{"); foreach (var col in table.Columns) { var propName = NamingConverter.ToPascalCase(col.Name, Array.Empty<string>()); var propType = TypeMapper.MapToCSharp(col, options); if (!string.IsNullOrWhiteSpace(col.Comment)) sb.AppendLine($" /// <summary>{Clean(col.Comment)}</summary>"); // 联合主键时用 Order 指定列顺序,单主键直接 [Key] if (col.IsPrimaryKey && table.HasCompositeKey) sb.AppendLine($" [Key, Column(\"{col.Name}\", Order = {col.Ordinal})]"); else if (col.IsPrimaryKey) sb.AppendLine(" [Key]"); else if (options.UseColumnAttribute) sb.AppendLine($" [Column(\"{col.Name}\")]"); sb.AppendLine($" public {propType} {propName} {{ get; set; }}"); sb.AppendLine(); } sb.AppendLine("}"); return sb.ToString(); }

这段代码有三个设计点值得说清楚。第一,类声明用了partial,生成的文件是OrderItem.g.cs,业务侧的手写扩展放在OrderItem.cs里,重新生成时只覆盖.g.cs,手写代码永远不会丢——这个习惯救过我很多次。第二,文件头那三行auto-generated注释不是摆设,Visual Studio 和很多静态分析工具识别这个标记后会跳过告警,同时也在提醒后面接手的人「这个文件别手改」。第三,Clean方法负责把注释里的\r\n、*/这类会破坏 XML 文档注释的字符替换掉,Oracle 里的字段注释偶尔会带换行,不处理的话生成出来的文件直接编译报错。

ClassTemplate最终产出的文件长这样,可以对照着看效果:

// <auto-generated> 由实体生成器自动生成,请勿手工修改 // 需要扩展请使用 partial class 写在另一个文件里 // </auto-generated> using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Company.OrderService.Entities; /// <summary>订单明细表</summary> [Table("T_ORDER_ITEM")] public partial class OrderItem { /// <summary>主键 ID</summary> [Key] public long Id { get; set; } /// <summary>订单号</summary> [Column("ORDER_NO")] public string? OrderNo { get; set; } /// <summary>成交金额</summary> [Column("DEAL_AMOUNT")] public decimal? DealAmount { get; set; } }

ORDER_NO映射成string?是因为列可空且UseNullableValueType打开;DEAL_AMOUNT是NUMBER(18,2),Scale > 0走 decimal 分支。落盘这一步在EntityGenerator里,逻辑很薄但容易漏:Directory.CreateDirectory要放在写文件之前,文件名用类名.g.cs,写之前先读一遍旧内容做字符串比较,内容相同就跳过,避免 git 里出现一堆「改了时间戳但内容没变」的 diff。

5. 常见问题排查:五个把我卡过半天的坑

5.1 类型与数据相关的坑

现象:生成的金额字段是decimal,但读出来小数位变成两位以外的脏数据,或者 double 字段比较时永远不相等。原因:早期版本为了省事把NUMBER一律映射成double,浮点数二进制表示本身就不精确,0.1 + 0.2 != 0.3这种问题在对账场景下就是事故。 解决:所有Scale > 0的NUMBER强制走decimal,并且TypeMapper里不再提供「全部转 double」的开关。已经生成过的项目要全量重跑一次,别手工改,容易漏。

现象:NUMBER(19)的雪花 ID 生成出来是decimal,接到前端 JSON 序列化后精度丢了。原因:Precision > 18时long确实装不下,映射成decimal是对的,但decimal序列化成 JSON 数字后,JavaScript 的Number只有 53 位安全整数,19 位直接截断。 解决:这类字段在实体上加[JsonConverter(typeof(LongToStringConverter))],或者干脆让生成器对超长整型列自动加[Column(TypeName = "NUMBER")]并提示调用方用字符串传输。生成器本身不解决序列化问题,但应该在属性注释里标出这个风险。

5.2 连接、编码与文件覆盖的坑

现象:程序启动就抛ORA-12514或者直接提示找不到驱动,同事机器上能跑,我这边不行。原因:项目引的是非托管的Oracle.DataAccess,本机没装对应位数的 Oracle 客户端,或者装了但PATH里找不到。 解决:统一换Oracle.ManagedDataAccess,csproj 里只留这一个 Oracle 相关 PackageReference。如果历史项目必须用非托管版,就在Program.cs启动时判断Environment.Is64BitProcess并打印明确提示,别让用户去看英文堆栈。

现象:生成出来的注释全是乱码,字段注释显示成问号。原因:客户端和服务端的字符集不一致,NLS_LANG没配或者配错了,ODP.NET 读VARCHAR2时按客户端字符集解码。 解决:托管驱动在连接串里加Unicode=true,同时确认服务端的NLS_CHARACTERSET是AL32UTF8。这一步没法在代码里兜底,因为乱码发生在驱动解码阶段,拿到手已经是坏的了。生成器能做的是在注释乱码时打一条警告,提示去查NLS_DATABASE_PARAMETERS。

现象:重新生成之后,前几天在实体类里手写的几个计算属性没了,git 里一片红色。原因:生成器无差别覆盖同名文件,而业务代码直接写在了生成文件里。 解决:生成文件名固定带.g.cs后缀,实体类声明用partial,业务扩展放在同名的另一个文件里。另外在EntityGenerator里加一条保护:如果目标文件存在且文件头没有auto-generated标记,说明被人工改过,直接跳过并打印警告,不覆盖。

现象:表里有ORDER、USER、GROUP这类列名,生成出来的类编译不过。原因:列名转成 PascalCase 后撞上 C# 关键字,或者撞上System命名空间里的类型名(比如String、Object),编译器报「类型或命名空间名称不存在」。 解决:属性和参数名撞关键字时加@前缀;撞类型名时(STRING、OBJECT、TASK这类)在NamingConverter里维护一个冲突名单,自动追加Value或者Field后缀。名单一开始肯定不全,遇到一个补一个,别有心理负担,任何生成器都是这么迭代出来的。

注意:大小写问题在 Oracle 里不会报错,只会安静地返回空结果。我在ReadTableAsync里加的那个「零列就抛异常」就是被这个坑逼出来的,别省掉。

6. 二次开发:把生成器改成团队能长期用的工具

6.1 三个改造方向

默认版本能跑,但真正在团队里用起来,大概率要动三处。第一是对接 ORM:如果项目用 Dapper,只要有[Column]就够了;如果用 EF Core,主键、自增、并发令牌这些都要额外标注;如果用 SqlSugar,特性名字又不一样。做法是把ClassTemplate抽成一个接口,DapperTemplate、EfCoreTemplate、SqlSugarTemplate各实现一份,配置里用"Template": "efcore"切换。

第二是增量生成。表结构不是天天变,每次都全量重写会产生大量无意义的 git diff。我在EntityGenerator里加了一个简单的内容哈希对比:

// 内容一致就不落盘,减少无意义的 git diff var newContent = template.Render(table, options); if (File.Exists(filePath)) { var oldContent = await File.ReadAllTextAsync(filePath); if (string.Equals(oldContent, newContent, StringComparison.Ordinal)) { _logger.Info($"{table.TableName} 未变化,跳过"); return GenerateResult.Skipped(filePath); } } await File.WriteAllTextAsync(filePath, newContent, new UTF8Encoding(false));

new UTF8Encoding(false)那个false是不要 BOM。Visual Studio 默认写 UTF-8 with BOM,生成器如果不显式指定,第一次和第二次生成出来的文件编码可能不一致,git 会把它当成二进制处理,diff 直接变成「Binary files differ」,看起来像文件损坏,实际只是 BOM 的问题。

第三是表过滤。一个 schema 里几百张表,全生成出来没人看。配置里加IncludeTables/ExcludeTables两个通配符列表,默认排除TMP_、BAK_、LOG_开头的表。这一条看着简单,但能省掉大量「生成器跑完之后输出目录里一堆垃圾文件」的抱怨。

改造点改动文件影响范围建议优先级
多 ORM 模板Templating/新增实现Core 内部高
内容哈希跳过EntityGenerator.cs单函数高
表过滤GenerateOptions+Program.cs配置层中
自定义类型覆盖TypeMapper.cs映射层中

6.2 改完怎么验证

改完之后别急着全量跑,先建一张专门的测试表,把能想到的类型都塞进去:NUMBER(10,2)、NUMBER(19)、NUMBER、VARCHAR2(200)、CLOB、DATE、TIMESTAMP WITH TIME ZONE、BLOB、联合主键,再加一列中文注释和一列全大写下划线命名。跑一遍生成,直接编译,能过第一关。

第二关是属性数量。用反射断言「类里的 public 属性数 == 表里的列数」,这一步能抓出元数据查询漏列的问题。第三关是类型断言,把生成的类实例化一个,GetProperty("DealAmount").PropertyType应该等于typeof(decimal?),写几条这样的断言放在tests/里,以后改映射表的时候跑一下就知道有没有改坏。这三关加起来不到五十行代码,但每次改TypeMapper我都会跑一遍。

从那以后我每次动生成器的模板或者映射规则,都强制先跑这三关,再拿一张真实业务表做对照——生成器最可怕的地方不是报错,是它安静地把金额字段类型改错了,等到对账对不上才有人发现。希望帮到你。

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

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

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

立即咨询