Oracle表结构生成C#实体类:数据字典驱动与避坑校验

发布时间:2026/11/2 21:31:49
Oracle表结构生成C#实体类:数据字典驱动与避坑校验

简介:面向C#/.NET开发者与Oracle数据库应用维护人员的实体类生成工具包,围绕自动生成与表结构对应的C#实体类展开,缓解手写映射代码繁琐、易错的问题。压缩包共62个文件,以cs源码、dll依赖库、xaml界面文件及xml配置为主,另有csproj、sln等工程文件与少量资源图标,整体约2.99MB,可直接在Visual Studio中打开研究。已有214人学习下载。内容涵盖Oracle连接字符串配置、表与视图的实体类生成、主键与表名注解、字段类型映射,以及DbContext上下文和LINQ增删改查示例;同时涉及Code First迁移、Repository与Unit of Work等扩展思路,便于读者理解ORM映射链路,并据此改造为适合自身项目的代码生成与数据访问方案。

1. 从 Oracle 表结构倒推 C# 实体类:一份能直接跑的生成器拆解

接一个跑了七八年的 Oracle 库,第一件事往往不是写查询,而是给几十上百张表补齐 C# 实体类。手敲三五张还行,到第五十张就开始复制粘贴加改名,然后某个可空列漏了个问号,线上抛出一个空引用。这份资源就是冲这个场景来的:一套基于数据字典的 C# 实体类生成器,读ALL_TAB_COLUMNS、ALL_CONSTRAINTS这些系统视图,按配置把 Oracle 表结构翻译成 C# POCO,带类型映射表、可空判断、命名风格转换和一份批量生成入口。适合正在用 C# 对接 Oracle、又不想被重量级 ORM 绑死的人,尤其适合已经用 Dapper 手工写 SQL、只缺实体层的团队。下面按“怎么选、元数据怎么读、类型怎么映射、哪里会翻车、生成完怎么验”的顺序拆,照着敲能跑出第一批实体。

2. 手写、ORM 反推与元数据生成:三条路线怎么选

2.1 手写 POCO 的死线在哪里

前二十张表手写其实没什么问题,反而更快——你知道每个字段的业务含义,能顺手写上注释和校验。问题出在第二十一张之后,以及每次表结构变更的时候。手写实体的真实成本不在第一次写,而在没人记得同步。

一段典型的手写实体长这样:

// 手写实体的典型形态:属性名是照着 C# 习惯改的,但和库里的大写列名对不上 public class OrderItem { public decimal OrderId { get; set; } public string ProductCode { get; set; } public DateTime CreatedAt { get; set; } }

代码本身没问题,问题在映射。Oracle 里这张表的列名是ORDER_ID、PRODUCT_CODE、CREATED_AT,未加引号的标识符在入库时会被折叠成大写。Dapper 默认的列名匹配只做大小写不敏感比较,不会帮你把下划线去掉,于是ORDER_ID匹配不上OrderId,属性静静躺在默认值上,你还以为查到了数据。要绕过去,要么每个查询都写SELECT ORDER_ID AS "OrderId",要么在生成阶段就把属性名和列名对齐。前者是体力活,后者才是一次性投入。

手写还有两个隐性成本。一是可空性:Oracle 的NULLABLE是Y/N,C# 里对应的是int?和int、string?和string,手写时全靠脑子记,漏一个就等着线上报错。二是类型精度:NUMBER(19,4)到底是decimal还是double,不同的人写出来不一样,同一个项目里能凑出三种风格。

2.2 ORM 反向生成能做到哪一步

第二条路线是把实体层的活交给 ORM 的数据库优先脚手架。以常见的 EF Core 为例,一条命令就能把整库的表反推成DbContext加实体:

# 从现有 Oracle 库反推实体与 DbContext,输出到 Models 目录 dotnet ef dbcontext scaffold \ "User Id=app;Password=***;Data Source=10.0.0.12:1521/ORCLPDB" \ Oracle.EntityFrameworkCore \ --output-dir Models \ --context-dir Data \ --no-onconfiguring \ --use-database-names

