C# Web开发教程(三)

实体类之间的三种关系

  • 一对一
  • 一对多
  • 多对多(靠中间表维持关系)
  • C#C中,实现以上三种关系,可以分三部进行
- 业务类中设置导航属性
- XXConfig类中配置外键
- 主程序API交互
  • API套路演示
一对多: HasOne(...).WithMany(...);

一对一: HasOne(...).WithOne(...);

多对多: HasMany(...).WithMany(...);
  • 实例演示一对多关系(Article和Comment)
// 业务类Article
......
namespace ConsoleAppObjectRelation
{
    class Article
    {
        public int Id { get; set; }
        public string Title { get; set; }
        public string Content { get; set; }
        public List<Comment> Comments { get; set; } = new List<Comment>();
    }
}

// 对应的配置类
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ConsoleAppObjectRelation
{
    class ArticleConfig : IEntityTypeConfiguration<Article>
    {
        public void Configure(EntityTypeBuilder<Article> builder)
        {
            
            builder.ToTable("T_Articles");
            builder.Property(obj => obj.Content).IsRequired().IsUnicode();
            builder.Property(obj => obj.Title).IsRequired().IsUnicode().HasMaxLength(255);
            
        }
    }
}


// 业务类Comment
......
namespace ConsoleAppObjectRelation
{
    class Comment
    {
        public int Id { get; set; }
        public string Message { get; set; }
        // 外键关联
        public Article Article { get; set; }
    }
}

// 对应的配置类
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ConsoleAppObjectRelation
{
    class CommentConfig : IEntityTypeConfiguration<Comment>
    {
        public void Configure(EntityTypeBuilder<Comment> builder)
        {

            builder.ToTable("T_Comments");
            builder.Property(obj => obj.Message).IsRequired().IsUnicode();
            // 关键配置
            builder.HasOne<Article>(commentObj => commentObj.Article).WithMany(articleObj => articleObj.Comments).IsRequired();

        }
    }
}


// Context类注册
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
using System;


namespace ConsoleAppObjectRelation
{
    class MyDbContext : DbContext
    {

       
        public DbSet<Article>  Articles { get; set; }
        public DbSet<Comment> Comments { get; set; }
      

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);


            string connStr = "Server=.;Database=demo3;Trusted_Connection=True;MultipleActiveResultSets=true";
            optionsBuilder.UseSqlServer(connStr);

           optionsBuilder.LogTo(Console.WriteLine);

           
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
        }


    }
}

- 创建迁移记录并更新数据库,验证效果

  • 插入数据实例演示
using System;

namespace ConsoleAppObjectRelation
{
    class Program
    {
        static void Main(string[] args)
        {
           

            using (MyDbContext contextObj = new MyDbContext())
            {
                Article articleObj = new Article();
                articleObj.Title = "不知道叫什么的Title222";
                articleObj.Content = "不知道叫什么的Content222";

                Comment commentObj1 = new Comment { Message = "Hello333" };
                Comment commentObj2 = new Comment { Message = "Hello444" };

                articleObj.Comments.Add(commentObj1);
                articleObj.Comments.Add(commentObj2);

                contextObj.Articles.Add(articleObj);
                // 由于在 CommentConfig 中配置了与 Article 的关系,EF Core 会自动处理外键关联。即使没有显式将评论添加到 Comments DbSet,它们也会因为关联到文章而被保存
                // contextObj.Comments.Add(commentObj1);

                contextObj.SaveChanges();

            }

            Console.WriteLine("数据更新完成!!!");

        }
    }
}

  • 获取数据实例演示(正向查询)
- 坑演示

......

namespace ConsoleAppObjectRelation
{
    class Program
    {
        static void Main(string[] args)
        {
           

            using (MyDbContext contextObj = new MyDbContext())
            {
                var articleObj = contextObj.Articles.Single(obj => obj.Id == 1);
                // 输出正常
                Console.WriteLine(articleObj.Title);
                foreach (var comment in articleObj.Comments)
                {	
                	// 这段不会输出
                	// 只加载了 Article 实体本身,但没有加载其关联的 Comments 集合。因此,当尝试遍历 articleObj.Comments 时,该集合为空,循环不会执行
                    Console.WriteLine(comment.Message);
                }

            }
		
            Console.WriteLine("数据操作完成!!!");

        }
    }
}

- 解决办法: 在原来的基础上,新增Include()方法

using Microsoft.EntityFrameworkCore; // 新增Include拓展方法
......

namespace ConsoleAppObjectRelation
{
    class Program
    {
        static void Main(string[] args)
        {
           

            using (MyDbContext contextObj = new MyDbContext())
            {
               
				// 新增Include()方法,显示包含Comments集合
                var articleObj = contextObj.Articles.Include(a=>a.Comments).Single(obj => obj.Id == 1);
                Console.WriteLine(articleObj.Title);
                foreach (var comment in articleObj.Comments)
                {	
                	// 正常输出
                    Console.WriteLine(comment.Message);
                }

            }

            Console.WriteLine("数据操作完成!!!");

        }
    }
}

  • 获取数据实例演示(反向查询),同样的,需要使用Include()
......
// 这里若没有添加Include,程序就报错了
var commentObj = contextObj.Comments.Include(c => c.Article).Single(obj => obj.Id == 1);
Console.WriteLine(commentObj.Message);
Console.WriteLine(commentObj.Article.Title);
......

SQL性能优化

  • 使用Select只提取需求的字段,提高性能
......
// 使用First()提取对象,使用Select提取需求的字段
var articleObj = contextObj.Articles.Select(a => new { a.Id, a.Title }).First();
Console.WriteLine($"{articleObj.Id}-->{articleObj.Title}");

- 底层SQL语句如下:

SELECT TOP(1) [t].[Id], [t].[Title]
FROM [T_Articles] AS [t]

builder.HasOne<Article>(commentObj => commentObj.Article).WithMany(articleObj => articleObj.Comments).IsRequired();
            builder.HasOne<Article>(commentObj => commentObj.Article).WithMany(articleObj => articleObj.Comments)
                .HasForeignKey(commentObj=>commentObj.ArticleId)
                .IsRequired();
  • 显示指定外键
// Comment.cs
......
namespace ConsoleAppObjectRelation
{
    class Comment
    {
        public int Id { get; set; }
        ......
        // 新增字段(外键用)
        public int ArticleId { get; set; }
    }
}

// CommentConfig.cs

......

namespace ConsoleAppObjectRelation
{
    class CommentConfig : IEntityTypeConfiguration<Comment>
    {
        public void Configure(EntityTypeBuilder<Comment> builder)
        {

            builder.ToTable("T_Comments");
            ......
            // builder.HasOne<Article>(commentObj => commentObj.Article).WithMany(articleObj => articleObj.Comments).IsRequired();
            // 显式指定外键
            builder.HasOne<Article>(commentObj => commentObj.Article).WithMany(articleObj => articleObj.Comments)
                .HasForeignKey(commentObj=>commentObj.ArticleId)
                .IsRequired();

        }
    }
}

// 主程序
......
var commentObj = contextObj.Comments.Single(c => c.Id == 1);
Console.WriteLine($"{commentObj.Message}-->{commentObj.ArticleId}");

- 底层SQL语句: 并没有涉及join的连表操作,所以性能上更好!
SELECT TOP(2) [t].[Id], [t].[ArticleId], [t].[Message]
      FROM [T_Comments] AS [t]
      WHERE [t].[Id] = 1
      
- 上面的代码还可以再优化一下,比如[t].[Message]若不需要,可以加上Select()筛选

var commentObj = contextObj.Comments.Select(c=>new { c.Id,c.ArticleId }).Single(c => c.Id == 1);
Console.WriteLine($"{commentObj.Id}-->{commentObj.ArticleId}");

SELECT TOP(2) [t].[Id], [t].[ArticleId]
      FROM [T_Comments] AS [t]
      WHERE [t].[Id] = 1

  • 注意事项:以下两句的区别
            // 没有显式指定外键属性,EF Core 会按照约定自动推断外键名称(通常是"导航属性名+Id",即 ArticleId)
            builder.HasOne<Article>(commentObj => commentObj.Article).WithMany(articleObj => articleObj.Comments).IsRequired();
            // 显式指定外键
            builder.HasOne<Article>(commentObj => commentObj.Article).WithMany(articleObj => articleObj.Comments)
                .HasForeignKey(commentObj=>commentObj.ArticleId)
                .IsRequired();
                
- 推荐"显示指定外键"的写法,更易于理解和维护(隐式外键更加简洁,但可能在某些情况下不够明确)
  • 反着配置,也行
// CommentConfig(推荐)
builder.HasOne<Article>(c=>c.Article).WithMany(a=>a.Comments).isRequired();

