georgepwall1991/LinqContraband

GitHub: georgepwall1991/LinqContraband

一款基于 Roslyn 的 EF Core 静态分析器,在编译时检测数据访问层的性能反模式和安全隐患。

Stars: 9 | Forks: 0

# LinqContraband
![LinqContraband 图标](https://static.pigsec.cn/wp-content/uploads/repos/2026/04/c3360866aa053601.png) ### 阻止糟糕的查询被偷运到生产环境 [![NuGet](https://img.shields.io/nuget/v/LinqContraband.svg)](https://www.nuget.org/packages/LinqContraband) [![下载量](https://img.shields.io/nuget/dt/LinqContraband.svg)](https://www.nuget.org/packages/LinqContraband) [![许可证: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![构建](https://img.shields.io/github/actions/workflow/status/georgepwall1991/LinqContraband/dotnet.yml?label=build)](https://github.com/georgepwall1991/LinqContraband/actions/workflows/dotnet.yml) [![覆盖率](https://github.com/georgepwall1991/LinqContraband/blob/master/.github/badges/coverage.svg)](https://github.com/georgepwall1991/LinqContraband/actions/workflows/dotnet.yml)
**LinqContraband** 是您 Entity Framework Core 查询的 TSA(安检局)。它会在您输入代码时进行扫描,没收那些性能杀手——例如客户端评估、N+1 风险和 Sync-over-Async——在它们进入生产环境之前。 ### ⚡ 为什么使用 LinqContraband? * **零运行时开销:** 它完全在编译时运行。对您的应用程序没有性能成本。 * **尽早发现 Bug:** 在 IDE 中修复 N+1 查询和笛卡尔爆炸,而不是在凌晨 3 点的停机期间。 * **强制执行最佳实践:** 充当你团队数据访问模式的自动化代码审查员。 * **通用支持:** 适用于 VS、Rider、VS Code 和 CI/CD 管道。兼容所有现代 EF Core 版本。 ## 🚀 安装 通过 NuGet 安装。无需配置。 ``` dotnet add package LinqContraband ``` 分析器将立即开始扫描您的代码以查找违禁品。 ## 👮‍♂️ 规则 ### LC001: 本地方法走私者 当 EF Core 遇到无法翻译的方法时,它可能会切换到客户端评估(获取所有行)或抛出运行时异常。这会将快速的 SQL 查询变成巨大的内存泄漏。 **👶 像对 10 岁孩子一样解释:** 想象你雇了一位翻译把书翻译成西班牙语,但你使用了他不懂的俚语。他无法完成工作,所以他把整本字典递给你,说“你自己搞定。”你不得不阅读整本字典只是为了找一个词。 **❌ 罪行:** ``` // CalculateAge is a local C# method. EF Core doesn't know SQL for it. var query = db.Users.Where(u => CalculateAge(u.Dob) > 18); ``` **✅ 修复:** 将逻辑提取到查询之外。 ``` var minDob = DateTime.Now.AddYears(-18); var query = db.Users.Where(u => u.Dob <= minDob); ``` 明确映射用于翻译的方法是豁免的。LC001 不会报告标有 `[DbFunction]` 或 `EntityFrameworkCore.Projectables` 的 `[Projectable]` 属性的方法。 ### LC002: 过早具体化 这是 EF Core 中的“Select *”。通过过早具体化,您通过网络传输整个表,在内存中丢弃其中的 99%,并让垃圾回收器忙碌不已。 **👶 像对 10 岁孩子一样解释:** 想象你想要一个意大利辣香肠披萨。你不是只点辣香肠,而是点了一个包含餐厅里所有配料的披萨。当它送到时,你必须花一个小时挑掉凤尾鱼、菠萝和蘑菇才能吃。这既浪费食物又浪费时间。 **❌ 罪行:** ``` // ToList() executes the query (SELECT * FROM Users). // Where() then filters millions of rows in memory. var query = db.Users.ToList().Where(u => u.Age > 18); // Same crime with other materializers: AsEnumerable, ToDictionary, etc. var query2 = db.Users.AsEnumerable().Where(u => u.Age > 18); var query3 = db.Users.ToDictionary(u => u.Id).Where(kvp => kvp.Value.Age > 18); // Redundant second materializer var query4 = db.Users.ToArray().ToList(); ``` **✅ 修复:** 保留在提供程序上执行的批准查询工作,然后一次性具体化。 ``` // SELECT * FROM Users WHERE Age > 18 var query = db.Users.Where(u => u.Age > 18).ToList(); var query2 = db.Users.Where(u => u.Age > 18).ToDictionary(u => u.Id); var query3 = db.Users.ToList(); ``` **🛡️ 可靠性说明:** - LC002 仅在 `IQueryable` 来源可证明时,遵循直接链、单赋值本地跳转和集合构造函数。 - 它对模糊的多重赋值、字段/属性来源以及没有明确 `IQueryable` 安全等效项的重载保持沉默。 - 修复器有意保持保守,并跳过有风险的情况,例如本地跳转重写或改变形状的具体化器,如 `ToDictionary(...).Where(...)`。 ### LC003: 优先使用 Any() 而不是 Count() > 0 Count() > 0 强制数据库扫描所有匹配的行以返回总数(例如 5000)。Any() 生成 IF EXISTS (...),允许数据库在找到第一个匹配项后立即停止扫描。 **👶 像对 10 岁孩子一样解释:** 想象你想知道罐子里还有没有饼干。Count() > 0 就像把整个罐子倒在桌子上,一个个数了 500 块饼干才说“有”。Any() 就像打开盖子,看到一块饼干,然后立即说“有”。 **❌ 罪行:** ``` // Counts 1,000,000 rows just to see if one exists. if (db.Users.Count() > 0) { ... } ``` **✅ 修复:** ``` // Checks IF EXISTS (SELECT 1 ...) if (db.Users.Any()) { ... } ``` ### LC004: 延迟执行泄漏 当 LinqContraband 能够证明被调用者实际上通过迭代、计数或具体化该参数来强制执行内存操作时,才会标记将 `IQueryable` 传递给接受 `IEnumerable` 的方法的情况。这会阻止进一步的提供程序端组合并隐藏真实的查询成本。 **👶 像对 10 岁孩子一样解释:** 想象你有一张“自建汉堡”的优惠券。你把它给了厨师,但他不让你选择配料,而是立即递给你一个普通汉堡说“太晚了,我已经做好了。” **❌ 罪行:** ``` public void ProcessUsers(IEnumerable users) { // Iterates and fetches ALL users from DB immediately. foreach(var u in users) { ... } } // Passing IQueryable to IEnumerable parameter. ProcessUsers(db.Users); ``` **✅ 修复:** 将参数更改为 `IQueryable` 以允许组合,或者如果您*打算*获取所有内容,请在调用站点显式调用 `.ToList()`。 ``` public void ProcessUsers(IQueryable users) { // Now we can filter! SELECT ... WHERE Age > 10 foreach(var u in users.Where(x => x.Age > 10)) { ... } } ``` ### LC005: 多次 OrderBy 调用 这是一个逻辑错误,表现得像一个性能错误。第二个 OrderBy 完全忽略第一个。数据库为第一列创建排序计划,然后将其丢弃以按第二列排序。 **👶 像对 10 岁孩子一样解释:** 想象告诉某人按花色(红桃、黑桃……)对一副牌进行排序。他们刚一完成,你就说“实际上,按数字(2、3、4……)排序。”第一次排序的工作全都白费了,因为你改变了规则。 **❌ 罪行:** ``` // Sorts by Name, then immediately discards it to sort by Age. var query = db.Users.OrderBy(u => u.Name).OrderBy(u => u.Age); ``` **✅ 修复:** 正确地链接它们。 ``` var query = db.Users.OrderBy(u => u.Name).ThenBy(u => u.Age); ``` ### LC006: 笛卡尔爆炸风险 如果 User 有 10 个 Orders,而 Order 有 10 个 Items,则获取所有数据会为每个 User 创建 100 行。如果有 1000 个 User,那就是传输了 100,000 行。`AsSplitQuery` 通过 3 个独立、干净的查询获取 Users、Orders 和 Items。 **👶 像对 10 岁孩子一样解释:** 想象老师问 30 个学生吃了什么。她不是得到 30 个答案,而是让每个学生单独列出他们吃的每一根薯条。你最终会得到数千个答案(“我吃了薯条 #1”,“我吃了薯条 #2”),而不是仅仅“我吃了薯条”。 **❌ 罪行:** ``` // Fetches Users * Orders * Roles rows. var query = db.Users.Include(u => u.Orders).Include(u => u.Roles).ToList(); ``` **✅ 修复:** 使用 `.AsSplitQuery()` 在单独的 SQL 查询中获取相关数据。 ``` // Fetches Users, then Orders, then Roles (3 queries). var query = db.Users.Include(u => u.Orders).AsSplitQuery().Include(u => u.Roles).ToList(); ``` ### LC007: N+1 循环者 数据库查询具有很高的固定开销(延迟、连接池)。执行 100 个查询比执行 1 个获取 100 个项目的查询慢约 100 倍。LC007 涵盖的范围不止 `Find(...)`:当显式加载、查询具体化、聚合和 EF 基于集合的执行器在可证明为 EF 支持的源上每次迭代运行一次时,它会捕获这些情况。 **👶 像对 10 岁孩子一样解释:** 想象你需要 10 个鸡蛋。你开车去商店,买*一个*鸡蛋,开车回家。再开车回去,买*一个*鸡蛋,开车回家。你这样做 10 次。你花了一整天开车,而不是一次性买一整盒。 **❌ 罪行:** ``` foreach (var id in ids) { // Executes 1 query per ID. Latency kills you here. var user = db.Users.Find(id); } // Explicit loading is the same trap in a different disguise. foreach (var user in db.Users.ToList()) { db.Entry(user).Collection(u => u.Orders).Load(); } ``` **✅ 修复:** 在循环外批量获取数据。如果问题是显式加载,请使用预加载。 ``` // Executes 1 query for all IDs. var users = db.Users.Where(u => ids.Contains(u.Id)).ToList(); // LC007 can auto-fix this shape to eager loading. var usersWithOrders = db.Users.Include(u => u.Orders).ToList(); ``` ### LC008: Sync-over-Async 在 Web 应用程序中,线程是有限的资源。阻塞线程等待 SQL (I/O) 意味着该线程无法为其他用户服务。在负载下,这会导致“线程饥饿”,即使 CPU 使用率很低,也会导致 503 错误。 **👶 像对 10 岁孩子一样解释:** 想象一个服务员接了你的单,然后走进厨房,盯着厨师 20 分钟直到食物准备好。没有其他人得到服务。这就是 Sync-over-Async。Async 意味着服务员接单后,在食物烹饪时去服务其他桌子。 **❌ 罪行:** ``` public async Task> GetUsersAsync() { // Blocks the thread while waiting for DB. return db.Users.ToList(); } ``` **✅ 修复:** 使用 Async 对应方法并 await 它。 ``` public async Task> GetUsersAsync() { // Frees up the thread while waiting. return await db.Users.ToListAsync(); } ``` ### LC009: 跟踪税 EF Core 为它获取的每个实体拍摄一张“快照”以检测更改。对于只读仪表板,此快照过程会消耗 CPU 并使每行的内存使用量翻倍。 **👶 像对 10 岁孩子一样解释:** 想象你去博物馆。你承诺不触摸任何东西。但保安仍然跟着你,对你看到的每一幅画拍摄高分辨率照片,以防你决定在一幅画上画胡子。这浪费了他们的时间和内存。 **❌ 罪行:** ``` public List GetUsers() { // EF Core tracks these entities, but we never modify them. return db.Users.ToList(); } ``` **✅ 修复:** 向查询添加 `.AsNoTracking()`。 ``` public List GetUsers() { // Pure read. No tracking overhead. return db.Users.AsNoTracking().ToList(); // Or, if you need identity resolution without full tracking: return db.Users.AsNoTrackingWithIdentityResolution().ToList(); } ``` ### LC010: SaveChanges 循环税 打开并提交数据库事务是一项昂贵的操作。在循环内部执行此操作(例如,针对 100 个项目)意味着 100 个单独的事务,这可能比单个批量提交慢 1000 倍。 **👶 像对 10 岁孩子一样解释:** 想象寄 100 封信。不是把它们一次都放进邮箱,而是放一封进去,等邮递员拿走,然后再放下一封。寄出你的邀请函需要 100 天! **❌ 罪行:** ``` foreach (var user in users) { user.LastLogin = DateTime.Now; // Opens a transaction and commits for EVERY user. db.SaveChanges(); } ``` **✅ 修复:** 批量处理更改并保存一次。 ``` foreach (var user in users) { user.LastLogin = DateTime.Now; } // One transaction, one roundtrip. db.SaveChanges(); ``` ### LC011: 实体缺少主键 EF Core 中的实体需要主键来跟踪身份。如果你没有定义主键,EF Core 可能会抛出运行时异常或阻止你稍后更新记录。 **👶 像对 10 岁孩子一样解释:** 想象一个没有书名或 ISBN 号码的图书馆。你借一本书,但因为没有办法唯一识别它,图书管理员找不到它,或者更糟,给了你错误的那一本。 **❌ 罪行:** ``` public class Product { // No 'Id', 'ProductId', or [Key] attribute defined. public string Name { get; set; } } ``` **✅ 修复:** 使用 `Id` 约定、`[Key]` 属性或 Fluent API 定义主键。 ``` // 1. Convention: Id or {ClassName}Id public class Product { public int Id { get; set; } public string Name { get; set; } } // 2. Attribute: [Key] public class Product { [Key] public int ProductCode { get; set; } public string Name { get; set; } } // 3. Fluent API (in OnModelCreating) modelBuilder.Entity().HasKey(p => p.ProductCode); // 4. Separate Configuration (IEntityTypeConfiguration) public class ProductConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.HasKey(p => p.ProductCode); } } ``` ### LC012: 优化批量删除 `RemoveRange()` 在逐个(或批量)删除实体之前将其提取到内存中。`ExecuteDelete()` (EF Core 7+) 执行直接的 SQL DELETE,这要快几个数量级。 **👶 像对 10 岁孩子一样解释:** 想象你想扔掉一堆旧杂志。`RemoveRange` 就像拿起每本杂志,阅读封面,然后把它扔进垃圾箱。`ExecuteDelete` 就像把整个盒子一次倒进垃圾箱。 **❌ 罪行:** ``` var oldUsers = db.Users.Where(u => u.LastLogin < DateTime.Now.AddYears(-1)); // Fetches all old users into memory, then deletes them. db.Users.RemoveRange(oldUsers); ``` **✅ 修复:** 使用 `ExecuteDelete()` 进行直接 SQL 执行。 ``` // Executes: DELETE FROM Users WHERE LastLogin < ... db.Users.Where(u => u.LastLogin < DateTime.Now.AddYears(-1)).ExecuteDelete(); ``` **⚠️ 警告:** `ExecuteDelete` 绕过 EF Core Change Tracking,因此 `Deleted` 事件和客户端级联不会触发。此分析器不提供自动代码修复,因为切换到 `ExecuteDelete` 会更改应用程序的语义行为(通过跳过拦截器和事件)。您必须手动验证使用它是安全的。 ### LC013: 已释放的上下文查询 EF Core `IQueryable` 和 `IAsyncEnumerable` 查询是延迟的,这意味着在你迭代它们之前它们不会执行。如果你使用本地的 `DbContext` 构建查询,而该上下文已被释放(通过 `using`),并且你返回该查询,当调用者尝试使用它时就会爆炸。 **👶 像对 10 岁孩子一样解释:** 想象买一张电影票。但这张票*仅在*售票亭内有效。一旦你走到剧院(返回查询),票就在你手中溶解了。 **❌ 罪行:** ``` public IQueryable GetUsers(bool adultsOnly) { using var db = new AppDbContext(); var query = db.Users; // The analyzer catches alias-based returns and conditional branches: return adultsOnly ? query.Where(u => u.Age >= 18) : query; } ``` **✅ 修复:** 在上下文仍然存活时具体化结果(例如,`.ToList()`)。 ``` public List GetUsers(bool adultsOnly) { using var db = new AppDbContext(); var query = adultsOnly ? db.Users.Where(u => u.Age >= 18) : db.Users; // Executes the query immediately. Safe to return. return query.ToList(); } ``` LC013 仅是分析器。它不提供自动代码修复,因为安全的修复措施取决于你应该具体化、更改返回约定还是更改上下文生命周期。 ### LC014: 避免查询中的字符串大小写转换 在 LINQ 查询(例如 `Where` 子句)中使用 `ToLower()` 或 `ToUpper()` 会阻止数据库在该列上使用索引。这会强制全表扫描,这对于大型数据集来说明显更慢。 **👶 像对 10 岁孩子一样解释:** 想象在电话簿中查找“John”。如果你查找“John”,你可以直接跳到“J”。但如果你决定先将簿中的每个名字都转换为小写,你就必须从头到尾阅读*每一个名字*来检查它是否匹配“john”。 **❌ 罪行:** ``` // Forces a full table scan because the index on 'Name' cannot be used. var user = db.Users.Where(u => u.Name.ToLower() == "john").FirstOrDefault(); ``` **✅ 修复:** 使用带有不区分大小写比较的 `string.Equals`,或者将数据库排序规则配置不区分大小写。 ``` // 1. Use string.Equals (translated to efficient SQL if supported) var user = db.Users.Where(u => string.Equals(u.Name, "john", StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); // 2. Or, rely on DB collation (if case-insensitive by default) var user = db.Users.Where(u => u.Name == "john").FirstOrDefault(); ``` ### LC015: 确定性分页需要 OrderBy 分页(`Skip`/`Take`)和获取 `Last` 项依赖于特定的排序顺序。如果查询是无序的,数据库可以以任何随机顺序返回结果,使得分页不可预测,并且 `Last()` 结果不确定。当单个查询链同时包含 `Skip(...)` 和 `Take(...)` 时,LinqContraband 会为无序链报告一个主要警告,而不是在两个调用上重复相同的根本原因消息。 **👶 像对 10 岁孩子一样解释:** 想象一位老师让你“跳过前 5 名学生并挑选下一个。”如果学生排成一队,你知道该选谁。但如果他们在操场上随机乱跑,你根本不知道“前 5 名”是谁,而且你可能每次选的人都不同。 **❌ 罪行:** ``` // Randomly skips 10 rows. The result is unpredictable. var page2 = db.Users.Skip(10).Take(10).ToList(); // Which user is "Last"? Random. var last = db.Users.Last(); // Chunks of 10 users. But who is in the first chunk? Random. var chunks = db.Users.Chunk(10).ToList(); ``` **✅ 修复:** 先对数据进行显式排序。 ``` // Defined order: Sort by ID, then skip. var page2 = db.Users.OrderBy(u => u.Id).Skip(10).Take(10).ToList(); // Defined order: Sort by Date, then get last. var last = db.Users.OrderBy(u => u.CreatedAt).Last(); // Defined order: Sort by Name, then chunk. var chunks = db.Users.OrderBy(u => u.Name).Chunk(10).ToList(); ``` ### LC016: 避免在查询中使用 DateTime.Now 在 LINQ 查询中使用 `DateTime.Now`(或 `UtcNow`)会阻止数据库执行计划被有效缓存,因为常量值每毫秒都在变化。这也使得在没有模拟系统时钟的情况下无法进行单元测试。 **👶 像对 10 岁孩子一样解释:** 想象烤蛋糕。如果食谱说“烤 30 分钟”,你可以每天都用它。但如果食谱说“烤到时钟在周二下午 4:03 整显示时间”,你只能用一次,然后你就得写个新食谱。 **❌ 罪行:** ``` // The value of DateTime.Now is baked into the SQL as a constant. // This constant changes every time, forcing a new query plan. var query = db.Users.Where(u => u.Dob < DateTime.Now); ``` **✅ 修复:** 在查询之前将日期存储在变量中。 ``` // The variable is passed as a parameter (@p0). The plan is cached. var now = DateTime.Now; var query = db.Users.Where(u => u.Dob < now); ``` ### LC017: 整个实体投影 当只需要几个属性时加载整个实体浪费了带宽、内存和 CPU。对于具有 10 个以上属性的大型实体,这可能导致数据传输量比必要的多 10-40 倍。 **👶 像对 10 岁孩子一样解释:** 想象你想知道你朋友的电话号码。你不仅仅是问号码,而是让他们背诵整个自传——名字、地址、最喜欢的食物、他们度过的每一个假期。你只需要一个事实,但你得到了一整本书。 **❌ 罪行:** ``` // Loads all 12 columns for every product, but only uses Name var products = db.Products.Where(p => p.Price > 100).ToList(); foreach (var p in products) { Console.WriteLine(p.Name); // Only Name is ever accessed! } ``` **✅ 修复:** 使用 `.Select()` 仅投影您需要的内容。内置修复器采取安全路线,并插入匿名类型投影,以便现有的下游属性访问仍然可以编译。 ``` // Safe fixer output: projects only the needed property but preserves p.Name usage var products = db.Products .Where(p => p.Price > 100) .Select(p => new { p.Name }) .ToList(); foreach (var p in products) { Console.WriteLine(p.Name); } ``` ### LC018: 避免在 FromSqlRaw 中使用插值 在 `FromSqlRaw` 中使用插值字符串(`$"{var}"`)是一个重大的安全风险。它将变量直接嵌入到 SQL 字符串中,绕过参数化并使你的数据库面临 SQL 注入攻击。 **👶 像对 10 岁孩子一样解释:** 想象一家银行,你在纸条上写下名字取钱。如果你使用一支特殊的笔,可以擦掉“名字:John”并写上“把金库里所有的东西都给 John”,你就刚刚抢劫了银行。在 `FromSqlRaw` 中使用插值字符串就像使用那支可擦笔。 **❌ 罪行:** ``` // Potential SQL Injection! var users = db.Users.FromSqlRaw($"SELECT * FROM Users WHERE Name = '{name}'").ToList(); ``` **✅ 修复:** 使用 `FromSqlInterpolated` 或 `FromSql` (EF Core 7+),它们会自动参数化字符串。 ``` // Safe: EF Core handles parameterization var users = db.Users.FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {name}").ToList(); ``` **🛡️ 可靠性说明:** - LC018 负责直接传入 `FromSqlRaw(...)` 的插值字符串和非常量 `+` 连接。 - 更广泛的构造 SQL 流程,例如本地别名、`string.Format(...)`、`string.Concat(...)` 和 `StringBuilder`,由 `LC037` 负责。 ### LC019: 条件 Include 表达式 在 `Include()` 或 `ThenInclude()` 中使用条件(三元或空合并)表达式**总是**会在运行时抛出 `InvalidOperationException`。EF Core 不支持条件导航加载。 **👶 像对 10 岁孩子一样解释:** 想象你在一家餐厅,你告诉服务员“如果是星期二,给我披萨;否则给我意大利面”——但这名服务员一次只懂一个订单。他会困惑,每次都把你的盘子摔在地上。 **❌ 罪行:** ``` // ALWAYS throws InvalidOperationException at runtime var orders = db.Orders.Include(o => useCustomer ? o.Customer : o.Supplier).ToList(); ``` **✅ 修复:** 拆分为单独的条件 Include 调用。 ``` var query = db.Orders.AsQueryable(); if (useCustomer) query = query.Include(o => o.Customer); else query = query.Include(o => o.Supplier); var orders = query.ToList(); ``` ### LC020: StringComparison 走私者 在 LINQ 查询中使用带有 `StringComparison` 参数的 `string.Contains`、`StartsWith` 或 `EndsWith` 通常无法转换为 SQL。这会强制 EF Core 从数据库中提取所有记录并在你应用程序的内存中进行过滤。 **👶 像对 10 岁孩子一样解释:** 想象让机器人在一个大箱子里找所有的红色积木。但你给机器人一个非常复杂的规则:“找红色的积木,但只有当它们是 90 年代特定蜡笔盒中‘日落深红’的确切色调时。”机器人困惑了,直接把*整个*箱子递给你让你自己分类。 **❌ 罪行:** ``` // Likely to cause client-side evaluation var users = db.Users.Where(u => u.Name.Contains("admin", StringComparison.OrdinalIgnoreCase)).ToList(); ``` **✅ 修复:** 使用简单的重载。数据库通常配置有特定的大小写敏感性(排序规则)。 ``` // Translates to SQL LIKE var users = db.Users.Where(u => u.Name.Contains("admin")).ToList(); ``` ### LC021: 避免 IgnoreQueryFilters 全局查询过滤器通常用于关键的安全和逻辑,例如多租户或软删除。使用 `IgnoreQueryFilters()` 会绕过这些保护,并可能导致数据泄漏。 **👶 像对 10 岁孩子一样解释:** 想象一座高安全性建筑,每扇门都有锁。`IgnoreQueryFilters` 就像一把能一次打开所有门的万能钥匙。它很强大,但如果你意外使用它,你可能会进入你不该去的地方。 **❌ 罪行:** ``` // Might accidentally see data from other tenants or deleted items var allUsers = db.Users.IgnoreQueryFilters().ToList(); ``` **✅ 修复:** 确保绕过全局过滤器对于特定任务(例如,管理工具)是故意的且必要的。 ### LC022: Select 投影内的 ToList 在 IQueryable 上的 `Select()` 投影内调用 `ToList()`、`ToArray()` 或类似的集合具体化器会强制客户端评估或在 EF Core 3+ 中抛出异常。EF Core 原生处理集合投影。 **👶 像对 10 岁孩子一样解释:** 想象你让面包师给 100 个纸杯蛋糕加糖霜。但对于每个纸杯蛋糕,你告诉他们“首先,把所有的糖霜放在一个单独的碗里,然后从碗里给纸杯蛋糕加糖霜。”面包师会很沮丧,因为他们可以直接给纸杯蛋糕加糖霜——多余的碗步骤毫无意义,而且减慢了一切。 **❌ 罪行:** ``` // Forces client-side evaluation of the sub-collection var result = db.Users.Select(u => u.Orders.ToList()).ToList(); ``` **✅ 修复:** 从投影中移除具体化器。EF Core 会处理它。 ``` // EF Core projects the collection natively var result = db.Users.Select(u => u.Orders).ToList(); ``` **🛡️ 可靠性说明:** - LC022 适用于正常的 `IQueryable.Select(...)` 投影,其中集合具体化器是多余的或强制客户端评估。 - 像 `GroupBy(...).Select(g => g.ToList())` 这样的分组投影由 `LC024` 负责,它提供了更具体的不可转换 `GroupBy` 指导。 ### LC023: 建议 Find/FindAsync `FirstOrDefault(x => x.Id == id)` 总是去数据库。`Find(id)` 首先检查实体是否已经加载到你应用程序的内存中(Change Tracker),这要快得多。 **👶 像对 10 岁孩子一样解释:** 想象你想知道你是否有蓝色衬衫。`FirstOrDefault` 就像开车去商店买一件新的来检查。`Find` 就像先看看你的衣柜。如果在衣柜里,你就省了一趟路! **❌ 罪行:** ``` // Always hits the database var user = db.Users.FirstOrDefault(u => u.Id == userId); ``` **✅ 修复:** 对主键查找使用 `Find` 或 `FindAsync`。 ``` // Checks local memory first, then DB if not found var user = db.Users.Find(userId); ``` ### LC024: 带有不可转换投影的 GroupBy EF Core 只能转换 `GroupBy().Select()` 投影中的 `g.Key` 和聚合函数(`Count`、`Sum`、`Average`、`Min`、`Max`)。直接访问组元素(例如 `g.ToList()`、`g.Where()`、`g.First()`)会强制客户端评估或抛出异常。 **👶 像对 10 岁孩子一样解释:** 想象老师问“每个班有多少学生?”这很容易——只需数一下名单上的名字。但如果老师说“对于每个班级,告诉我每个学生吃的每样东西,”老师不得不去问每个学生。计数很快;午餐调查则不然。 **❌ 罪行:** ``` // g.ToList() can't be translated to SQL var result = db.Orders.GroupBy(o => o.CustomerId) .Select(g => new { Key = g.Key, Items = g.ToList() }).ToList(); ``` **✅ 修复:** 在 GroupBy 投影中仅使用 Key 和聚合函数。 ``` var result = db.Orders.GroupBy(o => o.CustomerId) .Select(g => new { Key = g.Key, Count = g.Count(), Total = g.Sum(o => o.Amount) }).ToList(); ``` **🛡️ 可靠性说明:** - LC024 负责在 `GroupBy(...).Select(...)` 内部的分组元素访问,例如 `g.ToList()`、`g.Where(...)` 和 `g.First()`。 - 该分组投影情况有意从 `LC022` 中排除,以便由一条规则负责诊断。 ### LC025: 带有 Update/Remove 的 AsNoTracking `AsNoTracking()` 对速度很有好处,但它告诉 EF Core“我只读这个。”如果你随后尝试 `Update()` 或 `Remove()` 该实体,EF Core 必须“猜测”发生了什么变化,这会导致低效的 SQL 更新每一列。 **👶 像对 10 岁孩子一样解释:** 想象你借了一本图书馆的书,但告诉图书管理员,“我只是看看图片。”然后你回家重写了三章。当你归还它时,图书管理员必须重读整本书才能弄清楚你改了什么。 **❌ 罪行:** ``` // Fetches as read-only var user = db.Users.AsNoTracking().First(u => u.Id == id); user.Name = "New Name"; // EF Core has to update ALL columns because it wasn't tracking changes db.Users.Update(user); ``` **✅ 修复:** 如果你计划修改实体,请移除 `AsNoTracking()`。 ``` // Tracked by default var user = db.Users.First(u => u.Id == id); user.Name = "New Name"; // EF Core knows exactly which column changed db.SaveChanges(); ``` ### LC026: 缺少 CancellationToken 异步数据库操作可能需要时间。如果用户取消请求,服务器应该停止查询。如果不传递 `CancellationToken`,数据库会继续为一个已经离开的用户工作! **👶 像对 10 岁孩子一样解释:** 想象你让机器人去很远的场地给你拿球。走到一半,你改变主意大喊“停!”如果机器人没有在听你的喊叫(没有 CancellationToken),它会一直走到场地,拿到球,然后一路走回来,即使你已经不想要它了。这是浪费机器人的电池! **❌ 罪行:** ``` public async Task> GetUsers(CancellationToken ct) { // Violation: CancellationToken is ignored return await db.Users.ToListAsync(); } ``` **✅ 修复:** 将 token 传递给异步方法。 ``` public async Task> GetUsers(CancellationToken ct) { // Correct: Robot is listening! return await db.Users.ToListAsync(ct); } ``` ### LC027: 缺少显式外键属性 没有相应 FK 属性的导航属性(例如 `public Customer Customer { get; set; }`)会导致 EF Core 在幕后创建一个“影子属性”。影子 FK 使得在不加载导航实体的情况下设置关系变得更加困难,产生效率较低的 API 序列化,并可能导致细微的性能问题。 **👶 像对 10 岁孩子一样解释:** 想象你把朋友的电话号码写在一张藏在你桌子底下的秘密便利贴上。当有人问“你朋友的号码是多少?”时,你必须爬到桌子底下才能找到它。如果你只是把号码写在你的地址簿里(显式 FK),任何人都可以立即查到。 **❌ 罪行:** ``` public class Order { public int Id { get; set; } // No CustomerId — EF creates a shadow FK public Customer Customer { get; set; } } ``` **✅ 修复:** 添加显式 FK 属性。 ``` public class Order { public int Id { get; set; } public int CustomerId { get; set; } public Customer Customer { get; set; } } ``` ### LC028: 深层 ThenInclude 链 深度超过 3 层的 `ThenInclude` 链会生成带有许多 LEFT JOIN 的复杂 SQL。这通常是过度获取的迹象——加载本应使用 `Select` 投影的深度嵌套数据。 **👶 像对 10 岁孩子一样解释:** 想象让你的朋友带上他们的妈妈,妈妈带上外婆,外婆带上曾外婆,曾外婆带上曾曾外婆。在某个时候,车满了,每个人都不舒服。最好只要求你真正需要的特定人员。 **❌ 罪行:** ``` // 4 levels deep — complex JOIN query var orders = db.Orders .Include(o => o.Customer) .ThenInclude(c => c.Address) .ThenInclude(a => a.Country) .ThenInclude(c => c.Region) .ThenInclude(r => r.Continent) .ToList(); ``` **✅ 修复:** 使用 `Select` 投影仅加载所需的嵌套数据。 ``` var orders = db.Orders.Select(o => new { o.Id, CustomerName = o.Customer.Name, Country = o.Customer.Address.Country.Name }).ToList(); ``` ### LC029: 冗余标识 Select `Select(x => x)` 告诉数据库“对于每一项,返回该项。”这是默认行为,所以把它写出来只会让你的代码更难阅读。 **👶 像对 10 岁孩子一样解释:** 想象你让朋友去商店买苹果。但随后你说,“对于你找到的每一个苹果,确保你带回来的苹果是一个苹果。”你的朋友会奇怪地看着你,因为他们本来就要那样做! **❌ 罪行:** ``` // Violation: Does nothing var users = db.Users.Select(u => u).ToList(); ``` **✅ 修复:** 移除冗余的 Select。 ``` // Correct var users = db.Users.ToList(); ``` ### LC030: DbContext 生命周期审查 将 `DbContext` 作为字段或属性保存是一种生命周期异味。对于 scoped 类型可能没问题,但在长期存在的服务中是有风险的,因为 `DbContext` 不是线程安全的,且设计为短暂的。在保留它之前审查生命周期,对于长期存在的服务最好使用 `IDbContextFactory`。该规则有意是建议性的:它寻找可能长期存在的形式,例如托管服务或传统中间件,并在显然是 scoped 的请求类型上保持沉默。 **👶 像对 10 岁孩子一样解释:** 想象你有一把画笔,班里的每个人必须同时共用它。颜料混在一起,刷毛断了,每个人都弄得一团糟!相反,当每个人需要时,从一个盒子里给他们自己的画笔,并在完成后放。 **❌ 罪行:** ``` public sealed class Worker : BackgroundService { private readonly AppDbContext _db; // Review: long-lived hosted services should not hold DbContext directly public Worker(AppDbContext db) => _db = db; } ``` **✅ 修复:** 使用 `IDbContextFactory` 创建短暂实例。 ``` public class MySingletonService { private readonly IDbContextFactory _factory; public MySingletonService(IDbContextFactory factory) => _factory = factory; public void DoWork() { using var db = _factory.CreateDbContext(); // ... } } ``` ### LC031: 无界查询具体化 在没有 `Take()`、`First()`、`Single()` 或类似边界方法的情况下,在 `DbSet` 的 IQueryable 链上调用 `.ToList()` 或 `.ToArray()` 有将整个表加载到内存的风险——这是生产环境中 EF Core 应用程序内存不足错误的最常见原因。 **👶 像对 10 岁孩子一样解释:** 想象你去图书馆说“给我所有的书。”图书管理员开始把书堆到推车上——成千上万本。你的手臂断了。你只需要前 10 本!你应该说“给我前 10 本书”。 **❌ 罪行:** ``` // Could load millions of rows into memory var users = db.Users.Where(u => u.IsActive).ToList(); ``` **✅ 修复:** 给查询添加界限。 ``` // Loads at most 1000 rows var users = db.Users.Where(u => u.IsActive).Take(1000).ToList(); ``` ### LC032: 用于批量标量更新的 ExecuteUpdate 当 `foreach` 循环加载被跟踪的 EF 实体仅为了分配标量属性,然后立即调用 `SaveChanges()` 时,EF Core 必须首先具体化并跟踪每一行。`ExecuteUpdate()` 将相同的批量更改转换为一次基于集合的 SQL 更新。 **👶 像对 10 岁孩子一样解释:** 想象你需要在 10,000 个盒子上贴上相同的贴纸。慢方法是打开每个盒子,触摸它,然后再次关闭它。快方法是使用一台大型压印机一次标记所有匹配的盒子。 **❌ 罪行:** ``` using var db = new AppDbContext(); foreach (var user in db.Users.Where(u => u.IsActive)) { user.Name = "Archived"; } db.SaveChanges(); ``` **✅ 修复:** 当你进行统一的标量更改且不需要更改跟踪回调时,使用 `ExecuteUpdate()`。 ``` db.Users .Where(u => u.IsActive) .ExecuteUpdate(setters => setters.SetProperty(u => u.Name, "Archived")); ``` **⚠️ 警告:** `ExecuteUpdate()` 绕过更改跟踪和实体回调,因此此规则保持建议性,不提供自动修复器。 ### LC033: FrozenSet 成员缓存 如果 `private static readonly HashSet` 初始化一次,然后仅用于 `Contains(...)`,那么你是在为你从未使用的可变性付费。在 .NET 8+ 上,`FrozenSet` 更适合这些成员缓存。 **👶 像对 10 岁孩子一样解释:** 想象你制作了一份 VIP 客人名单,然后把它永远封塑了。你不再需要可擦写的白板了。封塑的名单检查起来更快,而且没人能意外在上面乱涂乱画。 **❌ 罪行:** ``` private static readonly HashSet ElevatedRoles = new(StringComparer.OrdinalIgnoreCase) { "admin", "ops" }; var elevated = roles.Where(role => ElevatedRoles.Contains(role)).ToList(); ``` **✅ 修复:** 当集合只构建一次且仅用于成员检查时,将缓存转换为 `FrozenSet`。 ``` private static readonly FrozenSet ElevatedRoles = new string[] { "admin", "ops" } .ToFrozenSet(StringComparer.OrdinalIgnoreCase); ``` **🛡️ 可靠性说明:** - LC033 仅报告具有内联初始化器且可以安全重写的 `private static readonly HashSet` 字段。 - 它要求编译中的每个源引用都是直接的 `Contains(...)` 使用,并跳过别名、枚举、变异和表达式树使用。 - 修复器有意狭窄,如果字段声明不再与分析器证明的形状匹配,则退出。 ### LC034: 避免在 ExecuteSqlRaw 中使用插值 将插值输入传递给 `ExecuteSqlRaw(...)` 或 `ExecuteSqlRawAsync(...)` 会构建原始 SQL 文本,并为 SQL 注入敞开大门。EF Core 已经为此提供了一个安全的插值路径。 **👶 像对 10 岁孩子一样解释:** 想象给食堂写一张纸条说“扔掉 ${name} 的午餐”。如果有人在空白处涂写额外的指令,食堂可能会扔掉*所有人*的午餐。你需要一个带有锁定的名字框的表格。 **❌ 罪行:** ``` await db.Database.ExecuteSqlRawAsync($"DELETE FROM Users WHERE Name = '{name}'"); ``` **✅ 修复:** 使用 EF Core 的安全插值 API。 ``` await db.Database.ExecuteSqlAsync($"DELETE FROM Users WHERE Name = {name}"); ``` **🛡️ 可靠性说明:** - LC034 仅在替换是分析器证明并保持相同语义时提供修复器。 - LC034 负责直接传入 `ExecuteSqlRaw(...)` 和 `ExecuteSqlRawAsync(...)` 的插值字符串和非常量 `+` 连接。 - 更复杂的字符串构建情况由 [LC037](docs/LC037_RawSqlStringConstruction.md) 单独涵盖。 ### LC035: ExecuteDelete / ExecuteUpdate 之前缺少 Where `ExecuteDelete()` 和 `ExecuteUpdate()` 之所以强大,是因为它们批量影响行。这也意味着缺少过滤器可以在一个语句中删除或更新整个表。 **👶 像对 10 岁孩子一样解释:** 想象你原本想擦掉白板上的一行,但你却按下了巨大的“擦除整个白板”按钮。 **❌ 罪行:** ``` await db.Users.ExecuteDeleteAsync(); ``` **✅ 修复:** 在批量操作之前添加真正的过滤器。 ``` await db.Users .Where(u => !u.IsActive) .ExecuteDeleteAsync(); ``` **⚠️ 警告:** 此规则仅供参考。没有安全的自动修复器,因为 LinqContraband 无法为您猜测缺失的谓词。 ### LC036: 跨线程捕获的 DbContext `DbContext` 不是线程安全的。将其捕获到 `Task.Run(...)`、`Parallel.ForEach(...)` 或线程池回调中可能会产生竞争条件、释放错误和非常令人困惑的数据访问失败。 **👶 像对 10 岁孩子一样解释:** 想象两个人试图在同一时间在同一本笔记本上写字。他们互相碰胳膊,互相覆盖,毁了这一页。 **❌ 罪行:** ``` await Task.Run(() => db.Users.Count()); ``` **✅ 修复:** 在委托内部创建一个新的上下文,通常通过 `IDbContextFactory`。 ``` await Task.Run(async () => { await using var innerDb = await factory.CreateDbContextAsync(); return await innerDb.Users.CountAsync(); }); ``` **🛡️ 可靠性说明:** - LC036 在委托创建并使用自己的新上下文时保持沉默。 - 此规则默认为 `Warning`,因为跨线程 `DbContext` 使用既常见又高风险。 ### LC037: 原始 SQL 字符串构造 即使最终调用点是 `FromSqlRaw(...)` 或 `ExecuteSqlRaw(...)`,真正的 Bug 通常开始得更早:字符串连接、`string.Format(...)` 或 `StringBuilder` 从用户输入组装 SQL。 **👶 像对 10 岁孩子一样解释:** 想象用随机的备用碎片建造火车轨道,希望火车仍然去你想去的地方。一个坏碎片它就会脱轨。 **❌ 罪行:** ``` var sql = "SELECT * FROM Users WHERE Name = '" + name + "'"; var users = db.Users.FromSqlRaw(sql).ToList(); ``` **✅ 修复:** 使用 EF Core 的参数化或安全插值 API,而不是手工构建的 SQL 字符串。 ``` var users = db.Users .FromSql($"SELECT * FROM Users WHERE Name = {name}") .ToList(); ``` **🛡️ 可靠性说明:** - LC037 在原始 SQL 流程可证明时捕获 `string.Concat(...)`、`string.Format(...)`、`StringBuilder` 和本地别名跳转。 - 直接插值字符串和非常量 `+` 调用点模式有意由 `LC018` (`FromSqlRaw`) 和 `LC034` (`ExecuteSqlRaw*`) 负责。 ### LC038: 过度预加载 长 `Include(...)` / `ThenInclude(...)` 链会使查询大小爆炸、数据重复并创建非常昂贵的 SQL。超过某一点,投影或拆分策略通常更清晰、成本更低。 **👶 像对 10 岁孩子一样解释:** 想象订购一个背包,然后让商店把你的书、桌子、椅子和整个卧室也塞进去。技术上他们可以尝试,但这会变成一次糟糕的送货。 **❌ 罪行:** ``` var users = db.Users .Include(u => u.Orders) .Include(u => u.Roles) .Include(u => u.Addresses) .Include(u => u.Payments) .ToList(); ``` **✅ 修复:** 仅投影你需要的,拆分查询,或以更集中的步骤加载相关数据。 ``` var users = db.Users .AsSplitQuery() .Include(u => u.Orders) .Include(u => u.Roles) .ToList(); ``` **⚙️ 配置:** 在 `.editorconfig` 中使用 `dotnet_code_quality.LC038.include_threshold` 调整阈值。默认值为 `4`。 ### LC039: 嵌套 SaveChanges 在一个方法中对同一上下文重复调用 `SaveChanges()` 或 `SaveChangesAsync()` 通常意味着额外的往返、支离破碎的工作单元以及更高的部分持久化机会。 **👶 像对 10 岁孩子一样解释:** 想象把作业的每一页都装在单独的信封里邮寄,而不是发送一个完成的包裹。 **❌ 罪行:** ``` db.Users.Add(user); await db.SaveChangesAsync(); db.AuditEntries.Add(audit); await db.SaveChangesAsync(); ``` **✅ 修复:** 批量相关工作并在工作单元完成时保存一次。 ``` db.Users.Add(user); db.AuditEntries.Add(audit); await db.SaveChangesAsync(); ``` **🛡️ 可靠性说明:** - LC039 是建议性的,并抑制明显的事务边界模式。 - 如果拆分保存是有意的,请通过事务或清晰的注释边界保持显式。 ### LC040: 混合跟踪和不跟踪 在一个方法中对同一来源的 `DbContext` 混合跟踪查询与 `AsNoTracking()` 查询通常表明意图混乱。这使得推理更新、身份解析以及为什么某些实体被跟踪而其他实体没有变得更加困难。 **👶 像对 10 岁孩子一样解释:** 想象你的一半足球队穿着有号码的球衣,另一半是隐形的。教练再也无法分辨谁在球场上了。 **❌ 罪行:** ``` var user = await db.Users.FirstAsync(u => u.Id == id); var related = await db.Users.AsNoTracking().Where(u => u.ManagerId == id).ToListAsync(); ``` **✅ 修复:** 为该方法选择一种跟踪模式,或者拆分工作,使每个范围都有一个单一明确的目的。 ``` var user = await db.Users.AsNoTracking().FirstAsync(u => u.Id == id); var related = await db.Users.AsNoTracking().Where(u => u.ManagerId == id).ToListAsync(); ``` ### LC041: 单实体标量投影 如果你使用 `First*` 或 `Single*` 获取整个实体,然后立即只读取一个标量属性,那你就是过度获取。将该投影推入 SQL 中。 **👶 像对 10 岁孩子一样解释:** 想象让图书馆送来一整套百科全书,只因为你想读一句话。 **❌ 罪行:** ``` var user = await db.Users.FirstAsync(u => u.Id == id); return user.Name; ``` **✅ 修复:** 在具体化之前投影你需要的一个值。 ``` return await db.Users .Where(u => u.Id == id) .Select(u => u.Name) .FirstAsync(); ``` **🛡️ 可靠性说明:** - LC041 仅修复分析器证明的单属性使用。 - 修复器目前针对受保护的 `var` 局部模式,并对逃避严重或形状改变的情况保持沉默。 ### LC042: 缺少查询标签 当复杂的 EF 查询带有 `TagWith(...)` 标签时,在日志中查找和诊断要容易得多。没有标签,慢查询分析就变成了猜测工作。 **👶 像对 10 岁孩子一样解释:** 想象一大堆午餐盒,上面没有名字。当出现问题时,没人知道哪个午餐是谁的。 **❌ 罪行:** ``` var users = await db.Users .Where(u => u.IsActive) .OrderBy(u => u.Name) .Take(50) .ToListAsync(); ``` **✅ 修复:** 在链的根部附近添加有意义的查询标签。 ``` var users = await db.Users .TagWith("Active users list") .Where(u => u.IsActive) .OrderBy(u => u.Name) .Take(50) .ToListAsync(); ``` **⚙️ 配置:** 使用 `dotnet_code_quality.LC042.query_operator_threshold` 调整复杂度阈值。默认值为 `3`。 ### LC043: 异步可枚举缓冲 如果 `IAsyncEnumerable` 被缓冲到列表或数组中,然后立即循环一次,你会失去流式传输的好处,并无缘无故地将所有内容保存在内存中。 **👶 像对 10 岁孩子一样解释:** 想象等所有的玩具都成一大堆到了才开始玩,即使你可以每个玩具一到就玩。 **❌ 罪行:** ``` var users = await stream.ToListAsync(); foreach (var user in users) { Console.WriteLine(user.Name); } ``` **✅ 修复:** 使用 `await foreach` 直接流式传输序列。 ``` await foreach (var user in stream) { Console.WriteLine(user.Name); } ``` **🛡️ 可靠性说明:** - LC043 有意针对狭窄的、分析器证明的 v1 模式:立即缓冲,随后在同一方法中进行一次线性循环。 - 修复器仅重写那些安全的情况,并不尝试转换更广泛的异步流使用。 ## ⚙️ 配置 你可以在 `.editorconfig` 文件中配置这些规则的严重性: ``` [*.cs] dotnet_diagnostic.LC001.severity = error dotnet_diagnostic.LC002.severity = error dotnet_diagnostic.LC003.severity = warning # 可选的规则特定阈值 dotnet_code_quality.LC038.include_threshold = 4 dotnet_code_quality.LC042.query_operator_threshold = 3 ``` 诸如 `LC009`、`LC017`、`LC023`、`LC026`、`LC027`、`LC029`、`LC030`、`LC031`、`LC032`、`LC033`、`LC035`、`LC038`、`LC039`、`LC040`、`LC041`、`LC042` 和 `LC043` 之类的建议性规则默认为 `Info`,因此它们作为提示出现,而不会淹没更高置信度的警告。 ## 🤝 贡献 发现了偷运糟糕查询的新方法?[提出问题](https://github.com/georgepwall1991/LinqContraband/issues) 或提交 PR! 许可证:[MIT](LICENSE)
标签:EF Core, N+1问题, NuGet包, ORM, Roslyn Analyzer, SOC Prime, SQL优化, 多人体追踪, 实体框架, 客户端评估, 开发工具, 性能优化, 最佳实践, 检测绕过, 编译器检查, 错误基检测, 静态代码分析