ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

EF Core调用PostgreSQL原生函数:similarity映射实战与踩坑指南

EF Core调用PostgreSQL原生函数:similarity映射实战与踩坑指南 做.NET后台的兄弟应该都遇到过这种破事业务方提了一个“搜索联系人名字越接近排越前”的需求数据库是 PostgreSQL扩展也装好了similarity(张三, 张珊)在 psql 里跑得飞快结果一回到 EF Core 的 LINQ 查询里写Where(u MyFunctions.Similarity(u.Name, keyword) 0.3)直接抛异常——could not be translated。我第一次遇到这个报错时第一反应是换方案干脆把所有数据拖回内存用 C# 算相似度。几千条数据还行几十万条一跑慢到怀疑人生。后来被这个问题逼着把 EF Core 的自定义映射机制啃了一遍才明白EF Core 完全有能力调用 PostgreSQL 原生函数问题只在于它默认不认识这些函数需要我们给它一张“翻译对照表”。这篇文章就围绕这个话题把函数映射的原理、实操、踩坑一次讲清楚适合正在用 EF Core Npgsql 做真实项目的朋友参考。1. 为什么 EF Core 对 PostgreSQL 原生方法“视而不见”查询翻译失败的本质1.1 一个最典型的失败现场先还原现场。EF Core 8 Npgsql 8实体User表users。我需要按用户输入的keyword做名字相似度搜索PostgreSQL 的pg_trgm扩展提供了similarity(text, text)函数返回两个字符串的相似度 0~1。自然写法是var keyword 张珊; var users await context.Users .Where(u MyFunctions.Similarity(u.Name, keyword) 0.3) .OrderByDescending(u MyFunctions.Similarity(u.Name, keyword)) .Take(20) .ToListAsync();MyFunctions.Similarity是我写的静态方法看着挺合理但 EF Core 抛出一段经典报错The LINQ expression u MyFunctions.Similarity(u.Name, __keyword_0) 0.3 could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly...提could not be translated的开发者很多但真正去搞清楚“为什么不能翻译”的很少。这话拆开理解就是EF Core 的查询翻译器遇到一个它不认识的方法调用既不知道这个方法该生成什么 SQL又不知道它该保持原样还是被剔除于是直接放弃治疗。1.2 翻译器到底在查什么映射表与未映射方法可以把 EF Core 的 SQL 翻译器想象成一个查字典的翻译员。它手里有一本“方法翻译字典”里面登记着遇到哪个 C# 方法就应该生成哪段 SQL。这本字典分成两层。第一层是 EF Core 框架自带的基础翻译比如string.ToUpper()翻译成UPPER(...)、string.Contains()翻译成LIKE %...%。第二层是数据库提供程序Provider补充的翻译表比如 Npgsql 提供程序额外注册了EF.Functions.ILike、EF.Functions.ArrayContains这类 PostgreSQL 特有的函数。HasDbFunction做的就是往这本字典里添新词条告诉翻译器“当你看到一个静态方法MyFunctions.Similarity时请把它翻译成 PostgreSQL 的similarity函数调用”。翻译器在扫描 LINQ 表达式树时会对每一个方法调用节点查字典。查得到就替换成对应的 SQL 表达式节点查不到就抛could not be translated。这里有一个很容易被忽略的细节只有当方法调用节点出现在“可以被 SQL 化”的位置时翻译器才会尝试查字典。如果是Where、OrderBy这种条件或排序位置EF Core 3.0 之后是强制的服务端翻译查不到就报错。如果是Select里某个不参与 SQL 的纯客户端计算比如Select(u new { Age ComputeAge(u.Birthday) })翻译器可能选择不动它。1.3 客户端评估看上去能跑其实慢得离谱有人可能会想既然服务端翻译不了那我先把数据捞回来再用 C# 方法算不也一样吗小数据量确实可以但掩盖了一个性能陷阱。我接过一个真实案例同事在Where外面先.ToList()再过滤理由就是“EF Core 翻译不了这个函数干脆在内存里处理”。数据表当时只有两千行感觉不出来。后来数据涨到五十万行查询直接变成把整张表拉进应用服务器内存然后再逐行算相似度、排序、分页。一次搜索把 CPU 打满接口超时。所以我的原则是能用数据库函数解决的一定要让它在数据库里算完再把结果取回来。EF Core 的翻译失败并不是“这条路走不通”而是“你还没告诉它怎么走”。2. 从 C# 方法到 SQL 函数函数映射的工作原理2.1 LINQ 表达式进入翻译管线后发生了什么既然要自定义映射最好先对大致的内部管线有个概念不然配置报错时会无从下手。EF Core 执行一次查询大体经历这么几步先是你的 LINQ 查询被编译成表达式树然后表达式树被拆解、规范化最终形成一个由SelectExpression、TableExpression、WhereExpression等节点组成的可翻译结构最后QuerySqlGenerator把这个结构输出成 SQL 文本。自定义方法映射的接入点就在“表达式树节点被翻译成 SQL 节点”这一步。具体来说当翻译器遇到MyFunctions.Similarity(u.Name, keyword)这个MethodCallExpression节点时会拿着方法名去方法翻译表里找。HasDbFunction注册的条目会让它命中一张元数据这个 C# 方法对应哪个数据库函数名、哪个 schema、参数怎么对应、返回值是什么类型。命中之后这个节点会被替换成一个 SQL 函数表达式翻译器后续就把它当成一个普通的 SQL 函数调用处理。2.2 HasDbFunction 做了什么用代码说更直接。在DbContext的OnModelCreating里写这样一段protected override void OnModelCreating(ModelBuilder modelBuilder) { var method typeof(MyFunctions) .GetMethod(nameof(MyFunctions.Similarity))!; modelBuilder.HasDbFunction(method, b { b.HasName(similarity); b.HasSchema(public); }); }这行配置做三件事告诉 EF CoreMyFunctions.Similarity这个 C# 静态方法是一个“可翻译数据库函数”不是普通本地代码。指定它在数据库里的真实名字函数名similarity所属 schema 是public。让翻译器在生成 SQL 时按照参数位置生成similarity(参数1, 参数2)的调用。注意method通过GetMethod反射拿到所以MyFunctions.Similarity必须在编译期存在。如果方法名拼错反射会返回 null!会掩盖问题运行期才炸这点后面踩坑部分会展开。其实 EF Core 还支持更简洁的属性方式在方法上用[DbFunction]特性。[DbFunction(similarity, Schema public)] public static float Similarity(string left, string right) throw new NotSupportedException();加了特性后EF Core 会在模型构建时自动发现并注册OnModelCreating里就不需要再写了。两者可以混用但如果两种方式都配置同一个方法HasDbFunction的优先级高于特性并且可以额外覆盖参数类型等细节。2.3 数据库函数、参数与返回值的对应关系映射关系本质上是一张表C# 侧原型方法PostgreSQL 侧静态类方法名Similarity数据库函数名similarity方法参数顺序(left, right)函数参数位置(text, text)方法返回类型float函数返回类型real方法所在类任意函数所属 schemapublicEF Core 默认按参数顺序生成 SQL 调用所以 C# 方法的参数顺序必须和数据库函数的参数顺序一致。参数名可以不同但不建议故意搞得不一样否则排查问题时要多绕一个弯。返回值类型的映射也很关键。similarity在 PostgreSQL 里的返回类型是real单精度浮点Npgsql 通常把它映射为 C# 的float。如果你按大多数教程写成double会有隐患某些比较场景下 PG 会因为类型推断问题报错或者 Npgsql 在读取时做隐式转换结果不符合预期。这点后面踩坑部分会专门讲。3. 实操把 similarity 函数映射进 EF Core3.1 准备环境PostgreSQL 扩展与数据模型先确认环境。我用的是 .NET 8 EF Core 8 Npgsql.EntityFrameworkCore.PostgreSQL 8.xPostgreSQL 16。NuGet 包别装混了EF Core 8 就用 8.x 的 Npgsql 包EF Core 9 就用 9.x版本错位经常导致一些莫名其妙的行为差异。similarity函数由pg_trgm扩展提供先确认扩展装上psql -U postgres -d mydb -c CREATE EXTENSION IF NOT EXISTS pg_trgm;然后一个简单的实体public class User { public int Id { get; set; } public string? Name { get; set; } public string? Email { get; set; } }对应users表这个不用多解释。3.2 定义函数原型静态方法与 DbFunction 特性函数原型就是一个“永远不执行”的静态方法它存在的意义只是给 EF Core 一个可以识别的 C# 签名。我用的是[DbFunction]特性方式using Microsoft.EntityFrameworkCore; public static class MyFunctions { [DbFunction(similarity, Schema public)] public static float Similarity(string left, string right) throw new NotSupportedException(仅供 EF Core 翻译使用不能在内存中调用); }方法体里写throw new NotSupportedException()是刻意的。它不是在等待别人实现而是在声明这个方法没有 C# 实现它只是一个翻译锚点。如果你在普通代码里不小心调用了它立刻抛异常能马上发现问题如果返回一个默认值反而掩盖了错误。[DbFunction]第一个参数是数据库函数名必须和 PostgreSQL 里的函数名一致。Schema public指定函数所在的 schema如果函数在默认搜索路径里也可以不写但写了更明确也不容易因为不同的连接配置导致搜不到。3.3 在 OnModelCreating 注册映射如果用了[DbFunction]EF Core 会自动发现OnModelCreating可以不写。但我更推荐显式注册理由在后面会说。显式写法protected override void OnModelCreating(ModelBuilder modelBuilder) { var method typeof(MyFunctions) .GetMethod(nameof(MyFunctions.Similarity)) ?? throw new InvalidOperationException(找不到 Similarity 方法); modelBuilder.HasDbFunction(method, b { b.HasName(similarity); b.HasSchema(public); }); }加了个 null 检查比直接!好一点配置写错时异常信息更直观。3.4 在 LINQ 中调用并验证生成的 SQL映射注册完成后就可以在 LINQ 里正常用了var keyword 张珊; var users await context.Users .Where(u MyFunctions.Similarity(u.Name, keyword) 0.3) .OrderByDescending(u MyFunctions.Similarity(u.Name, keyword)) .Take(20) .Select(u new { u.Id, u.Name, Score MyFunctions.Similarity(u.Name, keyword) }) .ToListAsync();打开 EF Core 的 SQL 日志可以看到生成的 SQLSELECT u.Id, u.Name, similarity(u.Name, __keyword_0) AS Score FROM Users AS u WHERE similarity(u.Name, __keyword_0) 0.3 ORDER BY similarity(u.Name, __keyword_0) DESC LIMIT 20;这就是自定义映射的核心价值你的 C# 静态方法在 LINQ 里被如实翻译成了数据库函数调用过滤、排序、投影都在数据库端完成返回的只有 20 条结果。查 SQL 日志我一般用optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information)开发时打开生产环境换文件日志。Npgsql 还会在参数前面打上参数类型比如__keyword_0张珊 (Type String)排查类型问题很有用。4. 处理更复杂的原生函数参数类型、Schema、重载4.1 参数与返回值的类型映射similarity比较简单参数就是text返回real。实际项目里经常遇到 PostgreSQL 特殊类型的函数典型就是 JSONB 操作。比如我想在 LINQ 里过滤 JSONB 数组的长度数据库函数是jsonb_array_length(jsonb)返回int。C# 侧我用string接收 JSON默认会被映射成text直接调用时会报函数不存在。解决办法是显式覆盖参数存储类型public static class JsDbFunctions { [DbFunction(jsonb_array_length, Schema public)] public static int JsonbArrayLength(string jsonb) throw new NotSupportedException(); }注册时覆盖参数类型var method typeof(JsDbFunctions) .GetMethod(nameof(JsDbFunctions.JsonbArrayLength))!; modelBuilder.HasDbFunction(method, b { b.HasName(jsonb_array_length); b.HasSchema(public); b.HasParameter(jsonb).HasStoreType(jsonb); });HasParameter(jsonb)里的参数名要和你 C# 方法的参数名一致它不是数据库参数名而是用来定位 C# 侧签名。这样翻译器就知道这个参数不是 text而是 jsonb生成 SQL 时才不会搞错。常见的参数类型覆盖我列个表C# 参数类型默认映射常见覆盖目标典型函数stringtextjsonb、varchar(n)jsonb_array_lengthint[]integer[]text[]array_length、unneststringtextbyteapgp_sym_decryptfloatrealdouble precision部分统计函数4.2 Schema 与函数可见性PostgreSQL 里函数挂在 schema 下默认搜索路径通常包含pg_catalog和public。如果你函数放在自定义 schema比如thirdparty.similarity那么映射时HasSchema(thirdparty)必须写上。还有一个容易忽略的点Npgsql 连接字符串里的Search Path会影响 EF Core 翻译时对“是否加 schema 前缀”的判断。如果你的数据库里多个 schema 都存在同名函数建议映射里显式指定 schema避免被交给 search_path 猜。我见过一个案例测试环境 search_path 包含 public生产环境改了 search_path结果同一个函数在测试环境跑得好好的到生产就报函数不存在就是因为映射里没写 schema翻译出来的 SQL 不带 schema 前缀生产环境的 search_path 里又找不到。4.3 表值函数与集合返回有些 PostgreSQL 函数返回的不是标量而是记录集合比如search_users(keyword text) returns setof users。EF Core 也有对应的映射方式C# 侧让方法返回IQueryableT。[DbFunction(search_users, Schema public)] public static IQueryableUser SearchUsers(string keyword) throw new NotSupportedException();映射后它可以像表一样参与 LINQvar results await MyFunctions.SearchUsers(张) .Where(u u.Email ! null) .ToListAsync();但表值函数这块Npgsql provider 在不同版本上的支持程度有差异我的建议是动手前先写个小 demo 验证一下当前包版本的行为不要直接复用网上的旧代码。特别是函数返回结构复杂的记录类型比如返回自定义 composite type时踩坑概率明显高于标量函数。4.4 函数重载与命名冲突PostgreSQL 允许同名不同参的函数比如similarity(text, text)和某个项目自定义的similarity(integer, integer)。EF Core 的映射注册以MethodInfo为维度也就是 C# 方法不同即使最终映射成同一个数据库函数名也没问题。处理重载时建议显式注册而不是只用特性两个不同的 C# 方法都可以配上[DbFunction(同名字, ...)]它们会被当作两个独立方法映射到同一个数据库函数。若你还需要分别控制参数类型就必须用HasDbFunctionHasParameter分开配置特性方式做不到这个粒度。5. 我踩过的坑和一个完整的排查链路5.1 坑一扩展装了但函数却调不到有次把similarity映射做好本地跑通结果部署到测试服务器后接口报错PostgreSQL 给的错误是function similarity(text, text) does not exist。我第一反应是扩展没装上去一查CREATE EXTENSION也执行了直接 psql 里执行SELECT similarity(a,b)也能返回。最后发现是测试服务器的数据库里扩展被装到了另一个 schema 下。CREATE EXTENSION pg_trgm;会把函数创建在数据库的默认扩展目标 schema如果该数据库设置的默认 schema 不是 public函数就不在 public 里而我的连接字符串 search_path 只包含 public。这类问题看生成的 SQL 最直接确认翻译出来的函数名带不带 schema 前缀再看函数实际挂在哪个 schema 下。5.2 坑二返回类型不匹配PG 直接报错另一个真实案例同事跟我用了几乎一样的映射但他把Similarity的返回类型写成double。查询在Where里比较时还好一旦在Select里投影成Score MyFunctions.Similarity(...)EF Core 生成的 SQL 没问题PG 也能算但 Npgsql 读取real类型字段并填充到 C#double属性时会报类型转换异常。所以返回类型要和 PostgreSQL 函数真实返回类型对齐。similarity返回real就用floatPG 返回double precision的函数才用double。不确定时去 psql 里执行\df 函数名看返回类型别靠猜。5.3 坑三函数名大小写与标识符引用PostgreSQL 对未加双引号的标识符会统一转成小写。EF Core 生成 SQL 时默认对函数名做普通标识符处理不会加引号。所以你的数据库函数如果用了大写字母或者驼峰命名比如MyFunction并且创建时用了双引号那么映射时HasName也要写成MyFunction这种形式否则翻译出来的 SQL 会被 PG 强制转成小写导致函数不存在。我没少见有人在这里踩坑因为 SQL Server 的标识符规则和 PostgreSQL 不一样很多人从 SQL Server 切过来后习惯保留大写结果函数名怎么都对不上。5.4 一次完整的排查链路从报错到定位把一次完整的排查过程串起来这样遇到问题可以照这个思路走。第一步看异常信息。EF Core 抛could not be translated说明问题在映射层优先检查方法名、schema、参数顺序去OnModelCreating里把注册代码核对一遍。PG 抛function xxx does not exist说明翻译成功但目标函数找不到优先检查扩展、schema、search_path、大小写。第二步打开 SQL 日志。LogTo里能看到完整 SQL复制到 psql 手动执行。如果 psql 里能跑通问题就在参数绑定或类型映射psql 里跑不通看具体报错定位是函数缺失还是类型不匹配。第三步验证元数据。用\df查看函数签名和 schema用\dx查看扩展安装位置用SHOW search_path;确认搜索路径。第四步检查版本。EF Core 的翻译行为在不同版本有变化特别是HasDbFunction和HasParameter的 API 细节。如果代码从旧版本项目里粘过来优先看当前版本的官方文档而不是盲信网上的老代码。5.5 坑四EF Core 版本升级带来的 API 变化DbFunctionAttribute从 EF Core 2.1 就有HasDbFunction也是同一时期引入的但细节一直在变。EF Core 8 对可空上下文nullable context更敏感如果你的数据库函数返回类型可空而 C# 方法签名没加?有可能出现“模型构建期间未映射”或运行时类型断言失败。升级版本后如果发现原本正常的映射突然不对优先看 release notes 里关于模型构建和函数映射的变更。Npgsql provider 的版本绑定也很关键Npgsql.EntityFrameworkCore.PostgreSQL 7.x 和 8.x 在某些函数映射行为上就不完全一致。6. 从映射到优化让原生函数在复杂查询里真正发挥价值6.1 在 Where / OrderBy / Select 中的不同表现similarity这类函数可以在查询的多个位置出现翻译器每次遇到都会生成对应的 SQL 片段。但有几点要注意第一同一个函数调用如果在一个查询里出现多次EF Core 默认会分别翻译不会自动做公共表达式提取。比如我在 Where 里写一次、OrderBy 里写一次、Select 里写一次生成的 SQL 里就会有三份similarity(u.Name, __keyword_0)。PostgreSQL 的表达式优化器通常能处理但 SQL 文本会冗余。如果函数开销很大可以考虑把查询拆成两步或者改用数据库侧的表达式索引。第二参数尽量用变量而不是常量。写Similarity(u.Name, 张珊)时 EF Core 会生成常量参数虽然也能跑但不利于 PostgreSQL 的查询计划缓存。传参是一个比较好的习惯。6.2 配合索引加速相似度搜索similarity是pg_trgm扩展里的函数真正做相似度搜索时如果只是WHERE similarity(name, keyword) 0.3PostgreSQL 通常还会做顺序扫描因为普通 trigram 索引gin_trgm_ops并不能直接加速similarity()函数的比较。想要用上索引通常要改用%操作符或者-距离操作符比如CREATE INDEX idx_users_name_trgm ON users USING GIN (name gin_trgm_ops);然后在 EF Core 里使用 Npgsql 提供的EF.Functions.ILike或者操作符翻译来命中索引。自定义函数映射解决了“能调用”的问题但不代表“能高效调用”。函数能被翻译、能被索引利用是两件事我见过太多人在这点上想当然。如果查询模式很固定比如就固定对name列做相似度排序那么在 PostgreSQL 里创建表达式索引也是一个方向。表达式索引和函数映射配合时需要保证生成的 SQL 表达式和索引表达式完全一致包括 schema 前缀、函数名大小写差一点可能就命中不了。6.3 什么时候用 HasTranslation什么时候用 HasDbFunctionHasDbFunction适合“数据库里存在一个函数我想在 LINQ 里调用它”的场景配置简单直接。它本质上让 EF Core 在遇到这个 C# 方法时生成函数名(参数...)的 SQL。HasTranslation则是把一串 SQL 表达式的组装逻辑写在 C# 侧适合“我要的不是一个函数调用而是一段更灵活的表达式”。例如 PostgreSQL 的操作符JSONB 包含它不是函数不能直接HasDbFunction网上有一些通过HasTranslation把它接出来的示例。不过这类底层 API 不稳定每个版本都可能变如果不是项目必须我更推荐把这类查询用FromSqlInterpolated写死而不是在 EF Core 翻译层硬接。6.4 原生查询与自定义映射的边界自定义映射适合“函数需要在 LINQ 查询里和其他条件组合”的场景比如过滤、排序、分页、投影都要在一个查询里完成。如果只是单次调用某个函数比如导数据时调用一下FromSqlInterpolated或者ExecuteSqlInterpolated反而更直接不用额外定义原型方法、也不用维护映射配置。我自己的习惯是同一个周期内如果这个函数会被三个以上的 LINQ 查询复用就做自定义映射如果只是临时用一次就直接写 SQL。这样既享受了强类型 LINQ 的便利又不会让模型层堆积一堆一次性使用的函数原型。维护成本往往不在写代码那一刻而在半年后有人改 schema、升级版本、或者换连接字符串时。
返回列表