// ArticleConfig
builder.HasMany<Comment>(a=>a.Comments).WithOne(c=>c.Article).isRequired();

- 注意事项:无论使用哪种配置方式,Entity Framework Core 都会在 T_Comments 表中创建一个指向 T_Articles 表的外键字段。
  这两种配置方式只是从不同角度描述同一个关系,最终都会在数据库中创建相同的外键约束
  • 单向导航
- User表: 会被很多表单引用
	--> 请假表
	--> 部门表
	--> 采购表
// User.cs
......
namespace ConsoleAppObjectRelation
{
    class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
}

// 简单配置
......
namespace ConsoleAppObjectRelation
{
    class UserConfig : IEntityTypeConfiguration<User>
    {
        public void Configure(EntityTypeBuilder<User> builder)
        {
            builder.ToTable("T_Users");
        }
    }
}


// Leave.cs
......
namespace ConsoleAppObjectRelation
{
    class Leave
    {
        public int Id { get; set; }
        public string Message { get; set; }
        public User Requester { get; set; }
        public User Approver { get; set; }
    }
}

// 配置类
......

namespace ConsoleAppObjectRelation
{
    class LeaveConfig : IEntityTypeConfiguration<Leave>
    {
        public void Configure(EntityTypeBuilder<Leave> builder)
        {

            builder.ToTable("T_Leaves");
            // 设置两个外键,一对多
            builder.HasOne<User>(l => l.Requester).WithMany().IsRequired();
            builder.HasOne<User>(l => l.Approver).WithMany();

        }
    }
}

// 主程序插入数据,并测试效果
......
//User user1 = new User { Name="Jim Green" };
//User user2 = new User { Name="Kate Green" };
//Leave leave = new Leave { Message = "我想请假",Requester=user1,Approver=user2 };

//contextObj.Users.Add(user1);
//contextObj.Users.Add(user2);
//contextObj.Leaves.Add(leave);
//contextObj.SaveChanges();

// 简单测试
var leaveOjb = contextObj.Leaves.FirstOrDefault();
if(leaveOjb != null)
{
Console.WriteLine(leaveOjb.Message);
}

// 插入数据还可以这么搞
 var user3 = new User { Name = "Lilei" };
 var leave3 = new Leave { Message = "世界那么大...", Requester = user3 };
 contextObj.Leaves.Add(leave3); // 只保存了Leave,连带User也被保存~
 contextObj.SaveChanges();

自关联实例演示

// OrgUnit.cs
......
namespace ConsoleAppObjectRelation
{
    class OrgUnit
    {
        public int Id { get; set; }
        public String Name { get; set; }
        public OrgUnit Parent { get; set; }
        public List<OrgUnit> Children { get; set; } = new List<OrgUnit>();
    }
}

// 配置类
......
namespace ConsoleAppObjectRelation
{
    class OrgUnitConfig : IEntityTypeConfiguration<OrgUnit>
    {
        public void Configure(EntityTypeBuilder<OrgUnit> builder)
        {

            builder.ToTable("T_OrgUnits");
            // 关键配置,自关联
            builder.HasOne(obj => obj.Parent).WithMany(obj => obj.Children);

        }
    }
}

// 主程序: 先插入一些数据(顺杆爬那种数据保存的方式,容易踩坑,显式保存最好![虽然啰嗦了一点])

......
OrgUnit orgRoot = new OrgUnit { Name = "xxx全球总部" };
OrgUnit orgAsia = new OrgUnit { Name = "xxx亚洲总部" };
OrgUnit orgEuro = new OrgUnit { Name = "xxx欧洲总部" };
orgAsia.Parent = orgRoot;
orgEuro.Parent = orgRoot;

OrgUnit orgAsiaChina = new OrgUnit { Name = "xxx中国区" };
OrgUnit orgAsiaJapan = new OrgUnit { Name = "xxx日本区" };
orgAsiaChina.Parent = orgAsia;
orgAsiaJapan.Parent = orgAsia;

OrgUnit orgEuroFrance = new OrgUnit { Name = "xxx法国区" };
OrgUnit orgEuroItaly = new OrgUnit { Name = "xxx意大利区" };
orgEuroFrance.Parent = orgEuro;
orgEuroItaly.Parent = orgEuro;

contextObj.OrgUnits.Add(orgRoot);
contextObj.OrgUnits.Add(orgAsia);
contextObj.OrgUnits.Add(orgEuro);

contextObj.OrgUnits.Add(orgAsiaChina);
contextObj.OrgUnits.Add(orgAsiaJapan);

contextObj.OrgUnits.Add(orgEuroFrance);
contextObj.OrgUnits.Add(orgEuroItaly);

contextObj.SaveChanges();

// 输出树形结构示例(主程序)

......
// 递归+循环
static void PrintChildren(int identLevel,MyDbContext contextObj,OrgUnit parent)
{
    var children = contextObj.OrgUnits.Where(obj => obj.Parent == parent);
    foreach (var child in children)
    {
        Console.WriteLine(new String('\t',identLevel)+child.Name);
        PrintChildren(identLevel+1,contextObj,child);
    }
}

// 主程序调用
......
var orgUnitRoot = contextObj.OrgUnits.Single(obj => obj.Parent == null);
Console.WriteLine(orgUnitRoot.Name);
PrintChildren(1,contextObj,orgUnitRoot);
......

// 输出结果

xxx全球总部
        xxx亚洲总部
                xxx中国区
                xxx日本区
        xxx欧洲总部
                xxx法国区
                xxx意大利区

一对一关系演示

// Order.cs
......
namespace ConsoleAppObjectRelation
{
    class Order
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Address { get; set; }
	
        public Delivery Delivery { get; set; } // 导航属性,指向配送信息
    }
}

// 配置类
......
namespace ConsoleAppObjectRelation
{
    class OrderConfig : IEntityTypeConfiguration<Order>
    {
        public void Configure(EntityTypeBuilder<Order> builder)
        {

            builder.ToTable("T_Orders");
            // 绑定一对一的关系并显示指定外键
            builder.HasOne<Delivery>(orderObj=>orderObj.Delivery).WithOne(dObj=>dObj.Order)
                .HasForeignKey<Delivery>(dObj => dObj.OrderId);

        }
    }
}

// Delivery.cs
......
namespace ConsoleAppObjectRelation
{
    class Delivery
    {
        public int Id { get; set; }
        public string CompanyName { get; set; }
        public string Number { get; set; }
        public Order Order { get; set; } // 导航属性,指向所属订单
        // 显示指明外键
        public int OrderId { get; set; } // 外键,明确指向 Order 的 Id
    }
}

// 配置类
......
namespace ConsoleAppObjectRelation
{
    class DeliveryConfig : IEntityTypeConfiguration<Delivery>
    {
        public void Configure(EntityTypeBuilder<Delivery> builder)
        {

            builder.ToTable("T_Deliverys");

        }
    }
}


// 主程序
.....
 Order order = new Order { Name="xxx购买意向", Address="厦门市"};
 Delivery delivery = new Delivery { CompanyName = "Aclas", Number = "5710085", Order = order };

contextObj.Orders.Add(order);
contextObj.Deliverys.Add(delivery);

contextObj.SaveChanges();
....

多对多关系演示(老师和学生)

  • 注意事项: 从EF Core5.0开始,支持多对多的关系(需要中间表)
// Student.cs
......
namespace ConsoleAppObjectRelation
{
    class Student
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public List<Teacher> Teacher { get; set; } = new List<Teacher>();
    }
}

// StudentConfig.cs
......
namespace ConsoleAppObjectRelation
{
    class StudentConfig : IEntityTypeConfiguration<Student>
    {
        public void Configure(EntityTypeBuilder<Student> builder)
        {
            builder.ToTable("T_Students");
            // 多对多关系并指定中间表的名称
            builder.HasMany<Teacher>(s => s.Teacher).WithMany(t => t.Student)
                .UsingEntity(j => j.ToTable("T_Students_Teachers"));

        }
    }
}

// Teacher.cs
......
namespace ConsoleAppObjectRelation
{
    class Teacher
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public List<Student> Student { get; set; } = new List<Student>();
    }
}

// TeacherConfig.cs
......
namespace ConsoleAppObjectRelation
{
    class TeacherConfig : IEntityTypeConfiguration<Teacher>
    {
        public void Configure(EntityTypeBuilder<Teacher> builder)
        {
            builder.ToTable("T_Teachers");
           
        }
    }
}

// 主程序
......
Teacher teacher1 = new Teacher { Name = "JimGreen" };
Teacher teacher2 = new Teacher { Name = "KateGreen" };
Teacher teacher3 = new Teacher { Name = "LiLei" };