参数需要逐个交代清楚。第二个位置参数是驱动包名,必须和项目里引用的版本一致;--output-dir决定实体落盘位置;--no-onconfiguring表示不把连接字符串硬编码进OnConfiguring,避免凭据进仓库;--use-database-names让生成的属性名保持库里的原始大小写,不加这个参数,脚手架会按自己的规则改写,反而更难对照。

这条路线的好处是快,几分钟就能拿到一整套能跑的上下文。但它有几个绕不开的边界。第一,它会生成大量导航属性和反向集合,存量库里的外键往往建得不规范,生成出来的关系图可能互相缠绕,编译能过但用起来很别扭。第二,视图、同义词、复合主键、分区表这些对象,脚手架的覆盖程度参差不齐,遇到报错时排查成本不比手写低。第三,如果你本来就打算用 Dapper 写 SQL,那引入整个 ORM 只为了拿实体,等于为了喝牛奶养了一头牛。

2.3 元数据驱动的生成器骨架

第三条路线是自己读数据字典生成。它的定位很明确:只解决“表结构 → C# 类”这一件事,不引入运行时依赖,生成出来的就是普通 POCO,后面接 Dapper、接手写 ADO.NET、接任何 ORM 都可以。

先把工程建起来,注意生成器要独立于业务项目,避免把 Oracle 驱动依赖带进主程序:

# 生成器单独一个控制台工程,只在开发机上跑 dotnet new console -n EntityGen cd EntityGen dotnet add package Oracle.ManagedDataAccess.Core

第一条命令创建控制台模板,第二条进入目录,第三条引入托管版 Oracle 驱动。这里建议用托管版而不是依赖本机安装的客户端版本,理由是生成器通常在开发机或 CI 上跑,托管版不需要额外装客户端,部署面小,版本也好锁。参数上唯一要注意的是包版本要和业务项目保持一致,否则生成的代码在业务侧编译时可能遇到类型不匹配。

三条路线的取舍可以放在一张表里看:

路线适合规模初次成本结构变更后的维护成本运行时依赖
手写 POCO20 张表以内低高,靠人记无
ORM 反向生成50 张表以内、关系简单低中,重新生成即覆盖绑定 ORM
元数据驱动生成不限,尤其存量库中低,重跑脚本无,产物是普通类

选第三条的理由不是它更先进,而是它把“易错的部分”交给机器:列名到属性名的转换、可空性判断、类型精度映射,这三件事人工做一定会出错,而且错得很安静。生成器写一次,之后每次表结构变更重跑一遍,差异是确定的。

3. 读透 Oracle 数据字典:ALL_TAB_COLUMNS 里的五个关键字段

3.1 列级元数据:类型、长度、精度、可空

生成实体的全部输入,基本都能从ALL_TAB_COLUMNS拿到。这条查询是整个生成器的心脏:

-- 读列级元数据:类型、字节长度、字符长度、精度、标度、可空、默认值 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 FROM ALL_TAB_COLUMNS c WHERE c.OWNER = :owner AND c.TABLE_NAME = :tableName ORDER BY c.COLUMN_ID;

参数只有两个,owner是 schema 名,tableName是表名,两个都用绑定变量传,不要拼字符串——表名里出现特殊字符时拼串会直接语法错误,而且拼串写法在评审时过不了。COLUMN_ID决定列在表里的物理顺序,生成实体时按它排序,产物的字段顺序和表结构一致,对照起来省事。

各字段的含义和用途需要说清楚:

  • DATA_TYPE是类型名,VARCHAR2、NUMBER、DATE、TIMESTAMP(6)、CLOB这些都是它的取值,映射规则完全依赖它;
  • DATA_LENGTH是字节长度,CHAR_LENGTH是字符长度。对VARCHAR2(50 CHAR)这种按字符声明的列,DATA_LENGTH可能是 200(按 UTF-8 估算),CHAR_LENGTH才是 50。判断字符串长度上限时用后者;
  • DATA_PRECISION和DATA_SCALE只对数值类型有意义。精度是总位数,标度是小数位数,NUMBER(19,4)对应精度 19、标度 4;
  • NULLABLE只有Y和N两个值,直接决定 C# 那边要不要加问号;
  • DATA_DEFAULT是列的默认值表达式,类型是LONG,读取时能拿到原始文本,但别在 SQL 里对它做TO_CHAR或字符串拼接,会报错。需要处理默认值就在 C# 侧读出来再解析。

