☰
EF Core + PostgreSQL 蛇形命名:一行配置与自定义约定实战
2026/9/26 11:21:07 网站建设 项目流程

最近接了一个新项目,技术栈是 ASP.NET Core + EF Core 8 CodeFirst + PostgreSQL。团队里 DBA 和运维同事有一个硬性要求:数据库表名、字段名必须用蛇形命名法(snake_case),也就是created_at、is_deleted这种风格。但 C# 这边的类名、属性名按照团队规范又是标准的驼峰命名(PascalCase),比如CreatedAt、IsDeleted。这两套命名规范本身各有道理,问题是 EF Core CodeFirst 默认生成表结构时,表名直接用 DbSet 属性名,字段名直接用实体属性名,出来的全是"CreatedAt"、"OrderItems"这种带引号的驼峰标识符。PostgreSQL 对带引号的标识符是严格区分大小写的,手写 SQL 的时候非常痛苦。

这篇文章就把我在实际项目里解决这个问题的完整过程写下来。核心结论先放在前面:用 Npgsql 提供的UseSnakeCaseNamingConvention()一行代码就能搞定大部分需求;如果遇到特殊场景,再基于IModelFinalizingConvention自定义约定做精细化控制。我会把这两种方案的原理、代码、迁移结果和容易翻车的细节都过一遍,给正在做 EF Core + PostgreSQL 选型或者准备统一命名规范的同学一个可以直接参考的落地方案。

1. 为什么 PostgreSQL 项目里特别需要蛇形命名

1.1 PostgreSQL 的标识符大小写语义和 MySQL 差异很大

很多从 MySQL 转过来的同学会在 PostgreSQL 上栽跟头,因为两者对标识符大小写的处理逻辑完全不同。MySQL 在 Windows 上默认对表名大小写不敏感,在 Linux 上对表名敏感,但列名基本不区分大小写;PostgreSQL 则把标识符分成两类:不加双引号的标识符会被折叠成小写,加了双引号的标识符则严格区分大小写。

这就造成了一个很尴尬的局面。EF Core 默认生成的 SQL 是这样的:

SELECT o."Id", o."CreatedAt", o."Title" FROM "Orders" AS o WHERE o."Id" = 1;

这条 SQL 在 PostgreSQL 里其实能正常执行,因为 EF Core 生成的 SQL 里给每个表名和列名都加了双引号,PostgreSQL 会按原样解析。但问题在于,你在 pgAdmin、DataGrip、Navicat 这类数据库管理工具里看到的表名和列名也是带着双引号的"Orders"、"CreatedAt"。如果你自己写 SQL 排查数据:

SELECT Id, CreatedAt FROM Orders;

这条 SQL 大概率会直接报relation "orders" does not exist。因为不带引号的Orders被折叠成了小写orders,而真实表名是带引号的"Orders",两者不匹配。必须老老实实写成:

SELECT "Id", "CreatedAt" FROM "Orders";

一次两次还能忍,时间长了谁受得了。尤其是团队里有 DBA、数据分析师、运维同学,他们不是 .NET 开发,没有 EF Core 帮你拼 SQL,全靠手工写脚本。一套全驼峰的表结构在 PostgreSQL 里就是一场灾难。

1.2 从 C# 命名到数据库命名的天然冲突

C# 语言本身的命名规范就是 PascalCase,类名用BlogPost,属性名用CreatedAt,这是从语言层面就定死的习惯。EF Core 作为 CodeFirst 框架,它的默认行为就是"类名即表名、属性名即列名",这在 SQL Server 上问题不大,因为 SQL Server 对大小写不敏感,CreatedAt和createdat在使用上没有区别。

但 PostgreSQL 不是这样。前面说了,PostgreSQL 的存储语义决定了它更适合created_at、blog_post这种全小写加下划线的风格。数据库生态里的各种工具、运维脚本、监控系统,默认也是按 snake_case 来解析元数据的。EF Core 虽然能把带引号的驼峰标识符跑通,但整个 PostgreSQL 生态并不买账。