Student student1 = new Student { Name = "aaa"};
Student student2 = new Student { Name = "bbb" };
Student student3 = new Student { Name = "ccc" };

teacher1.Student.Add(student1);
teacher1.Student.Add(student2);
teacher2.Student.Add(student2);
teacher2.Student.Add(student3);
teacher3.Student.Add(student1);
teacher3.Student.Add(student3);

contextObj.Teachers.Add(teacher1);
contextObj.Teachers.Add(teacher2);
contextObj.Teachers.Add(teacher3);

contextObj.Students.Add(student1);
contextObj.Students.Add(student2);
contextObj.Students.Add(student3);

contextObj.SaveChanges();

// 查询数据演示

 var teachers = contextObj.Teachers.Include(t => t.Student);
 foreach (var teacher in teachers)
 {
     Console.WriteLine($"老师的姓名为:{teacher.Name}");
     foreach (var student in teacher.Student)
     {
     	Console.WriteLine($"学生的姓名为:{student.Name}");
     }
 }
 
 // 结果:
 
老师的姓名为:JimGreen
学生的姓名为:aaa
学生的姓名为:bbb
老师的姓名为:KateGreen
学生的姓名为:bbb
学生的姓名为:ccc
老师的姓名为:LiLei
学生的姓名为:aaa
学生的姓名为:ccc
数据操作完成!!!
 

常见的查询语法

  • 举例(正向和反向)
- 查询所有评论包含"微软"的文章(正向查询)

......
var searchArticleObjs = contextObj.Articles.Where(a => a.Comments.Any(c => c.Message.Contains("微软")));
foreach (var articleObj in searchArticleObjs)
{
	Console.WriteLine(articleObj.Title);
}
......

- 反向查询: 结果一模一样

......
var items = contextObj.Comments.Where(c => c.Message.Contains("微软")).Select(c=>c.Article);
foreach (var item in items)
{
	Console.WriteLine(item.Title);
}
......

注意事项

  1. 重复结果处理:反向查询可能会返回重复的文章(如果一篇文章有多条匹配评论),如果需要去重,可以添加.Distinct()

    var items = contextObj.Comments
        .Where(c => c.Message.Contains("微软"))
        .Select(c => c.Article)
        .Distinct();
    
  2. 性能考虑:对于大型数据集,正向查询(使用 EXISTS)通常性能更好,因为它可以在找到第一个匹配项后立即停止搜索。

  3. 业务语义:从业务逻辑角度看,正向查询更符合"查找包含特定评论的文章"这一需求,而反向查询更像是"先找到评论再找对应的文章"。

虽然这两段代码最终可能返回相同的结果集,但它们的实现方式和底层SQL生成是不同的。在选择使用哪种方式时,应考虑数据量大小、性能需求和代码可读性等因素。

SQL性能优化

在 EF Core 中,IEnumerable 和 IQueryable 的区别与用途

在 Entity Framework Core 中,IEnumerableIQueryable 都是用于处理数据集合的接口,但它们有着根本性的区别和不同的使用场景。

1. 核心区别

IQueryable

  • 延迟执行(Deferred Execution)IQueryable 查询不会立即执行,而是构建一个表达式树(Expression Tree)
  • 数据库端执行:查询逻辑会被转换为 SQL 并在数据库服务器上执行
  • 支持组合查询:可以动态构建复杂的查询条件
  • 高效处理大数据集:只返回需要的数据,减少网络传输

IEnumerable

  • 立即执行或内存中执行:当调用 GetEnumerator() 或使用 foreach 时立即执行查询
  • 客户端执行:所有数据先加载到内存,然后在内存中进行过滤、排序等操作
  • 适合小数据集:对于小量数据操作效率高
  • 功能丰富:支持所有 LINQ 操作,但操作在内存中进行

2. 为什么需要 IQueryable

性能优化

// 使用 IQueryable - 高效,只在数据库执行
var query = context.Products.Where(p => p.Price > 100); // 不执行查询
var result = query.OrderBy(p => p.Name).Take(10).ToList(); // 生成并执行SQL: SELECT TOP 10 ... WHERE Price > 100 ORDER BY Name

// 使用 IEnumerable - 低效,先加载所有数据到内存
var allProducts = context.Products.ToList(); // 执行SQL: SELECT * FROM Products
var filtered = allProducts.Where(p => p.Price > 100).OrderBy(p => p.Name).Take(10); // 在内存中处理

动态查询构建

// 使用 IQueryable 可以动态构建查询
IQueryable<Product> query = context.Products;

if (!string.IsNullOrEmpty(searchTerm))
{
    query = query.Where(p => p.Name.Contains(searchTerm));
}

if (categoryId.HasValue)
{
    query = query.Where(p => p.CategoryId == categoryId.Value);
}

// 只有在调用 ToList() 时才执行查询
var results = query.OrderBy(p => p.Price).ToList();

数据库特定功能支持

// IQueryable 可以转换为数据库特定函数
var products = context.Products
    .Where(p => EF.Functions.Like(p.Name, "%apple%"))
    .ToList(); // 使用数据库的 LIKE 功能

// 而使用 IEnumerable 则无法利用数据库特定功能

3. 使用场景对比

使用 IQueryable 的场景

  1. 大数据集查询:只需要部分数据时
  2. 动态查询:根据用户输入构建不同条件的查询
  3. 数据库端操作:需要利用数据库特定功能(如全文搜索、空间查询等)
  4. 分页查询:只需要特定页面的数据

使用 IEnumerable 的场景

  1. 小数据集操作:数据量小,可以全部加载到内存
  2. 复杂内存操作:需要执行无法转换为 SQL 的复杂逻辑
  3. 已加载数据的处理:对已经从数据库获取的数据进行进一步处理
  4. 本地集合操作:操作内存中的集合,而非数据库数据

4. 转换关系

在 EF Core 中,通常的工作流程是:

// 从 IQueryable 开始(数据库查询)
IQueryable<Product> query = context.Products.Where(p => p.Price > 50);

// 添加更多查询条件(仍在数据库执行)
query = query.OrderBy(p => p.Name);

// 当调用以下方法时,查询被执行,转换为 IEnumerable
IEnumerable<Product> products = query.ToList();

// 现在在内存中操作(IEnumerable)
var expensiveProducts = products.Where(p => p.Price > 100);

5. 性能影响

使用 IEnumerable 不当可能导致严重的性能问题:

// 错误用法 - 导致大量数据加载到内存
var allProducts = context.Products.ToList(); // 加载所有产品
var filtered = allProducts.Where(p => p.Price > 100); // 在内存中过滤

// 正确用法 - 在数据库中过滤
var filtered = context.Products.Where(p => p.Price > 100).ToList();

6. 总结

特性 IQueryable IEnumerable
执行位置 数据库服务器 客户端内存
延迟执行 支持 支持(但执行后操作在内存)
查询组合 支持动态组合 组合后立即执行
性能 大数据集高效 小数据集高效
功能 受限于可转换为SQL的功能 支持所有LINQ操作
使用场景 数据库查询、动态查询、分页 内存数据操作、复杂处理

在 EF Core 中,IQueryable 的存在是为了提供高效的数据库查询能力,而 IEnumerable 则用于内存中的数据操作。理解它们的区别并根据具体场景选择合适的接口,是编写高效 EF Core 应用程序的关键。

  • SQL查询方式的差异
- 执行以下代码,底层的SQL语句如下

var items = contextObj.Comments.Where(c => c.Message.Contains("微软")).Select(c=>c.Article);
foreach (var item in items)
{
	Console.WriteLine(item.Title);
}

......
SELECT [t0].[Id], [t0].[Content], [t0].[Title]
FROM [T_Comments] AS [t]
INNER JOIN [T_Articles] AS [t0] ON [t].[ArticleId] = [t0].[Id]
WHERE [t].[Message] LIKE N'%微软%'

- 把循环的逻辑注释掉,看看此时的差异(底层并没有执行SQL语句)
 var items = contextObj.Comments.Where(c => c.Message.Contains("微软")).Select(c=>c.Article);
 //foreach (var item in items)
 //{
 //    Console.WriteLine(item.Title);
 //}
 
- 总结差异
    - 第一段代码通过foreach循环触发了查询执行,因此生成了SQL语句。
    - 第二段代码没有触发查询执行,因为查询只是被定义,但没有被枚举,所以没有SQL语句执行。

为什么会有这种差异?

  • 延迟执行机制:LINQ查询返回的是IQueryableIEnumerable类型,它们代表一个“查询计划”而不是实际数据。查询计划只有在调用GetEnumerator()方法(如通过foreach)或调用ToList()ToArray()First()等方法时才会执行。
  • 性能优化:这种机制允许开发者先构建查询(可能添加更多条件),然后在需要时才执行,避免不必要的数据库调用。