提示:DATA_DEFAULT在部分老版本里返回的是LONG,某些客户端工具展示时会截断。生成器里如果要用它,取出来之后立刻转成string,别留在IDataReader上反复访问。

权限上还有个常见问题:ALL_TAB_COLUMNS只显示当前用户有权限访问的对象。如果生成器连的账号不是对象属主,可能读不全。换用DBA_TAB_COLUMNS需要额外授权,一般做法是给生成器单独开一个只读账号,把目标 schema 的查询权限授全,别图省事直接用业务账号跑。

3.2 主键、自增与序列:ALL_CONSTRAINTS 与 ALL_TAB_IDENTITY_COLS

只有列信息还不够,实体类要不要标主键、主键是单列还是复合、有没有自增,这些得从约束视图里捞。主键查询这样写:

-- 主键列与列在复合主键中的顺序,POSITION 从 1 开始 SELECT cc.COLUMN_NAME, cc.POSITION FROM ALL_CONSTRAINTS ac JOIN ALL_CONS_COLUMNS cc ON cc.OWNER = ac.OWNER AND cc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME WHERE ac.OWNER = :owner AND ac.TABLE_NAME = :tableName AND ac.CONSTRAINT_TYPE = 'P' ORDER BY cc.POSITION;

CONSTRAINT_TYPE = 'P'过滤出主键约束,POSITION给出复合主键里的列序。这里有个细节:ALL_CONS_COLUMNS的关联条件是OWNER加CONSTRAINT_NAME,不能只关联约束名,因为不同 schema 下可能有同名约束。生成实体时把主键列收集成一个HashSet<string>,渲染时在属性上打标记,后续写仓储代码可以直接用。

自增列要看版本。较新的库支持 identity 列,从ALL_TAB_IDENTITY_COLS读:

-- 12c 之后的 identity 列;老库这一句会报对象不存在,需要 try/catch 兜住 SELECT tc.COLUMN_NAME, tc.GENERATION_TYPE FROM ALL_TAB_IDENTITY_COLS tc WHERE tc.OWNER = :owner AND tc.TABLE_NAME = :tableName;

GENERATION_TYPE是ALWAYS或BY DEFAULT,前者表示不允许显式插入,后者允许。生成器读到这一列,就在实体上加一个注释说明是自增列,插入方法里跳过它。老库没有 identity 概念,自增靠序列加触发器实现,那就退回到ALL_SEQUENCES查序列名,或者干脆不管——实体类本身不需要知道序列,那是仓储层的事。

还有一类对象容易被忘掉:虚拟列。虚拟列在ALL_TAB_COLUMNS里有记录,但它的值由表达式算出来,不能写。判断依据是ALL_TAB_COLS.VIRTUAL_COLUMN = 'YES'(注意是ALL_TAB_COLS,不是ALL_TAB_COLUMNS)。生成器如果不处理,用户拿到实体往里赋值,写库时直接报错。

3.3 把查询结果落成中间模型

从数据字典读出来的东西,不要直接往字符串模板里塞,先落成一个中间模型。这一步多写几十行,后面改映射规则时能省掉大量返工:

// 中间模型只承载生成实体所需的信息,和业务实体完全解耦 public sealed record OracleColumn( string Name, // 原始列名,全大写或带引号的原样 string DataType, // VARCHAR2 / NUMBER / TIMESTAMP(6) ... int? Precision, // NUMBER 的总位数,其它类型为 null int? Scale, // NUMBER 的小数位,其它类型为 null int CharLength, // 字符长度,字符串类型判断上限用 bool IsNullable, // NULLABLE = 'Y' int Ordinal, // COLUMN_ID,决定属性顺序 bool IsPrimaryKey, // 从约束视图合并进来 string? DefaultValue // DATA_DEFAULT 原文,可能为 null ); // 一张表的完整描述,表和列一起传进渲染器 public sealed record OracleTable( string Owner, string Name, IReadOnlyList<OracleColumn> Columns);