另外还有一个很现实的问题:数据同步工具和 CDC 组件。很多增量同步软件在解析 PostgreSQL 的 WAL 日志或者系统目录时,对带引号的驼峰列名处理得并不好,经常出现字段映射错乱。而 snake_case 是全小写,所有工具都能正确识别,省掉一堆兼容性问题。

1.3 这是团队协作问题,不只是个人偏好

我在项目里推动蛇形命名的另一个原因,是代码审查和 SQL 审查的效率。DBA 同事审核表结构时,看到created_at、updated_at一眼就知道含义,看到CreatedAt、UpdatedAt还得确认是不是驼峰风格、需不需要加引号,来回沟通的成本非常高。统一命名规范之后,业务开发写 SQL 不用再纠结大小写问题,DBA 写巡检脚本也不用特意绕开带引号的标识符。这套规范一旦在团队的数据库设计文档里定下来,EF Core 这边就必须跟上。

所以问题的本质是:C# 侧保持 PascalCase 不动,数据库侧映射到 snake_case,两边各管各的。EF Core CodeFirst 要做的就是在生成模型的时候把表名和列名做一次约定转换,而不是让开发人员在每个实体类上手动标注[Table]和[Column]。

2. EF Core CodeFirst 的命名生成机制到底发生在哪一步

2.1 从实体类型到关系模型:表名和列名是怎么被决定的

要理解命名策略,得先知道 EF Core 的模型构建流程。EF Core 启动时会把所有实体类通过DbContext注册到ModelBuilder,然后经过一系列约定(Convention)的处理,最终生成一个IModel。这个IModel才是 EF Core 生成 SQL、执行迁移的真正依据。

IModel内部的核心对象是IEntityType(实体类型)和IProperty(属性)。IEntityType会对应一张表,默认表名取自DbSet属性名,如果用的是Set<T>()方式注册,则取实体类型名;IProperty对应一个列,默认列名就是 C# 属性名。整个过程中,表名和列名并不是写死的字符串,而是存在模型里的注解(Annotation)中。

EF Core 内置了非常多约定,比如DbSetNameConvention负责设定表名,PropertyDiscoveryConvention负责发现属性,KeyDiscoveryConvention负责寻找主键。命名策略就是这些约定在模型构建完成前做最后的统一改写。你可以在模型的 finalizing 阶段拿到所有已经计算好的表名、列名、索引名,然后统一替换成 snake_case。这个阶段就是IModelFinalizingConvention约定接口的切入点。

我画一条简化的流程线:

实体类定义 -> DbContext 注册 -> 约定发现属性/关系 -> 生成默认表名/列名 -> 模型 finalizing -> 命名策略转换 -> 迁移/查询

关键点在于"模型 finalizing"这一步。EF Core 把所有需要转换的标识符都放在这一环处理,这就是为什么改动命名策略不需要动任何实体类,完全可以在模型层面统一拦截。

2.2 Npgsql 内置命名策略的原理和用法

Npgsql.EntityFrameworkCore.PostgreSQL很早就注意到 PostgreSQL 社区对 snake_case 的需求,所以从 6.0 版本开始提供了UseSnakeCaseNamingConvention()扩展方法。这个方法来自Npgsql.EntityFrameworkCore.PostgreSQL命名空间,使用起来非常简洁:

optionsBuilder.UseNpgsql( "Host=localhost;Port=5432;Database=blogdb;Username=postgres;Password=your_password", o => o.UseSnakeCaseNamingConvention());

它做的事情本质上就是注册了一个模型约定,在模型 finalizing 阶段把默认约定生成的所有标识符——表名、列名、索引名、外键约束名、主键约束名——统一做一次 snake_case 转换。转换只改数据库侧标识符,C# 类名和属性名完全不受影响。