是否触发真正的查询,简单区分方法

  • 使用终结方法会立即执行查询,而非终结方法就不会立即查询
- 终结方法: 循环遍历, ToArray(),ToList(),Min(),Max(),Count()
- 非终结方法: GroupBy(),OrderBy(),Include(),Skip(),Take()

- 简单判断: 如果一个方法的返回值类型是IQueryable类型,该方法就是"非终结方法",否则就是"终结方法"
  • 实例演示
 		// 主程序自定义一个查询方法
        static void QueryArticles(string searchWords, bool searchAll, bool orderByPrice,double upperPrice)
        {
            using (MyDbContext contextObj = new MyDbContext())
            {
                var articles = contextObj.Articles.Where(a => a.Price >= upperPrice);
                if (searchAll)
                {
                    articles = articles.Where(a => a.Title.Contains(searchWords) || a.Content.Contains(searchWords));
                }
                else
                {
                    articles = articles.Where(a => a.Title.Contains(searchWords));
                }

                if (orderByPrice)
                {
                    articles = articles.OrderBy(a => a.Price);
                }

                foreach (var a in articles)
                {
                    Console.WriteLine(a.Title);
                }
                
            }
        }
        
        // 主程序调用
        QueryArticles("风", true, true, 20);
        
- 返回结果:
......
      SELECT [t].[Id], [t].[Content], [t].[Price], [t].[Title]
      FROM [T_Articles] AS [t]
      WHERE (CAST([t].[Price] AS float) >= @__upperPrice_0) AND (((@__searchWords_1 LIKE N'') OR (CHARINDEX(@__searchWords_1, [t].[Title]) > 0)) OR ((@__searchWords_1 LIKE N'') OR (CHARINDEX(@__searchWords_1, [t].[Content]) > 0)))
      ORDER BY [t].[Price]
阿尔卑斯山
大自然
  • 意义体现
- IQueryable代表一个对数据库中数据进行查询的一个逻辑,这个查询是一个延迟查询。
- 我们可以调用"非终结方法"向IQueryable中添加查询逻辑,当执行终结方法的时候才真正生成SQL语句来执行查询。
- 可以实现以前要靠SQL拼接实现的动态查询逻辑。

分页

  • 需要用的API: Skip(3).Take(8)
  • 数值的范围: LongCountCount
  • 页数:long pageCount = (long)Math.Ceiling(count*1.0/pageSize)
static void PrintPage(int pageIndex, int pageSize)
{
    using (var contextObj = new MyDbContext()) // 创建数据库上下文
    {
        // 构建基础查询:获取所有标题不包含"知道"的文章
        var arts = contextObj.Articles.Where(a => !a.Title.Contains("知道"));
        
        // 应用分页:跳过前面的记录,取指定数量的记录
        var items = arts.Skip((pageIndex - 1) * pageSize).Take(pageSize);
        
        // 遍历分页结果并打印标题
        foreach (var item in items)
        {
            Console.WriteLine(item.Title);
        }
        
        // 获取总记录数
        long count = arts.LongCount();
        
        // 计算总页数(向上取整)
        long pageCount = (long)Math.Ceiling(count * 1.0 / pageSize);
        
        // 输出总页数
        Console.WriteLine($"总页数为: {pageCount}");
    }
}
  • 小小优化
static void PrintPage(int pageIndex, int pageSize)
{
    using (var contextObj = new MyDbContext())
    {
        // 添加排序确保分页结果一致
        var baseQuery = contextObj.Articles
            .Where(a => a.Title.Contains("风"))
            .OrderBy(a => a.Id); // 添加适当的排序字段
        
        // 一次性获取分页数据和总记录数
        var pagedItems = baseQuery
            .Skip((pageIndex - 1) * pageSize)
            .Take(pageSize)
            .ToList(); // 立即执行查询
        
        long count = baseQuery.Count(); // 执行计数查询
        
        // 输出当前页内容
        foreach (var item in pagedItems)
        {
            Console.WriteLine(item.Title);
        }
        
        // 计算并输出总页数
        long pageCount = (long)Math.Ceiling(count / (double)pageSize);
        Console.WriteLine($"总页数为: {pageCount}");
    }
}

SQL性能优化

  • DataReaderDataTable获取DB数据的区别
- DataReader: 分批从数据库服务器读取数据,内存占用小,DB连接占用时间长
	- 注意事项: DB支持有限个数的连接,当连接时长比较久时,高并发场景就会影响效率
	- 应用场景: IQueryable采用这种方式
	
- DataTable: 把所有数据都一次性从数据库服务器都加载到客户端内存中。内存占用大,节省DB连接
  • 验证IQueryable采用的方式
- 用insert into select多插入一些数据,然后加上Delay/Sleep的遍历lQueryable
- 在遍历执行的过程中,停止sQLServer服务器,IQueryable内部就是在调用DataReader
- SQL执行语句(棋盘格算法,2的N次方...),插入大量数据

insert into T_Articles(Title,Content)
select Title,Content from T_Articles

  • 如何停止SQLServer服务
- 按下 Win + R 键,输入 sqlservermanager<版本号>.msc 后回车。例如:

    - 对于 SQL Server 2019/2022,可以尝试输入 sqlservermanager16.msc

    - 对于 SQL Server 2019,可以尝试输入 sqlservermanager15.msc

    - 对于 SQL Server 2017,可以尝试输入 sqlservermanager14.msc

    - 如果不确定,也可以直接在“开始”菜单中搜索 “SQL Server 配置管理器

- 本机用的是sql server2012的版本,执行: SQLServerManager11.msc

sqlServer

  • 主程序逻辑
......
foreach (var art in contextObj.Articles)
{
    Console.WriteLine(art.Title);
    Thread.Sleep(10);
}
......

- 然后停止SQLServer服务,果然报错了
......
Microsoft.Data.SqlClient.SqlException:“A transport-level error has occurred when receiving results from the server. (provider: Session Provider, error: 19 - Physical connection is not usable)”

  • 解决办法:
 - 一次性加载数据到内存:用lQueryable的ToArray()、ToArrayAsync()、ToList()、ToListAsync()等方法。
 - 等ToArray()执行完毕,再停止sqlServer服务,测试效果---程序正常跑,不会被异常中断
 
 //foreach (var art in contextObj.Articles)
 //{
 //    Console.WriteLine(art.Title);
 //    Thread.Sleep(10);
 //}

// 相比之前,新增一个ToList()方法
foreach (var art in contextObj.Articles.ToList())
{
    Console.WriteLine(art.Title);
    Thread.Sleep(10);
}

  • 何时需要一次性加载
- 遍历lQueryable并且进行数据处理的过程很耗时(没什么好说的)
- 如果方法需要返回查询结果,并且在方法里销毁DbContext的话,是不能返回lQueryable的。必须一次性加载

// 主程序自定义测试方法: 这种写法有坑,你不能返回一个依赖于短生命周期 DbContext 的 IQueryable
static IQueryable<Article> SearchObj(string searchWord)
{
    using (var contextObj = new MyDbContext())
    {
    // 由于逻辑是丢到using执行,所以这段逻辑跑完以后,contextObj会被丢弃
    return contextObj.Articles.Where(a => a.Title.Contains(searchWord));
    }
}

// 调用方法,跑起来报错了: System.ObjectDisposedException:“Cannot access a disposed context instance...
var arts = SearchObj("知道");
foreach (var art in arts)
{
	Console.WriteLine(art.Title);
}
......
---临时解决办法,把using语句去掉,即可正常执行(项目中别这样用,会造成内存泄漏)
 
static IQueryable<Article> SearchObj(string searchWord)
{
    // using (var contextObj = new MyDbContext())
    var contextObj = new MyDbContext();
    return contextObj.Articles.Where(a => a.Title.Contains(searchWord));

}

---正确解决办法,使用"临时contextObj"的时候,把需要的数据强制一次性加载到内存,这样就没有后顾之忧了
.....
// 使用 IEnumerable<Article>
static IEnumerable<Article> SearchObj(string searchWord)
{
    using (var contextObj = new MyDbContext())
    {
    	// 使用ToArray(),一次性加载到内存
    	return contextObj.Articles.Where(a => a.Title.Contains(searchWord)).ToArray();
    } 

}

- 泄漏的后果是什么?随着时间推移或请求量增加(例如在Web服务器中),这种代码会:

	- 耗尽连接池:连接池中的可用连接被一个个取走且永不归还。池的大小是有限的(默认上限通常是100左右)。

	- 后续请求开始等待:当一个新的请求需要数据库连接时,它必须等待一个可用的连接从池中分配。但因为所有连接都被泄漏占用了,没有空闲连接。

	- 请求超时(Timeout Exception):等待一段时间后(例如30秒),如果还是没有可用连接,ADO.NET 会抛出类似 System.InvalidOperationException: Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool. 的异常。

	- 应用程序性能急剧下降或完全崩溃:用户看到的是网站变慢、报错,最终整个应用程序可能因为无法处理任何涉及数据库的请求而失去功能