这里用record而不是class,是因为中间模型生成之后只读不改,值语义更符合用途。Ordinal保存COLUMN_ID,IsPrimaryKey是在读取阶段就把主键查询的结果合并进去,而不是留到渲染阶段再查一次——少一次往返,也避免了渲染器依赖数据库连接。

DefaultValue保留原文是有意的。Oracle 的默认值表达式千奇百怪,SYSDATE、'N'、0都可能出现,硬解析容易出错。生成时先原样写进注释,需要转成 C# 默认值的场景再单独处理。这一步做完,生成器的输入输出就固定了:输入是OracleTable,输出是一个字符串。中间任何环节出问题,都能单独替换。

4. 类型映射与实体生成:从 NUMBER(10,0) 到 int? 的完整对照

4.1 一张表看完 Oracle 到 C# 的映射规则

类型映射是整个生成器最容易写错的地方,错法还很隐蔽——编译能过,运行时才炸。先把对照表定下来:

Oracle 类型C# 类型判定条件与说明
NUMBER(p,0),p ≤ 10int/int?标度为 0 且精度不超过 10 位
NUMBER(p,0),10 < p ≤ 18long/long?同上,位数放宽
NUMBER(p,s),s > 0decimal/decimal?金额、比率一律用 decimal
NUMBER(无精度无标度)decimal/decimal?裸 NUMBER 可存 38 位精度,绝不能当 int
BINARY_FLOATfloat/float?
BINARY_DOUBLE、FLOATdouble/double?
DATEDateTime/DateTime?自带时分秒,不要当纯日期用
TIMESTAMP(n)DateTime/DateTime?纳秒精度在 C# 侧会损失,够用即可
TIMESTAMP WITH TIME ZONEDateTimeOffset/DateTimeOffset?带时区偏移,用 DateTime 会丢信息
VARCHAR2、NVARCHAR2、CHAR、NCHARstring引用类型,可空性用?表达
CLOB、NCLOBstring大文本,读取时注意流式处理
BLOB、RAWbyte[]二进制,注意大对象的内存占用
ROWIDstring

映射函数写成纯函数,输入OracleColumn,输出 C# 类型字符串:

// 类型映射集中在一处,改规则只动这个类 public static string ToCSharp(OracleColumn col) { // 去掉 TIMESTAMP(6) 这类带参数的括号,只留基名 var baseType = col.DataType.Split('(')[0].Trim().ToUpperInvariant(); string core = baseType switch { "NUMBER" when col.Scale is > 0 => "decimal", "NUMBER" when col.Precision is null || col.Scale is null => "decimal", // 裸 NUMBER "NUMBER" when col.Precision <= 10 => "int", "NUMBER" when col.Precision <= 18 => "long", "NUMBER" => "decimal", "BINARY_FLOAT" => "float", "BINARY_DOUBLE" or "FLOAT" => "double", "DATE" => "DateTime", "TIMESTAMP" => "DateTime", "TIMESTAMP WITH TIME ZONE" => "DateTimeOffset", "TIMESTAMP WITH LOCAL TIME ZONE" => "DateTime", "CLOB" or "NCLOB" => "string", "BLOB" or "RAW" => "byte[]", "CHAR" or "NCHAR" or "VARCHAR2" or "NVARCHAR2" or "ROWID" => "string", _ => "object" // 兜底,遇到未覆盖类型时人工介入 }; // 值类型才加可空标记,引用类型在启用可空上下文时同样加 return col.IsNullable ? core + "?" : core; }

这段逻辑有三处值得说明。第一,NUMBER的分支顺序不能乱,when子句从上往下匹配,必须先把“标度大于 0”和“裸 NUMBER”这两个特例处理掉,再判精度。裸 NUMBER 可以存小数,映射成int会直接截断。第二,TIMESTAMP的括号参数被Split('(')去掉了,所以TIMESTAMP(6)和TIMESTAMP(9)走同一个分支,精度差异在 C# 侧本来就是DateTime的 100 纳秒刻度,没必要区分。第三,兜底返回object而不是抛异常,是为了让批量生成能跑完,最后汇总一份“未映射类型清单”让人工确认,而不是第一张表就中断。

