LINQ

LINQ(Language Integrated Query,语言集成查询)是 .NET 框架中的一种查询技术,它允许开发者使用统一的语法查询不同类型的数据源(如集合、数据库、XML 等,像List这样的集合类也是可以用LINQ的!)。

查询构建

  • 查询表达式:使用类似 SQL 的语法(如 fromwhereselect
  • 方法语法:使用 LINQ 扩展方法(如 Where()Select()OrderBy()
  • 两者可以混合使用,功能等价

这是查询表达式语法,很像SQL:

var query = from device in _context.Devices
            where device.Status == "online"
            orderby device.CreateTime descending
            select new DeviceDTO
            {
                Id = device.Id,
                Name = device.Name
                // 其他属性...
            };

var result = query.ToList();  // 执行查询

注意:LINQ SQL是直接写到C#代码里的!也就是说,它可以做编译期检查,而不像SQL语句,语法错误要到运行期才能发现(这在spring 里使用mybatis时是很常见的问题)。

这是LINQ方法语法:

// 构建查询(延迟执行)
var query = _context.Devices
    .Where(d => d.Status == "online")  // 过滤条件
    .OrderByDescending(d => d.CreateTime);  // 排序

// 执行查询(立即执行)
var onlineDevices = query.ToList();  // 此时才执行 SQL 查询

对于集合类操作而言,LINQ等价与java的stream API。

对于数据库而言,LINQ等价于java的Hibernate。

所以说,C#用一套LINQ技术,归一化了java里好几个领域的技术。

JOIN查询

再来个join语句:

from cp in _context.CollectPoints 
join device in _context.Devices on cp.DeviceId equals device.Id 
select new { CollectPoint = cp, DeviceName = device.Name };

该LINQ功能说明:

  1. 数据源定义

    • from cp in _context.CollectPoints
      

      从CollectPoints表中获取所有记录,每个记录赋值给变量cp。

  2. 关联操作

    • join device in _context.Devices on cp.DeviceId equals device.Id
      

      使用join关键字将CollectPoints表与Devices表关联,关联条件是cp.DeviceId等于device.Id。
      这相当于 SQL 中的INNER JOIN操作,只返回两个表中匹配的记录。

  3. 结果投影

    • select new { CollectPoint = cp, DeviceName = device.Name }
      

      使用select关键字定义查询结果的结构,创建一个匿名类型对象,包含两个属性:

      • CollectPoint:值为采集点对象 cp(包含采集点的所有字段)。
      • DeviceName:值为关联设备的 Name 属性(设备名称)。

技术原理:

  • LINQ 转换:这段代码会被 Entity Framework Core 转换为 SQL 语句,大致如下:

    SELECT 
        cp.*, 
        d.Name AS DeviceName 
    FROM 
        CollectPoints cp
    INNER JOIN 
        Devices d ON cp.DeviceId = d.Id
    
  • 匿名类型new { ... } 创建的是一个编译时生成的匿名类型,用于临时存储查询结果,无需定义额外的实体类。

动态添加查询条件

动态添加查询条件,一般用LINQ的方法语法,因LINQ的查询表达式是一次性语法,必须以select结尾,且中间不能中断。比如:

var query = context.Devices.AsNoTracking()
            .Include(d => d.Cabinet)
            .AsQueryable();

// 这里用查询表达式语法就很难动态添加where条件
if (!string.IsNullOrEmpty(name)) query = query.Where(d => d.Name.Contains(name));

if (!string.IsNullOrEmpty(type)) query = query.Where(d => d.Type == type);

嵌套select

这是一个例子:

await query
            .OrderByDescending(p => p.CreateTime)
            .Skip((page - 1) * size)
            .Take(size)
            .Select(p => new AlarmPushDto
            {
                Id = p.Id,
                Name = p.Name,
                PushType = p.PushType,
                Enabled = p.Enabled,
                Remark = p.Remark,
                // 这里是嵌套select,注意,这里用的是同步ToList(),但整个LINQ还是ToListAsync(),这是没问题的
                Receivers = p.Receivers.Select(r => r.Receiver).ToList()
            })
            .ToListAsync();

使用上述嵌套select,建议在DBContext里配置HasMany导航属性:

modelBuilder.Entity<AlarmPush>()
             // 配置1:N的映射关系
            .HasMany(p => p.Receivers)
            .WithOne()
             // 配置关联字段,这里的ForeignKey并非物理数据库的外键,它只代表逻辑上的关联关系! 
            .HasForeignKey(r => r.PushId);

EF Core自动翻译的真实SQL为:

SELECT `t0`.`id`, `t0`.`name`, `t0`.`push_type`, `t0`.`enabled`, `t0`.`remark`, `t1`.`receiver`, `t1`.`push_id`
      FROM (
          SELECT `t`.`id`, `t`.`name`, `t`.`push_type`, `t`.`enabled`, `t`.`remark`, `t`.`create_time`
          FROM `t_alarm_push` AS `t`
          ORDER BY `t`.`create_time` DESC
          LIMIT @__p_1 OFFSET @__p_0
      ) AS `t0`
      LEFT JOIN `t_alarm_push_receiver` AS `t1` ON `t0`.`id` = `t1`.`push_id`
      ORDER BY `t0`.`create_time` DESC, `t0`.`id`, `t1`.`push_id`

这里因为有分页,所以EF Core先做了一个含limit offset的子查询,然后用子查询结果跟子表做left join。
注意:这里还有一个隐藏点,真实SQL查出来的是一个扁平化结构,但程序要的是一个1:N的嵌套结构,很显然,EF Core在查出扁平结构后,自动帮我们做了到嵌套结构的转换,这个转换过程叫“结果集映射”(Result Mapping)。java 的mybatis也有类似的技术ResultSetHandler。

风险:主表字段会重复传输,如果N很大,且主表字段很多,就要考虑这种传输的开销是否值得。如果再多来几层嵌套(1:N:M),这个代价就更大了,还不如分拆查询。

Include方法

配置了配置HasMany导航属性,也可用Include做表关联:

var entity = await context.AlarmPushes
                .Include(p => p.Receivers)
                .FirstOrDefaultAsync(p => p.Id == dto.Id);

EF Core翻译为:

SELECT `t0`.`id`, `t0`.`create_time`, `t0`.`enabled`, `t0`.`name`, `t0`.`push_type`, `t0`.`remark`, `t0`.`update_time`, `t1`.`push_id`, `t1`.`receiver`, `t1`.`create_time`, `t1`.`update_time`
      FROM (
          SELECT `t`.`id`, `t`.`create_time`, `t`.`enabled`, `t`.`name`, `t`.`push_type`, `t`.`remark`, `t`.`update_time`
          FROM `t_alarm_push` AS `t`
          WHERE `t`.`id` = @__dto_Id_0
          LIMIT 1
      ) AS `t0`
      LEFT JOIN `t_alarm_push_receiver` AS `t1` ON `t0`.`id` = `t1`.`push_id`
      ORDER BY `t0`.`id`, `t1`.`push_id`

也是子查询left join子表,跟嵌套select的翻译结果差不多。与嵌套select不同的是,Include会把子表的所有属性选出来,而嵌套select是可以指定部分属性的,更加灵活。

另外,两者用法上有点区别:Include常用于修改场景,因为它能获得实体的全属性,方便后续SaveChanges,所以一般不配AsNoTracking。而嵌套Select则常用于查询场景(因为可以精确到部分属性),一般会配置AsNoTracking。

拆分查询:AsSplitQuery

我们在前面的嵌套select里面讲到了嵌套查询的风险:一旦N很大,主表字段很多,会造成主表字段的重复传输。AsSplitQuery方法就是为了解决这个问题的。它会把单查询拆分成两次查询,第一次查主表字段,第二次只查从表字段,最后在内存里做两者的合并。

举个三表关联、关系为1:N:1的例子:

var query = context.TmplDetails.AsNoTracking()
            .Where(pd => pd.TmplId == tmplId);

        ...

        var total = await query.CountAsync();

        var records = await query
            .OrderByDescending(pd => pd.CreateTime)
            .Skip((page - 1) * size)
            .Take(size)
            .Select(pd => new TmplDetailDto
            {
                Id = pd.Id,
                TmplId = pd.TmplId,
                GroupName = pd.GroupName,
                Name = pd.Name,
                Code = pd.Code,
                ......
                Remark = pd.Remark,
                // 这里用到了嵌套select(Join方法的最后一个参数实际代表了投影操作)
                LinkedRules = context.PointRuleRelations.AsNoTracking()
                    .Where(pr => pr.PointId == pd.Id)
                    .Join(context.Rules.AsNoTracking(),
                        pr => pr.RuleId,
                        ar => ar.Id,
                        (pr, ar) => new
                        {
                            Id = ar.Id,
                            Name = ar.Name
                        })
                    .ToList()
            })
            .ToListAsync<object>();

翻译后的SQL为:

SELECT `t0`.`id`, `t0`.`tmpl_id`, `t0`.`group_name`, `t0`.`name`, `t0`.`code`, ..., `t0`.`remark`, `t1`.`Id`, `t1`.`Name`, `t1`.`point_id`, `t1`.`rule_id`
      FROM (
          SELECT `t`.`id`, `t`.`tmpl_id`, `t`.`group_name`, `t`.`name`, `t`.`code`, ..., `t`.`remark`, `t`.`create_time`
          FROM `t_tmpl_detail` AS `t`
          WHERE `t`.`tmpl_id` = @__tmplId_0
          ORDER BY `t`.`create_time` DESC
          LIMIT @__p_2 OFFSET @__p_1
      ) AS `t0`
      LEFT JOIN (
          SELECT `t3`.`id` AS `Id`, `t3`.`name` AS `Name`, `t2`.`point_id`, `t2`.`rule_id`
          FROM `t_point_rule_relation` AS `t2`
          INNER JOIN `t_rule` AS `t3` ON `t2`.`rule_id` = `t3`.`id`
      ) AS `t1` ON `t0`.`id` = `t1`.`point_id`
      ORDER BY `t0`.`create_time` DESC, `t0`.`id`, `t1`.`point_id`, `t1`.`rule_id`

嵌套select被翻译为一个子查询,然后被主表left join,一次查询搞定,所有字段扁平化。

我们将代码改为拆分查询试试:

var query = context.TmplDetails.AsNoTracking()
            .Where(pd => pd.TmplId == tmplId);

        ...

        var total = await query.CountAsync();

        var records = await query
            .OrderByDescending(pd => pd.CreateTime)
            .Skip((page - 1) * size)
            .Take(size)
            .Select(pd => new TmplDetailDto
            {
                Id = pd.Id,
                TmplId = pd.TmplId,
                GroupName = pd.GroupName,
                Name = pd.Name,
                Code = pd.Code,
                ......
                Remark = pd.Remark,
                LinkedRules = context.PointRuleRelations.AsNoTracking()
                    .Where(pr => pr.PointId == pd.Id)
                    .Join(context.Rules.AsNoTracking(),
                        pr => pr.RuleId,
                        ar => ar.Id,
                        (pr, ar) => new
                        {
                            Id = ar.Id,
                            Name = ar.Name
                        })
                    .ToList()
            })
            // 这里使用AsSplitQuery拆分查询
            .AsSplitQuery()
            .ToListAsync<object>();

真实执行的SQL变成2次,第一次只查主表及主表字段:

SELECT `t`.`id`, `t`.`tmpl_id`, `t`.`group_name`, `t`.`name`, `t`.`code`, ..., `t`.`remark`
      FROM `t_tmpl_detail` AS `t`
      WHERE `t`.`tmpl_id` = @__tmplId_0
      ORDER BY `t`.`create_time` DESC, `t`.`id`
      LIMIT @__p_2 OFFSET @__p_1

第二次,还是关联查询,只查从表字段:

SELECT `t1`.`Id`, `t1`.`Name`, `t0`.`id`
      FROM (
          SELECT `t`.`id`, `t`.`create_time`
          FROM `t_tmpl_detail` AS `t`
          WHERE `t`.`tmpl_id` = @__tmplId_0
          ORDER BY `t`.`create_time` DESC
          LIMIT @__p_2 OFFSET @__p_1
      ) AS `t0`
      -- 因为只查从表字段,所以这里使用INNER JOIN而非LEFT JOIN!
      -- 但整个还是三表关联
      INNER JOIN (
          SELECT `t3`.`id` AS `Id`, `t3`.`name` AS `Name`, `t2`.`point_id`
          FROM `t_point_rule_relation` AS `t2`
          INNER JOIN `t_rule` AS `t3` ON `t2`.`rule_id` = `t3`.`id`
      ) AS `t1` ON `t0`.`id` = `t1`.`point_id`
      ORDER BY `t0`.`create_time` DESC, `t0`.`id`

可见,拆分查询,本质上是用更多的查询次数换取更少的网络字段传输,但它不一定能减少关联表的数量。

用不用拆分查询,取决于前面说的,N的大小及主表的字段数量、字段大小,需要实测对比。

实际上,也可以不用EF Core提供的AsSplitQuery拆分查询,完全自己手写拆分查询,比如第一次查询获得涉及的Id清单,第二次查询用IN语句,这样可以少关联一张主表,但是主从表数据的组装也得由自己来完成。

多路径嵌套

当LINQ里只有一个Include或一个嵌套Select,我们称之为单路径嵌套,即A和B是1:N的关系。

但如果A和B是1:N,A和C是1:M,在LINQ里写了两个Include或两个嵌套Select,这就是多路径嵌套,很显然,最终SQL会是:

A left join B left join C

这可能造成笛卡尔积爆炸,应尽量避免,可以将上述查询拆成两个查询执行,最后在内存里组装:

ids = A left join B
select C where ids in ()

当然,也可以使用上节提到的AsSplitQuery。

1:1关联

前面主要说的是1:N关联,本节讲1:1关联,一般说来,1:1关联比较明显,可以不必显式配HasOne(对应于1:N的HasMany,当然1:N下HasMany也可以不配,但配了更清晰)。

举个例子,机房下有多个机柜,但每个机柜只属于一个机房,后者就是一个1:1的关系,此时,我们只需配置一个导航属性及一个导航外键,EF Core就能自动识别,而无需配HasOne:

public class Room
{
    public long Id { get; set; }
    ...
}

public class Cabinet
{
    ...
        
    // 外键ID,命名必须满足:导航属性名+Id    
    public long? RoomId { get; set; } = null;
    
    // 导航属性
    public Room? Room { get; set; }
}

如果外键ID的命名不满足导航属性名+Id,就需要显式配置HasOne了:

modelBuilder.Entity<Cabinet>()
        .HasOne(p => p.Room)
        .WithOne()
        .HasForeignKey(p => p.RoomId);

string的Contains方法

等价于like %%。

SQL注入

LINQ里只要不使用动态特性,天然就能防御SQL注入,比如:

var joinQry = from cp in context.CollectPoints
            join device in context.Devices on cp.DeviceId equals device.Id
            where device.Name == deviceName
            select cp.Code;

上述的where部分,框架会自动帮我们做参数化,不用考虑SQL注入问题。

所谓动态特性包括:FromSqlRaw、动态LINQ字符串等,动态特性就需要我们自己处理SQL注入问题了。

SelectMany

等价与java stream的flatMap

Cast和OfType

Cast:对集合里的每个元素做Cast转型

OfType:对集合里的每个元素做类型过滤。

更多推荐