- 多个lQueryable的遍历嵌套。很多数据库的ADO.NETCoreProvider是不支持多个DataReader同时执行的(先把MyDbContext.cs连接字符串中的MultipleActiveResultSets=true删掉,其他数据库不支持这个[这是 SQL Server 特有的功能])

	// MyDbContext.cs
	protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);
		    // 这句先摘出来(丢回去就不会报错了)
            // ;MultipleActiveResultSets=true
            string connStr = "Server=.;Database=demo3;Trusted_Connection=True";
            optionsBuilder.UseSqlServer(connStr);
           ......
        }
        
// 主程序报错: System.InvalidOperationException:“There is already an open DataReader associated with this Connection which must be closed first
......
			   foreach (var art in contextObj.Articles) // 打开了一个DataReader
                {
                    Console.WriteLine(art.Title);
                    foreach (var comment in contextObj.Comments) // 又打开了一个DataReader
                    {
                        Console.WriteLine(comment.Message);
                    }
                }
- 同时打开了多个DataReader,就会报错,可以这么解决(在内存中遍历)

var articles = contextObj.Articles.ToList(); // 立即执行查询,关闭DataReader
var comments = contextObj.Comments.ToList(); // 立即执行另一个查询

foreach (var art in articles) // 现在是在内存中循环,没有活跃的DataReader
{
    Console.WriteLine(art.Title);
    foreach (var comment in comments) // 同样是在内存中循环
    {
        Console.WriteLine(comment.Message);
    }
}
   

EF Core提供的异步方法

- 保存方法
    - SaveChanges()--->SaveChangesAsync()
    
- 异步方法大部分是定义在Microsoft.EntityFrameworkCore这个命名空间下EntityFrameworkQueryableExtensions等类   中的扩展方法,记得using