注意:NUMBER(10)和NUMBER(10,0)在数据字典里可能表现不同,前者DATA_SCALE为 0,后者也是 0,但某些情况下DATA_SCALE会是 null。判断时用col.Scale is null || col.Scale == 0更稳妥。

4.2 用模板批量生成实体类

渲染环节有两种做法:T4 模板和字符串拼接。T4 在 Visual Studio 里体验好,但它依赖设计时环境,放在 CI 上跑比较别扭;字符串拼接土一点,胜在零依赖、行为可预测。这套生成器用的是后者:

// 渲染单张表的实体,输出可直接编译的 C# 源码 public static string Render(string ns, OracleTable table, IReadOnlySet<string> pkColumns) { var sb = new StringBuilder(); sb.AppendLine("// <auto-generated/>"); sb.AppendLine("// 由 EntityGen 根据数据字典生成,不要手工修改,改表结构后重新生成"); sb.AppendLine("#nullable enable"); sb.AppendLine("using System;"); sb.AppendLine(); sb.AppendLine($"namespace {ns};"); sb.AppendLine(); sb.AppendLine($"public sealed class {Naming.Pascal(table.Name)}"); sb.AppendLine("{"); foreach (var col in table.Columns.OrderBy(c => c.Ordinal)) { var csType = TypeMap.ToCSharp(col); var propName = Naming.Pascal(col.Name); // 主键列打注释,仓储层可以直接按注释约定扫 if (pkColumns.Contains(col.Name)) { sb.AppendLine($" // PRIMARY KEY: {col.Name}"); } // 非空的引用类型给默认值,避免生成可空警告 var initializer = csType == "string" ? " = string.Empty;" : string.Empty; sb.AppendLine($" public {csType} {propName} {{ get; set; }}{initializer}"); } sb.AppendLine("}"); return sb.ToString(); }

几个关键点。// <auto-generated/>这行标记会让部分静态分析工具跳过这个文件,避免生成代码刷出一屏警告。#nullable enable显式打开可空上下文,配合类型映射里加的问号,编译器能帮你盯住非空约束。Naming.Pascal负责把ORDER_ID转成OrderId,这个转换函数要单独写并配单元测试,USER_ID、ID、A_B_C这些边界情况都得覆盖。字符串类型加= string.Empty初始化,是因为 Oracle 里NOT NULL的VARCHAR2列在 C# 侧生成为非空string,如果不初始化,编译器会给警告,而且从数据库读到NULL(历史脏数据)时会变成null赋给非空属性。

主键信息以注释形式写进产物,而不是用特性标注。这是个取舍:特性需要引入额外依赖或者自定义特性类,注释零成本,仓储层用正则扫一下就能拿到主键列。如果项目里已经有统一的实体基类或标注约定,改成特性也就是几行的事。

4.3 生成入口与命令行参数

把读取、映射、渲染串起来的主流程,重点是错误处理策略——批量生成时不能因为一张表出错就整体中断:

// 主流程:先取表清单,再逐表生成,单表失败记录后继续 var tables = reader.ListTables(owner, tableFilter); // tableFilter 支持通配,如 "T_%" Console.WriteLine($"匹配到 {tables.Count} 张表"); var failed = new List<(string Table, string Reason)>(); foreach (var table in tables) { try { var columns = reader.ReadColumns(owner, table); // 内部合并主键、可空、精度 var code = EntityWriter.Render(ns, columns.Table, columns.PrimaryKeys); var path = Path.Combine(outDir, Naming.Pascal(table) + ".cs"); File.WriteAllText(path, code); Console.WriteLine($"[OK] {table} -> {path}"); } catch (Exception ex) { // 单表失败不影响其余表,最后统一汇总 failed.Add((table, ex.Message)); Console.Error.WriteLine($"[FAIL] {table}: {ex.Message}"); } } Console.WriteLine($"完成,失败 {failed.Count} 张"); return failed.Count == 0 ? 0 : 1;