有同学可能会问,为什么不让实体类本身改成 snake_case?这当然也是一种办法,比如把属性写成created_at。但 C# 里这么做会破坏命名规范,还会影响 JSON 序列化、DTO 映射、代码可读性。用命名策略的方式,实体类保持优雅的 PascalCase,数据库层自动转换成合作方要求的格式,这才是官方推荐的做法。

2.3 自定义约定是更底层的控制手段

如果你用的是 SQL Server,或者你需要同时兼容多种数据库,又或者 Npgsql 内置策略在某些细节上不满足你的需求,那就需要自己实现命名约定。EF Core 提供了IModelFinalizingConvention接口,允许你在模型 finalizing 阶段直接改写IEntityType的表名和IProperty的列名。这个方案的灵活度比 Npgsql 内置策略更高,但也意味着你得自己处理更多边界情况。第三部分我会给出完整实现,这里先记住一个结论:内置策略适合 90% 的标准场景,自定义约定适合剩下 10% 的特殊场景。

3. 实操:先用 Npgsql 一行切换,再用迁移验证

3.1 准备 Demo 项目和实体模型

为了把整个流程讲清楚,我准备了一个最小可运行的 Demo。先建一个控制台项目或者 ASP.NET Core WebAPI 项目,然后安装对应版本的 NuGet 包。

dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 8.0.0

实体类保持正常的 C# PascalCase 风格:

public class Blog { public int Id { get; set; } public string Title { get; set; } public string Summary { get; set; } public DateTime CreatedAt { get; set; } public int AuthorId { get; set; } public Author Author { get; set; } } public class Author { public int Id { get; set; } public string FullName { get; set; } public string Email { get; set; } public List<Blog> Blogs { get; set; } }

这段代码里没有任何[Table]、[Column]特性,所有命名都交给约定处理。

3.2 在 DbContext 中开启 SnakeCase 约定

DbContext的OnConfiguring方法里加上一行:

public class AppDbContext : DbContext { public DbSet<Blog> Blogs { get; set; } public DbSet<Author> Authors { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql( "Host=localhost;Port=5432;Database=snakecase_demo;Username=postgres;Password=your_password", o => o.UseSnakeCaseNamingConvention()); } }

如果是 ASP.NET Core 项目,一般在Program.cs里配置:

builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString, o => o.UseSnakeCaseNamingConvention()));

两种写法效果一样,都是告诉 Npgsql 在生成数据库标识符时使用 snake_case 策略。这里有个容易忽略的细节:UseSnakeCaseNamingConvention()属于 Npgsql 的 provider 配置,不是 DbContext 的通用配置,所以需要放在UseNpgsql的 provider 回调参数里,而不是直接链在UseNpgsql外面。

3.3 执行迁移命令并检查生成的 SQL

接下来生成迁移并更新数据库:

dotnet ef migrations add InitialCreate dotnet ef database update

打开Migrations目录下的迁移文件,你会看到表名、列名、约束名全部变成了 snake_case:

migrationBuilder.CreateTable( name: "blogs", columns: table => new { id = table.Column<int>(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), title = table.Column<string>(type: "text", nullable: false), summary = table.Column<string>(type: "text", nullable: false), created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), author_id = table.Column<int>(type: "integer", nullable: false) }, constraints: table => { table.PrimaryKey("pk_blogs", x => x.id); table.ForeignKey( name: "fk_blogs_authors_author_id", column: x => x.author_id, principalTable: "authors", principalColumn: "id", onDelete: ReferentialAction.Cascade); });

注意两个细节:主键约束名变成了pk_blogs,外键约束名变成了fk_blogs_authors_author_id。Npgsql 内置策略把前缀PK_和FK_也一并转成了小写。这个行为对部分团队来说可能有点意外,因为很多 SQL 规范要求约束名用大写前缀。后面我会专门讲这个问题。