- 常见方法
    - AddAsync()、AddRangeAsync()、AllAsync()、AnyAsync、
      AverageAsync、ContainsAsync、CountAsync、FirstAsync、
      FirstOrDefaultAsync、ForEachAsync、LongCountAsync、MaxAsync、
      MinAsync、SingleAsync、SingleOrDefaultAsync、SumAsync等

  • 演示几种异步方法
		// static void Main(string[] args)
        static async Task Main(string[] args)
        {
            using (MyDbContext contextObj = new MyDbContext())
            {	
            
            	// 使用CountAsync()
                var countNumber = await contextObj.Articles.CountAsync();
                Console.WriteLine($"文章总数为{countNumber}");
                
                // 新增一条记录并获取第一条数据
                var newObj = new Article { Title = "民生", Content = "我只关注民生...", Price = 777 };
                // 调用两个异步
                await contextObj.Articles.AddAsync(newObj);
                await contextObj.SaveChangesAsync();
                Console.WriteLine("保存数据成功!");
			   // 调用一个异步
                var firstObj = await contextObj.Articles.FirstAsync();
                Console.WriteLine(firstObj.Title);

            }
  • 注意事项: 有些方法是没有异步方法
- IQueryable的这些异步的扩展方法都是“立即执行"方法
- GroupBy、OrderBy、Join、Where等“非立即执行"方法则没有对应的异步方法。为什么?
	- “非立即执行"方法并没有实际执行SQL语句,并不是消耗IO的操作。

第一句话:理解“立即执行”方法

“IQueryable的这些异步的扩展方法都是‘立即执行’方法”

这句话是理解这个问题的钥匙。

  1. 什么是“立即执行”方法?

    • 这些方法当你调用它们时,会立即触发对数据源(如 SQL 数据库)的查询操作,执行 SQL 语句,并将结果物化到内存中。
    • 它们标志着 LINQ 查询链的终结。调用这些方法后,你得到的不再是一个“可以继续构建的查询计划”,而是一个实实在在的“结果”。
    • 典型代表ToList()ToArray()FirstOrDefault()Single()Count()Max()Any()Load() 等。
  2. 为什么它们的异步版本(ToListAsync(), FirstOrDefaultAsync() 等)是必需的?

    • 因为这些操作是 I/O 密集型的。它们需要通过网络向数据库发送命令,然后等待数据库执行查询并返回结果。
    • 这个“等待”的过程可能会花费可观的时间(几毫秒到几秒)。在异步编程模型中,我们不希望当前线程(特别是像 UI 线程或 ASP.NET 的请求线程)被这种“等待”所阻塞。
    • 异步方法(...Async())允许线程在“等待”数据库响应时被释放去处理其他工作,从而提高应用程序的吞吐量和响应能力

小结: 只有那些会真正执行查询、消耗 I/O 的方法,才需要有对应的异步版本,以避免阻塞线程。


第二句话:理解“非立即执行”方法

“GroupBy、OrderBy、Join、Where等‘非立即执行’方法则没有对应的异步方法。”

  1. 什么是“非立即执行”方法?

    • 这些方法被称为延迟执行查询操作符。它们不会立即触发任何数据库操作。
    • 它们的作用是构建查询表达式树。你可以把它们想象成在“草拟一份详细的 SQL 脚本说明书”,但还没有把它发给数据库去执行。
    • Where 是在说明书上添加 WHERE 子句。
    • OrderBy 是在添加 ORDER BY 子句。
    • Join 是在添加 INNER JOIN 子句。
    • GroupBy 是在添加 GROUP BY 子句。
    • 调用这些方法只是在不断地修改和丰富 IQueryable 对象所承载的表达式树
  2. 为什么它们没有异步版本?

    • 核心原因:因为它们不执行任何 I/O 操作! 它们只是在内存中操作表达式树,这是一个纯粹的、CPU 密集型的操作,速度极快。
    • 创建一个 WhereAsync 方法是毫无意义的。因为 Where 本身并不去数据库里筛选数据,它只是告诉最终的查询“你到时候要去数据库里这样筛选”。
    • 由于没有 I/O 等待,也就不存在“释放线程去处理其他工作”的收益,因此不需要异步模式。

综合起来:一个完整的例子

让我们用一段代码来把这两个概念串联起来:

// 1. 这些全都是“非立即执行”方法,只是在构建查询。
//    没有异步版本,因为它们不执行IO,只构建表达式树。
var query = context.Products
    .Where(p => p.Price > 100)     // -> 生成 SQL WHERE 子句
    .OrderBy(p => p.Name)          // -> 生成 SQL ORDER BY 子句
    .GroupBy(p => p.CategoryId);   // -> 生成 SQL GROUP BY 子句

// 此时此刻,没有 SQL 被发送到数据库!
// ‘query’ 只是一个包含了复杂指令的 IQueryable 对象。

// 2. 当你调用“立即执行”方法时,查询才真正发生。
//    这是IO操作,所以有异步版本。
var result = await query.ToListAsync(); // -> 执行生成的完整SQL,等待结果

// 上面这行代码的等价同步版本是:
// var result = query.ToList();

总结与类比

你可以把构建 IQueryable 查询的过程类比成网上购物

  • 非立即执行方法(Where, OrderBy...):就像你在购物网站上加商品到购物车、选择优惠券、填写收货地址。这些操作都只是在准备你的订单,并没有真正下单支付。这些操作很快,不需要“等待”。
  • 立即执行方法(ToList, FirstOrDefault...):就像你点击了“提交订单”按钮。这时才会真正发生与银行、仓库的交互(I/O 操作),这个过程需要时间,所以你可以选择“异步”等待(去做别的事,等快递通知),而不是傻傻地守在电脑前(线程阻塞)。

所以,规则很简单:

  • 需要与数据库通信(I/O) -> 用异步方法(...Async())。
  • 只是构建查询(CPU操作) -> 用普通的同步方法。

你的代码结构通常是:一连串的同步“非立即执行”方法调用链,最终以一个异步的“立即执行”方法结尾

三种循环的差异

好的,这是一个非常深入且实际的问题。这三种遍历方式在执行机制、性能特征和内存使用上有本质的区别。我们来逐一分解:

1. 直接遍历 IQueryable (foreach on IQueryable)

foreach (var art in contextObj.Articles)
{
    Console.WriteLine(art.Title);
}
  • 机制

    1. foreach 开始迭代时,EF Core 会将 contextObj.Articles 这个 IQueryable 所代表的整个查询(通常就是 SELECT * FROM Articles)发送到数据库执行。
    2. 数据库打开一个DataReader来流式传输结果。
    3. 每次 foreach 循环迭代时,EF Core 会通过这个活动的 DataReader 读取下一行数据,将其具体化(Materialize)为一个 Article 对象,然后你处理它。
    4. 在整个循环期间,数据库连接和 DataReader 始终保持打开状态。
  • 特点

    • 流式加载:数据是一条一条从数据库读取到内存的。内存中通常只存在一个 Article 对象(或一小批),内存占用极低,非常适合处理海量数据。
    • 连接占用:整个循环期间都占用着一个数据库连接。
    • 风险:如果在循环体内使用同一个 DbContext 执行另一个查询(例如通过导航属性懒加载数据),极易引发我们之前讨论过的 “There is already an open DataReader...” 异常。解决方案是启用 MARS 或在循环前使用 .Include 预先加载。
  • 适用场景:需要低内存开销地顺序处理大量数据,并且确定循环体内不会触发其他查询。


2. 遍历 ToListAsync() 的结果 (foreach on List<T>)

foreach (var art in await contextObj.Articles.ToListAsync())
{
    Console.WriteLine(art.Title);
}
  • 机制

    1. await contextObj.Articles.ToListAsync()立即执行:EF Core 将查询发送到数据库,并等待所有结果返回
    2. 数据库执行查询,将所有匹配的数据行一次性发送回来。
    3. EF Core 在内存中将这些数据行全部转换成 Article 对象,并放入一个 List<Article> 中。
    4. 至此,数据库连接关闭,DataReader 被释放。
    5. 然后 foreach 循环才开始,它迭代的是内存中这个已经完全准备好的列表。
  • 特点

    • 立即加载/贪婪加载:在循环开始前,所有数据都已加载到内存中。
    • 内存压力大:数据量越大,消耗的内存越多。有可能导致程序内存不足(OutOfMemoryException)。
    • 连接释放早:数据库连接在循环开始前就已释放,非常安全。循环体内可以随意使用同一个 DbContext 执行其他操作而不会导致 DataReader 冲突。
    • 响应快:如果后续要对同一数据集进行多次操作(如排序、多次遍历),因为数据已在内存中,速度会很快。
  • 适用场景:处理数据量不大的集合,或者你需要对数据进行多次操作,或者你需要在循环体内进行其他数据库查询。


3. 使用 AsAsyncEnumerable() 进行异步流式遍历 (await foreach)

await foreach (var art in contextObj.Articles.AsAsyncEnumerable())
{
    Console.WriteLine(art.Title);
}
  • 机制

    1. AsAsyncEnumerable() 方法将 IQueryable<T> 转换为 IAsyncEnumerable<T>
    2. await foreach 开始迭代时,EF Core 会发送查询并打开 DataReader,这与第一种方式类似。
    3. 关键区别在于:每次需要读取下一条记录时,它都是一个异步操作await foreach 会异步地等待下一条记录从数据库传来,然后再继续循环。
  • 特点

    • 异步流式加载:它结合了前两种方式的优点:低内存占用(流式加载)和非阻塞异步操作
    • 避免阻塞:在等待数据库返回下一条数据包时,可以释放线程去处理其他工作(如处理其他HTTP请求),这对于Web服务器等高并发场景的可伸缩性至关重要。
    • 连接占用:与第一种方式一样,在整个遍历期间连接和DataReader保持打开。
  • 适用场景:这是处理大量数据的最佳现代实践。当你需要以最低的内存 footprint 异步地、一条一条地处理海量数据库记录时,就应该使用 await foreach + AsAsyncEnumerable()

总结对比

特性 foreach (in IQueryable) foreach (in await ToListAsync()) await foreach (in AsAsyncEnumerable())
执行时机 循环开始时执行查询 循环立即执行查询 循环开始时执行查询
加载方式 同步流式加载 立即加载所有数据 异步流式加载
内存占用 (一次一条) (全部在内存) (一次一条)
数据库连接 整个循环期间保持打开 循环就已关闭 整个循环期间保持打开
线程阻塞 循环中会阻塞线程 await 时不阻塞,foreach时阻塞 全程不阻塞线程
安全性 易引发 DataReader 冲突 安全,无冲突风险 易引发 DataReader 冲突
适用场景 同步处理大量数据 处理小数据集或需内存操作 异步处理大量数据(首选)

最终建议

  • 数据量小且操作简单:使用 await ToListAsync() 然后进行 foreach,最简单安全。
  • 需要同步处理海量数据:使用直接的 foreach on IQueryable,但要非常小心嵌套查询的问题。
  • 需要异步处理海量数据(现代最佳实践):使用 await foreachAsAsyncEnumerable()。这是兼顾了性能、内存效率和异步优势的方案。

执行原生SQL语句的三种方式

- 非查询语句
- 实体查询
- 任意SQL查询
  • 非查询语句示例
using (var context = new MyDbContext())
{
    string title = "标题111";
    string content = "内容111";
    int price = 999;
    
    int affectedRows = await context.Database.ExecuteSqlInterpolatedAsync(
        $"INSERT INTO T_Articles (Title, Content, Price) VALUES ({title}, {content}, {price})");
        
    Console.WriteLine($"插入了 {affectedRows} 行数据");
}

- 注意事项: ExecuteSqlInterpolatedAsync()用于执行插值的 SQL 字符串,采用的是参数化的数据结构,所以可以避免SQL注入攻击
	- 拼接的SQL语句就有SQL注入的危险
  • FromSqlInterpolated: 执行原生 SQL 查询,并返回一个 IQueryable 对象
......
string titlePattern = "%自然%";
var filteredArts = contextObj.Articles
    .FromSqlInterpolated($"select * from T_Articles where Title like {titlePattern}")
    // FromSqlInterpolated()返回IQueryable对象,这里可以继续查询
    .Where(a => a.Price > 100)
    .OrderBy(a => a.PublishDate);
  • FromSqlInterpolatedExecuteSqlInterpolatedAsync同样是执行sql语句,它两的区别

未命名绘图

简单记忆:

  • Execute... -> 执行命令,返回数字(行数)。
  • From... -> 执行查询,返回数据(对象集合)。

总结与选择

  • 当你需要修改数据(插入、更新、删除)并且不关心返回具体数据内容,只想知道操作是否成功或影响了多少行时,使用 ExecuteSqlInterpolatedAsync
  • 当你需要从数据库检索数据,并且希望结果能自动转换为实体对象,还可能需要对结果进行进一步的筛选、排序等操作时,使用 FromSqlInterpolated

绕过Entity Framework Core的高级抽象,直接使用底层的ADO.NET对象

  • 优劣: 提供了最大的灵活性,但也需要手动管理更多细节
// 1. 从 DbContext 获取底层的数据库连接对象
DbConnection conn = contextObj.Database.GetDbConnection();

// 2. 检查连接是否已经打开,如果没有则异步打开连接
if(conn.State != System.Data.ConnectionState.Open)
{
    await conn.OpenAsync();
}

// 3. 创建一个命令对象 (DbCommand),用于执行 SQL 语句
using (var cmd = conn.CreateCommand())
{
    // 4. 设置要执行的 SQL 命令文本
    cmd.CommandText = "select Price,Count(*) from T_Articles group by Price";
    
    // 5. 异步执行命令并获取一个数据读取器 (DbDataReader)
    using (var reader = await cmd.ExecuteReaderAsync())
    {
        // 6. 循环读取结果集中的每一行
        while (await reader.ReadAsync())
        {
            // 7. 从当前行中按列索引获取值
            double price = reader.GetInt32(0);  // 获取第一列的值 (Price)
            int count = reader.GetInt32(1);     // 获取第二列的值 (Count(*))
            Console.WriteLine($"{price}--{count}");
        }
    }
}

- 输出结果
......
0--163835
999--2
888--2
777--1
70--1
45--1
77--1
60--1
40--1
数据操作完成!!
  • 使用Dapper示例用法: 混合使用 EF Core 和 Dapper
// 新建GroupArticlesByPrice类
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleAppObjectRelation
{
    class GroupArticlesByPrice // 用于接收查询结果
    {
        public int Price { get; set; } // 对应查询结果中的 Price 列
        public int PCount { get; set; } // PCount 属性对应查询结果中的 PCount 列(通过 Count(*) PCount 别名创建)
    }
}

// 主程序		
			  // contextObj.Database.GetDbConnection() 从 EF Core 的 DbContext 中获取底层的 ADO.NET 数据库连接对象。	
			  // Query属于Dapper的拓展方法: 执行 SQL 查询并将结果映射到指定的类型, 返回一个 IEnumerable<GroupArticlesByPrice>,可以直接进行遍历
			  var items = contextObj.Database.GetDbConnection().Query<GroupArticlesByPrice>(
                        "select Price,Count(*) PCount from T_Articles group by Price"
                    );
                // Dapper 会自动将查询结果的列映射到 GroupArticlesByPrice 类的属性,基于名称匹配(不区分大小写)
                foreach (var item in items)
                {
                    Console.WriteLine($"{item.Price}--{item.PCount}");
                }

总结

这段代码展示了如何在 EF Core 项目中利用 Dapper 执行高性能的原始 SQL 查询,并结合两者的优势:

  • 使用 EF Core 管理数据库连接和上下文生命周期。
  • 使用 Dapper 执行高效的原始 SQL 查询和简单的对象映射。
  • 特别适合执行复杂的查询或需要最佳性能的场景。

这种混合方法在许多实际项目中非常常见,因为它结合了 EF Core 的便利性和 Dapper 的性能优势

EF Core跟踪业务类的状态(根据快照)

  • 通俗理解: 首次跟踪的时候,先标记一下当前业务类的状态,执行了业务逻辑以后,再和之前的状态进行对比,从而得出结论
- 五种实体的状态

    - 已添加(Added):DbContext正在跟踪此实体,但数据库中尚不存在该实体.
    
    - 未改变(Unchanged)):DbContext正在跟踪此实体,该实体存在于数据库中,其属性值和从数据库中读取到的值一致,未发生改变.
    
    - 已修改(Modified):DbContext正在跟踪此实体,并存在于数据库中,并且其部分或全部属性值已修改.
    
    - 已删除(Deleted):DbContext正在跟踪此实体,并存在于数据库中,但在下次调用SaveChanges时要从数据库中删除对应数据.
    
    - 已分离(Detached):DbContext未跟踪该实体.