tableFilter参数支持%通配,对应 SQL 里的LIKE,第一次跑建议只匹配两三张表试水,别一上来就全库。outDir是输出目录,生成前先清空或者按表名覆盖,避免残留上一次的旧文件——这一点很重要,表改名之后旧实体还在目录里,编译时会出现两个同名类。返回码设计成“有失败就非零”,方便在 CI 里做门禁。

命令行参数建议直接用手写解析,别为了三个参数引入一个框架:

# 生成指定 schema 下以 T_ 开头的表,输出到当前项目的 Models 目录 dotnet run --project EntityGen -- \ --owner APP \ --filter "T_%" \ --out ./Models \ --namespace MyApp.Models

--owner是 schema 名,大小写敏感,全大写是常规写法;--filter是表名通配;--out决定产物落盘位置;--namespace写进生成文件的namespace声明,必须和业务项目的命名空间一致,否则引用时要额外 using。连接字符串不要放命令行参数,从环境变量读,避免进 shell 历史。

5. 避坑:Oracle 实体类生成最容易翻车的五处

5.1 标识符层面:全大写列名与带引号的对象

坑一:属性永远是默认值,数据库里明明有数据。

现象是查询返回了行,但实体上某个属性一直是0或null,断点看IDataReader里字段名是ORDER_ID,实体属性叫OrderId。原因是 Oracle 对未加引号的标识符统一折叠成大写,而 Dapper 的默认映射只做大小写不敏感比较,不会做下划线到驼峰的转换,两个名字对不上。解决办法有两个方向:一是在 SQL 里显式起别名SELECT ORDER_ID AS "OrderId",注意别名要加双引号才能保留大小写;二是在生成器里统一用 Pascal 命名,再配一个自定义的ITypeMap把列名和属性名对齐。长期看第二种更省事,但需要给 Dapper 配一次映射规则,配置放在数据访问层的初始化里,别散落在各个仓储。

坑二:表在ALL_TAB_COLUMNS里查不到,或者查询报表不存在。

现象是生成器提示“匹配到 0 张表”,但用工具连上去明明能看到。原因是建表时表名加了双引号,比如CREATE TABLE "OrderDetail",这种对象的真实名字就是大小写混合的,而查询里写WHERE TABLE_NAME = 'ORDERDETAIL'匹配不上。更麻烦的是拼 SQL 时如果不加引号,Oracle 会把名字折叠成大写再去查,直接报ORA-00942。解决办法是生成器内部保留两份名字:一份是数据字典里的原始值,用于拼 SQL 时加引号;一份是折叠成大写后的值,用于匹配和过滤。渲染 C# 类名时用原始值转 Pascal,不要用折叠后的。

5.2 类型与空值层面:NUMBER 精度、DATE 时间和 DBNull

坑三:金额字段算出来对不上,小数部分被吃掉。

现象是NUMBER列存了1234.56,读出来变成1234。原因是类型映射时把裸NUMBER(DATA_PRECISION和DATA_SCALE都为空)当成了整数,映射成int。裸 NUMBER 能存 38 位有效数字加小数位,是 Oracle 里最灵活也最危险的数值类型。解决办法是把“精度为空”作为独立分支,直接映射decimal,只有在DATA_SCALE == 0且DATA_PRECISION有值时才考虑整型。改完映射规则后,把库里所有NUMBER列的DATA_PRECISION、DATA_SCALE拉出来过一遍,人工确认没有漏网的特殊列。

坑四:时间部分莫名丢失,插进去永远是零点。

现象是 Oracle 里DATE类型的列存了时分秒,C# 侧写回去之后时间变成00:00:00。原因是 Oracle 的DATE其实包含年月日时分秒,但很多人在 C# 里用DateTime.Date或者只取日期部分赋值,写库时自然就截断了。另一个来源是参数类型:给OracleParameter设OracleDbType.Date时,驱动会按日期处理,需要完整时间就用OracleDbType.TimeStamp或者直接传DateTime让驱动推断。解决办法是实体统一用DateTime,仓储层插入时参数类型显式指定TimeStamp,需要毫秒以上精度就把列改成TIMESTAMP(6),别在DATE上纠结。

