ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

EF Core映射PostgreSQL原生函数:从HasDbFunction到LINQ查询翻译实战

EF Core映射PostgreSQL原生函数:从HasDbFunction到LINQ查询翻译实战 后端开发换数据库这件事最耗神的往往不是改代码而是把那些在旧数据库上养成的手感推翻重来。做.NET的朋友从SQL Server切到PostgreSQL时最常见的第一次崩溃就是LINQ查询里用不了PostgreSQL那些“真香”的原生函数。整条查询写好跑一下就抛The LINQ expression could not be translated然后你只能盯着错误日志怀疑人生。这篇文章想聊的就是这个主题——EF Core自定义映射PostgreSQL原生函数。它要解决的核心问题很简单让EF Core认识那些默认不认识的PostgreSQL函数让你在LINQ里像调用普通C#方法一样使用它们同时保持查询可组合、参数化、可测试。适合谁看正在.NET 6/8/9项目里接PostgreSQL、被翻译报错卡住的开发者或者准备从SQL Server迁移过来、想提前避坑的团队。1. 为什么需要自定义映射PostgreSQL原生函数1.1 最典型的场景搜索需求逼死翻译器先讲个真实案例。几年前我接了一个博客系统的重构项目业务上有个很普通的需求用户搜索“cafe”数据库里存的是“café”也得搜出来。SQL Server时代我用REPLACE或者干脆存规范化列倒腾得动。切到PostgreSQL之后第一个想到的就是unaccent()这个扩展函数一条SQL就能搞定的东西放在LINQ里却写不出来var list await context.Articles .Where(a EF.Functions.Unaccent(a.Title) keyword) // 根本没有这个API .ToListAsync();EF Core并不提供EF.Functions.Unaccent。你再试试直接调a.Title.Normalize()或者手写一个C#方法处理重音符号EF Core直接告诉你“无法翻译”。这就是问题的核心EF Core的查询翻译器只认识它注册过的那套函数映射PostgreSQL里上百个好用的原生函数默认只有一小部分在Npgsql Provider里注册过。剩下的要么你去用裸SQL要么就得自己动手把函数映射进来。1.2 三种方案对比为什么HasDbFunction最值得学当时我身边很多同事的第一反应是这还不简单直接用FromSqlRaw拼一段带函数的SQL不就行了。确实能跑但代价不小FromSqlRaw和ExecuteSqlRaw写出来的SQL脱离了LINQ表达式树后续的Where、OrderBy、Select没法直接拼上去组合性很差。拼接用户输入的时候要非常小心SQL注入虽然EF Core的参数化机制还在但手写SQL越多翻车概率越高。整个代码库会分裂成“LINQ区”和“SQL字符串区”维护的人得来回切换上下文。第二种方案是用EF Core自带的EF.Functions。Npgsql Provider确实内置了不少好东西比如EF.Functions.ILike()做不区分大小写的模糊匹配EF.Functions.ToTsQuery()做全文检索这些都很好用。但内置列表终究有限像unaccent、jsonb_array_length、jsonb_extract_path_text、pg_trgm的相似度函数以及你自己写的业务函数统统不在里面。第三种方案就是标题里说的这套玩法用ModelBuilder.HasDbFunction()做自定义函数映射。你可以声明一个普通C#静态方法作为“占位符”然后告诉EF Core你在表达式树里看到对这个方法的调用就翻译成某个名字的PostgreSQL函数。这样写出来的查询仍然是纯LINQ能继续叠加筛选和排序参数照常自动绑定测试的时候也容易验证翻译SQL对不对。它是EF Core留给我们的正规扩展点不是绕开框架的歪门邪道。1.3 HasDbFunction的核心机制方法占位符 SQL函数名理解这个机制不需要太高深的知识。你可以把HasDbFunction想象成一个“翻译词典”左侧是C#方法签名右侧是数据库函数名和Schema。EF Core翻译查询的时候遇到左侧的方法调用就去查词典查到之后在生成的SQL里替换成右侧的数据库函数。比如声明了一个PostgresFunctions.Unaccent(string)映射到unaccent(text)那下面的LINQ写法.Where(a PostgresFunctions.Unaccent(a.Title) hello)翻译出来就是 PostgreSQL 认识的SELECT * FROM articles AS a WHERE unaccent(a.Title) __p_0整个过程里EF Core仍然负责参数化、类型推导、组合子查询你只是往它的词典里加了一个词条。接下来我就把从零到一的全过程走一遍包括版本选型、函数分类写法、进阶配置和常见坑一步步说清楚。2. 动手前的准备版本选型与最小可运行项目2.1 推荐的技术栈组合函数映射这个功能从EF Core 3.0之后就稳定了但不同版本在细节上还是有差别尤其是参数配置和翻译SQL检查这块。我个人用的组合是.NET 8你项目用.NET 6或.NET 9也没问题本文代码在8上面验证过EF Core 8.0.xNpgsql.EntityFrameworkCore.PostgreSQL 8.0.xPostgreSQL 14、15、16都行我本机长期用16安装包的时候别装错了主包是Npgsql.EntityFrameworkCore.PostgreSQL它会把底层的Npgsql驱动带上来。用命令装就一行dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL另外要强调一件事PostgreSQL本身得先跑起来。网上关于postgresql安装教程、postgresql使用教程的内容很多我这边不展开只说两个排查重点。第一保证psql能连上连接串里的Host、Port别写错第二某些扩展函数比如unaccent需要你提前在目标数据库里创建扩展这一步在代码里搞不了得用SQL操作CREATE EXTENSION IF NOT EXISTS unaccent;如果你在Windows上装完发现服务起不来通常就是postgresql.conf或数据目录权限的问题先用pg_ctl status看日志定位别急着重装。2.2 搭一个最小可运行Demo项目我习惯把所有验证都压到一个尽量小的项目里这样排查问题时干扰最少。建一个控制台或者最小Web API项目定义一个实体和DbContext就够了public class Article { public int Id { get; set; } public string Title { get; set; } public string Content { get; set; } public string TagsJson { get; set; } // jsonb列先按字符串映射 }public class BlogDbContext : DbContext { public DbSetArticle Articles SetArticle(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql(Hostlocalhost;Databaseblog;Usernamepostgres;Passwordpostgres); } protected override void OnModelCreating(ModelBuilder modelBuilder) { // 函数映射写在这里 } }如果是既有项目直接在你自己的DbContext.OnModelCreating里加映射代码就行。建好表、插两条测试数据先跑通一个最简单的Where查询把环境验证好再开始加函数映射。很多人在这一步就翻车——数据库连接都没通就急着写映射代码最后分不清是翻译问题还是连接问题。3. 核心实操四类PostgreSQL原生函数的映射写法3.1 unaccent扩展函数做重音符号不敏感搜索先看最经典的unaccent。这个函数不属于默认的pg_catalog它在unaccent扩展里。如果你的程序连的是PostgreSQL默认的publicschema函数就安装在public下。表已经建好、扩展也创建了接下来写C#侧的占位方法。我建议把所有占位方法集中到一个静态类里方便统一管理public static class PostgresFunctions { public static string Unaccent(string input) throw new InvalidOperationException(此方法仅供EF Core查询翻译使用请不要在内存中直接调用。); }占位方法体随便你怎么写我通常直接抛异常这样万一某个地方在内存里误调用了会立刻暴露问题而不是悄悄返回错误结果。然后去OnModelCreating注册映射modelBuilder.HasDbFunction( typeof(PostgresFunctions).GetMethod(nameof(PostgresFunctions.Unaccent))!, options { options.HasName(unaccent); options.HasSchema(public); options.HasParameter(input).HasStoreType(text); });这里有个很容易忽略的细节HasSchema(public)不是必须的但强烈建议写上。因为PostgreSQL的search_path在不同会话里不一定包含public如果把函数放在一个不常见schema里又没显式指定EF生成SQL后数据库根本找不到这个函数。另外HasParameter(input)是给参数配置存储类型用的PostgreSQL的unaccent(text)参数类型是text这里要写对。映射完成后查询就可以这么写var keyword cafe; var results await context.Articles .Where(a PostgresFunctions.Unaccent(a.Title) PostgresFunctions.Unaccent(keyword)) .Select(a new { a.Id, a.Title }) .ToListAsync();生成SQL大概长这样SELECT a.Id, a.Title FROM articles AS a WHERE unaccent(a.Title) unaccent(__keyword_0)注意两边的keyword也自动参数化了这就是走翻译管线的好处。如果只想做包含匹配可以把unaccent跟EF.Functions.ILike配合起来实现“重音不敏感 大小写不敏感”的模糊搜索var results await context.Articles .Where(a EF.Functions.ILike( PostgresFunctions.Unaccent(a.Title), $%{PostgresFunctions.Unaccent(keyword)}%)) .ToListAsync();这里%和keyword都在客户端拼好但最终传给数据库的是参数不会引入注入风险。3.2 全文检索组合函数to_tsvector加plainto_tsquery全文检索比单个函数的映射稍微麻烦一点。PostgreSQL原生做法是to_tsvector(english, title) plainto_tsquery(english, query)这里面有两个函数加一个操作符。HasDbFunction默认只能映射“一个函数调用”没法直接表达“两个函数加运算符”的复合结构。这不是说不能做而是有两种选择。第一种选择是用HasTranslation手工组装表达式树——这个API比较底层我得承认写起来挺吃力不同EF Core版本的构造函数还不太一样。第二种选择是我更推荐的在PostgreSQL端先包一个函数把复合逻辑收拢成一个函数调用再映射这个函数。既然我们已经熟门熟路写SQL直接在数据库里建一个wrapperCREATE OR REPLACE FUNCTION public.search_title(title text, query text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT to_tsvector(english, title) plainto_tsquery(english, query) $$;把to_tsvector、、plainto_tsquery全部藏进search_title里。然后C#侧声明public static bool SearchTitle(string title, string query) throw new InvalidOperationException(仅供EF Core查询翻译使用。);映射注册modelBuilder.HasDbFunction( typeof(PostgresFunctions).GetMethod(nameof(PostgresFunctions.SearchTitle))!) .HasName(search_title) .HasSchema(public);查询时这样用var queryText postgresql docker; var ids await context.Articles .Where(a PostgresFunctions.SearchTitle(a.Title, queryText)) .Select(a a.Id) .ToListAsync();翻译出来的SQL简洁又稳定SELECT a.Id FROM articles AS a WHERE public.search_title(a.Title, __queryText_0)这里有几个细节值得说。第一LANGUAGE sql的wrapper比LANGUAGE plpgsql更轻IMMUTABLE关键字是我故意加的目的是将来可以用它直接建表达式索引后面讲性能的时候再展开。第二全文检索的语言配置在wrapper里写死了english如果你的业务用到中文分词就要按需改simple、chinese或者自定义parser改成参数传进去更灵活。第三如果团队里有人不喜欢建wrapper函数觉得“数据库里多一个东西很烦”那也可以用HasTranslation直接在EF侧构造表达式。我只给一个示意正式代码请参考你所用EF Core版本的APIoptions.HasTranslation(args { // args[0] 是title列args[1] 是查询词参数 // 这里需要构造 to_tsvector 和 plainto_tsquery 的 SqlFunctionExpression // 再用 SqlBinaryExpression 把它们拼成 比较 });这个方案更“纯EF”但修起来很费神。我的经验是对于像全文检索这种业务逻辑偏重、还可能经常调索引策略的场景把复杂度放到有名字、有版本、可单独测试的数据库函数里长期来看更好维护。3.3 jsonb处理函数在LINQ里操作jsonb字段PostgreSQL的jsonb能力是很多人选它的核心理由但EF Core对jsonb的默认认知很有限TagsJson这种字段你映射成string也就能查询个整列。想在数据库里直接取某个键、算数组长度就得映射jsonb函数。我常用的是这两个jsonb_array_length(jsonb) -- 返回数组元素个数 jsonb_extract_path_text(jsonb, text) -- 按路径提取文本值对应的占位方法public static int JsonArrayLength(string jsonb) throw new InvalidOperationException(仅供EF Core查询翻译使用。); public static string JsonExtractPathText(string jsonb, string path) throw new InvalidOperationException(仅供EF Core查询翻译使用。);注册映射modelBuilder.HasDbFunction( typeof(PostgresFunctions).GetMethod(nameof(PostgresFunctions.JsonArrayLength))!) .HasName(jsonb_array_length); modelBuilder.HasDbFunction( typeof(PostgresFunctions).GetMethod(nameof(PostgresFunctions.JsonExtractPathText))!) .HasName(jsonb_extract_path_text);jsonb_array_length和jsonb_extract_path_text都在pg_catalog里所以不写HasSchema也能解析PostgreSQL默认优先查找pg_catalog。如果你不想依赖隐式行为也可以显式HasSchema(pg_catalog)。实体属性这里有个知识盲区TagsJson虽然C#侧是string但数据库列应该是jsonb类型映射时建议显式指定modelBuilder.EntityArticle() .Property(a a.TagsJson) .HasColumnType(jsonb);有了这些映射查询就能这么写var rows await context.Articles .Where(a PostgresFunctions.JsonArrayLength(a.TagsJson) 0) .Where(a PostgresFunctions.JsonExtractPathText(a.TagsJson, author) admin) .Select(a new { a.Id, a.TagsJson }) .ToListAsync();翻译SQL大概长这样SELECT a.Id, a.TagsJson FROM articles AS a WHERE jsonb_array_length(a.TagsJson) 0 AND jsonb_extract_path_text(a.TagsJson, __p_1) admin这个写法会把过滤条件下推到数据库执行而不是把整列jsonb拉回内存再解析。数据量小的时候感觉不到差别数据量大起来内存和CPU的差距就非常明显了。3.4 内置标量函数与自定义业务函数md5和计算函数最后一类最通用把PostgreSQL内置的标量函数、或者你自己写的业务函数都能映射进来。比如内置函数md5(text)注册方式如出一辙public static string Md5(string input) throw new InvalidOperationException(仅供EF Core查询翻译使用。); modelBuilder.HasDbFunction( typeof(PostgresFunctions).GetMethod(nameof(PostgresFunctions.Md5))!) .HasName(md5);再比如我碰过的一个电商项目订单表要根据金额和会员等级算一个“优先发货评分”这个逻辑写在数据库函数里比挪到C#里强得多。PostgreSQL端CREATE OR REPLACE FUNCTION public.shipping_priority(amount numeric, level int) RETURNS numeric LANGUAGE sql IMMUTABLE AS $$ SELECT CASE WHEN level 3 THEN amount * 2 WHEN level 2 THEN amount * 1.5 ELSE amount END $$;C#侧照旧public static decimal ShippingPriority(decimal amount, int level) throw new InvalidOperationException(仅供EF Core查询翻译使用。);映射modelBuilder.HasDbFunction( typeof(PostgresFunctions).GetMethod(nameof(PostgresFunctions.ShippingPriority))!) .HasName(shipping_priority) .HasSchema(public);在LINQ里拿它做排序都能直接用var list await context.Orders .OrderByDescending(o PostgresFunctions.ShippingPriority(o.Amount, o.MemberLevel)) .Take(50) .ToListAsync();这种模式的价值在于业务规则以函数形式沉淀在数据库里后你在LINQ侧不需要写一堆if的表达式拼装映射还保证了翻译成SQL时用的是同一个函数不会出现C#和SQL逻辑漂移的问题。4. 进阶细节参数、Schema与翻译SQL验证4.1 HasDbFunction的参数配置与类型映射只写HasName和HasSchema其实能跑通大部分场景但遇到参数类型复杂的函数就得配HasParameter。这个配置项接受一个参数名然后你可以继续设置HasStoreType、IsNullable、HasPrecision等。比如上面shipping_priority的amount是numeric如果C#这边用的是decimal默认映射一般没问题但如果你用的是double就可能翻译成double precision跟数据库的numeric对不上函数调用在数据库端会报类型错误。另一个容易踩的是返回类型。HasDbFunction会根据你方法声明的返回类型推断SQL表达式的类型所以占位方法返回int、string、bool、decimal时要尽量贴近真实业务类型。比如jsonb_array_length返回整数方法就声明为int如果列可能是空的那就声明为int?并且可以在注册时配置options.HasParameter(jsonb).IsNullable();EF Core拿到这个信息后翻译时能更准确地判断结果是否可能为NULL后续再对这个字段做??、 null之类的操作才不会生成错误的SQL。类似的配置还包括HasPrecision你用numeric(12,2)存储金额时给函数参数配上同样的精度能避免很多诡异的舍入差异。4.2 Schema与扩展依赖为什么函数找不到函数映射最常见的翻车现场就是“函数明明存在但EF Core生成SQL后数据库报function xxx does not exist”。原因基本都在Schema上。PostgreSQL解析函数名依赖search_pathEF Core生成SQL时如果没带Schema就完全交给数据库的search_path去猜。你的函数建在public下还好一旦建在自定义schema里比如extensions而当前数据库用户的search_path又没包含它立刻炸。解决方式很粗暴但有效映射时把Schema写全。unaccent如果装在public就HasSchema(public)如果出于安全考虑把扩展装进了独立schemaCREATE EXTENSION unaccent SCHEMA extensions;那映射就要跟上options.HasName(unaccent); options.HasSchema(extensions);另外要注意扩展函数跟普通函数的权限体系一样。创建扩展通常需要较高权限如果生产库不允许应用账号执行CREATE EXTENSION就得让DBA先装好应用侧只做映射。很多团队在测试环境能跑、生产环境跑不了查到最后都是扩展没安装或者Schema对不上。4.3 翻译SQL怎么验证别等运行时才看函数映射有一个天然好处——翻译结果是可以提前看的。EF Core 5之后IQueryable提供了ToQueryString()方法可以在不执行查询的情况下拿到将要发送给数据库的SQL。我强烈建议每次写新的函数映射都先执行一下这段代码var query context.Articles .Where(a PostgresFunctions.Unaccent(a.Title) cafe) .Select(a new { a.Id, a.Title }); Console.WriteLine(query.ToQueryString());输出类似SELECT a.Id, a.Title FROM articles AS a WHERE unaccent(a.Title) __value_0看到SQL里函数名、参数、Schema都对再真正执行查询。如果你用的是真正的EF Core应用也可以开日志optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging();日志里会输出每条查询的SQL和参数值排查问题比盯着异常信息高效得多。最后再提一个习惯写完函数映射后用psql先手测一遍函数本身。比如SELECT unaccent(café); SELECT public.search_title(Hello PostgreSQL, postgres);函数本身都不对映射再熟练也是白搭。这条链条检查完再把查询放回EF Core里基本就是一路顺风。5. 常见问题与排查技巧实录5.1 “could not be translated”的7种常见原因这个报错是函数映射最频繁出现的拦路虎。我整理了几年里遇到过的代表性原因做成速查表报错表现常见原因解决办法调用占位方法提示“请勿在内存中调用”查询提前被枚举表达式树没有整体翻译检查是否有ToList()、AsEnumerable()截断了查询could not be translated且方法已注册方法名或Schema配置不正确用ToQueryString()看翻译结果确认函数名函数存在但数据库报not existSchema没写对或search_path不匹配显式配置HasSchema参数类型不匹配C#类型与PG函数参数类型不一致用HasParameter(...).HasStoreType(...)指定类型返回类型推断错方法返回类型与函数实际返回类型不一致修正占位方法返回类型多环境一个行一个不行扩展只在部分环境创建统一用迁移SQL建扩展并确认schema同一个方法被重复注册多个DbContext或多次调用HasDbFunction检查注册代码避免重复配置排查时的固定动作是先把查询缩小到只包含一个函数调用用ToQueryString()输出SQL再用psql手动执行这段SQL最后检查EF侧的参数绑定。三步下来九成问题都能定位。5.2 性能陷阱函数包裹导致索引失效映射函数能跑通不等于能跑得快这里面的坑比翻译报错还隐蔽。以WHERE unaccent(a.Title) p为例如果表里数据量大这个查询通常会全表扫描因为普通B-Tree索引建立在title原始列上而查询条件对列做了函数变换索引自然派不上用场。解决办法是建表达式索引让PostgreSQL知道“函数处理后的值”也需要索引CREATE INDEX idx_articles_unaccent_title ON articles (unaccent(title));但这里有个前置条件表达式索引里的函数必须是IMMUTABLE的。unaccent在部分PostgreSQL版本里没有被标记为IMMUTABLE直接建索引会报错。通用做法是先包一层强制标记为IMMUTABLE的wrapper函数再建表达式索引CREATE OR REPLACE FUNCTION public.f_unaccent(text) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT public.unaccent(public.unaccent, $1) $$; CREATE INDEX idx_articles_f_unaccent_title ON articles (public.f_unaccent(title));全文检索也一样search_title里用了to_tsvector(english, title)想加速就要建匹配的GIN索引CREATE INDEX idx_articles_fts ON articles USING GIN (to_tsvector(english, title));注意索引表达式必须跟查询里的表达式完全一致语言配置不一样索引就匹配不上。这些细节看着琐碎但在生产环境里一个没有对症索引的函数查询可能就是慢查询告警的源头。5.3 迁移生成、多数据库与部署权限的坑还有一个经常被问的问题HasDbFunction会不会自动帮我在数据库里创建函数答案是不会。它只是EF Core翻译器的映射配置PostgreSQL端函数的创建需要你自己负责。有两条路线一是在迁移文件里手写SQL比如migrationBuilder.Sql( CREATE OR REPLACE FUNCTION public.search_title(title text, query text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT to_tsvector(english, title) plainto_tsquery(english, query) $$; );二是用独立的部署脚本管理函数EF这边只管映射。我个人倾向后者因为函数变更频率跟实体结构变更不一定同步混在一个迁移里容易把DbContext的迁移历史搞得很乱。多数据库支持是另一个坑。如果项目未来要同时支持SQL Server和PostgreSQL千万别在OnModelCreating里无条件注册PostgreSQL函数映射否则切到SQL Server时只要查询用到这些占位方法翻译器就会报错。建议按Provider条件注册if (Database.IsNpgsql()) { modelBuilder.HasDbFunction( typeof(PostgresFunctions).GetMethod(nameof(PostgresFunctions.Unaccent))!, options { options.HasName(unaccent); options.HasSchema(public); }); }最后是部署权限。CREATE EXTENSION unaccent不一定允许生产库的应用账号执行很多公司出于安全考虑会限制扩展安装。让DBA提前在目标库执行好扩展创建应用的迁移脚本只保留函数自身的CREATE OR REPLACE这样上线时少踩很多权限的坑。6. 关于函数映射我的几点实操体会6.1 先确认函数本身再写映射这个经验我重复过很多次PostgreSQL端的函数一定先在 psql 里跑通再谈映射。自己在数据库里敲几遍SELECT确认参数个数、类型、返回结果和边界情况再回C#写占位方法和注册代码。顺序反过来的话你会同时面对“函数逻辑问题”和“EF翻译问题”排查成本直接翻倍。6.2 占位方法抛异常不是Bug是安全网很多人看到我的占位方法里写throw new InvalidOperationException第一反应是不理解方法一调就炸怎么写测试其实这正是它该有的样子。占位方法只允许出现在表达式树里由EF Core翻译成SQL永远不应该在内存里被真正执行。如果某天你发现某个查询在运行时抛了这个异常说明查询在翻译前被提前枚举了或者占位方法用错了地方。这种“快速失败”的设计能帮你尽早抓到错误的调用时机。6.3 建议单独开一个PostgresFunctions类并保持纯净我在项目里会把所有PostgreSQL映射相关的占位方法集中在同一个静态类跟普通业务代码分开。类里面只放“供翻译”的方法不掺任何业务实现逻辑。这样团队其他人看到这个类第一眼就能知道“这里的东西是EF翻译用的”规约清晰减少误用。后续如果函数多了还可以按模块再拆成PostgresFunctions.Search、PostgresFunctions.Json这种小类但类名要避免重复。6.4 下一步可以怎么扩展如果你把本文这套跑熟了可以继续往两个方向深入。第一是研究HasTranslation它能让你绕过数据库wrapper函数直接在EF侧组合多个数据库原生函数生成等效SQL适合不想在数据库里留一堆中间函数的团队但需要你对表达式树API有一定掌握。第二是关注Npgsql官方文档里的函数映射清单有些你以为要手动映射的函数官方其实早就内置了用EF.Functions更省事。函数映射这件事说到底就是一种“翻译词典”的维护工作把边界画清楚、把验证流程固定下来PostgreSQL在EF Core里的体验就会顺畅得多。
返回列表