索引名也会受影响。比如给AuthorId加一个普通索引,迁移文件里是这样:

migrationBuilder.CreateIndex( name: "ix_blogs_author_id", table: "blogs", column: "author_id");

原来 EF Core 默认的索引名是IX_blogs_AuthorId,经过 snake_case 转换后变成ix_blogs_author_id。表名和列名全部变成了 PostgreSQL 生态最常见的形式,直接在 pgAdmin 里看表结构非常干净,手写 SQL 也不需要再加双引号了:

SELECT id, title, created_at FROM blogs WHERE author_id = 1;

这里再强调一次:实体类名是Blog、Author,属性名是CreatedAt、FullName,一点都没变。CodeFirst 项目里代码侧和数据库侧的命名彻底解耦,这也是标题里说的"类名仍使用驼峰命名"的关键。

4. 自定义约定实现:不依赖 Npgsql 也能做,且更可控

4.1 自己写 SnakeCase 转换器的注意事项

虽然 Npgsql 内置策略已经很好用,但有些团队可能用的是 SQL Server,或者想在所有关系数据库上保持同一套命名策略,又或者想对转换逻辑做精细化控制。这时候就需要自己实现约定。

转换器本身看起来简单,但容易写错。比如OrderID这种连续大写缩写的场景,一个简单的Regex.Replace(@"([a-z0-9])([A-Z])", "$1_$2")会把APIKey转成apikey而不是api_key,因为API和Key之间没有小写字母夹在中间,正则匹配不到。更稳妥的手写逻辑是要判断当前位置的前一个字符和后一个字符:

internal static string ToSnakeCase(string input) { if (string.IsNullOrEmpty(input)) return input; var sb = new StringBuilder(input.Length + 8); for (var i = 0; i < input.Length; i++) { var c = input[i]; if (char.IsUpper(c)) { if (i > 0 && ShouldSeparate(input, i)) { sb.Append('_'); } sb.Append(char.ToLowerInvariant(c)); } else { sb.Append(c); } } return sb.ToString(); } private static bool ShouldSeparate(string input, int index) { var prev = input[index - 1]; var next = index + 1 < input.Length ? input[index + 1] : '\0'; return char.IsLower(prev) || char.IsDigit(prev) || (char.IsUpper(prev) && char.IsLower(next)); }

这个转换器的思路是:遇到大写字母时,如果前一个字符是小写字母或者数字,说明这里是一个单词边界,需要插入下划线;如果前一个字符是大写字母但后一个字符是小写字母,说明当前大写字母是一个新单词的开头,也要插入下划线。比如OrderID会正确转成order_id,HTTPServer会转成http_server。实际项目里属性名绝大多数是常规 PascalCase,这个转换器已经覆盖得足够好。

4.2 基于 IModelFinalizingConvention 的完整实现

有了转换器,接下来实现IModelFinalizingConvention:

using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Metadata.Builders; public class SnakeCaseConvention : IModelFinalizingConvention { public void ProcessModelFinalizing( IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context) { foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) { // 继承体系下只处理基类表名,避免重复转换 if (entityType.BaseType is not null) continue; var tableName = entityType.GetTableName(); if (!string.IsNullOrEmpty(tableName) && !HasExplicitTableName(entityType)) { entityType.SetTableName(ToSnakeCase(tableName)); } foreach (var property in entityType.GetDeclaredProperties()) { var columnName = property.GetColumnName(); if (!string.IsNullOrEmpty(columnName) && !HasExplicitColumnName(property)) { property.SetColumnName(ToSnakeCase(columnName)); } } } } private static bool HasExplicitTableName(IConventionEntityType entityType) { return entityType.FindAnnotation(RelationalAnnotationNames.TableName)? .GetConfigurationSource() == ConfigurationSource.Explicit; } private static bool HasExplicitColumnName(IConventionProperty property) { return property.FindAnnotation(RelationalAnnotationNames.ColumnName)? .GetConfigurationSource() == ConfigurationSource.Explicit; } }

这段代码里有几个点需要解释。

第一,为什么要检查HasExplicitTableName和HasExplicitColumnName。因为约定不应该覆盖开发人员通过[Table("business_log")]、[Column("custom_name")]显式指定的数据库名称。如果开发人员已经显式命名,再转换一次就是画蛇添足。判断方式是通过注解的ConfigurationSource是否来自 Explicit 来决定。

第二,为什么要用GetDeclaredProperties()而不是GetProperties()。因为 EF Core 的继承映射中,基类属性和派生类属性可能会被合并处理,如果在每个实体类型上都遍历所有属性,很可能出现同一个属性被重复转换的情况。GetDeclaredProperties()只返回当前类型自己声明的属性,避免重复处理。

第三,为什么这里只处理了表名和列名,没有处理索引名和外键名。为了让示例代码保持简洁。如果你想彻底掌握命名,可以继续遍历entityType.GetIndexes()和entityType.GetForeignKeys(),用同样的ToSnakeCase逻辑改写它们的 Name。建议按项目实际需求来裁剪。

4.3 注册自定义约定并处理显式指定名称的场景

实现类写好后,在 DbContext 里注册:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { configurationBuilder.Conventions.Add(_ => new SnakeCaseConvention()); }

ConfigureConventions是 EF Core 专门留给开发者扩展约定的入口。注意不要在项目里同时使用UseSnakeCaseNamingConvention()和自定义SnakeCaseConvention,否则会出现重复转换。比如表名Blogs被内置策略转成blogs,自定义约定再处理一次还是blogs,看起来结果一样,但如果你后续在自定义约定里加了其他逻辑,就会变得很难排查。选择一个方案走到底。

实际用下来,自定义约定最常用的场景就是只转换一部分实体。比如有些表是给第三方系统用的,对方要求保持驼峰命名;有些表是内部核心表,必须用 snake_case。这种情况在约定里添加过滤条件即可:

var shouldConvert = entityType.ClrType.GetCustomAttributes(typeof(SnakeCaseTableAttribute), false).Length > 0;

或者更简单粗暴一些,直接用命名空间前缀过滤:命名空间以Domain.Entities开头的实体才参与转换。这种控制粒度是 Npgsql 内置策略做不到的。

5. 你大概率会踩的坑都在这里

5.1 索引名、外键名也被转换,前缀大小写别惊讶

前面迁移文件里的例子已经展示了,Npgsql 内置策略转换的范围涉及所有数据库标识符,不只是表名和列名。索引名IX_blogs_AuthorId变成ix_blogs_author_id,主键约束名PK_blogs变成pk_blogs,外键约束名FK_blogs_Authors_AuthorId变成fk_blogs_authors_author_id。

这对 PostgreSQL 本身没影响,因为 PG 对约束名和索引名的解析同样遵循大小写折叠规则。但如果团队内部 DBA 给了一堆数据库设计规范,明确要求索引名必须以IX_开头、主键约束名必须以PK_开头,而且是严格大写,那么内置策略就不完全满足要求了。这时候有两个选择:一是接受全部小写的约定名,写规范的时候直接按转换后的格式定义;二是走自定义约定,在处理索引名时把IX前缀保留大写,其余部分转 snake_case。我个人推荐第一个方案,因为完全没有必要为了前缀大小写多维护一套自定义代码。只要整个团队在同一个约定上对齐,小写前缀使用起来没有任何问题。

5.2 已有数据表不要直接开 SnakeCase,迁移会生成一堆重命名

这是最容易翻车的场景,尤其是项目已经上线、数据库里已经有真实数据的情况。假设你已经按照 EF Core 默认规则生成了Blogs表和CreatedAt列,此时再打开UseSnakeCaseNamingConvention(),重新生成一个迁移,EF Core 会认为你要把Blogs重命名为blogs,把CreatedAt重命名为created_at。迁移会变成这样:

migrationBuilder.RenameTable( name: "Blogs", newName: "blogs"); migrationBuilder.RenameColumn( name: "CreatedAt", table: "blogs", newName: "created_at");

如果你只是改了个命名策略,没有真实业务上的重命名需求,那这种大范围重命名迁移会非常危险。涉及到外键引用、索引依赖、报表脚本、缓存表结构的地方,一旦某个环节没同步更新,线上就炸了。我踩过一次,明明只是想统一命名风格,结果因为一个索引依赖没有一起处理,导致某个查询直接报字段找不到。

所以我的建议是:命名策略必须在第一次生成迁移之前就定好,项目初期就加上。如果项目已经跑了很久,数据库结构已经很稳定,与其通过 EF 迁移去改名,不如先评估业务影响,用数据库原生脚本在维护窗口期批量重命名,然后清掉旧迁移重新建基线。总之不要让 EF Core 自动生成的重命名迁移成为默认方案。

5.3 ExecuteSqlRaw 和手写 SQL 不受命名策略保护

命名策略只影响 EF Core 自己生成的 SQL。如果你在代码里用了ExecuteSqlRaw、FromSqlRaw、FromSqlInterpolated这类裸 SQL 查询,EF Core 不会帮你做任何列名转换。比如:

var blogs = await _context.Blogs .FromSqlRaw("SELECT id, title, created_at FROM blogs WHERE author_id = {0}", authorId) .ToListAsync();

这条 SQL 里的表名和列名必须自己写成实际的 snake_case,因为 EF Core 不解析裸 SQL 内容。如果写成FromSqlRaw("SELECT Id, Title FROM Blogs ..."),PostgreSQL 会直接把Id折叠成id,然后报column "id" does not exist,看起来和数据库字段对不上,实际上只是你没加引号。更隐蔽的问题是,如果用了FromSqlRaw参与 LINQ 组合,后面又接了一个 EF Core 生成的查询片段,两个片段的列名风格不一致,就会拼出奇怪的 SQL。所以项目中如果大量手写 SQL,建议建立规范,所有裸 SQL 里的表名字段名统一使用数据库实际名称。

5.4 迁移历史表与命名策略无关,别在这上面浪费时间

__EFMigrationsHistory是 EF Core 自己用的迁移记录表,存放的是每次执行的迁移标识和对应的版本信息。这张表的表名是写死的,不管你怎么改命名策略,它都叫__EFMigrationsHistory。有人问过要不要改这张表的名字,我的答案是不要。这张表是 EF Core 迁移机制的一部分,改了之后 EF Core 无法定位自己的迁移记录,后续dotnet ef database update会直接不认账。命名策略的目标是业务表,不是框架内部表。

5.5 显式指定名称优先,不是所有名字都会被转

用[Table("orders_history")]或者[Column("custom_title")]显式指定的名称,Npgsql 内置策略不会覆盖。这一点很多人容易忽略,以为打开UseSnakeCaseNamingConvention()之后所有表都会强制变成小写。实际上转换只发生在"没有显式名称覆盖"的情况下。如果你在实体类上用了特性,那就以特性的值为准。这也是合理的:开发者手动指定了名称,说明有特定的数据库兼容需求,约定不应该僭越显式配置。

在我上面提到的自定义约定实现里,也通过ConfigurationSource.Explicit判断确认了这一点。所以当你发现某个表没有变成 snake_case 的时候,先检查是不是有[Table]或[Column]在起作用,而不要一头扎进命名策略代码里找 bug。

5.6 Npgsql 版本和 EF Core 版本要匹配

UseSnakeCaseNamingConvention()从 Npgsql.EntityFrameworkCore.PostgreSQL 6.0 开始提供。如果你的 EF Core 是 6.0,对应安装 6.0.x 的 Npgsql 包;EF Core 8.0 就安装 8.0.x 的包。版本不匹配最常见的问题不是编译报错,而是运行期行为异常,模型构建时提示找不到扩展方法或者约定没有生效。这种问题排查起来很烦人,所以记住一个原则:Npgsql.EntityFrameworkCore.PostgreSQL 的主版本号必须和 EF Core 主版本号一致。

6. 进一步思考:可以做得更细

6.1 部分实体不转换的 Whitelist 方案

前面提到自定义约定可以按类型过滤决定是否转换。实际项目中我遇到过这样的需求:同一套 EF Core 模型,大部分表用 snake_case,但有几张表是给老系统对接的,老系统要求必须保持CustomerInfo这种驼峰表名。直接在实体上放一个自定义 Attribute 是最清晰的方案:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class KeepOriginalNamingAttribute : Attribute { }

然后在约定里判断:

if (entityType.ClrType.GetCustomAttribute<KeepOriginalNamingAttribute>() != null) continue;

这个方案比维护一个静态表名白名单要直观得多。新同事看到实体类上的特性,一眼就知道这张表不参与默认命名转换。

6.2 把命名策略写进团队脚手架模板

命名策略这种基础设施,最好在团队的项目模板里直接预置好。新建项目时脚手架自动带上 Npgsql 8.0 包和UseSnakeCaseNamingConvention(),比写在 Wiki 里等待有人想起要高效得多。我在公司里做过一次统一治理,把原本散落在各个仓库里的命名实现统一成一个共享的EntityFrameworkCore.Conventions.SnakeCase类库,所有新建项目直接引用,旧项目批量升级。这样团队内部不会出现"这个项目用 snake_case、那个项目用默认驼峰"的分裂状态。

6.3 如果项目同时支持 PostgreSQL 和 MySQL,考虑自定义约定

如果项目是那种多数据库适配的产品,Npgsql 内置策略就绑死了 PostgreSQL。想让 MySQL 也用 snake_case,SQL Server 也想统一,那就必须用自定义约定。前面实现的SnakeCaseConvention并不依赖任何特定数据库 provider,它操作的是 EF Core 抽象模型层的表名和列名注解,对UseSqlServer、UseMySql、UseSqlite同样生效。跨数据库场景下,自定义约定是比 Npgsql 自带策略更通用的解法。代价就是需要自己维护转换器、处理显式名称覆盖、索引约束名等多个边界情况,比内置方案多写一些代码。不过这些代码写一次,放在共享库,往后所有项目都能受益。

6.4 迁移文件审查要纳入 CR 流程

开启命名策略之后,第一次生成的迁移文件会和之前默认风格差异很大。建议把迁移文件当成正式的代码提交来审查,逐项核对表名、列名、索引名、外键名是否符合预期。很多命名问题在迁移阶段就能发现,不用等到数据库建完再去补救。我一般会打开生成的 SQL 脚本再快速扫一遍:

dotnet ef migrations script -o init.sql

看一看init.sql里有没有异常命名,比如某个列没有被转换、某个约束名还是默认驼峰。确认无误后再提交,可以省掉后续至少一轮改表结构的折腾。

最后再说几句实在话

EF Core CodeFirst 做 PostgreSQL 蛇形命名这件事,技术含量不算高,Npgsql 内置策略能覆盖绝大多数场景。真正麻烦的是团队规范的一致性和项目历史包袱。我现在做新项目的时候,第一行 DbContext 配置就会把UseSnakeCaseNamingConvention()加上,然后告诉所有开发人员:C# 代码里正常写 PascalCase,数据库层你不用管。这样就够了。如果你正在折腾一个已经有一堆旧迁移的项目,我的建议很直接:把这篇文章里提到的坑过一遍,然后开一个分支单独跑一次迁移,把生成的 SQL 逐个核对完再谈合并。命名策略这种事,晚定不如早定,早定比晚定省心太多。

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

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

立即咨询