坑五:非空字符串属性遇到数据库里的 NULL 直接抛异常。

现象是接口报NullReferenceException,堆栈指向属性访问。原因是生成器把VARCHAR2一律生成成了非空的string,但历史数据里这个列存在NULL(约束是后来加的,或者用NOT NULL但绕过约束写入过)。解决办法是生成时严格按NULLABLE判断,Y生成string?,N生成string并给string.Empty初始化;同时在仓储层读取时统一做一次DBNull判断,DBNull.Value转成null或空串,别让DBNull流到业务层。这一步有个后悔药:在读数据的公共方法里加一层转换,比在几百个实体上打补丁划算得多。

5.3 环境层面:驱动版本与连接字符串

坑六:本地能跑,换台机器就报“找不到程序集”。

现象是生成器在开发机上正常,在 CI 或者同事机器上抛FileNotFoundException,提示某个驱动程序集加载失败。原因是项目里间接引用了多个版本的 Oracle 驱动,运行时加载到了错误的那个;或者用了依赖本机客户端的非托管版本,而那台机器没装客户端。解决办法是生成器工程只引托管驱动,并且在csproj里显式锁定版本,不要用浮动版本号;业务工程和生成器工程各自维护依赖,不要共享bin目录。连接字符串这边也有个高频问题:Data Source写成host:port:SID和host:port/service_name是两种格式,写错了分别报ORA-12505和ORA-12514,遇到连接错误先把这两段拆开单独验证。

6. 生成之后先验哪三张表:一份可复跑的校验清单

生成器跑通不代表产物能用。我一般不会一次性生成全库,而是先挑三张有代表性的表试跑,确认三件事都能对上再放开。

第一张是带复合主键的表,用来验证主键查询和POSITION排序是否正确。检查点是生成文件顶部的PRIMARY KEY注释数量对得上,列顺序和ALL_CONS_COLUMNS.POSITION一致。第二张是含 LOB 字段的表,验证CLOB映射成string、BLOB映射成byte[],而且读取时不会因为大字段把内存撑爆——取一条数据,看CLOB内容是否完整。第三张是含小数和可空列的表,验证NUMBER(19,4)生成了decimal?而不是int,NULLABLE='Y'的字符串列带了问号。

三张表过完之后,用一个小脚本做批量对拍,把生成实体的属性名和数据库列名做一次双向比对:

// 对拍:属性名转回大写下划线,和列名集合比较,缺一或多一都报出来 var props = typeof(Order).GetProperties() .Select(p => Naming.ToSnakeUpper(p.Name)) // OrderId -> ORDER_ID .ToHashSet(StringComparer.OrdinalIgnoreCase); var cols = reader.ReadColumns("APP", "ORDERS").Table.Columns .Select(c => c.Name) .ToHashSet(StringComparer.OrdinalIgnoreCase); var missing = cols.Except(props).ToList(); // 库里有、实体里没有 var extra = props.Except(cols).ToList(); // 实体里有、库里没有 Console.WriteLine($"缺列 {missing.Count},多列 {extra.Count}");

ToSnakeUpper是Pascal的逆运算,专门用来对拍,实现上就是把每个大写字母前面补下划线再整体转大写。Except用OrdinalIgnoreCase比较器,避免大小写差异误报。缺列说明生成器没覆盖到,多半是虚拟列或权限问题;多列说明实体没重新生成,文件是旧的。

验收标准很简单:缺列和多列都为零,三张样表的增删改查各跑一遍不报错,生成目录里没有重复类名。做到这一步,剩下的表可以批量跑,跑完再抽查几张。从那以后我每接一个 Oracle 库,都先把生成器在三张样表上跑通再放开全库,宁可前面多花半小时,也不想在几百个生成文件里找一个漏掉的可空标记。希望帮到你。

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