最近玩EFCore 6,发生了一个诡异的问题。封装好的代码,在一个简单的Where后发现取回来的结果数量确是对的,但列表里的每一个记录内容都一样。
sql如下:
select * from project_cost
where year_month>=202201 and year_month<=202201 and project_id=1
这个是再简单不过的sql了,我到数据库里查一下,没问题。是返回了62条记录并且每条记录里的信息都不一样。
但在.NET Core里,我发现确实是返回了62条数据,但每条数据的内容都完全一模一样!于是开始我的各种神奇debug之旅。
刚开始就怀疑是不是sql有问题。于是找网上的教程开打印sql的debug方式,结果没有一个好用的。后来发现了一个方式,可以在代码里debug出来要执行的sql。
代码如下:
/// <summary>
/// Gets objects from database by filter.
/// </summary>
/// <param name="predicate">Specified a filter</param>
/// <returns></returns>
public virtual async Task<IQueryable<TEntity>> WhereAsync(Expression<Func<TEntity, bool>> predicate)
{
return await Task.Run(() =>
{
var result = _dbSet.Where<TEntity>(predicate).AsQueryable<TEntity>();
var sql = result.ToQueryString();
return result;
});
}
debug结果如下:
DECLARE @__projectId_0 int = 3;
DECLARE @__startMonth_1 int = 202201;
DECLARE @__endMonth_2 int = 202207;
SELECT [p].[id], [p].[employee_id], [p].[employee_name], [p].[title_id], [p].[title_name], [p].[project_id], [p].[project_name], [p].[project_status], [p].[total_amount], [p].[total_cost], [p].[year_month]
FROM [project_cost] AS [p]
WHERE (([p].[project_id] = @__projectId_0) AND ([p].[year_month] >= @__startMonth_1)) AND ([p].[year_month] <= @__endMonth_2)
结果拿这个到数据库里直接跑,结果还是对的,但debug看result就是“记录数量一样,但每条记录都是第一条的内容”。
这边找不到问题,又开始看这个表跟其他表不一样的地方,这个不是一个实体表,是一个视图,为了方便直接在数据库中做了视图,然后代码里做了一个Entity对应就可以简化代码量。原先以为因为视图的原因,是不是EFCore什么地方的设置导致了视图和实体表的操作不一样。
但反向通过上面的例子,也证明了视图和实体表用起来没有区别。毕竟sql都打印出来了,跑的结果也出来了。但确实因为这个view的结果和其他表的同样Where操作结果就不一样。
因为这是一个共通函数,是所有repository的基类,所有EF的查询都要通过这个函数,其他实体表也有调用这个Where方法的,结果就没有问题。仅仅在目前这个View上发生了问题。
view粘一下。
SELECT
1 id,
p.id project_id,
p.name project_name,
p.project_status project_status,
convert(int, LEFT ( TE.date, 6 )) year_month,
e.id employee_id,
e.name employee_name,
t.id title_id,
t.title title_name,
SUM(te.TotalAmount) / 3600 total_amount,
SUM ( TE.TotalAmount* t.rate ) / 3600 total_cost
FROM
project p
LEFT JOIN TimeEntry TE ON TE.projectid = p.gid
LEFT JOIN employee e ON TE.userid= e.gid
LEFT JOIN RoleTitle t ON t.id= e.title
GROUP BY
p.id,
p.name,
p.project_status,
convert(int, LEFT ( TE.date, 6 )),
e.id,
e.name,
t.id,
t.title
再次review这个视图,目光不知道为什么就突然聚焦到了这个不一样的id字段上。之前这个视图是没有这个字段的,但为了统一使用自定义的Repository,Repository要求一个Template TKey,是每个Entity要求一个主键的Key。
Repository定义部分如下:
public class Repository<TEntity> : IRepository<TEntity> where TEntity : class, new()
{
protected readonly DbSet<TEntity> _dbSet;
protected readonly IAppDbContext _unitOfWork;
public Repository(IAppDbContext unitOfWork)
{
_unitOfWork = unitOfWork;
_dbSet = ((AppDbContext)unitOfWork).Set<TEntity>();
}
......
}
TEntity需要的IEntity如下:
public interface IEntity
{
int Id { get; set; }
}
所以必须要一个id字段才能使用Repository,才能使用EF相关功能。
然后突然联想到上周五发生的一个奇怪的事情:我在PG数据库里批量插入的时候,总提醒我id的key有问题,有重复插入。但我数据库里配置的是id字段不是主键,可以重复插入,代码里entity上的[key]字段也拿掉了,批量插入的里面identity插入也没启用。后来跟到BulkInsert源代码才发现,即使没有关于key的定义,它也会把名字叫“id”的字段当成主键使用,必须启用identity insert模式才可以插入。
想到这个,我回头来看这个view的结果,唯一跟其他实体表不一样的就是,这个表的id返回的都是1,是同样的内容,会不会EFCore内部也默认这个id字段是主键,然后因为id一样,所以就自动给后面id一样的赋值跟前面一模一样的内容了。
想到这个,我立刻修改view的id字段如下,让sqlserver的view每一条记录都能返回一个独立的数字。
convert(int, row_number() over(order by p.name)) id,
debug,测试,发现数据正确了!
回想起来这2个问题,还真特别的巧合。因为之前也没有花时间在看EFCore相关的源代码,所以也从来没有想到过这一层。
咱也出个结论吧,只要是命名为id的字段,无论你如何定义,EFCore都会认为这个字段是主键,并且不会重复,相关的一些逻辑都是以这个为基础进行设计的。
也为了扩展,专门google了一下,发现这么一个结论:CodeFirst会自动认为你的ID字段是主键,即使你没有任何相关的定义。如果你要用其他方式做主键的话,你需要显示指定,一个是[Key],一个是在DbContext的定义中指定这个字段为主键
modelBuilder.Entity<AuthorCourseLesson>()
.HasKey(p => new { p.AuthorId, p.CourseLessonId });
跟上面一样,沉浸式研究东西的时候经常容易灵光一闪。突然想到,那为什么EFCore会根据ID用同样的内容填充呢,有没有可能,它没有真实创建新对象填充,只是同一个对象不停地塞进List里呢?如果我来设计这块内容,为了性能和更高效地使用内存,我会将找到的id相同的对象拿过来直接塞进列表里去。于是就来验证了一下,在debug中断模式下,immediate窗口里做了一些实验:
l[0] == l[1]
true
l[0].GetHashCode()
33250821
l[1].GetHashCode()
33250821
l[2].GetHashCode()
33250821
l[61].GetHashCode()
33250821
l[62].GetHashCode()
'l[62].GetHashCode()' threw an exception of type 'System.ArgumentOutOfRangeException'
l.Add(l[0])
Expression has been evaluated and has no value
l[62].GetHashCode()
33250821
l.Add(new ProjectCost(){ Id=1})
Expression has been evaluated and has no value
l[63].GetHashCode()
10176385
结果果然验证了我的猜想。
一共62个object,尝试了一下,0,1,2以及61都是同一个hashcode。再塞一个0进去成62,hashcode也一样。创建新的new就是另外一个hashcode了(63)。我印象里.NET的hashcode和java应该一样,值对象是可以直接对比的,但对象类型如果想要做判等操作,不能只重载operator =,要同时重写hashcode,以确保两个对象的hashcode算法一致。如果没有特别重写hashcode,那两个对象即使所有字段都相同,hashcode也会返回不同的值。
为了最终验证,因为上面那个add的对象和其他对象内容不一样,我给Entity对象增加了一个自定义的Clone方法,每一个字段都手工赋值,保证内容一样,再进行测试,验证了想法的准确性。
l.Add(l[0].Clone(l[0]))
Expression has been evaluated and has no value
l[63]
'l[63]' threw an exception of type 'System.ArgumentOutOfRangeException'
l[62].GetHashCode()
20806745
l[61].GetHashCode()
49992845
l[60].GetHashCode()
49992845
最终确认,60和61相同,后用自定义clone添加的同样内容的对象62,hashcode不一样。
每天一点,紧跟潮流 :)