SAVECHANGES()的操作
  - 已分离"和“未改变"的实体,SaveChanges()忽略;
  - “已添加"的实体,SaveChanges()插入数据库;
  - “已修改"的实体,SaveChanges()更新到数据库;
  - “已删除"的实体,SaveChanges()从数据库删除;

var items = contextObj.Articles.Take(3).ToArray();
var a1 = items[0];
var a2 = items[1];
var a3 = items[2];

var a4 = new Article { Title = "非常完美", Content = "666", Price = 123 };
var a5 = new Article { Title = "非常完美111", Content = "666888", Price = 456 };

a1.Price += 1;
contextObj.Remove(a2);
contextObj.Articles.Add(a4);

EntityEntry e1 = contextObj.Entry(a1);
EntityEntry e2 = contextObj.Entry(a2);
EntityEntry e3 = contextObj.Entry(a3);
EntityEntry e4 = contextObj.Entry(a4);
EntityEntry e5 = contextObj.Entry(a5);

Console.WriteLine($"e1的状态为{e1.State}"); // 从数据库获取的,且已被修改,所以状态为Modified
Console.WriteLine($"e1的状态为{e1.DebugView.LongView}"); // 输出 a1 的详细调试信息,包括原始值和当前值的对比
'''
e1的状态为Article {Id: 1} Modified
  Id: 1 PK
  Content: '不知道叫什么的Content'
  Price: 61 Modified Originally 60
  Title: '不知道叫什么的Title'
  Comments: []
'''

Console.WriteLine($"e2的状态为{e2.State}"); // 被标记为删除,所以状态为Deleted
Console.WriteLine($"e3的状态为{e3.State}"); // 从数据库获取但未被修改,所以状态为Unchanged
Console.WriteLine($"e4的状态为{e4.State}"); // 被添加到上下文,所以状态为Added
Console.WriteLine($"e5的状态为{e5.State}"); // 是新创建的但未添加到上下文,所以状态为Detached(未被上下文跟踪)
  • 某些业务场景(比如查询(数据的插入更新,就不推荐用))不需要EF Core跟踪实体的状态(毕竟会产生开销),可以调用AsNoTracking()避免跟踪,从而节省内存开销
......
var arts = contextObj.Articles.AsNoTracking().Take(20).ToArray();
foreach (var art in arts)
{
	Console.WriteLine(art.Title);
}

var artObj = arts[0];
// Detached(未被跟踪)
Console.WriteLine(contextObj.Entry(artObj).State);

为什么状态是 Detached?

  • 当使用 AsNoTracking() 时,EF Core 只是简单地从数据库读取数据并创建对象,但不会将这些对象添加到其内部的更改跟踪器中。

  • 由于这些对象没有被跟踪,DbContext 不知道它们的存在,所以它们的状态是 Detached

  • 如果你尝试修改这些对象并调用 SaveChanges(),EF Core 不会检测到这些更改,也不会将它们保存到数据库。

    var arts = contextObj.Articles.AsNoTracking().Take(20).ToArray();
    ......
    var artObj = arts[0];
    Console.WriteLine($"现在的价格是: {artObj.Price}");
    Console.WriteLine(contextObj.Entry(artObj).State);
    artObj.Price += 10; // 不会被写入数据库
    contextObj.SaveChanges();
    
    • 这里如果实在想保存可以这么做
     var arts = contextObj.Articles.AsNoTracking().Take(20).ToArray();
     ......
    var artObj = arts[0];
    artObj.Price += 10;
    // 添加到ContextObj,然后标记状态为Modified
    contextObj.Attach(artObj);
    contextObj.Entry(artObj).State = EntityState.Modified;
    
    contextObj.SaveChanges(); // 成功写入数据库
    
    - 注意事项: 手动标记实体状态的方式,是一种非主流的写法,在大型项目中尽量不要这么搞,表多字段多的情况,坑很多!
    
    • 还可以这么写

      var artObj = new Article { Id = 1 };
      artObj.Price += 10;
      //  获取这个对象的 EntityEntry
      var entry1 = contextObj.Entry(artObj);
      entry1.Property("Price").IsModified = true;
      Console.WriteLine(entry1.DebugView.LongView);
      
      contextObj.SaveChanges();
      
  • 删除功能可以这么写(节省开销: 如果按正常的做法,先查一遍,然后再删除.而下面的步骤不用查了,直接删...性能提升...)

......
var artObj = new Article { Id = 7 }; // 定位
contextObj.Entry(artObj).State = EntityState.Deleted; // 标记状态
contextObj.SaveChanges();
......

EF Core对批量更新的支持

  • 截止到5.0版本,是不支持的,contextObj.AddRange()方法,本质也是循环遍历,一条一条更新
 var art1 = new Article { Title = "111", Content = "222", Price = 666 };
 var art2 = new Article { Title = "222", Content = "333", Price = 777 };
 var art3 = new Article { Title = "333", Content = "444", Price = 888 };
contextObj.AddRange(art1,art2,art3);

contextObj.SaveChanges();
  • 暂时的解决办法,通过第三方库EFCore.BulkExtensions
# 安装兼容版本
Install-Package EFCore.BulkExtensions -Version 5.4.2

// 测试
......
 var art1 = new Article { Title = "111", Content = "222", Price = 666 };
 var art2 = new Article { Title = "222", Content = "333", Price = 777 };
 var art3 = new Article { Title = "333", Content = "444", Price = 888 };

var articles = new List<Article> { art1, art2, art3 };
await contextObj.BulkInsertAsync(articles);

全局查询筛选器---builder.HasQueryFilter

  • 所有查询配置一个默认查询,无需手动再添加
    • 好处显而易见,不用每次都手动再写一次
    • 应用场景: 软删除,多租户系统(每次都要先查一次租户字段)
  • 示例
// Article业务类新增字段
......
public bool IsDelete { get; set; }
......
// 主程序调用
......
 var artObj = contextObj.Articles.Single(a => a.Id == 2);
 artObj.IsDelete = true;
 await contextObj.SaveChangesAsync();
 ......
// 配置类添加默认的查询器
......

namespace ConsoleAppObjectRelation
{
    class ArticleConfig : IEntityTypeConfiguration<Article>
    {
        public void Configure(EntityTypeBuilder<Article> builder)
        {
            
           ......
            builder.HasQueryFilter(obj => obj.IsDelete == false);
            
        }
    }
}


// 主程序测试
......
 var arts = contextObj.Articles.Where(a => a.Id > 0).Take(10);
 foreach (var art in arts)
 {
 	Console.WriteLine(art.Id + "---" +art.Title);
 }
 
 - 结果: ID=2的数据默认被过滤了...
1---不知道叫什么的Title
3---大自然
4---阿尔卑斯山
5---大自然2
6---不知道叫什么的Title
8---大自然
9---阿尔卑斯山
10---大自然2
11---不知道叫什么的Title
12---不知道叫什么的Title222
  • 如果想忽略默认的过滤器,可以调用IgnoreQueryFilters()
