C# LINQ开发心得
LINQ
LINQ(Language Integrated Query,语言集成查询)是 .NET 框架中的一种查询技术,它允许开发者使用统一的语法查询不同类型的数据源(如集合、数据库、XML 等,像List这样的集合类也是可以用LINQ的!)。
查询构建
- 查询表达式:使用类似 SQL 的语法(如
from、where、select) - 方法语法:使用 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功能说明:
-
数据源定义:
-
from cp in _context.CollectPoints从CollectPoints表中获取所有记录,每个记录赋值给变量cp。
-
-
关联操作:
-
join device in _context.Devices on cp.DeviceId equals device.Id使用join关键字将CollectPoints表与Devices表关联,关联条件是cp.DeviceId等于device.Id。
这相当于 SQL 中的INNER JOIN操作,只返回两个表中匹配的记录。
-
-
结果投影:
-
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:对集合里的每个元素做类型过滤。
更多推荐




所有评论(0)