var arts = contextObj.Articles.IgnoreQueryFilters().Where(a => a.Id > 0).Take(10);
foreach (var art in arts)
{
	Console.WriteLine(art.Id + "---" +art.Title);
}

- 结果: 刚才被忽略的Id=2的记录又回来了...
1---不知道叫什么的Title
2---不知道叫什么的Title222 // 又回来了...
3---大自然
4---阿尔卑斯山
5---大自然2
6---不知道叫什么的Title
8---大自然
9---阿尔卑斯山
10---大自然2
11---不知道叫什么的Title

一个完整的高并发架构流程(非数据库方案)

假设一个用户下单的高并发场景,这些非数据库方案如何协同工作:

  1. 入口: 用户请求首先到达 负载均衡器 (Nginx)。
  2. 路由: 负载均衡器将请求转发给一台有空闲处理能力的 C# Web服务器(可能是众多实例之一)。
  3. 认证: C#服务器从 分布式缓存(Redis) 中获取用户的会话信息以验证身份。
  4. 核心业务逻辑(读): 检查商品库存?不去数据库,而是查询 Redis缓存
  5. 核心业务逻辑(写): 扣减库存成功,需要创建订单。C#服务器将创建订单的指令作为一条消息,迅速发送到 消息队列(RabbitMQ) 中,然后立即返回响应给用户“下单排队成功”。
  6. 异步处理: 一个或多个专门的 C#后台工作者服务(消费者)从消息队列中取出订单消息,慢慢地、可靠地与数据库交互,完成订单的最终落库、扣减真实库存等操作。即使数据库压力大,也只是影响后台消费者的处理速度,而不会导致前端Web服务器瘫痪或响应用户过慢。
  7. 通知: 订单处理完成后,可以再通过消息队列触发另一个发送App推送或短信的通知服务。

所以, 缓存、消息队列、水平扩展 这一整套架构模式。它们通过在数据库前方建立多道“缓冲区”,将同步的、直接的数据库访问转变为异步的、间接的、基于内存的操作,从而成就了高并发系统。

数据库方案

  • 基于乐观锁悲观锁方案
  • 悲观锁
- 就表锁,行锁(推荐)
	- EF Core不提供悲观锁的API,只能使用原生的SQL语句去执行,本次以"MySQL为例"
  • 并发问题演示
// 业务类
......
namespace ConsoleAppORM
{
    class House
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Owner { get; set; }
    }
}

// 主程序
......

namespace ConsoleAppORM
{
    class Program
    {
       
        static void Main(string[] args)
        {

            Console.WriteLine("请输入姓名");
            var name = Console.ReadLine();;
            using (MyDbContext dbObject = new MyDbContext())
            {

                var h1 = dbObject.Houses.Single(h => h.Id == 1);
                if (!string.IsNullOrEmpty(h1.Owner))
                {
                    Console.WriteLine($"房子已经被{h1.Owner}占了");
                    return;
                }

                h1.Owner = name;
                Thread.Sleep(5000);
                dbObject.SaveChanges();
                Console.WriteLine($"新房已经被{h1.Owner}占了");
                Console.ReadLine();

            }
            Console.WriteLine("数据处理完成");
        }

        static bool test(string s)
        {
            return s.Contains("杨");
        }
    }
}

- 编译一下,打开debug目录,同时运行两个程序

并发结果1:
请输入姓名
Kate
新房已经被Kate占了

并发结果2:
请输入姓名
Jim
新房已经被Jim占了

- 检查数据库记录: Name名字存储的是"Jim"
  • 引入悲观锁
// 主程序
Console.WriteLine("请输入姓名");
var name = Console.ReadLine(); ;
using (MyDbContext dbObject = new MyDbContext())
using (var transactionObj = dbObject.Database.BeginTransaction())
{
    // 使用FOR UPDATE锁定查询的行z(不同的数据库语法不一样)
    var h1 = dbObject.Houses.FromSqlInterpolated($"select * from T_Houses where Id=1 for update").Single();
    
    // 检查房屋是否已被占用
    if (!string.IsNullOrEmpty(h1.Owner))
    {
        if(h1.Owner==name)
        {
            Console.WriteLine($"房子已经被你占了");
        }
        else
        {
            Console.WriteLine($"房子已经被{h1.Owner}占了");
        }
        return;
    }
    
    // 占用房屋
    h1.Owner = name;
    Thread.Sleep(5000); // 模拟耗时操作
    Console.WriteLine("恭喜你抢到了...");
    dbObject.SaveChanges(); // 保存更改到数据库
    transactionObj.Commit(); // 提交事务,释放锁
    Console.ReadLine();
}
  • 引入乐观锁
- 查一下update语句影响的行数,如果为0,说明并发冲突了,然后抛出异常
	update T_houses set owner="tom" where Id=1 and owner="" // 影响行数1
	update T_houses set owner="Jim" where Id=1 and owner="" // 影响行数0
// 配置类中,配置并发令牌
......

namespace ConsoleAppORM
{
    class HouseConfig : IEntityTypeConfiguration<House>
    {
        public void Configure(EntityTypeBuilder<House> builder)
        {
           ......
            // 设置并发令牌
            builder.Property(h => h.Owner).IsConcurrencyToken();
            
        }
    }
}


// 主程序
......
 		  Console.WriteLine("请输入您的姓名");
            string name = Console.ReadLine();
            using MyDbContext ctx = new MyDbContext();
            var h1 = await ctx.Houses.SingleAsync(h => h.Id == 1);
            if (string.IsNullOrEmpty(h1.Owner))
            {
                await Task.Delay(5000);
                h1.Owner = name;
                try
                {
                    await ctx.SaveChangesAsync();
                    Console.WriteLine("抢到手了");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var entry = ex.Entries.First();
                    var dbValues = await entry.GetDatabaseValuesAsync();
                    string newOwner = dbValues.GetValue<string>(nameof(House.Owner));
                    Console.WriteLine($"并发冲突,被{newOwner}提前抢走了");
                }
            }
            else
            {
                if (h1.Owner == name)
                {
                    Console.WriteLine("这个房子已经是你的了,不用抢");
                }
                else
                {
                    Console.WriteLine($"这个房子已经被{h1.Owner}抢走了");
                }
            }
            Console.ReadLine();

  • 声明RowVer字段,实现行记录(允许多字段并发)的乐观锁并发
// House.cs
......

namespace ConsoleAppObjectRelation
{
    class House
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Owner { get; set; }
        public byte[] RowVer { get; set; } // 新增字段,用于并发控制的行版本(Row Version)字段,类型为 byte[],EF Core 会将其视为并发令牌(Concurrency Token)
    }
}

// HouseConfig.cs
......

namespace ConsoleAppObjectRelation
{
    class HouseConfig : IEntityTypeConfiguration<House>
    {
        public void Configure(EntityTypeBuilder<House> builder)
        {

            builder.ToTable("T_Houses");
            builder.Property(obj => obj.Name).IsRequired();
            builder.Property(obj => obj.RowVer).IsRowVersion(); // 新增配置,行版本字段(SQL Server 中的 rowversion 类型),用于自动跟踪行的版本变化

        }
    }
}

// 主程序测试
......
 		   Console.WriteLine("请输入您的姓名");
            string name = Console.ReadLine();
            using MyDbContext ctx = new MyDbContext();
            var h1 = await ctx.Houses.SingleAsync(h => h.Id == 1);
            if (string.IsNullOrEmpty(h1.Owner))
            {
                await Task.Delay(5000);
                h1.Owner = name;
                try
                {
                    await ctx.SaveChangesAsync();
                    Console.WriteLine("抢到手了");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var entry = ex.Entries.First();
                    var dbValues = await entry.GetDatabaseValuesAsync();
                    string newOwner = dbValues.GetValue<string>(nameof(House.Owner));
                    Console.WriteLine($"并发冲突,被{newOwner}提前抢走了");
                }
            }
            else
            {
                if (h1.Owner == name)
                {
                    Console.WriteLine("这个房子已经是你的了,不用抢");
                }
                else
                {
                    Console.WriteLine($"这个房子已经被{h1.Owner}抢走了");
                }
            }
            Console.ReadLine();


- 测试结果:
	- ...抢到手了
	- ...并发冲突,被Kate提前抢走了

总结:

  • 这段代码演示了如何使用 EF Core 的行版本并发控制来处理多用户同时更新同一记录的场景。
  • 通过 IsRowVersion() 配置并发令牌,EF Core 会在更新时自动检查 RowVer 字段是否发生变化(数据库被存储为时间戳),若变化则抛出 DbUpdateConcurrencyException
  • 这是一种常见的乐观并发控制实现方式,适用于高并发、低冲突的应用场景
posted @ 2025-08-26 11:33  清安宁  阅读(13)  评论(0)    收藏  举报